From f44350e7985aef4fe96cd62bec16d17031f6c919 Mon Sep 17 00:00:00 2001 From: buergi Date: Mon, 26 Jan 2026 22:48:47 +0100 Subject: [PATCH] Added Conrad provider --- docs/usage/information_provider_system.md | 13 + .../Providers/ConradProvider.php | 363 ++++++++++++++++++ .../InfoProviderSystem/ConradSettings.php | 67 ++++ .../InfoProviderSettings.php | 5 +- translations/messages.de.xlf | 12 + translations/messages.en.xlf | 12 + 6 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 src/Services/InfoProviderSystem/Providers/ConradProvider.php create mode 100644 src/Settings/InfoProviderSystem/ConradSettings.php diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md index 13df7f10..af94a4a3 100644 --- a/docs/usage/information_provider_system.md +++ b/docs/usage/information_provider_system.md @@ -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 diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php new file mode 100644 index 00000000..747f8cd2 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -0,0 +1,363 @@ +. + */ + +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"; + } + } +} diff --git a/src/Settings/InfoProviderSystem/ConradSettings.php b/src/Settings/InfoProviderSystem/ConradSettings.php new file mode 100644 index 00000000..ce213bbc --- /dev/null +++ b/src/Settings/InfoProviderSystem/ConradSettings.php @@ -0,0 +1,67 @@ +. + */ + +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; +} \ No newline at end of file diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php index d4679e23..fb31bdb9 100644 --- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php +++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php @@ -63,7 +63,10 @@ class InfoProviderSettings #[EmbeddedSettings] public ?PollinSettings $pollin = null; - + #[EmbeddedSettings] public ?BuerklinSettings $buerklin = null; + + #[EmbeddedSettings] + public ?ConradSettings $conrad = null; } diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 9fa2ef52..330cf900 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -12675,6 +12675,18 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön 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. + + + settings.ips.conrad + Conrad + + + + + settings.ips.conrad.help + 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. + + settings.behavior.sidebar.rootNodeRedirectsToNewEntity diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 5c4151d6..c95923e2 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12676,6 +12676,18 @@ Please note, that you can not impersonate a disabled user. If you try you will g 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. + + + settings.ips.conrad + Conrad + + + + + settings.ips.conrad.help + 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. + + settings.behavior.sidebar.rootNodeRedirectsToNewEntity