Part-DB-server/src/Services/InfoProviderSystem/Providers/PollinProvider.php
Marc be35c36c58
Added info provider for Buerklin (#1151)
* Fixed Typos and mistranslations in GDPR mode (DSGVO Modus)
Fixed Typo enviroment

* Create BuerklinProvider based on LCSCProvider

* Update GET URLs for Buerklin

* Add getToken function analog to Octopart

* Remove line break in docs

* Remove trailing / in ENDPOINT_URL
Use Autowire to use values of environment variables
Remove unwanted Code from LCSC-Provider
Map json response to DTO variables

* Fix variable reference errors ($term → $keyword)
Ensure array keys exist before accessing them
Optimize API calls to prevent unnecessary requests
Improve error handling for better debugging
Enhance readability and maintainability of functions

* Bumped version v1.16.2

* Update BuerklinProvider.php

Change Order of Capabilities

* Change order of capabilities in LCSCProvider.php

* Change order of capabilities in PollinProvider.php

* Try to fix getToken BuerklinProvider.php

* Add ip_buerklin_oauth to knpu_oauth2_client.yaml

* Update buerklin authorize URL in knpu_oauth2_client.yaml

* Update knpu_oauth2_client.yaml

* Adapt Buerklin InfoProvider to new Settings mechanism

* According to Buerklin API spec it's really 'token' as urlAuthorize endpoint

* Rückgabewert ist schon ein Array deshalb kein toArray

* Fix API-Access, add image, price and parameter retrieval (Datasheets not yet implemented because it is not available in the API response)

* Add Caching of requests, use default query params (language and currency) using a function, Fix Footprint assignment, translate German code comments

* Remove DATASHEET from ProviderCapabilities because the Bürklin API doesn't include Datasheet URLs at the moment, more reverse engineering needed

* Update BuerklinSettings with existing translatable strings

* Improve Buerklin Settings Page

* Added Translation strings for Buerklin Info Provider

* Improve Buerklin Provider help message

* Adapt Buerklin-provider to new settings system

* Adapt Buerklin-provider to new settings system: add missing instance of BuerklinSettings

* Improve Compliance Parameters parsing

* Remove language-dependent RoHs Date code and use shortened ISO format, Add Customs Code without parseValueField

* Fix no results for keyword search

* Implement BatchInfoProviderInterface for Buerklin Provider

* Rename searchBatch to searchByKeywordsBatch to correctly implement BatchInfoProviderInterface

* Fix Bulk Info Provider Import for Buerklin

* Tranlate comments to English to prepare for Pull-Request

* Add phpUnit unit tests for BuerklinProvider

* Try fixing PHPStan issues

* Remove OAuthTokenManager from BuerklinProviderTest

Removed OAuthTokenManager mock from BuerklinProviderTest setup.

* Fix Settings must not be instantiated directly

* Fix UnitTest for value_typ

* edd5fb3319 (r2622249199)
Revert "Change order of capabilities in LCSCProvider.php"

This reverts commit dfd6f33e52.

* edd5fb3319 (r2622249861)
Revert "Change order of capabilities in PollinProvider.php"

This reverts commit fc2e7265be.

* Use language setting for ProductShortURL

* Update default language for Buerklin provider to English in documentation

* Add suggested improvements from SonarQube

* Removed unused use directives

* Revert SonarQube proposed change. Having more than one return is acceptable nowadays

* Improve getProviderInfo: disable oauth_app_name, add settings_class, improve disabled_help

* Implement retrieveROPCToken as proposed in https://github.com/Part-DB/Part-DB-server/pull/1151#discussion_r2622976206

* Add missing ) to retrieveROPCToken

* add use OAuthTokenManager and create instance in constructor

* Revert the following commits that tried to implement getToken using OAuthTokenManager

Revert "add use OAuthTokenManager and create instance in constructor"This reverts commit 2a1e7c9b0974ebd7e082d5a2fa62753d6254a767.Revert "Add missing ) to retrieveROPCToken"This reverts commit 8df5cfc49e.
Revert "Implement retrieveROPCToken as proposed in https://github.com/Part-DB/Part-DB-server/pull/1151#discussion_r2622976206"
This reverts commit 66cc732082.

* Remove OAuthTokenManager leftovers

* Disable buerklin provider if settings fields are empty

* Improved docs

* Added TODO comment

---------

Co-authored-by: root <root@part-db.fritz.box>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-01-04 21:05:47 +01:00

251 lines
No EOL
9.2 KiB
PHP

<?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\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
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\PollinSettings;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class PollinProvider implements InfoProviderInterface
{
public function __construct(private readonly HttpClientInterface $client,
private readonly PollinSettings $settings,
)
{
}
public function getProviderInfo(): array
{
return [
'name' => 'Pollin',
'description' => 'Webscraping from pollin.de to get part information',
'url' => 'https://www.pollin.de/',
'disabled_help' => 'Enable the provider in provider settings',
'settings_class' => PollinSettings::class,
];
}
public function getProviderKey(): string
{
return 'pollin';
}
public function isActive(): bool
{
return $this->settings->enabled;
}
public function searchByKeyword(string $keyword): array
{
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
'query' => [
'search' => $keyword
]
]);
$content = $response->getContent();
//If the response has us redirected to the product page, then just return the single item
if ($response->getInfo('redirect_count') > 0) {
return [$this->parseProductPage($content)];
}
$dom = new Crawler($content);
$results = [];
//Iterate over each div.product-box
$dom->filter('div.product-box')->each(function (Crawler $node) use (&$results) {
$results[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $node->filter('meta[itemprop="productID"]')->attr('content'),
name: $node->filter('a.product-name')->text(),
description: '',
preview_image_url: $node->filter('img.product-image')->attr('src'),
manufacturing_status: $this->mapAvailability($node->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $node->filter('a.product-name')->attr('href')
);
});
return $results;
}
private function mapAvailability(string $availabilityURI): ManufacturingStatus
{
return match( $availabilityURI) {
'http://schema.org/InStock' => ManufacturingStatus::ACTIVE,
'http://schema.org/OutOfStock' => ManufacturingStatus::DISCONTINUED,
default => ManufacturingStatus::NOT_SET
};
}
public function getDetails(string $id): PartDetailDTO
{
//Ensure that $id is numeric
if (!is_numeric($id)) {
throw new \InvalidArgumentException("The id must be numeric!");
}
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
'query' => [
'search' => $id
]
]);
//The response must have us redirected to the product page
if ($response->getInfo('redirect_count') > 0) {
throw new \RuntimeException("Could not resolve the product page for the given id!");
}
$content = $response->getContent();
return $this->parseProductPage($content);
}
private function parseProductPage(string $content): PartDetailDTO
{
$dom = new Crawler($content);
$productPageUrl = $dom->filter('meta[property="product:product_link"]')->attr('content');
$orderId = trim($dom->filter('span[itemprop="sku"]')->text()); //Text is important here
//Calculate the mass
$massStr = $dom->filter('meta[itemprop="weight"]')->attr('content');
//Remove the unit
$massStr = str_replace('kg', '', $massStr);
//Convert to float and convert to grams
$mass = (float) $massStr * 1000;
//Parse purchase info
$purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl);
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $orderId,
name: trim($dom->filter('meta[property="og:title"]')->attr('content')),
description: $dom->filter('meta[property="og:description"]')->attr('content'),
category: $this->parseCategory($dom),
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
//TODO: Find another way to determine the manufacturing status, as the itemprop="availability" is often is not existing anymore in the page
//manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $productPageUrl,
notes: $this->parseNotes($dom),
datasheets: $this->parseDatasheets($dom),
parameters: $this->parseParameters($dom),
vendor_infos: [$purchaseInfo],
mass: $mass,
);
}
private function parseDatasheets(Crawler $dom): array
{
//Iterate over each a element withing div.pol-product-detail-download-files
$datasheets = [];
$dom->filter('div.pol-product-detail-download-files a')->each(function (Crawler $node) use (&$datasheets) {
$datasheets[] = new FileDTO($node->attr('href'), $node->text());
});
return $datasheets;
}
private function parseParameters(Crawler $dom): array
{
$parameters = [];
//Iterate over each tr.properties-row inside table.product-detail-properties-table
$dom->filter('table.product-detail-properties-table tr.properties-row')->each(function (Crawler $node) use (&$parameters) {
$parameters[] = ParameterDTO::parseValueIncludingUnit(
name: rtrim($node->filter('th.properties-label')->text(), ':'),
value: trim($node->filter('td.properties-value')->text())
);
});
return $parameters;
}
private function parseCategory(Crawler $dom): string
{
$category = '';
//Iterate over each li.breadcrumb-item inside ol.breadcrumb
$dom->filter('ol.breadcrumb li.breadcrumb-item')->each(function (Crawler $node) use (&$category) {
//Skip if it has breadcrumb-item-home class
if (str_contains($node->attr('class'), 'breadcrumb-item-home')) {
return;
}
$category .= $node->text() . ' -> ';
});
//Remove the last ' -> '
return substr($category, 0, -4);
}
private function parseNotes(Crawler $dom): string
{
//Concat product highlights and product description
return $dom->filter('div.product-detail-top-features')->html('') . '<br><br>' . $dom->filter('div.product-detail-description-text')->html('');
}
private function parsePrices(Crawler $dom): array
{
//TODO: Properly handle multiple prices, for now we just look at the price for one piece
//We assume the currency is always the same
$currency = $dom->filter('meta[property="product:price:currency"]')->attr('content');
//If there is meta[property=highPrice] then use this as the price
if ($dom->filter('meta[itemprop="highPrice"]')->count() > 0) {
$price = $dom->filter('meta[itemprop="highPrice"]')->attr('content');
} else {
$price = $dom->filter('meta[property="product:price:amount"]')->attr('content');
}
return [
new PriceDTO(1.0, $price, $currency)
];
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::PRICE,
ProviderCapabilities::DATASHEET
];
}
}