Merge branch 'amazon_info_provider'

This commit is contained in:
Jan Böhmer 2026-02-22 23:43:54 +01:00
commit 430a564592
13 changed files with 461 additions and 13 deletions

View file

@ -303,7 +303,17 @@ That method is not officially supported nor encouraged by Part-DB, and might bre
The following env configuration options are available:
* `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory)
### Custom provider
### Canopy / Amazon
The Canopy provider uses the [Canopy API](https://www.canopyapi.co/) to search for parts and get shopping information from Amazon.
Canopy is a third-party service that provides access to Amazon product data through their API. Their trial plan offers 100 requests per month for free,
and they also offer paid plans with higher limits. To use the Canopy provider, you need to create an account on the Canopy website and obtain an API key.
Once you have the API key, you can configure the Canopy provider in Part-DB using the web UI or environment variables:
* `PROVIDER_CANOPY_API_KEY`: The API key you got from Canopy (mandatory)
### Custom providers
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
as it is a valid Symfony service, it will be automatically loaded and can be used.

View file

@ -72,12 +72,7 @@ class ScanDialogType extends AbstractType
'placeholder' => 'scan_dialog.mode.auto',
'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) {
null => 'scan_dialog.mode.auto',
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp',
BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin',
BarcodeSourceType::LCSC => 'scan_dialog.mode.lcsc',
default => 'scan_dialog.mode.' . $enum->value,
},
]);

View file

@ -454,4 +454,28 @@ class PartRepository extends NamedDBElementRepository
return $qb->getQuery()->getOneOrNullResult();
}
/**
* Finds a part based on the provided SPN (Supplier Part Number), with an option for case sensitivity.
* If no part is found with the given SPN, null is returned.
* @param string $spn
* @param bool $caseInsensitive
* @return Part|null
*/
public function getPartBySPN(string $spn, bool $caseInsensitive = true): ?Part
{
$qb = $this->createQueryBuilder('part');
$qb->select('part');
$qb->leftJoin('part.orderdetails', 'o');
if ($caseInsensitive) {
$qb->where("LOWER(o.supplierpartnr) = LOWER(:spn)");
} else {
$qb->where("o.supplierpartnr = :spn");
}
$qb->setParameter('spn', $spn);
return $qb->getQuery()->getOneOrNullResult();
}
}

View file

@ -95,7 +95,7 @@ final class PartInfoRetriever
$escaped_keyword = hash('xxh3', $keyword);
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
//Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 10);
return $provider->searchByKeyword($keyword);
});
@ -122,7 +122,7 @@ final class PartInfoRetriever
$escaped_part_id = hash('xxh3', $part_id);
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
//Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 10);
return $provider->getDetails($part_id);
});

View file

@ -0,0 +1,227 @@
<?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\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Settings\InfoProviderSystem\BuerklinSettings;
use App\Settings\InfoProviderSystem\CanopySettings;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\When;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Use canopy API to retrieve infos from amazon
*/
class CanopyProvider implements InfoProviderInterface
{
public const BASE_URL = "https://rest.canopyapi.co/api";
public const SEARCH_API_URL = self::BASE_URL . "/amazon/search";
public const DETAIL_API_URL = self::BASE_URL . "/amazon/product";
public const DISTRIBUTOR_NAME = 'Amazon';
public function __construct(private readonly CanopySettings $settings,
private readonly HttpClientInterface $httpClient, private readonly CacheItemPoolInterface $partInfoCache)
{
}
public function getProviderInfo(): array
{
return [
'name' => 'Amazon (Canopy)',
'description' => 'Retrieves part infos from Amazon using the Canopy API',
'url' => 'https://canopyapi.co',
'disabled_help' => 'Set Canopy API key in the provider configuration to enable this provider',
'settings_class' => CanopySettings::class
];
}
public function getProviderKey(): string
{
return 'canopy';
}
public function isActive(): bool
{
return $this->settings->apiKey !== null;
}
private function productPageFromASIN(string $asin): string
{
return "https://www.{$this->settings->getRealDomain()}/dp/{$asin}";
}
/**
* Saves the given part to the cache.
* Everytime this function is called, the cache is overwritten.
* @param PartDetailDTO $part
* @return void
*/
private function saveToCache(PartDetailDTO $part): void
{
$key = 'canopy_part_'.$part->provider_id;
$item = $this->partInfoCache->getItem($key);
$item->set($part);
$item->expiresAfter(3600 * 24); //Cache for 1 day
$this->partInfoCache->save($item);
}
/**
* Retrieves a from the cache, or null if it was not cached yet.
* @param string $id
* @return PartDetailDTO|null
*/
private function getFromCache(string $id): ?PartDetailDTO
{
$key = 'canopy_part_'.$id;
$item = $this->partInfoCache->getItem($key);
if ($item->isHit()) {
return $item->get();
}
return null;
}
public function searchByKeyword(string $keyword): array
{
$response = $this->httpClient->request('GET', self::SEARCH_API_URL, [
'query' => [
'domain' => $this->settings->domain,
'searchTerm' => $keyword,
],
'headers' => [
'API-KEY' => $this->settings->apiKey,
]
]);
$data = $response->toArray();
$results = $data['data']['amazonProductSearchResults']['productResults']['results'] ?? [];
$out = [];
foreach ($results as $result) {
$dto = new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $result['asin'],
name: $result["title"],
description: "",
preview_image_url: $result["mainImageUrl"] ?? null,
provider_url: $this->productPageFromASIN($result['asin']),
vendor_infos: [$this->priceToPurchaseInfo($result['price'], $result['asin'])]
);
$out[] = $dto;
$this->saveToCache($dto);
}
return $out;
}
private function categoriesToCategory(array $categories): ?string
{
if (count($categories) === 0) {
return null;
}
return implode(" -> ", array_map(static fn($cat) => $cat['name'], $categories));
}
private function feauturesBulletsToNotes(array $featureBullets): string
{
$notes = "<ul>";
foreach ($featureBullets as $bullet) {
$notes .= "<li>" . $bullet . "</li>";
}
$notes .= "</ul>";
return $notes;
}
private function priceToPurchaseInfo(?array $price, string $asin): PurchaseInfoDTO
{
$priceDtos = [];
if ($price !== null) {
$priceDtos[] = new PriceDTO(minimum_discount_amount: 1, price: (string) $price['value'], currency_iso_code: $price['currency'], includes_tax: true);
}
return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin));
}
public function getDetails(string $id): PartDetailDTO
{
//Check that the id is a valid ASIN (10 characters, letters and numbers)
if (!preg_match('/^[A-Z0-9]{10}$/', $id)) {
throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)");
}
//Use cached details if available and the settings allow it, to avoid unnecessary API requests, since the search results already contain most of the details
if(!$this->settings->alwaysGetDetails && ($cached = $this->getFromCache($id)) !== null) {
return $cached;
}
$response = $this->httpClient->request('GET', self::DETAIL_API_URL, [
'query' => [
'asin' => $id,
'domain' => $this->settings->domain,
],
'headers' => [
'API-KEY' => $this->settings->apiKey,
],
]);
$product = $response->toArray()['data']['amazonProduct'];
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $product['asin'],
name: $product['title'],
description: '',
category: $this->categoriesToCategory($product['categories']),
manufacturer: $product['brand'] ?? null,
preview_image_url: $product['mainImageUrl'] ?? $product['imageUrls'][0] ?? null,
provider_url: $this->productPageFromASIN($product['asin']),
notes: $this->feauturesBulletsToNotes($product['featureBullets'] ?? []),
vendor_infos: [$this->priceToPurchaseInfo($product['price'], $product['asin'])]
);
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::PRICE,
];
}
}

View file

@ -201,7 +201,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
public function productMediaToDatasheets(array $productMedia): array
{
$files = [];
foreach ($productMedia['manuals'] as $manual) {
foreach ($productMedia['manuals'] ?? [] as $manual) {
//Filter out unwanted languages
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
continue;

View file

@ -0,0 +1,46 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\LabelSystem\BarcodeScanner;
final readonly class AmazonBarcodeScanResult implements BarcodeScanResultInterface
{
public function __construct(public string $asin) {
if (!self::isAmazonBarcode($asin)) {
throw new \InvalidArgumentException("The provided input '$asin' is not a valid Amazon barcode (ASIN)");
}
}
public static function isAmazonBarcode(string $input): bool
{
//Amazon barcodes are 10 alphanumeric characters
return preg_match('/^[A-Z0-9]{10}$/i', $input) === 1;
}
public function getDecodedForInfoMode(): array
{
return [
'ASIN' => $this->asin,
];
}
}

View file

@ -101,6 +101,10 @@ final class BarcodeScanHelper
return $this->parseLCSCBarcode($input);
}
if ($type === BarcodeSourceType::AMAZON) {
return new AmazonBarcodeScanResult($input);
}
//Null means auto and we try the different formats
$result = $this->parseInternalBarcode($input);
@ -135,6 +139,11 @@ final class BarcodeScanHelper
return $this->parseLCSCBarcode($input);
}
//Try amazon barcode
if (AmazonBarcodeScanResult::isAmazonBarcode($input)) {
return new AmazonBarcodeScanResult($input);
}
throw new InvalidArgumentException('Unknown barcode');
}

View file

@ -144,7 +144,12 @@ final readonly class BarcodeScanResultHandler
return $this->resolvePartFromLCSC($barcodeScan);
}
throw new \InvalidArgumentException("Barcode does not support resolving to a local entity: ".get_class($barcodeScan));
if ($barcodeScan instanceof AmazonBarcodeScanResult) {
return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin)
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
}
return null;
}
/**
@ -258,6 +263,13 @@ final readonly class BarcodeScanResultHandler
return $this->getCreationInfoForEIGP114($scanResult);
}
if ($scanResult instanceof AmazonBarcodeScanResult) {
return [
'providerKey' => 'canopy',
'providerId' => $scanResult->asin,
];
}
return null;
}

View file

@ -36,12 +36,12 @@ enum BarcodeSourceType: string
/**
* This barcode is a user defined barcode defined on a part lot
*/
case USER_DEFINED = 'user_defined';
case USER_DEFINED = 'user';
/**
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
*/
case EIGP114 = 'eigp114';
case EIGP114 = 'eigp';
/**
* GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part.
@ -50,4 +50,6 @@ enum BarcodeSourceType: string
/** For LCSC.com formatted QR codes */
case LCSC = 'lcsc';
case AMAZON = 'amazon';
}

View file

@ -0,0 +1,96 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Translation\TranslatableMessage as TM;
use Symfony\Component\Validator\Constraints as Assert;
#[Settings(label: new TM("settings.ips.canopy"))]
#[SettingsIcon("fa-plug")]
class CanopySettings
{
public const ALLOWED_DOMAINS = [
"amazon.de" => "DE",
"amazon.com" => "US",
"amazon.co.uk" => "UK",
"amazon.fr" => "FR",
"amazon.it" => "IT",
"amazon.es" => "ES",
"amazon.ca" => "CA",
"amazon.com.au" => "AU",
"amazon.com.br" => "BR",
"amazon.com.mx" => "MX",
"amazon.in" => "IN",
"amazon.co.jp" => "JP",
"amazon.nl" => "NL",
"amazon.pl" => "PL",
"amazon.sa" => "SA",
"amazon.sg" => "SG",
"amazon.se" => "SE",
"amazon.com.tr" => "TR",
"amazon.ae" => "AE",
"amazon.com.be" => "BE",
"amazon.com.cn" => "CN",
];
use SettingsTrait;
#[SettingsParameter(label: new TM("settings.ips.mouser.apiKey"),
formType: APIKeyType::class,
formOptions: ["help_html" => true], envVar: "PROVIDER_CANOPY_API_KEY", envVarMode: EnvVarMode::OVERWRITE)]
public ?string $apiKey = null;
/**
* @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant
*/
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS])]
public string $domain = "DE";
/**
* @var bool If true, the provider will always retrieve details for a part, resulting in an additional API request
*/
#[SettingsParameter(label: new TM("settings.ips.canopy.alwaysGetDetails"), description: new TM("settings.ips.canopy.alwaysGetDetails.help"))]
public bool $alwaysGetDetails = false;
/**
* Returns the real domain (e.g. amazon.de) based on the selected domain (e.g. DE)
* @return string
*/
public function getRealDomain(): string
{
$domain = array_search($this->domain, self::ALLOWED_DOMAINS);
if ($domain === false) {
throw new \InvalidArgumentException("Invalid domain selected");
}
return $domain;
}
}

View file

@ -72,4 +72,7 @@ class InfoProviderSettings
#[EmbeddedSettings]
public ?ConradSettings $conrad = null;
#[EmbeddedSettings]
public ?CanopySettings $canopy = null;
}

View file

@ -12569,5 +12569,29 @@ Buerklin-API Authentication server:
<target>Create [part] from label scan</target>
</segment>
</unit>
<unit id="8WZYwRJ" name="scan_dialog.mode.amazon">
<segment>
<source>scan_dialog.mode.amazon</source>
<target>Amazon barcode</target>
</segment>
</unit>
<unit id="BQWuR_G" name="settings.ips.canopy">
<segment>
<source>settings.ips.canopy</source>
<target>Canopy</target>
</segment>
</unit>
<unit id="44BfYzy" name="settings.ips.canopy.alwaysGetDetails">
<segment>
<source>settings.ips.canopy.alwaysGetDetails</source>
<target>Always fetch details</target>
</segment>
</unit>
<unit id="so_ms3t" name="settings.ips.canopy.alwaysGetDetails.help">
<segment>
<source>settings.ips.canopy.alwaysGetDetails.help</source>
<target>When selected, more details will be fetched from canopy when creating a part. This causes an additional API request, but gives product bullet points and category info.</target>
</segment>
</unit>
</file>
</xliff>