mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-03-01 04:49:36 +00:00
Added Conrad provider
This commit is contained in:
parent
ae4c0786b2
commit
f44350e798
6 changed files with 471 additions and 1 deletions
|
|
@ -278,6 +278,19 @@ The following env configuration options are available:
|
|||
* `PROVIDER_BUERKLIN_CURRENCY`: The currency you want to get prices in if available (optional, 3 letter ISO-code, default: `EUR`).
|
||||
* `PROVIDER_BUERKLIN_LANGUAGE`: The language you want to get the descriptions in. Possible values: `de` = German, `en` = English. (optional, default: `en`)
|
||||
|
||||
### Conrad
|
||||
|
||||
The conrad provider the [Conrad API](https://developer.conrad.com/) to search for parts and retried their information.
|
||||
To use it you have to request access to the API, however it seems currently your mail address needs to be allowlisted before you can register for an account.
|
||||
For testing it is possible to temporarily use an API key the [Conrad](https://www.conrad.com/) website uses to communicate with the backend, however, this might change at any time and stop working.
|
||||
|
||||
The following env configuration options are available:
|
||||
* `PROVIDER_CONRAD_KEY`: The API key you got from Conrad (mandatory)
|
||||
* `PROVIDER_CONRAD_ENABLED`: Set this to `1` to enable the Conrad provider
|
||||
* `PROVIDER_CONRAD_LANGUAGE`: The language you want to get the descriptions in (optional, default: `en`)
|
||||
* `PROVIDER_CONRAD_COUNTRY`: The country you want to get the prices for (optional, default: `DE`)
|
||||
* `PROVIDER_CONRAD_INCLUDE_VAT`: If set to `1`, the prices will be gross prices (including tax), otherwise net prices (optional, default: `1`)
|
||||
|
||||
### Custom provider
|
||||
|
||||
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
|
||||
|
|
|
|||
363
src/Services/InfoProviderSystem/Providers/ConradProvider.php
Normal file
363
src/Services/InfoProviderSystem/Providers/ConradProvider.php
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\ConradSettings;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class ConradProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://api.conrad.com';
|
||||
|
||||
public const DISTRIBUTOR_NAME = "Conrad";
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client,
|
||||
private readonly ConradSettings $settings,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Conrad',
|
||||
'description' => 'This provider uses the Conrad API to search for parts.',
|
||||
'url' => 'https://www.conrad.com/',
|
||||
'disabled_help' => 'Configure the API key in the provider settings to enable.',
|
||||
'settings_class' => ConradSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'conrad';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->settings->apiKey !== '' && $this->settings->apiKey !== null;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword, bool $b2b = false): array
|
||||
{
|
||||
$salesOrg = strtolower($this->settings->country);
|
||||
if ($salesOrg == 'gb' || $salesOrg == 'us') $salesOrg = "com";
|
||||
$lang = $this->settings->language;
|
||||
$btx = $b2b ? "b2b" : "b2c";
|
||||
|
||||
$response = $this->makeAPICall("/search/1/v3/facetSearch/$salesOrg/$lang/$btx", [], [
|
||||
'sort' => [["field"=>"_score","order"=>"desc"]],
|
||||
'from' => 0,
|
||||
'size' => 50,
|
||||
'query' => $keyword,
|
||||
]);
|
||||
|
||||
$products = $response['hits'] ?? [];
|
||||
|
||||
$productIds = array_map(fn($p) => $p['productId'], $products);
|
||||
$details = $this->getMultiDetails($productIds, false);
|
||||
$urls = [];
|
||||
foreach ($details as $item) {
|
||||
$urls[$item->provider_id] = $item->provider_url;
|
||||
}
|
||||
|
||||
$sanitize = fn($str) => preg_replace("/[\u{2122}\u{00ae}]/", "", $str);
|
||||
|
||||
if (is_array($products) && !empty($products)) {
|
||||
return array_map(fn($p) =>
|
||||
new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $p['productId'],
|
||||
name: $p['manufacturerId'],
|
||||
description: $p['title'],
|
||||
category: null,
|
||||
manufacturer: $sanitize($p['brand']['name']),
|
||||
preview_image_url: $p['image'],
|
||||
provider_url: $urls[$p['productId']] ?? null
|
||||
)
|
||||
, $products);
|
||||
}
|
||||
else if (!$b2b) {
|
||||
return $this->searchByKeyword($keyword, true);
|
||||
|
||||
}
|
||||
return [];
|
||||
}
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
$products = $this->getMultiDetails([$id]);
|
||||
if (is_array($products) && !empty($products)) {
|
||||
return $products[0];
|
||||
}
|
||||
throw new \RuntimeException('No part found with ID ' . $id);
|
||||
}
|
||||
private function getMultiDetails(array $ids, bool $queryPrices = true): array
|
||||
{
|
||||
$ep = $this->getLocalEndpoint();
|
||||
$response = $this->makeAPICall("/product/1/service/$ep/productdetails", [
|
||||
'language' => $this->settings->language,
|
||||
], [
|
||||
'productIDs' => $ids,
|
||||
]);
|
||||
|
||||
$products = $response['productDetailPages'] ?? [];
|
||||
//Create part object
|
||||
if (is_array($products) && !empty($products)) {
|
||||
return array_map(function($p) use ($queryPrices) {
|
||||
$info = $p['productShortInformation'] ?? [];
|
||||
$domain = $this->getDomain();
|
||||
$lang = $this->settings->language;
|
||||
$productPage = "https://www.$domain/$lang/p/".$info['slug'].'.html';
|
||||
$datasheets = array_filter($p['productMedia']['manuals'] ?? [], fn($q) => $q['type']=="da");
|
||||
$datasheets = array_map(fn($q) => new FileDTO($q['fullUrl'], $q['title']), $datasheets);
|
||||
$purchaseInfo = $queryPrices ? [$this->queryPrices($p['shortProductNumber'], $productPage)] : [];
|
||||
|
||||
$sanitize = fn($str) => preg_replace("/[\u{2122}\u{00ae}]/", "", $str);
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $p['shortProductNumber'],
|
||||
name: $info['manufacturer']['name'] ?? $p['shortProductNumber'],
|
||||
description: $info['shortDescription'],
|
||||
category: $info['articleGroupName'],
|
||||
manufacturer: $sanitize($p['brand']['displayName']),
|
||||
mpn: $info['manufacturer']['id'],
|
||||
preview_image_url: $info['mainImage']['imageUrl'],
|
||||
provider_url: $productPage,
|
||||
notes: $p['productFullInformation']['description'],
|
||||
datasheets: $datasheets,
|
||||
parameters: $this->parseParameters($p['productFullInformation']['technicalAttributes'] ?? []),
|
||||
vendor_infos: $purchaseInfo
|
||||
);
|
||||
}, $products);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
|
||||
private function makeAPICall(string $endpoint, array $queryParams = [], array $jsonParams = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->client->request('POST', self::ENDPOINT_URL . $endpoint, [
|
||||
'headers' => ['Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json;charset=UTF-8'],
|
||||
'query' => array_merge($queryParams, [
|
||||
'apikey' => $this->settings->apiKey
|
||||
]),
|
||||
'json' => $jsonParams,
|
||||
]);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException("Conrad API request failed: " .
|
||||
"Endpoint: " . $endpoint . " " .
|
||||
"QueryParams: " . json_encode($queryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . " " .
|
||||
"JsonParams: " . json_encode($jsonParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . " " .
|
||||
"Exception message: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Crawler $dom
|
||||
* @return ParameterDTO[]
|
||||
*/
|
||||
private function parseParameters(array $attr): array
|
||||
{
|
||||
return array_map(function ($p) {
|
||||
if (count($p['values']) == 1) {
|
||||
if (array_key_exists('unit', $p['values'][0])) {
|
||||
return ParameterDTO::parseValueField(
|
||||
name: $p['attributeName'],
|
||||
value: $p['values'][0]['value'],
|
||||
unit: $p['values'][0]['unit']['name'],
|
||||
);
|
||||
} else {
|
||||
return ParameterDTO::parseValueIncludingUnit(
|
||||
name: $p['attributeName'],
|
||||
value: $p['values'][0]['value'],
|
||||
);
|
||||
}
|
||||
}
|
||||
else if (count($p['values']) == 2) {
|
||||
$value = $p['values'][0]['value'] ?? null;
|
||||
$value2 = $p['values'][1]['value'] ?? null;
|
||||
$unit = $p['values'][0]['unit']['name'] ?? '';
|
||||
$unit2 = $p['values'][1]['unit']['name'] ?? '';
|
||||
if ($unit === $unit2 && is_numeric($value) && is_numeric($value2)) {
|
||||
if (array_key_exists('unit', $p['values'][0])) {
|
||||
return new ParameterDTO(
|
||||
name: $p['attributeName'],
|
||||
value_min: $value == null ? null : (float)$value,
|
||||
value_max: $value2 == null ? null : (float)$value2,
|
||||
unit: $unit,
|
||||
);
|
||||
} else {
|
||||
return new ParameterDTO(
|
||||
name: $p['attributeName'],
|
||||
value_min: $value == null ? null : (float)$value,
|
||||
value_max: $value2 == null ? null : (float)$value2,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback implementation
|
||||
$values = implode(", ", array_map(fn($q) =>
|
||||
array_key_exists('unit', $q) ? $q['value']." ". $q['unit'] : $q['value']
|
||||
, $p['values']));
|
||||
return ParameterDTO::parseValueIncludingUnit(
|
||||
name: $p['attributeName'],
|
||||
value: $values,
|
||||
);
|
||||
}, $attr);
|
||||
}
|
||||
|
||||
private function queryPrices(string $productId, ?string $productPage = null): PurchaseInfoDTO
|
||||
{
|
||||
$ep = $this->getLocalEndpoint();
|
||||
$response = $this->makeAPICall("/price-availability/4/$ep/facade", [
|
||||
'overrideCalculationSchema' => $this->settings->includeVAT ? 'GROSS' : 'NET'
|
||||
], [
|
||||
'ns:inputArticleItemList' => [
|
||||
"#namespaces" => [
|
||||
"ns" => "http://www.conrad.de/ccp/basit/service/article/priceandavailabilityservice/api"
|
||||
],
|
||||
'articles' => [
|
||||
[
|
||||
"articleID" => $productId,
|
||||
"calculatePrice" => true,
|
||||
"checkAvailability" => true,
|
||||
],
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$priceInfo = $response['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['price'] ?? [];
|
||||
$price = $priceInfo['price'] ?? 0;
|
||||
$currency = $priceInfo['currency'] ?? "EUR";
|
||||
$includesVat = $priceInfo['isGrossAmount'] == "true" ?? true;
|
||||
|
||||
return new PurchaseInfoDTO(
|
||||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $productId,
|
||||
prices: array_merge(
|
||||
[new PriceDTO(1.0, strval($price), $currency, $includesVat)]
|
||||
, $this->parseBatchPrices($priceInfo, $currency, $includesVat)),
|
||||
product_url: $productPage
|
||||
);
|
||||
}
|
||||
|
||||
private function parseBatchPrices(array $priceInfo, string $currency, bool $includesVat): array
|
||||
{
|
||||
$priceScale = array_filter($priceInfo['priceScale'] ?? [], fn($p) => $p['scaleFrom'] != 1);
|
||||
return array_map(fn($p) =>
|
||||
new PriceDTO($p['scaleFrom'], strval($p['pricePerUnit']), $currency, $includesVat)
|
||||
, $priceScale);
|
||||
}
|
||||
|
||||
private function getLocalEndpoint(): string
|
||||
{
|
||||
switch ($this->settings->country) {
|
||||
case "DE":
|
||||
return "CQ_DE_B2C";
|
||||
case "CH":
|
||||
return "CQ_CH_B2C";
|
||||
case "NL":
|
||||
return "CQ_NL_B2C";
|
||||
case "AT":
|
||||
return "CQ_AT_B2C";
|
||||
case "HU":
|
||||
return "CQ_HU_B2C";
|
||||
case "FR":
|
||||
return "HP_FR_B2B";
|
||||
case "IT":
|
||||
return "HP_IT_B2B";
|
||||
case "PL":
|
||||
return "HP_PL_B2B";
|
||||
case "CZ":
|
||||
return "HP_CZ_B2B";
|
||||
case "BE":
|
||||
return "HP_BE_B2B";
|
||||
case "DK":
|
||||
return "HP_DK_B2B";
|
||||
case "HR":
|
||||
return "HP_HR_B2B";
|
||||
case "SE":
|
||||
return "HP_SE_B2B";
|
||||
case "SK":
|
||||
return "HP_SK_B2B";
|
||||
case "SI":
|
||||
return "HP_SI_B2B";
|
||||
case "GB": // fall through
|
||||
case "US":
|
||||
default:
|
||||
return "HP_COM_B2B";
|
||||
}
|
||||
}
|
||||
|
||||
private function getDomain(): string
|
||||
{
|
||||
switch ($this->settings->country) {
|
||||
case "DK":
|
||||
return "conradelektronik.dk";
|
||||
case "DE": // fall through
|
||||
case "CH":
|
||||
case "NL":
|
||||
case "AT":
|
||||
case "HU":
|
||||
case "FR":
|
||||
case "IT":
|
||||
case "PL":
|
||||
case "CZ":
|
||||
case "BE":
|
||||
case "HR":
|
||||
case "SE":
|
||||
case "SK":
|
||||
case "SI":
|
||||
return "conrad.".strtolower($this->settings->country);
|
||||
case "GB": // fall through
|
||||
case "US":
|
||||
default:
|
||||
return "conrad.com";
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/Settings/InfoProviderSystem/ConradSettings.php
Normal file
67
src/Settings/InfoProviderSystem/ConradSettings.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Settings\InfoProviderSystem;
|
||||
|
||||
use App\Form\Type\APIKeyType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[Settings(label: new TM("settings.ips.conrad"), description: new TM("settings.ips.conrad.help"))]
|
||||
#[SettingsIcon("fa-plug")]
|
||||
class ConradSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
public const SUPPORTED_LANGUAGE = ["en", "de", "fr", "nl", "hu", "it", "pl", "cs", "da", "hr", "sv", "sk", "sl"];
|
||||
public const SUPPORTED_COUNTRIES = ["DE", "CH", "NL", "AT", "HU", "FR", "IT", "PL", "CZ", "BE", "DK", "HR", "SE", "SK", "SI", "GB", "US"];
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.mouser.apiKey"), description: new TM("settings.ips.mouser.apiKey.help"),
|
||||
formType: APIKeyType::class,
|
||||
formOptions: ["help_html" => true], envVar: "PROVIDER_CONRAD_KEY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
public ?string $apiKey = null;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class, formOptions: ["preferred_choices" => self::SUPPORTED_LANGUAGE],
|
||||
envVar: "PROVIDER_CONRAD_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
#[Assert\Language()]
|
||||
#[Assert\Choice(choices: self::SUPPORTED_LANGUAGE)]
|
||||
public string $language = "en";
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: CountryType::class, formOptions: ["preferred_choices" => self::SUPPORTED_COUNTRIES],
|
||||
envVar: "PROVIDER_CONRAD_COUNTRY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
#[Assert\Country]
|
||||
#[Assert\Choice(choices: self::SUPPORTED_COUNTRIES)]
|
||||
public string $country = "COM";
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.reichelt.include_vat"),
|
||||
envVar: "bool:PROVIDER_CONRAD_INCLUDE_VAT", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
public bool $includeVAT = true;
|
||||
}
|
||||
|
|
@ -63,7 +63,10 @@ class InfoProviderSettings
|
|||
|
||||
#[EmbeddedSettings]
|
||||
public ?PollinSettings $pollin = null;
|
||||
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?BuerklinSettings $buerklin = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?ConradSettings $conrad = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12675,6 +12675,18 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
|||
<target>Pollin.de bietet keine offizielle API an, daher extrahiert dieser Informationsanbieter die Daten per Webscraping aus der Website. Dies kann jederzeit aufhören zu funktionieren, die Nutzung erfolgt auf eigene Gefahr.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ocp5ktF" name="settings.ips.conrad">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.conrad</source>
|
||||
<target>Conrad</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Xlj6kj2" name="settings.ips.conrad.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.conrad.help</source>
|
||||
<target>Conrad.de bietet keine öffentlich verfügbare API an, daher extrahiert dieser Informationsanbieter die Daten per Webscraping aus der Website. Dies kann jederzeit aufhören zu funktionieren, die Nutzung erfolgt auf eigene Gefahr.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="TEm7uIg" name="settings.behavior.sidebar.rootNodeRedirectsToNewEntity">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source>
|
||||
|
|
|
|||
|
|
@ -12676,6 +12676,18 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
|||
<target>Pollin.de offers no official API, so this info provider webscrapes the website to extract info. It could break at any time, use it at your own risk.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ocp5ktF" name="settings.ips.conrad">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.conrad</source>
|
||||
<target>Conrad</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Xlj6kj2" name="settings.ips.conrad.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.conrad.help</source>
|
||||
<target>Conrad.de offers no publically available API, so this info provider webscrapes the website to extract info. It could break at any time, use it at your own risk.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="TEm7uIg" name="settings.behavior.sidebar.rootNodeRedirectsToNewEntity">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue