scripts/fetch_datasheet_lcsc.py
#!/usr/bin/env python3
"""Download a datasheet PDF by searching LCSC/jlcsearch for the URL.
Uses the jlcsearch community API (no auth required) to find parts by
MPN or LCSC code (Cxxxxx), then downloads the datasheet PDF directly
from LCSC's CDN (wmsc.lcsc.com). LCSC PDFs download without bot
protection — no special headers or browser fallback needed.
Usage:
python3 fetch_datasheet_lcsc.py --search <MPN_or_LCSC_code> [--output <path>]
python3 fetch_datasheet_lcsc.py <url> [--output <path>]
Exit codes:
0 = success (PDF downloaded)
1 = download failed after all attempts
2 = search failed (part not found)
Dependencies:
- requests (pip install requests) — preferred
- Falls back to urllib if requests is not installed
- playwright (optional) — for JS-rendered datasheet pages
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.parse
import urllib.request
_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
# Try to import optional dependencies; graceful fallback if not installed
try:
import requests as _requests
except ImportError:
_requests = None
try:
from playwright.sync_api import sync_playwright as _sync_playwright
except ImportError:
_sync_playwright = None
def _friendly_filename(mpn: str, description: str = "") -> str:
"""Build a human-readable filename (no extension) from MPN + description."""
def _sanitize(s):
s = re.sub(r'[/\\:*?"<>|,;]', "_", s)
s = re.sub(r"\s+", "_", s)
return re.sub(r"_+", "_", s).strip("_")
base = _sanitize(mpn)
if not description:
return base
desc = description.strip()
if len(desc) > 80:
desc = desc[:77].rsplit(" ", 1)[0]
desc = _sanitize(desc)
return f"{base}_{desc}" if desc else base
# ---------------------------------------------------------------------------
# jlcsearch API
# ---------------------------------------------------------------------------
def _parse_extra(component: dict) -> dict:
"""Parse the 'extra' field, which may be a JSON string or a dict."""
extra = component.get("extra")
if extra is None:
return {}
if isinstance(extra, str):
try:
parsed = json.loads(extra)
component["extra"] = parsed
return parsed
except (json.JSONDecodeError, TypeError):
return {}
return extra if isinstance(extra, dict) else {}
def search_lcsc(query: str) -> dict | None:
"""Search jlcsearch API for a part. Returns first match with datasheet URL.
Accepts MPN, LCSC code (Cxxxxx), or keyword search terms.
"""
url = f"https://jlcsearch.tscircuit.com/api/search?q={urllib.parse.quote(query)}&limit=5&full=true"
try:
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
except Exception as e:
print(f"[LCSC] Search failed: {e}", file=sys.stderr)
return None
components = data.get("components", [])
if not components:
return None
query_upper = query.upper()
# Parse extra for all components
for c in components:
_parse_extra(c)
# Prefer exact MPN or LCSC code match
for c in components:
mpn = c.get("mfr", "")
extra = c.get("extra") or {}
lcsc_num = extra.get("number", "") if isinstance(extra, dict) else ""
if not lcsc_num:
lcsc_num = f"C{c.get('lcsc', '')}"
if mpn.upper() == query_upper or lcsc_num.upper() == query_upper:
return c
# For short queries (< 6 chars), fuzzy results are unreliable —
# "1012C" might match "SI1012CR" which is a completely different part.
# Only return fuzzy matches for longer, more specific queries.
if len(query.strip()) < 6:
return None
# Return first result as fuzzy match
return components[0]
def search_lcsc_direct(lcsc_code: str) -> dict | None:
"""Query LCSC's wmsc API directly for a part by LCSC code (Cxxxxx).
This is a fallback when jlcsearch returns no results. The wmsc API
is unauthenticated and returns product details including datasheet URLs.
"""
if not re.match(r"^C\d+$", lcsc_code, re.IGNORECASE):
return None
url = f"https://wmsc.lcsc.com/ftps/wm/product/detail?productCode={lcsc_code.upper()}"
try:
req = urllib.request.Request(url, headers={
"User-Agent": _USER_AGENT,
"Accept": "application/json",
})
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
except Exception as e:
print(f"[LCSC] Direct lookup failed for {lcsc_code}: {e}", file=sys.stderr)
return None
result = data.get("result") or data
if not result or isinstance(result, str):
return None
# Normalize to jlcsearch-compatible dict format
pdf_url = ""
pdf_list = result.get("pdfUrl") or result.get("dataSheetUrl") or ""
if isinstance(pdf_list, str) and pdf_list:
pdf_url = pdf_list
elif isinstance(pdf_list, list) and pdf_list:
pdf_url = pdf_list[0] if isinstance(pdf_list[0], str) else ""
component = {
"mfr": result.get("productModel") or result.get("modelName") or "",
"description": result.get("productDescEn") or result.get("description") or "",
"datasheet": pdf_url,
"stock": result.get("stockNumber") or result.get("stockCount") or 0,
"price": result.get("productPriceList"),
"lcsc": lcsc_code.lstrip("Cc"),
"extra": {
"number": lcsc_code.upper(),
"mpn": result.get("productModel") or result.get("modelName") or "",
"description": result.get("productDescEn") or result.get("description") or "",
"manufacturer": {
"name": result.get("brandNameEn") or result.get("manufacturer") or "",
},
"datasheet": {
"pdf": pdf_url,
},
},
}
return component
def _get_datasheet_url(component: dict) -> str:
"""Extract the best datasheet URL from a jlcsearch component.
Prefers the direct PDF URL from extra.datasheet.pdf (wmsc.lcsc.com CDN),
falls back to the top-level datasheet URL (lcsc.com redirect).
"""
extra = component.get("extra") or {}
ds = extra.get("datasheet") or {}
if isinstance(ds, dict):
pdf_url = ds.get("pdf", "")
if pdf_url:
return pdf_url
# Fallback to top-level
return component.get("datasheet", "")
def _get_mpn(component: dict) -> str:
"""Extract MPN from component."""
extra = component.get("extra") or {}
return extra.get("mpn", "") or component.get("mfr", "")
def _get_lcsc_code(component: dict) -> str:
"""Extract LCSC code (Cxxxxx) from component."""
extra = component.get("extra") or {}
num = extra.get("number", "")
if num:
return num
lcsc_id = component.get("lcsc", "")
return f"C{lcsc_id}" if lcsc_id else ""
def _get_manufacturer(component: dict) -> str:
"""Extract manufacturer name from component."""
extra = component.get("extra") or {}
mfg = extra.get("manufacturer") or {}
if isinstance(mfg, dict):
return mfg.get("name", "")
return ""
def _get_description(component: dict) -> str:
"""Extract description from component."""
extra = component.get("extra") or {}
return extra.get("description", "") or component.get("description", "")
# ---------------------------------------------------------------------------
# URL normalization
# ---------------------------------------------------------------------------
def normalize_url(url: str) -> str:
"""Normalize datasheet URL for download."""
if url.startswith("//"):
url = "https:" + url
return url
# ---------------------------------------------------------------------------
# Download functions
# ---------------------------------------------------------------------------
def download_pdf(url: str, output_path: str) -> bool:
"""Download a PDF from a URL, trying multiple methods.
Returns True if a valid PDF was downloaded.
"""
url = normalize_url(url)
methods = []
if _requests is not None:
methods.append(("requests", _download_requests))
methods.append(("urllib", _download_urllib))
if _sync_playwright is not None:
methods.append(("playwright", _download_playwright))
for name, fn in methods:
try:
if fn(url, output_path):
with open(output_path, "rb") as f:
header = f.read(8)
if header.startswith(b"%PDF"):
size = os.path.getsize(output_path)
print(f"Downloaded {size:,} bytes via {name}: {output_path}")
return True
else:
os.remove(output_path)
except Exception:
if os.path.exists(output_path):
os.remove(output_path)
continue
return False
def _download_requests(url: str, output_path: str) -> bool:
"""Download using the requests library."""
resp = _requests.get(
url,
headers={"User-Agent": _USER_AGENT},
timeout=20,
allow_redirects=True,
)
resp.raise_for_status()
if len(resp.content) == 0:
return False
with open(output_path, "wb") as f:
f.write(resp.content)
return True
def _download_urllib(url: str, output_path: str) -> bool:
"""Download using Python urllib (fallback)."""
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=20) as resp:
with open(output_path, "wb") as f:
shutil.copyfileobj(resp, f)
return os.path.exists(output_path) and os.path.getsize(output_path) > 0
def _download_playwright(url: str, output_path: str) -> bool:
"""Download using Playwright headless browser (last resort)."""
with _sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
try:
try:
with page.expect_download(timeout=15000) as dl_info:
page.goto(url, timeout=15000, wait_until="domcontentloaded")
download = dl_info.value
download.save_as(output_path)
return os.path.exists(output_path) and os.path.getsize(output_path) > 0
except Exception:
pass
resp = page.goto(url, timeout=20000, wait_until="domcontentloaded")
if resp is None:
return False
body = resp.body()
if body and body[:4] == b"%PDF":
with open(output_path, "wb") as f:
f.write(body)
return True
return False
finally:
page.close()
browser.close()
def try_alternative_sources(mpn: str, output_path: str) -> bool:
"""Try alternative datasheet sources when LCSC URL fails."""
alternatives = []
mpn_upper = mpn.upper()
# Microchip direct URL pattern
if any(x in mpn_upper for x in ("ATMEGA", "ATTINY", "PIC", "SAMD", "SAM")):
alternatives.append(
f"https://ww1.microchip.com/downloads/aemDocuments/documents/MCU08/ProductDocuments/DataSheets/{mpn}-DataSheet.pdf"
)
for alt_url in alternatives:
if download_pdf(alt_url, output_path):
return True
return False
# ---------------------------------------------------------------------------
# PDF verification
# ---------------------------------------------------------------------------
def _extract_pdf_text(pdf_path: str, max_pages: int = 3) -> str:
"""Extract text from the first few pages of a PDF."""
try:
result = subprocess.run(
["pdftotext", "-l", str(max_pages), pdf_path, "-"],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
try:
with open(pdf_path, "rb") as f:
raw = f.read(200_000)
strings = re.findall(rb"[\x20-\x7e]{4,}", raw)
return " ".join(s.decode("ascii", errors="ignore") for s in strings)
except Exception:
return ""
def verify_datasheet(
pdf_path: str,
mpn: str,
description: str = "",
manufacturer: str = "",
) -> dict:
"""Verify a downloaded PDF is the correct datasheet."""
text = _extract_pdf_text(pdf_path)
if not text or len(text) < 50:
return {
"verified": False, "confidence": "unverified",
"mpn_found": False, "manufacturer_found": False,
"keyword_hits": 0, "keyword_total": 0,
"details": "Could not extract text from PDF",
}
text_upper = text.upper()
mpn_upper = mpn.upper()
mpn_found = mpn_upper in text_upper
if not mpn_found:
base_mpn = re.sub(
r"(DRLR|DRL|DGKR|DGK|DCKR|DCK|DBVR|DBV|PWPR|PWP|RGER|RGE|"
r"RGTR|RGT|NRND|TR|CT|ND|LT1G|LT3G|BK|PBF|-ND)$",
"", mpn_upper,
)
if base_mpn and len(base_mpn) >= 4 and base_mpn != mpn_upper:
mpn_found = base_mpn in text_upper
mfg_found = False
if manufacturer:
mfg_upper = manufacturer.upper()
mfg_found = mfg_upper in text_upper
if not mfg_found:
first_word = mfg_upper.split()[0] if " " in mfg_upper else ""
if first_word and len(first_word) >= 4:
mfg_found = first_word in text_upper
keywords = []
if description:
skip = {"the", "a", "an", "for", "and", "or", "with", "in", "to",
"of", "at", "by", "on", "no", "w", "smd", "smt"}
for word in re.split(r"[\s/,_-]+", description):
w = word.strip().upper()
if len(w) >= 3 and w not in skip and not re.match(r"^\d+$", w):
keywords.append(w)
keyword_hits = sum(1 for kw in keywords if kw in text_upper) if keywords else 0
keyword_total = len(keywords)
if mpn_found:
confidence = "verified"
details = f"MPN '{mpn}' found in PDF text"
elif mfg_found and keyword_hits >= max(1, keyword_total // 2):
confidence = "likely"
details = (f"MPN not found but manufacturer '{manufacturer}' present "
f"with {keyword_hits}/{keyword_total} description keywords")
elif keyword_hits >= max(2, keyword_total * 2 // 3):
confidence = "likely"
details = f"{keyword_hits}/{keyword_total} description keywords found"
elif keyword_hits == 0 and keyword_total >= 3 and not mfg_found:
confidence = "wrong"
details = (f"No MPN, manufacturer, or description keywords found in PDF "
f"(0/{keyword_total} keywords)")
else:
confidence = "unverified"
details = (f"MPN not found; {keyword_hits}/{keyword_total} keywords, "
f"manufacturer {'found' if mfg_found else 'not found'}")
return {
"verified": mpn_found, "confidence": confidence,
"mpn_found": mpn_found, "manufacturer_found": mfg_found,
"keyword_hits": keyword_hits, "keyword_total": keyword_total,
"details": details,
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Download a datasheet PDF via LCSC/jlcsearch")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("url", nargs="?", help="Direct URL to the PDF")
group.add_argument("--search", metavar="QUERY",
help="Search by MPN or LCSC code (e.g., GRM155R71C104KA88D or C14663)")
parser.add_argument("--output", "-o", help="Output file path (default: <MPN>.pdf)")
parser.add_argument("--json", action="store_true", help="Output result as JSON")
args = parser.parse_args()
if args.search:
component = search_lcsc(args.search)
if not component and re.match(r"^C\d+$", args.search, re.IGNORECASE):
print(f"jlcsearch returned no results for {args.search}, trying wmsc API...",
file=sys.stderr)
component = search_lcsc_direct(args.search)
if not component:
if args.json:
json.dump({"success": False, "error": "Part not found on LCSC"}, sys.stdout)
else:
print(f"No LCSC results for '{args.search}'", file=sys.stderr)
sys.exit(2)
mpn = _get_mpn(component)
lcsc_code = _get_lcsc_code(component)
desc = _get_description(component)
mfg = _get_manufacturer(component)
ds_url = _get_datasheet_url(component)
display_pn = mpn or lcsc_code or args.search
output_path = args.output or (_friendly_filename(display_pn, desc) + ".pdf")
if args.json:
result = {
"mpn": mpn,
"lcsc": lcsc_code,
"manufacturer": mfg,
"description": desc,
"datasheet_url": ds_url,
"in_stock": component.get("stock", 0),
"price": component.get("price"),
}
if not ds_url:
print(f"No datasheet URL for {display_pn}", file=sys.stderr)
if args.json:
result["success"] = False
result["error"] = "No datasheet URL in LCSC listing"
json.dump(result, sys.stdout)
sys.exit(2)
print(f"Downloading datasheet for {display_pn} ({lcsc_code})...", file=sys.stderr)
if download_pdf(ds_url, output_path):
vr = verify_datasheet(output_path, mpn or lcsc_code, desc, mfg)
if vr["confidence"] == "wrong":
print(f"WARNING: Downloaded PDF may be wrong datasheet for {display_pn}",
file=sys.stderr)
print(f" {vr['details']}", file=sys.stderr)
if args.json:
result["success"] = True
result["output"] = output_path
result["verification"] = vr
json.dump(result, sys.stdout)
sys.exit(0)
# Try alternative sources
if mpn:
print(f"LCSC URL failed, trying alternatives for {mpn}...", file=sys.stderr)
if try_alternative_sources(mpn, output_path):
vr = verify_datasheet(output_path, mpn, desc, mfg)
if args.json:
result["success"] = True
result["output"] = output_path
result["source"] = "alternative"
result["verification"] = vr
json.dump(result, sys.stdout)
sys.exit(0)
print(f"Failed to download datasheet for {display_pn}", file=sys.stderr)
if args.json:
result["success"] = False
result["error"] = "All download methods failed"
json.dump(result, sys.stdout)
sys.exit(1)
else:
# Direct URL mode
url = args.url
output_path = args.output or "datasheet.pdf"
if download_pdf(url, output_path):
if args.json:
json.dump({"success": True, "output": output_path, "url": url}, sys.stdout)
sys.exit(0)
else:
print(f"Failed to download PDF from {url}", file=sys.stderr)
if args.json:
json.dump({"success": False, "url": url, "error": "Download failed"}, sys.stdout)
sys.exit(1)
if __name__ == "__main__":
main()
scripts/sync_datasheets_lcsc.py
#!/usr/bin/env python3
"""Sync a local datasheets directory for a KiCad project via LCSC/jlcsearch.
Extracts components with MPNs or LCSC codes from a KiCad schematic (or
pre-computed analyzer JSON), searches jlcsearch for datasheet URLs,
downloads missing PDFs, and maintains a manifest.json file (legacy name index.json
still read for backward compat).
The manifest.json format matches across distributor skills so they can
contribute to the same datasheets directory. The source field
distinguishes which distributor provided the datasheet.
No API key required — uses the jlcsearch community API (free, no auth).
LCSC's CDN (wmsc.lcsc.com) serves PDFs directly without bot protection.
Download strategy per part:
1. Try the datasheet URL from the schematic itself
2. Search jlcsearch API → download from LCSC CDN (direct PDF URL)
3. Try manufacturer-specific alternative URL patterns
Usage:
python3 sync_datasheets_lcsc.py <file.kicad_sch>
python3 sync_datasheets_lcsc.py <analyzer_output.json> --output ./datasheets
python3 sync_datasheets_lcsc.py <file.kicad_sch> --force # retry failures
python3 sync_datasheets_lcsc.py <file.kicad_sch> --dry-run # preview only
python3 sync_datasheets_lcsc.py --mpn-list mpns.txt --dry-run
python3 sync_datasheets_lcsc.py --mpn-list mpns.txt --output ./datasheets
Dependencies:
- requests (pip install requests) — strongly recommended
- playwright (pip install playwright && playwright install chromium) — optional
"""
import argparse
import json
import os
import re
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
# Import from sibling script (same skill)
sys.path.insert(0, str(Path(__file__).parent))
from fetch_datasheet_lcsc import (
download_pdf,
normalize_url,
try_alternative_sources,
verify_datasheet,
search_lcsc,
_get_datasheet_url,
_get_mpn,
_get_lcsc_code,
_get_manufacturer,
_get_description,
)
# ---------------------------------------------------------------------------
# MPN filtering — distinguish real manufacturer part numbers from generic values
# ---------------------------------------------------------------------------
_GENERIC_VALUE_RE = re.compile(
r"^[\d.]+\s*[pnuμmkMGR]?[FHΩRfhω]?$"
r"|^[\d.]+\s*[kKmM]?[Ωω]?$"
r"|^[\d.]+\s*[pnuμm]?[Ff]$"
r"|^[\d.]+\s*[pnuμm]?[Hh]$"
r"|^[\d.]+%$"
r"|^DNP$|^NC$|^N/?A$",
re.IGNORECASE,
)
_SKIP_TYPES = {
"test_point", "mounting_hole", "fiducial", "graphic",
"jumper", "net_tie", "mechanical",
}
def is_real_mpn(mpn: str) -> bool:
"""Return True if the string looks like a real manufacturer part number."""
mpn = mpn.strip()
if not mpn or len(mpn) < 3:
return False
if _GENERIC_VALUE_RE.match(mpn):
return False
has_letter = any(c.isalpha() for c in mpn)
has_digit = any(c.isdigit() for c in mpn)
return has_letter and has_digit
# ---------------------------------------------------------------------------
# Filename sanitization — matches convention across distributor skills
# ---------------------------------------------------------------------------
def sanitize_filename(name: str) -> str:
"""Convert a string to a safe filename component (without extension)."""
name = re.sub(r'[/\\:*?"<>|,;]', "_", name)
name = re.sub(r"\s+", "_", name)
name = re.sub(r"_+", "_", name).strip("_")
if len(name) > 200:
name = name[:200]
return name
def friendly_filename(mpn: str, description: str = "", manufacturer: str = "") -> str:
"""Build a human-readable filename from MPN and description."""
base = sanitize_filename(mpn)
if not description:
return base
desc = description.strip()
if manufacturer and desc.lower().endswith(manufacturer.lower()):
desc = desc[: -len(manufacturer)].strip().rstrip(",").strip()
if len(desc) > 80:
desc = desc[:77].rsplit("_", 1)[0].rsplit(" ", 1)[0]
desc = sanitize_filename(desc)
return f"{base}_{desc}" if desc else base
# ---------------------------------------------------------------------------
# Manifest management (manifest.json; legacy index.json read for backward compat)
# ---------------------------------------------------------------------------
MANIFEST_FILENAME = "manifest.json"
LEGACY_MANIFEST_FILENAME = "index.json"
def _manifest_path(out_dir: Path) -> Path:
new = out_dir / MANIFEST_FILENAME
old = out_dir / LEGACY_MANIFEST_FILENAME
if new.exists() or not old.exists():
return new
return old
def load_index(path: Path) -> dict:
"""Load existing manifest.json (or legacy index.json) or return empty."""
path = _manifest_path(path.parent) if path.name in (MANIFEST_FILENAME, LEGACY_MANIFEST_FILENAME) else path
if path.exists():
try:
with open(path, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {"schematic": "", "last_sync": "", "parts": {}}
def save_index(path: Path, index: dict):
"""Write manifest atomically. Always writes manifest.json; removes any
legacy index.json sibling after a successful write."""
parent = path.parent
parent.mkdir(parents=True, exist_ok=True)
new_path = parent / MANIFEST_FILENAME
tmp = new_path.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(index, f, indent=2)
tmp.rename(new_path)
old_path = parent / LEGACY_MANIFEST_FILENAME
if old_path.exists() and old_path != new_path:
try:
old_path.unlink()
except OSError:
pass
# ---------------------------------------------------------------------------
# Schematic analysis — run analyzer or load pre-computed JSON
# ---------------------------------------------------------------------------
def get_analyzer_output(input_path: Path) -> dict | None:
"""Get analyzer output, either by running the analyzer or loading JSON."""
if input_path.suffix == ".json":
with open(input_path, "r") as f:
return json.load(f)
if input_path.suffix in (".kicad_sch", ".sch"):
kicad_scripts = Path(__file__).resolve().parent.parent.parent / "kicad" / "scripts"
if kicad_scripts.exists():
sys.path.insert(0, str(kicad_scripts))
try:
from analyze_schematic import analyze_schematic
return analyze_schematic(str(input_path))
except Exception as e:
print(f" Analyzer import failed ({e}), trying subprocess...",
file=sys.stderr)
analyzer = kicad_scripts / "analyze_schematic.py"
if not analyzer.exists():
print(f"Error: Cannot find analyze_schematic.py at {analyzer}",
file=sys.stderr)
return None
try:
result = subprocess.run(
[sys.executable, str(analyzer), str(input_path), "--compact"],
capture_output=True, text=True, timeout=120,
)
if result.returncode != 0:
print(f"Error: Analyzer failed: {result.stderr[:500]}",
file=sys.stderr)
return None
return json.loads(result.stdout)
except Exception as e:
print(f"Error: Failed to run analyzer: {e}", file=sys.stderr)
return None
print(f"Error: Unsupported input file type: {input_path.suffix}",
file=sys.stderr)
return None
# ---------------------------------------------------------------------------
# Part extraction from BOM
# ---------------------------------------------------------------------------
def extract_parts(analyzer_output: dict) -> list[dict]:
"""Extract unique parts with MPNs or distributor PNs from analyzer BOM.
A part is included if it has at least one of: a real MPN, an LCSC code,
a DigiKey PN, or a Mouser PN. Users set up KiCad projects differently.
"""
bom = analyzer_output.get("bom", [])
parts = []
for entry in bom:
if entry.get("dnp"):
continue
if entry.get("type", "") in _SKIP_TYPES:
continue
mpn = entry.get("mpn", "").strip()
lcsc_pn = entry.get("lcsc", "").strip()
digikey_pn = entry.get("digikey", "").strip()
mouser_pn = entry.get("mouser", "").strip()
has_mpn = is_real_mpn(mpn)
has_distributor_pn = bool(lcsc_pn or digikey_pn or mouser_pn)
# F2: fall back to Value field when MPN is empty but the Value
# looks MPN-shaped (and the symbol isn't a generic Device:R/C/L).
mpn_from_value = ""
if not has_mpn and not has_distributor_pn:
value = entry.get("value", "").strip()
lib_id = entry.get("lib_id", "")
if is_real_mpn(value) and not lib_id.startswith("Device:"):
mpn_from_value = value
mpn = value
has_mpn = True
if not has_mpn and not has_distributor_pn:
continue
parts.append({
"mpn": mpn if has_mpn else "",
"manufacturer": entry.get("manufacturer", ""),
"value": entry.get("value", ""),
"description": entry.get("description", ""),
"datasheet": entry.get("datasheet", ""),
"references": entry.get("references", []),
"type": entry.get("type", ""),
"lcsc": lcsc_pn,
"digikey": digikey_pn,
"mouser": mouser_pn,
})
return parts
def load_mpn_list(path: Path) -> list[dict]:
"""Read MPNs from a text file, one per line (KH-312).
Skips blank lines, full-line comments (``# ...``), and inline
``# ...`` comments. Filters non-MPN strings via ``is_real_mpn()``
and de-duplicates. Returns minimal part dicts compatible with
``extract_parts()`` output — distributor PN fields are empty since
MPN-list mode drives searches via MPN lookup only.
Intended for batch workflows (harness, bulk datasheet seeding)
that don't have a KiCad project to point at.
Note: MPNs containing a literal ``#`` character are not supported
(they would be silently truncated by the inline-comment stripper).
Use the positional schematic/JSON input for such parts instead.
"""
parts: list[dict] = []
seen: set[str] = set()
with open(path, "r", encoding="utf-8") as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#"):
continue
if "#" in line:
line = line.split("#", 1)[0].strip()
if not line:
continue
if not is_real_mpn(line):
print(f" Skipping '{line}': doesn't look like a real MPN",
file=sys.stderr)
continue
if line in seen:
continue
seen.add(line)
parts.append({
"mpn": line,
"manufacturer": "",
"value": "",
"description": "",
"datasheet": "",
"references": [],
"type": "",
"lcsc": "",
"digikey": "",
"mouser": "",
})
return parts
# ---------------------------------------------------------------------------
# Core sync logic
# ---------------------------------------------------------------------------
def sync_one_part(
part: dict,
output_dir: Path,
index: dict,
delay: float,
) -> dict:
"""Download datasheet for one part. Returns updated manifest entry."""
mpn = part["mpn"]
lcsc_pn = part.get("lcsc", "")
now = datetime.now(timezone.utc).isoformat()
# Use MPN for display/filename if available, otherwise LCSC code
display_pn = mpn or lcsc_pn
desc = part.get("description", "")
mfg = part.get("manufacturer", "")
filename = friendly_filename(display_pn, desc, mfg) + ".pdf"
output_path = output_dir / filename
# Strategy 1: Try the datasheet URL from the schematic itself
schematic_url = part.get("datasheet", "")
if schematic_url and schematic_url != "~" and "://" in schematic_url:
print(f" Trying schematic URL...", file=sys.stderr)
if download_pdf(schematic_url, str(output_path)):
size = os.path.getsize(str(output_path))
vr = verify_datasheet(str(output_path), display_pn, desc, mfg)
if vr["confidence"] == "wrong":
print(f" WARNING: PDF may be wrong datasheet — {vr['details']}",
file=sys.stderr)
result = {
"file": filename,
"manufacturer": mfg,
"description": desc,
"value": part["value"],
"datasheet_url": schematic_url,
"downloaded_date": now,
"source": "schematic",
"status": "ok",
"references": part["references"],
"size_bytes": size,
"verification": vr["confidence"],
}
if vr["confidence"] == "wrong":
result["verification_details"] = vr["details"]
return result
# Strategy 2: Search jlcsearch API
# Prefer LCSC code (exact match) over MPN keyword search
search_term = lcsc_pn or mpn
time.sleep(delay)
print(f" Searching LCSC for '{search_term}'...", file=sys.stderr)
component = search_lcsc(search_term)
# If LCSC code search failed but we have an MPN, try that
if component is None and lcsc_pn and mpn:
time.sleep(delay)
print(f" LCSC code not found, trying MPN '{mpn}'...", file=sys.stderr)
component = search_lcsc(mpn)
if component is not None:
ds_url = _get_datasheet_url(component)
lcsc_mpn = _get_mpn(component)
lcsc_mfg = _get_manufacturer(component) or mfg
lcsc_desc = _get_description(component) or desc
lcsc_code = _get_lcsc_code(component)
# Use the richer LCSC data for filename
effective_pn = lcsc_mpn or display_pn
if lcsc_desc:
filename = friendly_filename(effective_pn, lcsc_desc, lcsc_mfg) + ".pdf"
output_path = output_dir / filename
if ds_url:
print(f" Downloading from LCSC CDN...", file=sys.stderr)
if download_pdf(ds_url, str(output_path)):
size = os.path.getsize(str(output_path))
vr = verify_datasheet(str(output_path), effective_pn, lcsc_desc, lcsc_mfg)
if vr["confidence"] == "wrong":
print(f" WARNING: PDF may be wrong datasheet — {vr['details']}",
file=sys.stderr)
result = {
"file": filename,
"manufacturer": lcsc_mfg,
"description": lcsc_desc,
"value": part["value"],
"datasheet_url": ds_url,
"downloaded_date": now,
"source": "lcsc",
"lcsc": lcsc_code,
"status": "ok",
"references": part["references"],
"size_bytes": size,
"verification": vr["confidence"],
}
if vr["confidence"] == "wrong":
result["verification_details"] = vr["details"]
return result
# Strategy 3: Try alternative manufacturer sources
if mpn:
print(f" Trying alternative sources...", file=sys.stderr)
if try_alternative_sources(mpn, str(output_path)):
size = os.path.getsize(str(output_path))
vr = verify_datasheet(str(output_path), mpn, desc, mfg)
result = {
"file": filename,
"manufacturer": mfg,
"description": desc,
"value": part["value"],
"datasheet_url": "",
"downloaded_date": now,
"source": "alternative",
"status": "ok",
"references": part["references"],
"size_bytes": size,
"verification": vr["confidence"],
}
if vr["confidence"] == "wrong":
result["verification_details"] = vr["details"]
return result
if component is None:
return {
"manufacturer": mfg,
"description": desc,
"value": part["value"],
"references": part["references"],
"status": "not_found",
"error": f"No LCSC results for '{search_term}'" + (f" or '{mpn}'" if lcsc_pn and mpn else ""),
"last_attempt": now,
}
return {
"manufacturer": mfg,
"description": desc,
"value": part["value"],
"references": part["references"],
"status": "failed",
"error": "No datasheet URL or all download methods failed",
"last_attempt": now,
}
def sync_datasheets(
input_path: str | None = None,
output_dir: str | None = None,
force: bool = False,
force_all: bool = False,
delay: float = 0.5,
parallel: int = 1,
dry_run: bool = False,
as_json: bool = False,
mpn_list: str | None = None,
) -> dict:
"""Main sync function. Returns summary dict."""
if input_path is None and mpn_list is None:
return {"error": "Must provide either input_path or mpn_list"}
if input_path is not None and mpn_list is not None:
return {"error": "Cannot provide both input_path and mpn_list"}
if output_dir:
out_dir = Path(output_dir)
elif input_path:
out_dir = Path(input_path).resolve().parent / "datasheets"
else:
out_dir = Path.cwd() / "datasheets"
out_dir.mkdir(parents=True, exist_ok=True)
index_path = out_dir / MANIFEST_FILENAME
index = load_index(index_path)
if mpn_list:
mpn_list_path = Path(mpn_list).resolve()
print(f"Loading MPNs from {mpn_list_path.name}...", file=sys.stderr)
parts = load_mpn_list(mpn_list_path)
print(f"Loaded {len(parts)} unique MPNs", file=sys.stderr)
skipped_no_id = 0
else:
resolved_input = Path(input_path).resolve()
print(f"Analyzing {resolved_input.name}...", file=sys.stderr)
analyzer_output = get_analyzer_output(resolved_input)
if analyzer_output is None:
return {"error": "Failed to analyze schematic"}
parts = extract_parts(analyzer_output)
all_bom = analyzer_output.get("bom", [])
skipped_no_id = sum(
1 for e in all_bom
if not e.get("dnp") and e.get("type", "") not in _SKIP_TYPES
and not is_real_mpn(e.get("mpn", ""))
and not e.get("lcsc", "").strip()
and not e.get("digikey", "").strip()
and not e.get("mouser", "").strip()
)
print(f"Found {len(parts)} unique parts with identifiers "
f"({skipped_no_id} skipped without any identifier)", file=sys.stderr)
to_download = []
already_present = []
skipped_failed = []
for part in parts:
part_key = part["mpn"] or part.get("lcsc", "") or part.get("digikey", "") or part.get("mouser", "")
part["_key"] = part_key
existing = index.get("parts", {}).get(part_key, {})
status = existing.get("status", "")
if status == "ok":
old_file = existing.get("file", "")
if (out_dir / old_file).exists():
if not force_all:
already_present.append(part_key)
existing["references"] = part["references"]
continue
if status in ("failed", "not_found", "no_datasheet") and not (force or force_all):
skipped_failed.append(part_key)
continue
to_download.append(part)
if dry_run:
summary = {
"would_download": [p["_key"] for p in to_download],
"already_present": already_present,
"skipped_previous_failures": skipped_failed,
"skipped_no_identifier": skipped_no_id,
}
if as_json:
json.dump(summary, sys.stdout, indent=2)
else:
print(f"\nDry run — would download {len(to_download)} datasheets:")
for p in to_download:
print(f" {p['_key']} ({p['manufacturer'] or 'unknown mfg'})")
print(f"Already present: {len(already_present)}")
print(f"Skipped (previous failures): {len(skipped_failed)}")
print(f"Skipped (no identifier): {skipped_no_id}")
return summary
if not to_download:
msg = f"All {len(already_present)} datasheets up to date."
if skipped_failed:
msg += f" {len(skipped_failed)} previous failures (use --force to retry)."
print(msg, file=sys.stderr)
index["schematic"] = str(mpn_list or input_path)
index["last_sync"] = datetime.now(timezone.utc).isoformat()
save_index(index_path, index)
return {"downloaded": 0, "already_present": len(already_present),
"failed": len(skipped_failed)}
downloaded = []
failed = []
warnings = []
if parallel > 1:
lock = threading.Lock()
counter = [0] # mutable counter for progress
def _process_part(part):
part_key = part["_key"]
with lock:
counter[0] += 1
n = counter[0]
print(f"[{n}/{len(to_download)}] {part_key}", file=sys.stderr)
result = sync_one_part(part, out_dir, index, delay)
with lock:
index.setdefault("parts", {})[part_key] = result
if result["status"] == "ok":
downloaded.append(part_key)
vconf = result.get("verification", "")
vmark = ""
if vconf == "wrong":
vmark = " ⚠ WRONG DATASHEET?"
warnings.append(part_key)
elif vconf == "unverified":
vmark = " (unverified)"
print(f" OK: {result['file']} ({result['size_bytes']:,} bytes){vmark}",
file=sys.stderr)
else:
failed.append(part_key)
print(f" {result['status'].upper()}: {result.get('error', '')}",
file=sys.stderr)
index["schematic"] = str(mpn_list or input_path)
index["last_sync"] = datetime.now(timezone.utc).isoformat()
save_index(index_path, index)
with ThreadPoolExecutor(max_workers=parallel) as executor:
executor.map(_process_part, to_download)
else:
for i, part in enumerate(to_download):
part_key = part["_key"]
print(f"[{i+1}/{len(to_download)}] {part_key}", file=sys.stderr)
result = sync_one_part(part, out_dir, index, delay)
index.setdefault("parts", {})[part_key] = result
if result["status"] == "ok":
downloaded.append(part_key)
vconf = result.get("verification", "")
vmark = ""
if vconf == "wrong":
vmark = " ⚠ WRONG DATASHEET?"
warnings.append(part_key)
elif vconf == "unverified":
vmark = " (unverified)"
print(f" OK: {result['file']} ({result['size_bytes']:,} bytes){vmark}",
file=sys.stderr)
else:
failed.append(part_key)
print(f" {result['status'].upper()}: {result.get('error', '')}",
file=sys.stderr)
# Save after each download so progress is preserved on interrupt
index["schematic"] = str(mpn_list or input_path)
index["last_sync"] = datetime.now(timezone.utc).isoformat()
save_index(index_path, index)
summary = {
"downloaded": len(downloaded),
"already_present": len(already_present),
"failed": len(failed),
"verification_warnings": len(warnings),
"skipped_previous_failures": len(skipped_failed),
"skipped_no_identifier": skipped_no_id,
"total_identified_parts": len(parts),
"output_dir": str(out_dir),
"index_path": str(index_path),
}
if as_json:
json.dump(summary, sys.stdout, indent=2)
else:
print(f"\nDatasheet sync complete:", file=sys.stderr)
print(f" Downloaded: {len(downloaded)}", file=sys.stderr)
if downloaded:
for m in downloaded:
print(f" {m}", file=sys.stderr)
if warnings:
print(f" Verification warnings: {len(warnings)}", file=sys.stderr)
for m in warnings:
entry = index["parts"].get(m, {})
detail = entry.get("verification_details", "")
print(f" {m}: {detail}", file=sys.stderr)
print(f" Already present: {len(already_present)}", file=sys.stderr)
print(f" Failed: {len(failed)}", file=sys.stderr)
if failed:
for m in failed:
entry = index["parts"].get(m, {})
err = entry.get("error", "")
print(f" {m} — {err}", file=sys.stderr)
if skipped_failed:
print(f" Skipped (previous failures, use --force): "
f"{len(skipped_failed)}", file=sys.stderr)
print(f" Skipped (no identifier): {skipped_no_id}", file=sys.stderr)
print(f" Output: {out_dir}/", file=sys.stderr)
return summary
def main():
parser = argparse.ArgumentParser(
description="Sync datasheets for a KiCad project via LCSC/jlcsearch (no API key needed)",
)
input_group = parser.add_mutually_exclusive_group(required=True)
input_group.add_argument(
"input",
nargs="?",
help="Path to .kicad_sch file or pre-computed analyzer JSON",
)
input_group.add_argument(
"--mpn-list",
metavar="FILE",
help=("Path to a text file with one MPN per line (KH-312 batch mode). "
"Skips blank lines and '#' comments. Output defaults to "
"./datasheets/ in cwd when --output is not given."),
)
parser.add_argument(
"--output", "-o",
help=("Output directory (default: datasheets/ next to input, "
"or ./datasheets/ in cwd when --mpn-list is used)"),
)
parser.add_argument(
"--force", action="store_true",
help="Retry previously failed downloads",
)
parser.add_argument(
"--force-all", action="store_true",
help="Re-download everything, including already-present files",
)
parser.add_argument(
"--delay", type=float, default=0.5,
help="Seconds between API calls (default: 0.5)",
)
parser.add_argument(
"--parallel", type=int, default=1,
help="Number of parallel download workers (default: 1)",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Show what would be downloaded without doing it",
)
parser.add_argument(
"--json", action="store_true",
help="Output summary as JSON",
)
args = parser.parse_args()
result = sync_datasheets(
input_path=args.input,
output_dir=args.output,
force=args.force,
force_all=args.force_all,
delay=args.delay,
parallel=args.parallel,
dry_run=args.dry_run,
as_json=args.json,
mpn_list=args.mpn_list,
)
if "error" in result:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
SKILL.md
---
name: lcsc
description: Search LCSC Electronics for electronic components — find parts by LCSC number (Cxxxxx) or MPN, check stock/pricing, download datasheets, analyze specifications. Sister company to JLCPCB, same parts library. Sync and maintain a local datasheets directory for a KiCad project, or use batch MPN-list seeding (`--mpn-list`) for bulk workflows without a project. No API key needed — uses the free jlcsearch community API. Use this skill when the user mentions LCSC, JLCPCB parts library, JLCPCB assembly parts, production sourcing, Cxxxxx part numbers, needs to find LCSC equivalents for parts, is preparing a BOM for JLCPCB assembly, or wants to download datasheets and LCSC is available. For package cross-reference tables and BOM workflow, see the `bom` skill.
---
# LCSC Electronics — Component Search, Datasheets & Ordering
## Related Skills
| Skill | Purpose |
|-------|---------|
| `kicad` | Schematic analysis — extracts MPNs for part lookup |
| `bom` | BOM management — orchestrates sourcing across distributors |
| `jlcpcb` | PCB assembly — shares the same parts library |
| `spice` | Uses LCSC parametric data for behavioral SPICE models (no auth needed) |
LCSC is JLCPCB's sister company — they share the same parts library and `Cxxxxx` part numbers. Use LCSC for **production sourcing** (assembled boards from JLCPCB/PCBWay). DigiKey/Mouser are for prototyping. For BOM management and export workflows, see `bom`.
## Key Differences from DigiKey/Mouser
- **No API key needed** — jlcsearch community API is free and open
- **Lower prices** — especially for passives and Chinese-manufactured ICs
- **JLCPCB integration** — same LCSC part numbers used in JLCPCB assembly BOMs
- **Direct PDF downloads** — LCSC's CDN (wmsc.lcsc.com) serves datasheets without bot protection
- **Low MOQ** — many parts available in quantities as low as 1
- **Warehouses** — Shenzhen (JS), Zhuhai (ZH), Hong Kong (HK)
- **Website**: `https://www.lcsc.com`
## LCSC Part Numbers
Format: `Cxxxxx` (e.g., `C14663`). This is the universal identifier across both LCSC and JLCPCB. Use it for:
- Direct ordering on LCSC
- BOM matching in JLCPCB assembly (see `jlcpcb` skill)
- Cross-referencing between platforms
## jlcsearch API Reference
The jlcsearch community API is the recommended way to search LCSC. **No authentication required.**
**Base URL:** `https://jlcsearch.tscircuit.com`
### General Search
```
GET /api/search?q=<query>&limit=20&full=true
```
Parameters:
- `q` — search query (matches MPN, LCSC code, or description keywords)
- `package` — optional footprint filter (e.g., `0402`)
- `limit` — max results (default 100)
- `full` — set to `true` to include all fields (datasheet URL, specs, stock per warehouse)
### Category-Specific Search
```
GET /resistors/list.json?search=10k+0402
GET /capacitors/list.json?search=100nF+0402
GET /microcontrollers/list.json?search=STM32
GET /voltage_regulators/list.json?search=3.3V
```
### Response Format
Results are returned as `{"components": [...]}`. With `full=true`, each component has:
```json
{
"lcsc": 14663,
"mfr": "GRM155R71C104KA88D",
"package": "0402",
"description": "",
"datasheet": "https://www.lcsc.com/datasheet/...",
"stock": 2751535,
"price": [{"qFrom": 1, "qTo": 9, "price": 0.0069}, ...],
"basic": 0,
"extra": {
"number": "C71629",
"mpn": "GRM155R71C104KA88D",
"manufacturer": {"id": 4, "name": "Murata Electronics"},
"package": "0402",
"description": "16V 100nF X7R ±10% 0402 ...",
"quantity": 2751535,
"whs-js": 1234567,
"whs-zh": 567890,
"whs-hk": 0,
"moq": 100,
"order_multiple": 100,
"packaging": "Tape & Reel (TR)",
"packaging_num": 10000,
"datasheet": {"pdf": "https://wmsc.lcsc.com/wmsc/upload/file/pdf/v2/lcsc/...pdf"},
"images": [{"96x96": "...", "224x224": "...", "900x900": "..."}],
"rohs": true,
"url": "https://www.lcsc.com/product-detail/...",
"attributes": {
"Capacitance": "100nF",
"Voltage Rated": "16V",
"Temperature Coefficient": "X7R",
"Tolerance": "±10%"
},
"prices": [{"min_qty": 100, "max_qty": 499, "currency": "USD", "price": 0.0048}, ...]
}
}
```
Key fields:
- `lcsc` — numeric LCSC ID (without "C" prefix)
- `extra.number` — full LCSC code with prefix (e.g., `C71629`)
- `extra.mpn` — manufacturer part number
- `extra.manufacturer.name` — manufacturer
- `extra.datasheet.pdf` — **direct PDF URL** (wmsc.lcsc.com CDN, downloads without auth)
- `extra.attributes` — parametric specs (capacitance, voltage, etc.)
- `extra.quantity` — total stock across all warehouses
- `extra.whs-js`, `extra.whs-zh`, `extra.whs-hk` — stock per warehouse
- `basic` — `1` if JLCPCB basic part (no setup fee), `0` if extended
- `extra.moq` — minimum order quantity
- `extra.order_multiple` — must order in multiples of this
- `extra.rohs` — RoHS compliance (boolean)
### Rate Limits
The jlcsearch API is community-run with no documented rate limits, but be respectful — use delays of 0.5s between calls.
## Datasheet Download & Sync
LCSC's CDN serves datasheet PDFs directly — no bot protection, no special headers needed. This makes LCSC a reliable datasheet source alongside DigiKey.
### Datasheet Directory Sync
Use `sync_datasheets_lcsc.py` to maintain a `datasheets/` directory alongside a KiCad project. Same workflow and `manifest.json` format as the DigiKey and Mouser skills. **No API key required.**
```bash
# Sync datasheets for a KiCad project
python3 <skill-path>/scripts/sync_datasheets_lcsc.py <file.kicad_sch>
# Preview what would be downloaded
python3 <skill-path>/scripts/sync_datasheets_lcsc.py <file.kicad_sch> --dry-run
# Retry previously failed downloads
python3 <skill-path>/scripts/sync_datasheets_lcsc.py <file.kicad_sch> --force
# Custom output directory
python3 <skill-path>/scripts/sync_datasheets_lcsc.py <file.kicad_sch> -o ./my-datasheets
# Parallel downloads (3 workers)
python3 <skill-path>/scripts/sync_datasheets_lcsc.py <file.kicad_sch> --parallel 3
# Batch mode — sync from a plain MPN list (no KiCad project required)
python3 <skill-path>/scripts/sync_datasheets_lcsc.py --mpn-list mpns.txt --output ./datasheets
```
**MPN-list batch mode** (KH-312) — when you have a list of MPNs but no
KiCad project to point at. One MPN per line; blank lines and `#`
comments (full-line and inline) are skipped; generic values are filtered
via `is_real_mpn()` and de-duplicated. Output defaults to `./datasheets/`
in the current working directory when `--output` is omitted.
The script:
- **Runs the kicad schematic analyzer** to extract components, MPNs, and LCSC codes
- **Accepts any identifier** — MPN, LCSC code, or other distributor PNs from KiCad symbol properties
- **Prefers LCSC code** for search (exact match) — falls back to MPN keyword search
- **Falls back to wmsc.lcsc.com API** when jlcsearch has no results for an LCSC code (Cxxxxx)
- **Downloads from LCSC CDN** — direct PDF URLs, no bot protection
- **Writes `manifest.json` manifest** — same format as DigiKey/Mouser skills
- **Verifies PDF content** — checks MPN, manufacturer, and description keywords
- **Rate-limited** — 0.5s between API calls (configurable with `--delay`)
- **Saves progress incrementally** — safe to interrupt
### Single Datasheet Download
Use `fetch_datasheet_lcsc.py` for one-off downloads.
```bash
# Search by MPN
python3 <skill-path>/scripts/fetch_datasheet_lcsc.py --search "GRM155R71C104KA88D" -o datasheet.pdf
# Search by LCSC code
python3 <skill-path>/scripts/fetch_datasheet_lcsc.py --search "C14663" -o datasheet.pdf
# Direct URL download
python3 <skill-path>/scripts/fetch_datasheet_lcsc.py "https://wmsc.lcsc.com/..." -o datasheet.pdf
# JSON output
python3 <skill-path>/scripts/fetch_datasheet_lcsc.py --search "C14663" --json
```
The script:
- **OS-agnostic** — uses `requests` → `urllib` → `playwright` fallback chain (no wget/curl)
- **Validates PDF headers** — rejects HTML error pages
- **Falls back to alternative manufacturer sources** when LCSC URL fails
- **Exit codes**: 0 = success, 1 = download failed, 2 = search/API error
- **Dependencies**:
- `pip install requests` (recommended; urllib fallback works fine for LCSC)
- `pip install playwright && playwright install chromium` (optional; rarely needed for LCSC)
## LCSC Official API (Requires Approval)
**Base URL:** `https://ips.lcsc.com`. Requires API key + signature authentication. Contact `support@lcsc.com` for access. Rarely needed — jlcsearch covers most use cases.
## Web Search Fallback
If the jlcsearch API is unavailable, search LCSC by fetching the website directly:
```
https://www.lcsc.com/search?q=<query>
```
## Cross-Referencing & Missing Equivalents
LCSC part numbers are specific to the LCSC/JLCPCB ecosystem. Use the `extra.mpn` field to cross-reference on DigiKey/Mouser.
When an MPN has no exact LCSC match:
1. Search by key parameters (e.g., "100nF 0402 X7R 16V")
2. Look for pin-compatible alternatives from Chinese manufacturers
3. Verify specs and footprint match — pad dimensions can vary even within the same package size
4. As a last resort, mark as "consigned" and source separately
## Tips
- `basic` field matters — JLCPCB basic parts have no setup fee; extended parts cost $3 each
- Check stock per warehouse (`whs-js`, `whs-zh`, `whs-hk`) — availability varies
- `moq` and `order_multiple` — many parts require minimum quantities or specific multiples
- Datasheet quality varies for Chinese manufacturers — cross-reference MPN on DigiKey for better docs