Added Conrad provider

This commit is contained in:
buergi 2026-01-26 22:48:47 +01:00
parent ae4c0786b2
commit f44350e798
6 changed files with 471 additions and 1 deletions

View file

@ -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_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`) * `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 ### Custom provider
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long

View 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";
}
}
}

View 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;
}

View file

@ -63,7 +63,10 @@ class InfoProviderSettings
#[EmbeddedSettings] #[EmbeddedSettings]
public ?PollinSettings $pollin = null; public ?PollinSettings $pollin = null;
#[EmbeddedSettings] #[EmbeddedSettings]
public ?BuerklinSettings $buerklin = null; public ?BuerklinSettings $buerklin = null;
#[EmbeddedSettings]
public ?ConradSettings $conrad = null;
} }

View file

@ -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> <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> </segment>
</unit> </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"> <unit id="TEm7uIg" name="settings.behavior.sidebar.rootNodeRedirectsToNewEntity">
<segment state="translated"> <segment state="translated">
<source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source> <source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source>

View file

@ -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> <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> </segment>
</unit> </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"> <unit id="TEm7uIg" name="settings.behavior.sidebar.rootNodeRedirectsToNewEntity">
<segment state="translated"> <segment state="translated">
<source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source> <source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source>