Store LCSC number per part; use Key Attributes as part name
Deploy to LXC / deploy (push) Successful in 36s
Deploy to LXC / deploy (push) Successful in 36s
Adds a nullable lcsc_number column to parts so each part records its LCSC code for reordering -- surfaced as an "LCSC #" field on the add/edit forms (autofilled by the lookup) and as a click-through link to the LCSC product page on the detail view. The autofilled Name now comes from LCSC's human-readable "Key Attributes" summary (e.g. "RES 10kohm +/-1% 100mW 0603") scraped from the product page's SSR data, falling back to the manufacturer name if that scrape returns nothing. Also normalizes lowercase LCSC codes to uppercase so "c25804" resolves instead of dead-ending on a 404.
This commit is contained in:
@@ -383,6 +383,7 @@ export const parts = pgTable(
|
||||
name: text('name'),
|
||||
category: text('category').notNull(),
|
||||
mpn: text('mpn'),
|
||||
lcscNumber: text('lcsc_number'),
|
||||
value: text('value'),
|
||||
valueNumeric: doublePrecision('value_numeric'),
|
||||
voltage: text('voltage'),
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function lookupDigikeyPart(pn: string): Promise<PartLookupResult |
|
||||
// Best-effort mapping — UNVERIFIED against a live Digikey response; recheck once real credentials exist.
|
||||
return {
|
||||
mpn: product.ManufacturerProductNumber ?? pn,
|
||||
lcscNumber: null,
|
||||
name: product.Manufacturer?.Name ?? null,
|
||||
category: null,
|
||||
value: null,
|
||||
|
||||
@@ -32,9 +32,36 @@ function mapPackage(pkg: string | null | undefined): string | null {
|
||||
return m ? m[1] : pkg;
|
||||
}
|
||||
|
||||
export async function lookupLcscPart(code: string): Promise<PartLookupResult | null> {
|
||||
// Scrapes LCSC's own product page for the human-readable "Key Attributes" summary.
|
||||
// This reads a third-party page's internal Next.js SSR blob (not a documented API), so it may break silently — always returns null on any failure, never throws.
|
||||
async function fetchKeyAttributes(code: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`https://www.lcsc.com/product-detail/${code}.html`, {
|
||||
headers: { 'User-Agent': USER_AGENT, Accept: 'text/html' },
|
||||
signal: AbortSignal.timeout(8000)
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
const html = await res.text();
|
||||
const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
||||
if (!match) return null;
|
||||
|
||||
const parsed = JSON.parse(match[1]);
|
||||
const attrs = parsed?.props?.pageProps?.webData?.productKeyAttributes;
|
||||
return typeof attrs === 'string' && attrs.trim() ? attrs.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupLcscPart(raw: string): Promise<PartLookupResult | null> {
|
||||
// Validate before building the URL — this is the only untrusted input on the request path (prevents SSRF/path injection).
|
||||
if (!/^C\d+$/i.test(code)) return null;
|
||||
if (!/^C\d+$/i.test(raw)) return null;
|
||||
// LCSC/easyeda URLs are case-sensitive; normalize so lowercase input (e.g. "c25804") still resolves.
|
||||
const code = raw.toUpperCase();
|
||||
|
||||
// Fire the secondary Key Attributes scrape concurrently with the primary easyeda fetch.
|
||||
const keyAttrsPromise = fetchKeyAttributes(code);
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://easyeda.com/api/products/${code}/components`, {
|
||||
@@ -58,10 +85,13 @@ export async function lookupLcscPart(code: string): Promise<PartLookupResult | n
|
||||
const powerMatch = description.match(/(\d+(?:\.\d+)?\s?[mkM]?W)\b/);
|
||||
|
||||
const value = typeof cPara.Value === 'string' ? cPara.Value.trim() : '';
|
||||
const manufacturerName = cPara.Manufacturer ? String(cPara.Manufacturer) : null;
|
||||
const keyAttributes = await keyAttrsPromise;
|
||||
|
||||
return {
|
||||
mpn: cPara['Manufacturer Part'] || result.title || code,
|
||||
name: cPara.Manufacturer ? String(cPara.Manufacturer) : null,
|
||||
lcscNumber: code,
|
||||
name: keyAttributes || manufacturerName,
|
||||
category: mapCategory(result.tags),
|
||||
value: value || null,
|
||||
tolerance: toleranceMatch ? toleranceMatch[1].replace(/\s+/g, '') : null,
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface PartLookupResult {
|
||||
name: string | null;
|
||||
category: string | null;
|
||||
mpn: string | null;
|
||||
lcscNumber: string | null;
|
||||
value: string | null;
|
||||
voltage: string | null;
|
||||
tolerance: string | null;
|
||||
|
||||
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
name: parts.name,
|
||||
category: parts.category,
|
||||
mpn: parts.mpn,
|
||||
lcscNumber: parts.lcscNumber,
|
||||
value: parts.value,
|
||||
voltage: parts.voltage,
|
||||
tolerance: parts.tolerance,
|
||||
|
||||
@@ -85,6 +85,15 @@
|
||||
<dd class="font-mono font-medium text-gray-900 dark:text-white">{p.mpn}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if p.lcscNumber}
|
||||
<div class="col-span-2 sm:col-span-3">
|
||||
<dt class="text-gray-500 dark:text-gray-400">LCSC #</dt>
|
||||
<dd class="font-mono font-medium">
|
||||
<a href="https://www.lcsc.com/product-detail/{p.lcscNumber}.html" target="_blank" rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:underline dark:text-blue-400">{p.lcscNumber}</a>
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if p.value}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Value</dt>
|
||||
|
||||
@@ -10,6 +10,7 @@ const partSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
category: z.string().min(1, 'Category is required'),
|
||||
mpn: z.string().optional(),
|
||||
lcscNumber: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
voltage: z.string().optional(),
|
||||
tolerance: z.string().optional(),
|
||||
@@ -56,6 +57,7 @@ export const actions: Actions = {
|
||||
name: data.name || null,
|
||||
category: data.category,
|
||||
mpn: data.mpn || null,
|
||||
lcscNumber: data.lcscNumber || null,
|
||||
value: data.value || null,
|
||||
valueNumeric: parseValueNumeric(data.value),
|
||||
voltage: data.voltage || null,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
let category = $state(String(form?.values?.category ?? data.part.category ?? ''));
|
||||
let name = $state(String(val('name')));
|
||||
let mpn = $state(String(val('mpn')));
|
||||
let lcscNumber = $state(String(val('lcscNumber')));
|
||||
let value = $state(String(val('value')));
|
||||
let voltage = $state(String(val('voltage')));
|
||||
let tolerance = $state(String(val('tolerance')));
|
||||
@@ -22,6 +23,7 @@
|
||||
function onFound(r: PartLookupResult) {
|
||||
if (r.name != null) name = r.name;
|
||||
if (r.mpn != null) mpn = r.mpn;
|
||||
if (r.lcscNumber != null) lcscNumber = r.lcscNumber;
|
||||
if (r.category != null) category = r.category;
|
||||
if (r.value != null) value = r.value;
|
||||
if (r.voltage != null) voltage = r.voltage;
|
||||
@@ -74,6 +76,12 @@
|
||||
<input type="text" id="mpn" name="mpn" bind:value={mpn}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="lcscNumber" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">LCSC #</label>
|
||||
<input type="text" id="lcscNumber" name="lcscNumber" bind:value={lcscNumber}
|
||||
placeholder="e.g. C25804"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
|
||||
@@ -9,6 +9,7 @@ const partSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
category: z.string().min(1, 'Category is required'),
|
||||
mpn: z.string().optional(),
|
||||
lcscNumber: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
voltage: z.string().optional(),
|
||||
tolerance: z.string().optional(),
|
||||
@@ -47,6 +48,7 @@ export const actions: Actions = {
|
||||
name: data.name || null,
|
||||
category: data.category,
|
||||
mpn: data.mpn || null,
|
||||
lcscNumber: data.lcscNumber || null,
|
||||
value: data.value || null,
|
||||
valueNumeric: parseValueNumeric(data.value),
|
||||
voltage: data.voltage || null,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
let category = $state(String(form?.values?.category ?? ''));
|
||||
let name = $state(String(form?.values?.name ?? ''));
|
||||
let mpn = $state(String(form?.values?.mpn ?? ''));
|
||||
let lcscNumber = $state(String(form?.values?.lcscNumber ?? ''));
|
||||
let value = $state(String(form?.values?.value ?? ''));
|
||||
let voltage = $state(String(form?.values?.voltage ?? ''));
|
||||
let tolerance = $state(String(form?.values?.tolerance ?? ''));
|
||||
@@ -20,6 +21,7 @@
|
||||
function onFound(r: PartLookupResult) {
|
||||
if (r.name != null) name = r.name;
|
||||
if (r.mpn != null) mpn = r.mpn;
|
||||
if (r.lcscNumber != null) lcscNumber = r.lcscNumber;
|
||||
if (r.category != null) category = r.category;
|
||||
if (r.value != null) value = r.value;
|
||||
if (r.voltage != null) voltage = r.voltage;
|
||||
@@ -74,6 +76,12 @@
|
||||
placeholder="Manufacturer part number"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="lcscNumber" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">LCSC #</label>
|
||||
<input type="text" id="lcscNumber" name="lcscNumber" bind:value={lcscNumber}
|
||||
placeholder="e.g. C25804"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
|
||||
Reference in New Issue
Block a user