2025-02-21 09:32:54 +01:00
< ? php
/*
* This file is part of Part - DB ( https :// github . com / Part - DB / Part - DB - symfony ) .
*
* Copyright ( C ) 2019 - 2023 Jan Böhmer ( https :// github . com / jbtronics )
* Copyright ( C ) 2025 Marc Kreidler ( https :// github . com / mkne )
*
* 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 Symfony\Component\HttpFoundation\Cookie ;
2025-02-24 09:38:42 +01:00
use Symfony\Component\HttpClient\HttpOptions ;
2025-02-21 09:32:54 +01:00
use Symfony\Contracts\HttpClient\HttpClientInterface ;
class BuerklinProvider implements InfoProviderInterface
{
private const ENDPOINT_URL = 'https://buerklin.com/buerklinws/v2/buerklin/' ;
public const DISTRIBUTOR_NAME = 'Buerklin' ;
2025-02-24 09:38:42 +01:00
private const OAUTH_APP_NAME = 'ip_buerklin_oauth' ;
2025-02-21 09:32:54 +01:00
2025-02-24 09:38:42 +01:00
public function __construct ( private readonly HttpClientInterface $httpClient ,
private readonly OAuthTokenManager $authTokenManager , private readonly CacheItemPoolInterface $partInfoCache ,
private readonly string $clientId , private readonly string $secret ,
private readonly int $username , private readonly bool $password ,
private readonly string $currency , private readonly string $language )
2025-02-21 09:32:54 +01:00
{
}
2025-02-24 09:38:42 +01:00
/**
* Gets the latest OAuth token for the Buerklin API , or creates a new one if none is available
* @ return string
*/
private function getToken () : string
{
//Check if we already have a token saved for this app, otherwise we have to retrieve one via OAuth
if ( ! $this -> authTokenManager -> hasToken ( self :: OAUTH_APP_NAME )) {
$this -> authTokenManager -> retrieveClientCredentialsToken ( self :: OAUTH_APP_NAME );
}
$tmp = $this -> authTokenManager -> getAlwaysValidTokenString ( self :: OAUTH_APP_NAME );
if ( $tmp === null ) {
throw new \RuntimeException ( 'Could not retrieve OAuth token for Buerklin' );
}
return $tmp ;
}
2025-02-21 09:32:54 +01:00
public function getProviderInfo () : array
{
return [
'name' => 'Buerklin' ,
'description' => 'This provider uses the Buerklin API to search for parts.' ,
'url' => 'https://www.buerklin.com/' ,
2025-02-24 09:38:42 +01:00
'disabled_help' => 'Set the environment variables PROVIDER_BUERKLIN_CLIENT_ID, PROVIDER_BUERKLIN_SECRET, PROVIDER_BUERKLIN_USERNAME and PROVIDER_BUERKLIN_PASSWORD.'
2025-02-21 09:32:54 +01:00
];
}
public function getProviderKey () : string
{
return 'buerklin' ;
}
// This provider is always active
public function isActive () : bool
{
2025-02-24 09:38:42 +01:00
//The client ID has to be set and a token has to be available (user clicked connect)
return $this -> clientId !== '' && $this -> secret !== '' && $this -> username !== '' && $this -> password !== '' ;
2025-02-21 09:32:54 +01:00
}
/**
* @ param string $id
* @ return PartDetailDTO
*/
private function queryDetail ( string $id ) : PartDetailDTO
{
$response = $this -> buerklinClient -> request ( 'GET' , self :: ENDPOINT_URL . " /products " , [
'headers' => [
'Cookie' => new Cookie ( 'currencyCode' , $this -> currency )
],
'query' => [
'sku' => $id ,
],
]);
$arr = $response -> toArray ();
$product = $arr [ 'result' ] ? ? null ;
if ( $product === null ) {
throw new \RuntimeException ( 'Could not find product code: ' . $id );
}
return $this -> getPartDetail ( $product );
}
/**
* @ param string $url
* @ return String
*/
private function getRealDatasheetUrl ( ? string $url ) : string
{
if ( $url !== null && trim ( $url ) !== '' && preg_match ( " /^https: \ / \ /(datasheet \ .buerklin \ .com|www \ .buerklin \ .com \ /datasheet) \ /.*(C \ d+) \ .pdf $ / " , $url , $matches ) > 0 ) {
if ( preg_match ( " /^https: \ / \ /datasheet \ .buerklin \ .com \ /buerklin \ /(.* \ .pdf) $ / " , $url , $rewriteMatches ) > 0 ) {
$url = 'https://www.buerklin.com/datasheet/buerklin_datasheet_' . $rewriteMatches [ 1 ];
}
$response = $this -> buerklinClient -> request ( 'GET' , $url , [
'headers' => [
2025-02-23 00:39:45 +01:00
'Referer' => 'https://www.buerklin.com/de/p/' . $matches [ 2 ] . '/'
2025-02-21 09:32:54 +01:00
],
]);
if ( preg_match ( '/(previewPdfUrl): ?("[^"]+wmsc\.buerklin\.com[^"]+\.pdf")/' , $response -> getContent (), $matches ) > 0 ) {
//HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused
//See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934
$jsonObj = json_decode ( '{"' . $matches [ 1 ] . '": ' . $matches [ 2 ] . '}' );
$url = $jsonObj -> previewPdfUrl ;
}
}
return $url ;
}
/**
* @ param string $term
* @ return PartDetailDTO []
*/
private function queryByTerm ( string $term ) : array
{
2025-02-23 00:39:45 +01:00
$response = $this -> buerklinClient -> request ( 'GET' , self :: ENDPOINT_URL . " products/search/?curr= $this->currency &language=en&pageSize=50¤tPage=0&query=Laser&sort=relevance " , [
2025-02-21 09:32:54 +01:00
'headers' => [
'Cookie' => new Cookie ( 'currencyCode' , $this -> currency )
],
'query' => [
'keyword' => $term ,
],
]);
$arr = $response -> toArray ();
// Get products list
$products = $arr [ 'result' ][ 'productSearchResultVO' ][ 'productList' ] ? ? [];
// Get product tip
$tipProductCode = $arr [ 'result' ][ 'tipProductDetailUrlVO' ][ 'productCode' ] ? ? null ;
$result = [];
// Buerklin does not display Buerklin codes in the search, instead taking you directly to the
// detailed product listing. It does so utilizing a product tip field.
// If product tip exists and there are no products in the product list try a detail query
if ( count ( $products ) === 0 && $tipProductCode !== null ) {
$result [] = $this -> queryDetail ( $tipProductCode );
}
foreach ( $products as $product ) {
$result [] = $this -> getPartDetail ( $product );
}
return $result ;
}
/**
* Sanitizes a field by removing any HTML tags and other unwanted characters
* @ param string | null $field
* @ return string | null
*/
private function sanitizeField ( ? string $field ) : ? string
{
if ( $field === null ) {
return null ;
}
return strip_tags ( $field );
}
/**
* Takes a deserialized json object of the product and returns a PartDetailDTO
* @ param array $product
* @ return PartDetailDTO
*/
private function getPartDetail ( array $product ) : PartDetailDTO
{
// Get product images in advance
$product_images = $this -> getProductImages ( $product [ 'productImages' ] ? ? null );
$product [ 'productImageUrl' ] ? ? = null ;
// If the product does not have a product image but otherwise has attached images, use the first one.
if ( count ( $product_images ) > 0 ) {
$product [ 'productImageUrl' ] ? ? = $product_images [ 0 ] -> url ;
}
// Buerklin puts HTML in footprints and descriptions sometimes randomly
$footprint = $product [ " encapStandard " ] ? ? null ;
//If the footprint just consists of a dash, we'll assume it's empty
if ( $footprint === '-' ) {
$footprint = null ;
}
//Build category by concatenating the catalogName and parentCatalogName
$category = $product [ 'parentCatalogName' ] ? ? null ;
if ( isset ( $product [ 'catalogName' ])) {
$category = ( $category ? ? '' ) . ' -> ' . $product [ 'catalogName' ];
// Replace the / with a -> for better readability
$category = str_replace ( '/' , ' -> ' , $category );
}
return new PartDetailDTO (
provider_key : $this -> getProviderKey (),
provider_id : $product [ 'productCode' ],
name : $product [ 'productModel' ],
description : $this -> sanitizeField ( $product [ 'productIntroEn' ]),
category : $this -> sanitizeField ( $category ? ? null ),
manufacturer : $this -> sanitizeField ( $product [ 'brandNameEn' ] ? ? null ),
mpn : $this -> sanitizeField ( $product [ 'productModel' ] ? ? null ),
preview_image_url : $product [ 'productImageUrl' ],
manufacturing_status : null ,
provider_url : $this -> getProductShortURL ( $product [ 'productCode' ]),
footprint : $this -> sanitizeField ( $footprint ),
datasheets : $this -> getProductDatasheets ( $product [ 'pdfUrl' ] ? ? null ),
images : $product_images ,
parameters : $this -> attributesToParameters ( $product [ 'paramVOList' ] ? ? []),
vendor_infos : $this -> pricesToVendorInfo ( $product [ 'productCode' ], $this -> getProductShortURL ( $product [ 'productCode' ]), $product [ 'productPriceList' ] ? ? []),
mass : $product [ 'weight' ] ? ? null ,
);
}
/**
* Converts the price array to a VendorInfoDTO array to be used in the PartDetailDTO
* @ param string $sku
* @ param string $url
* @ param array $prices
* @ return array
*/
private function pricesToVendorInfo ( string $sku , string $url , array $prices ) : array
{
$price_dtos = [];
foreach ( $prices as $price ) {
$price_dtos [] = new PriceDTO (
minimum_discount_amount : $price [ 'ladder' ],
price : $price [ 'productPrice' ],
currency_iso_code : $this -> getUsedCurrency ( $price [ 'currencySymbol' ]),
includes_tax : false ,
);
}
return [
new PurchaseInfoDTO (
distributor_name : self :: DISTRIBUTOR_NAME ,
order_number : $sku ,
prices : $price_dtos ,
product_url : $url ,
)
];
}
/**
* Converts Buerklin currency symbol to an ISO code .
* @ param string $currency
* @ return string
*/
private function getUsedCurrency ( string $currency ) : string
{
//Decide based on the currency symbol
return match ( $currency ) {
'US$' , '$' => 'USD' ,
'€' => 'EUR' ,
'A$' => 'AUD' ,
'C$' => 'CAD' ,
'£' => 'GBP' ,
'HK$' => 'HKD' ,
'JP¥' => 'JPY' ,
'RM' => 'MYR' ,
'S$' => 'SGD' ,
'₽' => 'RUB' ,
'kr' => 'SEK' ,
'kr.' => 'DKK' ,
'₹' => 'INR' ,
//Fallback to the configured currency
default => $this -> currency ,
};
}
/**
* Returns a valid Buerklin product short URL from product code
* @ param string $product_code
* @ return string
*/
private function getProductShortURL ( string $product_code ) : string
{
2025-02-23 00:39:45 +01:00
return 'https://www.buerklin.com/de/p/' . $product_code . '/' ;
2025-02-21 09:32:54 +01:00
}
/**
* Returns a product datasheet FileDTO array from a single pdf url
* @ param string $url
* @ return FileDTO []
*/
private function getProductDatasheets ( ? string $url ) : array
{
if ( $url === null ) {
return [];
}
$realUrl = $this -> getRealDatasheetUrl ( $url );
return [ new FileDTO ( $realUrl , null )];
}
/**
* Returns a FileDTO array with a list of product images
* @ param array | null $images
* @ return FileDTO []
*/
private function getProductImages ( ? array $images ) : array
{
return array_map ( static fn ( $image ) => new FileDTO ( $image ), $images ? ? []);
}
/**
* @ param array | null $attributes
* @ return ParameterDTO []
*/
private function attributesToParameters ( ? array $attributes ) : array
{
$result = [];
foreach ( $attributes as $attribute ) {
//Skip this attribute if it's empty
if ( in_array ( trim (( string ) $attribute [ 'paramValueEn' ]), [ '' , '-' ], true )) {
continue ;
}
$result [] = ParameterDTO :: parseValueIncludingUnit ( name : $attribute [ 'paramNameEn' ], value : $attribute [ 'paramValueEn' ], group : null );
}
return $result ;
}
public function searchByKeyword ( string $keyword ) : array
{
return $this -> queryByTerm ( $keyword );
}
public function getDetails ( string $id ) : PartDetailDTO
{
$tmp = $this -> queryByTerm ( $id );
if ( count ( $tmp ) === 0 ) {
throw new \RuntimeException ( 'No part found with ID ' . $id );
}
if ( count ( $tmp ) > 1 ) {
throw new \RuntimeException ( 'Multiple parts found with ID ' . $id );
}
return $tmp [ 0 ];
}
public function getCapabilities () : array
{
return [
ProviderCapabilities :: BASIC ,
ProviderCapabilities :: PICTURE ,
ProviderCapabilities :: DATASHEET ,
ProviderCapabilities :: PRICE ,
ProviderCapabilities :: FOOTPRINT ,
];
}
}