mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-12 20:49:35 +00:00
Merge upstream/master and resolve translation conflict
Merged new Conrad info provider and generic web provider translations from upstream while keeping Update Manager translations.
This commit is contained in:
commit
6b27f3aa14
22 changed files with 1787 additions and 377 deletions
|
|
@ -277,8 +277,11 @@ class BOMImporter
|
|||
// Fetch suppliers once for efficiency
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
$supplierSPNKeys = [];
|
||||
$suppliersByName = []; // Map supplier names to supplier objects
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierSPNKeys[] = $supplier->getName() . ' SPN';
|
||||
$supplierName = $supplier->getName();
|
||||
$supplierSPNKeys[] = $supplierName . ' SPN';
|
||||
$suppliersByName[$supplierName] = $supplier;
|
||||
}
|
||||
|
||||
foreach ($csv->getRecords() as $offset => $entry) {
|
||||
|
|
@ -356,6 +359,41 @@ class BOMImporter
|
|||
}
|
||||
}
|
||||
|
||||
// Try to link existing part based on supplier part number if no Part-DB ID is given
|
||||
if ($part === null) {
|
||||
// Check all available supplier SPN fields
|
||||
foreach ($suppliersByName as $supplierName => $supplier) {
|
||||
$supplier_spn = null;
|
||||
|
||||
if (isset($mapped_entry[$supplierName . ' SPN']) && !empty(trim($mapped_entry[$supplierName . ' SPN']))) {
|
||||
$supplier_spn = trim($mapped_entry[$supplierName . ' SPN']);
|
||||
}
|
||||
|
||||
if ($supplier_spn !== null) {
|
||||
// Query for orderdetails with matching supplier and SPN
|
||||
$orderdetail = $this->entityManager->getRepository(\App\Entity\PriceInformations\Orderdetail::class)
|
||||
->findOneBy([
|
||||
'supplier' => $supplier,
|
||||
'supplierpartnr' => $supplier_spn,
|
||||
]);
|
||||
|
||||
if ($orderdetail !== null && $orderdetail->getPart() !== null) {
|
||||
$part = $orderdetail->getPart();
|
||||
$name = $part->getName(); // Update name with actual part name
|
||||
|
||||
$this->logger->info('Linked BOM entry to existing part via supplier SPN', [
|
||||
'supplier' => $supplierName,
|
||||
'supplier_spn' => $supplier_spn,
|
||||
'part_id' => $part->getID(),
|
||||
'part_name' => $part->getName(),
|
||||
]);
|
||||
|
||||
break; // Stop searching once a match is found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create unique key for this entry (name + part ID)
|
||||
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
|
||||
|
||||
|
|
|
|||
320
src/Services/InfoProviderSystem/Providers/ConradProvider.php
Normal file
320
src/Services/InfoProviderSystem/Providers/ConradProvider.php
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
<?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\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\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
readonly class ConradProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
private const SEARCH_ENDPOINT = '/search/1/v3/facetSearch';
|
||||
public const DISTRIBUTOR_NAME = 'Conrad';
|
||||
|
||||
private HttpClientInterface $httpClient;
|
||||
|
||||
public function __construct( HttpClientInterface $httpClient, private ConradSettings $settings)
|
||||
{
|
||||
//We want everything in JSON
|
||||
$this->httpClient = $httpClient->withOptions([
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Conrad',
|
||||
'description' => 'Retrieves part information from conrad.de',
|
||||
'url' => 'https://www.conrad.de/',
|
||||
'disabled_help' => 'Set API key in settings',
|
||||
'settings_class' => ConradSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'conrad';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return !empty($this->settings->apiKey);
|
||||
}
|
||||
|
||||
private function getProductUrl(string $productId): string
|
||||
{
|
||||
return 'https://' . $this->settings->shopID->getDomain() . '/' . $this->settings->shopID->getLanguage() . '/p/' . $productId;
|
||||
}
|
||||
|
||||
private function getFootprintFromTechnicalDetails(array $technicalDetails): ?string
|
||||
{
|
||||
foreach ($technicalDetails as $detail) {
|
||||
if ($detail['name'] === 'ATT_LOV_HOUSING_SEMICONDUCTORS') {
|
||||
return $detail['values'][0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/'
|
||||
. $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage()
|
||||
. '/' . $this->settings->shopID->getCustomerType();
|
||||
|
||||
$response = $this->httpClient->request('POST', $url, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
],
|
||||
'json' => [
|
||||
'query' => $keyword,
|
||||
'size' => 50,
|
||||
'sort' => [["field"=>"_score","order"=>"desc"]],
|
||||
],
|
||||
]);
|
||||
|
||||
$out = [];
|
||||
$results = $response->toArray();
|
||||
|
||||
foreach($results['hits'] as $result) {
|
||||
|
||||
$out[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $result['productId'],
|
||||
name: $result['manufacturerId'] ?? $result['productId'],
|
||||
description: $result['title'] ?? '',
|
||||
manufacturer: $result['brand']['name'] ?? null,
|
||||
mpn: $result['manufacturerId'] ?? null,
|
||||
preview_image_url: $result['image'] ?? null,
|
||||
provider_url: $this->getProductUrl($result['productId']),
|
||||
footprint: $this->getFootprintFromTechnicalDetails($result['technicalDetails'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function getFootprintFromTechnicalAttributes(array $technicalDetails): ?string
|
||||
{
|
||||
foreach ($technicalDetails as $detail) {
|
||||
if ($detail['attributeID'] === 'ATT.LOV.HOUSING_SEMICONDUCTORS') {
|
||||
return $detail['values'][0]['value'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $technicalAttributes
|
||||
* @return array<ParameterDTO>
|
||||
*/
|
||||
private function technicalAttributesToParameters(array $technicalAttributes): array
|
||||
{
|
||||
return array_map(static function (array $p) {
|
||||
if (count($p['values']) === 1) { //Single value attribute
|
||||
if (array_key_exists('unit', $p['values'][0])) {
|
||||
return ParameterDTO::parseValueField( //With unit
|
||||
name: $p['attributeName'],
|
||||
value: $p['values'][0]['value'],
|
||||
unit: $p['values'][0]['unit']['name'],
|
||||
);
|
||||
}
|
||||
|
||||
return ParameterDTO::parseValueIncludingUnit(
|
||||
name: $p['attributeName'],
|
||||
value: $p['values'][0]['value'],
|
||||
);
|
||||
}
|
||||
|
||||
if (count($p['values']) === 2) { //Multi value attribute (e.g. min/max)
|
||||
$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])) { //With unit
|
||||
return new ParameterDTO(
|
||||
name: $p['attributeName'],
|
||||
value_min: (float)$value,
|
||||
value_max: (float)$value2,
|
||||
unit: $unit,
|
||||
);
|
||||
}
|
||||
|
||||
return new ParameterDTO(
|
||||
name: $p['attributeName'],
|
||||
value_min: (float)$value,
|
||||
value_max: (float)$value2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// fallback implementation
|
||||
$values = implode(", ", array_map(fn($q) =>
|
||||
array_key_exists('unit', $q) ? $q['value']." ". ($q['unit']['name'] ?? $q['unit']) : $q['value']
|
||||
, $p['values']));
|
||||
return ParameterDTO::parseValueIncludingUnit(
|
||||
name: $p['attributeName'],
|
||||
value: $values,
|
||||
);
|
||||
}, $technicalAttributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $productMedia
|
||||
* @return array<FileDTO>
|
||||
*/
|
||||
public function productMediaToDatasheets(array $productMedia): array
|
||||
{
|
||||
$files = [];
|
||||
foreach ($productMedia['manuals'] as $manual) {
|
||||
//Filter out unwanted languages
|
||||
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = new FileDTO($manual['fullUrl'], $manual['title'] . ' (' . $manual['language'] . ')');
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Queries prices for a given product ID. It makes a POST request to the Conrad API
|
||||
* @param string $productId
|
||||
* @return PurchaseInfoDTO
|
||||
*/
|
||||
private function queryPrices(string $productId): PurchaseInfoDTO
|
||||
{
|
||||
$priceQueryURL = $this->settings->shopID->getAPIRoot() . '/price-availability/4/'
|
||||
. $this->settings->shopID->getShopID() . '/facade';
|
||||
|
||||
$response = $this->httpClient->request('POST', $priceQueryURL, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
'overrideCalculationSchema' => $this->settings->includeVAT ? 'GROSS' : 'NET'
|
||||
],
|
||||
'json' => [
|
||||
'ns:inputArticleItemList' => [
|
||||
"#namespaces" => [
|
||||
"ns" => "http://www.conrad.de/ccp/basit/service/article/priceandavailabilityservice/api"
|
||||
],
|
||||
'articles' => [
|
||||
[
|
||||
"articleID" => $productId,
|
||||
"calculatePrice" => true,
|
||||
"checkAvailability" => true,
|
||||
],
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$result = $response->toArray();
|
||||
|
||||
$priceInfo = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['price'] ?? [];
|
||||
$price = $priceInfo['price'] ?? "0.0";
|
||||
$currency = $priceInfo['currency'] ?? "EUR";
|
||||
$includesVat = !$priceInfo['isGrossAmount'] || $priceInfo['isGrossAmount'] === "true";
|
||||
$minOrderAmount = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['availabilityStatus']['minimumOrderQuantity'] ?? 1;
|
||||
|
||||
$prices = [];
|
||||
foreach ($priceInfo['priceScale'] ?? [] as $priceScale) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: max($priceScale['scaleFrom'], $minOrderAmount),
|
||||
price: (string)$priceScale['pricePerUnit'],
|
||||
currency_iso_code: $currency,
|
||||
includes_tax: $includesVat
|
||||
);
|
||||
}
|
||||
if (empty($prices)) { //Fallback if no price scales are defined
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: $minOrderAmount,
|
||||
price: (string)$price,
|
||||
currency_iso_code: $currency,
|
||||
includes_tax: $includesVat
|
||||
);
|
||||
}
|
||||
|
||||
return new PurchaseInfoDTO(
|
||||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $productId,
|
||||
prices: $prices,
|
||||
product_url: $this->getProductUrl($productId)
|
||||
);
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
$productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID()
|
||||
. '/product/' . $id;
|
||||
|
||||
$response = $this->httpClient->request('GET', $productInfoURL, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $data['shortProductNumber'],
|
||||
name: $data['productFullInformation']['manufacturer']['name'] ?? $data['productFullInformation']['manufacturer']['id'] ?? $data['shortProductNumber'],
|
||||
description: $data['productShortInformation']['title'] ?? '',
|
||||
category: $data['productShortInformation']['articleGroupName'] ?? null,
|
||||
manufacturer: $data['brand']['displayName'] !== null ? preg_replace("/[\u{2122}\u{00ae}]/", "", $data['brand']['displayName']) : null, //Replace ™ and ® symbols
|
||||
mpn: $data['productFullInformation']['manufacturer']['id'] ?? null,
|
||||
preview_image_url: $data['productShortInformation']['mainImage']['imageUrl'] ?? null,
|
||||
provider_url: $this->getProductUrl($data['shortProductNumber']),
|
||||
footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []),
|
||||
notes: $data['productFullInformation']['description'] ?? null,
|
||||
datasheets: $this->productMediaToDatasheets($data['productMedia'] ?? []),
|
||||
parameters: $this->technicalAttributesToParameters($data['productFullInformation']['technicalAttributes'] ?? []),
|
||||
vendor_infos: [$this->queryPrices($data['shortProductNumber'])]
|
||||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
}
|
||||
336
src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
Normal file
336
src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
<?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\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Exceptions\ProviderIDNotSupportedException;
|
||||
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\Settings\InfoProviderSystem\GenericWebProviderSettings;
|
||||
use PhpOffice\PhpSpreadsheet\Calculation\Financial\Securities\Price;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class GenericWebProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Website';
|
||||
|
||||
private readonly HttpClientInterface $httpClient;
|
||||
|
||||
public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings)
|
||||
{
|
||||
$this->httpClient = $httpClient->withOptions(
|
||||
[
|
||||
'headers' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
|
||||
],
|
||||
'timeout' => 15,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Generic Web URL',
|
||||
'description' => 'Tries to extract a part from a given product webpage URL using common metadata standards like JSON-LD and OpenGraph.',
|
||||
//'url' => 'https://example.com',
|
||||
'disabled_help' => 'Enable in settings to use this provider',
|
||||
'settings_class' => GenericWebProviderSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'generic_web';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->settings->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
try {
|
||||
return [
|
||||
$this->getDetails($keyword)
|
||||
]; } catch (ProviderIDNotSupportedException $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function extractShopName(string $url): string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host === false || $host === null) {
|
||||
return self::DISTRIBUTOR_NAME;
|
||||
}
|
||||
return $host;
|
||||
}
|
||||
|
||||
private function productJsonLdToPart(array $jsonLd, string $url, Crawler $dom): PartDetailDTO
|
||||
{
|
||||
$notes = $jsonLd['description'] ?? "";
|
||||
if (isset($jsonLd['disambiguatingDescription'])) {
|
||||
if (!empty($notes)) {
|
||||
$notes .= "\n\n";
|
||||
}
|
||||
$notes .= $jsonLd['disambiguatingDescription'];
|
||||
}
|
||||
|
||||
$vendor_infos = null;
|
||||
if (isset($jsonLd['offers'])) {
|
||||
|
||||
if (array_is_list($jsonLd['offers'])) {
|
||||
$offer = $jsonLd['offers'][0];
|
||||
} else {
|
||||
$offer = $jsonLd['offers'];
|
||||
}
|
||||
|
||||
//Make $jsonLd['url'] absolute if it's relative
|
||||
if (isset($jsonLd['url']) && parse_url($jsonLd['url'], PHP_URL_SCHEME) === null) {
|
||||
$parsedUrl = parse_url($url);
|
||||
$scheme = $parsedUrl['scheme'] ?? 'https';
|
||||
$host = $parsedUrl['host'] ?? '';
|
||||
$jsonLd['url'] = $scheme.'://'.$host.$jsonLd['url'];
|
||||
}
|
||||
|
||||
$prices = [];
|
||||
if (isset($offer['price'])) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: (string) $offer['price'],
|
||||
currency_iso_code: $offer['priceCurrency'] ?? null
|
||||
);
|
||||
} else if (isset($offer['offers']) && array_is_list($offer['offers'])) {
|
||||
//Some sites nest offers
|
||||
foreach ($offer['offers'] as $subOffer) {
|
||||
if (isset($subOffer['price'])) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: (string) $subOffer['price'],
|
||||
currency_iso_code: $subOffer['priceCurrency'] ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$vendor_infos = [new PurchaseInfoDTO(
|
||||
distributor_name: $this->extractShopName($url),
|
||||
order_number: (string) ($jsonLd['sku'] ?? $jsonLd['@id'] ?? $jsonLd['gtin'] ?? 'Unknown'),
|
||||
prices: $prices,
|
||||
product_url: $jsonLd['url'] ?? $url,
|
||||
)];
|
||||
}
|
||||
|
||||
$image = null;
|
||||
if (isset($jsonLd['image'])) {
|
||||
if (is_array($jsonLd['image'])) {
|
||||
if (array_is_list($jsonLd['image'])) {
|
||||
$image = $jsonLd['image'][0] ?? null;
|
||||
}
|
||||
} elseif (is_string($jsonLd['image'])) {
|
||||
$image = $jsonLd['image'];
|
||||
}
|
||||
}
|
||||
//If image is an object with @type ImageObject, extract the url
|
||||
if (is_array($image) && isset($image['@type']) && $image['@type'] === 'ImageObject') {
|
||||
$image = $image['contentUrl'] ?? $image['url'] ?? null;
|
||||
}
|
||||
|
||||
//Try to extract parameters from additionalProperty
|
||||
$parameters = [];
|
||||
if (isset($jsonLd['additionalProperty']) && array_is_list($jsonLd['additionalProperty'])) {
|
||||
foreach ($jsonLd['additionalProperty'] as $property) { //TODO: Handle minValue and maxValue
|
||||
if (isset ($property['unitText'])) {
|
||||
$parameters[] = ParameterDTO::parseValueField(
|
||||
name: $property['name'] ?? 'Unknown',
|
||||
value: $property['value'] ?? '',
|
||||
unit: $property['unitText']
|
||||
);
|
||||
} else {
|
||||
$parameters[] = ParameterDTO::parseValueIncludingUnit(
|
||||
name: $property['name'] ?? 'Unknown',
|
||||
value: $property['value'] ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $url,
|
||||
name: $jsonLd ['name'] ?? 'Unknown Name',
|
||||
description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '',
|
||||
category: isset($jsonLd['category']) && is_string($jsonLd['category']) ? $jsonLd['category'] : null,
|
||||
manufacturer: $jsonLd['manufacturer']['name'] ?? $jsonLd['brand']['name'] ?? null,
|
||||
mpn: $jsonLd['mpn'] ?? null,
|
||||
preview_image_url: $image,
|
||||
provider_url: $url,
|
||||
notes: $notes,
|
||||
parameters: $parameters,
|
||||
vendor_infos: $vendor_infos,
|
||||
mass: isset($jsonLd['weight']['value']) ? (float)$jsonLd['weight']['value'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes JSON in a forgiving way, trying to fix common issues.
|
||||
* @param string $json
|
||||
* @return array
|
||||
* @throws \JsonException
|
||||
*/
|
||||
private function json_decode_forgiving(string $json): array
|
||||
{
|
||||
//Sanitize common issues
|
||||
$json = preg_replace("/[\r\n]+/", " ", $json);
|
||||
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function getMetaContent(Crawler $dom, string $name): ?string
|
||||
{
|
||||
$meta = $dom->filter('meta[property="'.$name.'"]');
|
||||
if ($meta->count() > 0) {
|
||||
return $meta->attr('content');
|
||||
}
|
||||
|
||||
//Try name attribute
|
||||
$meta = $dom->filter('meta[name="'.$name.'"]');
|
||||
if ($meta->count() > 0) {
|
||||
return $meta->attr('content');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
//Add scheme if missing
|
||||
if (!preg_match('/^https?:\/\//', $id)) {
|
||||
//Remove any leading slashes
|
||||
$id = ltrim($id, '/');
|
||||
|
||||
$id = 'https://'.$id;
|
||||
}
|
||||
|
||||
$url = $id;
|
||||
|
||||
//If this is not a valid URL with host, domain and path, throw an exception
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) === false ||
|
||||
parse_url($url, PHP_URL_HOST) === null ||
|
||||
parse_url($url, PHP_URL_PATH) === null) {
|
||||
throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$id);
|
||||
}
|
||||
|
||||
//Try to get the webpage content
|
||||
$response = $this->httpClient->request('GET', $url);
|
||||
$content = $response->getContent();
|
||||
|
||||
$dom = new Crawler($content);
|
||||
|
||||
//Try to determine a canonical URL
|
||||
$canonicalURL = $url;
|
||||
if ($dom->filter('link[rel="canonical"]')->count() > 0) {
|
||||
$canonicalURL = $dom->filter('link[rel="canonical"]')->attr('href');
|
||||
} else if ($dom->filter('meta[property="og:url"]')->count() > 0) {
|
||||
$canonicalURL = $dom->filter('meta[property="og:url"]')->attr('content');
|
||||
}
|
||||
|
||||
//If the canonical URL is relative, make it absolute
|
||||
if (parse_url($canonicalURL, PHP_URL_SCHEME) === null) {
|
||||
$parsedUrl = parse_url($url);
|
||||
$scheme = $parsedUrl['scheme'] ?? 'https';
|
||||
$host = $parsedUrl['host'] ?? '';
|
||||
$canonicalURL = $scheme.'://'.$host.$canonicalURL;
|
||||
}
|
||||
|
||||
//Try to find json-ld data in the head
|
||||
$jsonLdNodes = $dom->filter('script[type="application/ld+json"]');
|
||||
foreach ($jsonLdNodes as $node) {
|
||||
$jsonLd = $this->json_decode_forgiving($node->textContent);
|
||||
//If the content of json-ld is an array, try to find a product inside
|
||||
if (!array_is_list($jsonLd)) {
|
||||
$jsonLd = [$jsonLd];
|
||||
}
|
||||
foreach ($jsonLd as $item) {
|
||||
if (isset($item['@type']) && $item['@type'] === 'Product') {
|
||||
return $this->productJsonLdToPart($item, $canonicalURL, $dom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//If no JSON-LD data is found, try to extract basic data from meta tags
|
||||
$pageTitle = $dom->filter('title')->count() > 0 ? $dom->filter('title')->text() : 'Unknown';
|
||||
|
||||
$prices = [];
|
||||
if ($price = $this->getMetaContent($dom, 'product:price:amount')) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $price,
|
||||
currency_iso_code: $this->getMetaContent($dom, 'product:price:currency'),
|
||||
);
|
||||
} else {
|
||||
//Amazon fallback
|
||||
$amazonAmount = $dom->filter('input[type="hidden"][name*="amount"]');
|
||||
if ($amazonAmount->count() > 0) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $amazonAmount->first()->attr('value'),
|
||||
currency_iso_code: $dom->filter('input[type="hidden"][name*="currencyCode"]')->first()->attr('value'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$vendor_infos = [new PurchaseInfoDTO(
|
||||
distributor_name: $this->extractShopName($canonicalURL),
|
||||
order_number: 'Unknown',
|
||||
prices: $prices,
|
||||
product_url: $canonicalURL,
|
||||
)];
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $canonicalURL,
|
||||
name: $this->getMetaContent($dom, 'og:title') ?? $pageTitle,
|
||||
description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '',
|
||||
manufacturer: $this->getMetaContent($dom, 'product:brand'),
|
||||
preview_image_url: $this->getMetaContent($dom, 'og:image'),
|
||||
provider_url: $canonicalURL,
|
||||
vendor_infos: $vendor_infos,
|
||||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ final class BarcodeRedirector
|
|||
throw new EntityNotFoundException();
|
||||
}
|
||||
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID()]);
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID(), 'highlightLot' => $lot->getID()]);
|
||||
|
||||
case LabelSupportedElement::STORELOCATION:
|
||||
return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ use App\Entity\UserSystem\User;
|
|||
use App\Helpers\Trees\TreeViewNode;
|
||||
use App\Services\Cache\UserCacheKeyGenerator;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\InfoProviderSystem\Providers\GenericWebProvider;
|
||||
use App\Settings\InfoProviderSystem\GenericWebProviderSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
|
@ -58,6 +60,7 @@ class ToolsTreeBuilder
|
|||
protected UserCacheKeyGenerator $keyGenerator,
|
||||
protected Security $security,
|
||||
private readonly ElementTypeNameGenerator $elementTypeNameGenerator,
|
||||
private readonly GenericWebProviderSettings $genericWebProviderSettings
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +150,13 @@ class ToolsTreeBuilder
|
|||
$this->urlGenerator->generate('info_providers_search')
|
||||
))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down');
|
||||
|
||||
if ($this->genericWebProviderSettings->enabled) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('info_providers.from_url.title'),
|
||||
$this->urlGenerator->generate('info_providers_from_url')
|
||||
))->setIcon('fa-treeview fa-fw fa-solid fa-book-atlas');
|
||||
}
|
||||
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('info_providers.bulk_import.manage_jobs'),
|
||||
$this->urlGenerator->generate('bulk_info_provider_manage')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue