Part-DB-server/src/Services/InfoProviderSystem/Providers/LCSCProvider.php

475 lines
16 KiB
PHP
Raw Normal View History

<?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) 2024 Nexrem (https://github.com/meganukebmp)
*
* 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\Settings\InfoProviderSystem\LCSCSettings;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface
{
2024-05-12 18:21:23 +02:00
private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
public const DISTRIBUTOR_NAME = 'LCSC';
public function __construct(private readonly HttpClientInterface $lcscClient, private readonly LCSCSettings $settings)
{
}
public function getProviderInfo(): array
{
return [
'name' => 'LCSC',
'description' => 'This provider uses the (unofficial) LCSC API to search for parts.',
'url' => 'https://www.lcsc.com/',
'disabled_help' => 'Enable this provider in the provider settings.',
'settings_class' => LCSCSettings::class,
];
}
public function getProviderKey(): string
{
return 'lcsc';
}
// This provider is always active
public function isActive(): bool
{
return $this->settings->enabled;
}
/**
* @param string $id
* @param bool $lightweight If true, skip expensive operations like datasheet resolution
* @return PartDetailDTO
*/
private function queryDetail(string $id, bool $lightweight = false): PartDetailDTO
{
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
'query' => [
'productCode' => $id,
],
]);
$arr = $response->toArray();
$product = $arr['result'] ?? null;
if ($product === null) {
throw new \RuntimeException('Could not find product code: ' . $id);
}
return $this->getPartDetail($product, $lightweight);
}
/**
* @param string $url
* @return String
*/
private function getRealDatasheetUrl(?string $url): string
{
2024-06-22 00:31:43 +02:00
if ($url !== null && trim($url) !== '' && preg_match("/^https:\/\/(datasheet\.lcsc\.com|www\.lcsc\.com\/datasheet)\/.*(C\d+)\.pdf$/", $url, $matches) > 0) {
if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) {
$url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1];
}
$response = $this->lcscClient->request('GET', $url, [
'headers' => [
'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html'
],
]);
if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.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
* @param bool $lightweight If true, skip expensive operations like datasheet resolution
* @return PartDetailDTO[]
*/
private function queryByTerm(string $term, bool $lightweight = false): array
{
// Optimize: If term looks like an LCSC part number (starts with C followed by digits),
// use direct detail query instead of slower search
if (preg_match('/^C\d+$/i', trim($term))) {
try {
return [$this->queryDetail(trim($term), $lightweight)];
} catch (\Exception $e) {
// If direct lookup fails, fall back to search
// This handles cases where the C-code might not exist
}
}
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
'json' => [
'keyword' => $term,
],
]);
$arr = $response->toArray();
// Get products list
$products = $arr['result']['productSearchResultVO']['productList'] ?? [];
// Get product tip
$tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null;
$result = [];
// LCSC does not display LCSC 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
2024-06-22 00:31:43 +02:00
if (count($products) === 0 && $tipProductCode !== null) {
$result[] = $this->queryDetail($tipProductCode, $lightweight);
}
foreach ($products as $product) {
$result[] = $this->getPartDetail($product, $lightweight);
}
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;
}
// Replace "range" indicators with mathematical tilde symbols
// so they don't get rendered as strikethrough by Markdown
$field = preg_replace("/~/", "\u{223c}", $field);
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, bool $lightweight = false): PartDetailDTO
{
// Get product images in advance
$product_images = $this->getProductImages($product['productImages'] ?? null);
2024-06-22 00:31:43 +02:00
$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) {
2024-06-22 00:31:43 +02:00
$product['productImageUrl'] ??= $product_images[0]->url;
}
// LCSC 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;
}
2024-02-24 23:32:50 +01:00
//Build category by concatenating the catalogName and parentCatalogName
2024-03-03 19:57:31 +01:00
$category = $product['parentCatalogName'] ?? null;
2024-02-24 23:32:50 +01:00
if (isset($product['catalogName'])) {
$category = ($category ?? '') . ' -> ' . $product['catalogName'];
}
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: $lightweight ? [] : $this->getProductDatasheets($product['pdfUrl'] ?? null),
images: $product_images, // Always include images - users need to see them
parameters: $lightweight ? [] : $this->attributesToParameters($product['paramVOList'] ?? []),
vendor_infos: $lightweight ? [] : $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 LCSC 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->settings->currency,
};
}
/**
* Returns a valid LCSC product short URL from product code
* @param string $product_code
* @return string
*/
private function getProductShortURL(string $product_code): string
{
return 'https://www.lcsc.com/product-detail/' . $product_code . '.html';
}
/**
* 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
2024-06-22 00:31:43 +02:00
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 $options = []): array
{
return $this->queryByTerm($keyword, true); // Use lightweight mode for search
}
/**
* Batch search multiple keywords asynchronously (like JavaScript Promise.all)
* @param array $keywords
* @param array $options
* @return array Results indexed by keyword
*/
public function searchByKeywordsBatch(array $keywords, array $options = []): array
{
if (empty($keywords)) {
return [];
}
$responses = [];
$results = [];
// Start all requests immediately (like JavaScript promises without await)
foreach ($keywords as $keyword) {
if (preg_match('/^C\d+$/i', trim($keyword))) {
// Direct detail API call for C-codes
$responses[$keyword] = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
'query' => [
'productCode' => trim($keyword),
],
]);
} else {
// Search API call for other terms
$responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
'json' => [
'keyword' => $keyword,
],
]);
}
}
// Now collect all results (like .then() in JavaScript)
foreach ($responses as $keyword => $response) {
try {
Add Quick Apply and batch update to bulk info provider import (#1316) * Add Quick Apply and Apply All buttons to bulk info provider import Adds the ability to apply provider search results to parts directly from the bulk import step 2 page without navigating to individual part edit forms. Includes per-result Quick Apply buttons and an Apply All button for batch operations. * Add navigation buttons and completion banner to bulk import step2 Adds Back to Jobs / Back to Parts buttons at the top of the page and a success banner when the job is completed, so users aren't stuck on the page after applying all parts. * Highlight top search result and remove skip reason prompt - Highlight the recommended/top priority result row with table-success class - Add "Top" badge to the recommended Quick Apply button - Use outline style for non-top Quick Apply buttons to differentiate - Remove the annoying "reason for skipping" prompt popup * Fix 500 error when field mapping has null field or no search results - Skip field mappings with null/empty field values in convertFieldMappingsToDto - Return empty DTO instead of throwing when no search results found - Remove unnecessary try/catch workaround in researchPart * Fix PHPStan error: remove redundant null check on BulkSearchResponseDTO * Improve bulk import UI: split active/history jobs, fix text visibility, add match highlighting - Split manage page into Active Jobs and History sections - Fix source keyword text color (remove text-muted for better visibility) - Add exact match indicators: green check badge when name or MPN matches - Add translation keys for new UI elements * Fix spinning icon, text visibility, auto-priority, and SPN match highlighting - Replace spinning icon with static icon on Active Jobs header - Match highlighting now checks source keyword against name, MPN, AND provider ID (SPN) - Show green "Match" badge in source field column when any field matches 100% - Auto-increment priority when adding new field mapping rows - Fix text-muted visibility issues on table-success background * Fix broken images and improve match highlighting consistency - Hide broken external provider images with onerror fallback - Make source keyword text green when any match is detected - All matched fields (name, MPN, SPN, or any source keyword) show green text * Fix TypeError in LCSCProvider when keyword is numeric string PHP auto-casts numeric string array keys to int. When a search keyword is a pure number (e.g., a part number like "12345"), the foreach loop passes an int to processSearchResponse() which expects string. Cast keyword to string explicitly. * Clean up stale pending jobs and add job ID to display - Auto-delete pending jobs with 0 results (from failed searches/500 errors) - Show job ID (#N) in manage page and step2 to distinguish identical jobs - Move timestamp to subtitle line on manage page for cleaner layout * Fix tests to match updated bulk search behavior (no more RuntimeException) The bulk search service now returns empty response DTOs instead of throwing RuntimeException when no results are found. Updated tests to use assertFalse(hasAnyResults()) instead of catching exceptions. * Add comprehensive test coverage for bulk import controller Covers Quick Apply, Apply All, delete, stop, mark completed/skipped/pending, manage page active/history split, stale job cleanup, research endpoints, and various error paths. Increases patch coverage significantly. * Fix duplicate test method names in bulk import tests * Fix last duplicate test method name (testQuickApplyWithNoSearchResults) * Fixed translation key in translation messages * Moved table rendering logic into macro * fixed visual glitch with button success outline * Use native httpfoundation method to convert json to an array * Show a more user friendly error message, when * Allow to automatically create new manufacturers within quick apply --------- Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-05-04 21:56:18 +02:00
$keyword = (string) $keyword;
$arr = $response->toArray(); // This waits for the response
$results[$keyword] = $this->processSearchResponse($arr, $keyword);
} catch (\Exception $e) {
$results[$keyword] = []; // Empty results on error
}
}
return $results;
}
private function processSearchResponse(array $arr, string $keyword): array
{
$result = [];
// Check if this looks like a detail response (direct C-code lookup)
if (isset($arr['result']['productCode'])) {
$product = $arr['result'];
$result[] = $this->getPartDetail($product, true); // lightweight mode
} else {
// This is a search response
$products = $arr['result']['productSearchResultVO']['productList'] ?? [];
$tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null;
// If no products but has tip, we'd need another API call - skip for batch mode
foreach ($products as $product) {
$result[] = $this->getPartDetail($product, true); // lightweight mode
}
}
return $result;
}
public function getDetails(string $id, array $options = []): PartDetailDTO
{
$tmp = $this->queryByTerm($id, false);
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,
];
}
public function getHandledDomains(): array
{
return ['lcsc.com'];
}
public function getIDFromURL(string $url): ?string
{
//Input example: https://www.lcsc.com/product-detail/C258144.html?s_z=n_BC547
//The part between the "C" and the ".html" is the unique ID
$matches = [];
if (preg_match("#/product-detail/(\w+)\.html#", $url, $matches) > 0) {
return $matches[1];
}
return null;
}
}