#!/usr/bin/env python3 """ Download JLCPCB parts database from yaqwsx/jlcparts pre-built cache. This downloads the full JLCPCB catalog (~421MB compressed, ~1.5GB SQLite) from GitHub Pages in ~5 minutes instead of the broken JLCSearch API approach. The cache.sqlite3 file contains all JLCPCB parts with stock, pricing, and category data. We then convert it into the format expected by the KiCad MCP server's JLCPCBPartsManager. """ import json import os import shutil import sqlite3 import subprocess import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).parent / "python")) from utils.platform_helper import PlatformHelper # noqa: E402 DATA_DIR = PlatformHelper.get_data_dir() DATA_DIR.mkdir(parents=True, exist_ok=True) CACHE_DIR = DATA_DIR / "jlcparts_cache" CACHE_DIR.mkdir(parents=True, exist_ok=True) BASE_URL = "https://yaqwsx.github.io/jlcparts/data" MAX_PARTS = 30 # probe up to this many split volumes TARGET_DB = DATA_DIR / "jlcpcb_parts.db" def curl_download(url: str, dest: Path) -> bool: """Download url to dest with curl. Returns False on 4xx/5xx or network error.""" result = subprocess.run( ["curl", "-L", "-f", "-o", str(dest), "--progress-bar", url], capture_output=False ) return result.returncode == 0 def download_files() -> bool: """Download split archive volumes (z01..zNN) then cache.zip, stopping volumes at 404 or MAX_PARTS.""" print("Downloading jlcparts database (~421MB)...") for i in range(1, MAX_PARTS + 1): part = f"cache.z{i:02d}" dest = CACHE_DIR / part if dest.exists() and dest.stat().st_size > 1000: print(f" {part} already exists, skipping") continue print(f" Downloading {part}...") if not curl_download(f"{BASE_URL}/{part}", dest): # -f causes non-zero exit on 4xx/5xx; treat as end of volumes if dest.exists(): dest.unlink() print(f" {part} not found — {i - 1} volumes total") break dest = CACHE_DIR / "cache.zip" if dest.exists() and dest.stat().st_size > 1000: print(" cache.zip already exists, skipping") else: print(" Downloading cache.zip...") if not curl_download(f"{BASE_URL}/cache.zip", dest): print(" ERROR downloading cache.zip") return False return True def extract_database() -> bool: """Extract the split 7z archive to get cache.sqlite3.""" cache_sqlite = CACHE_DIR / "cache.sqlite3" if cache_sqlite.exists() and cache_sqlite.stat().st_size > 100_000_000: print(f"cache.sqlite3 already extracted ({cache_sqlite.stat().st_size // (1024*1024)}MB)") return True print("Extracting archive (requires 7z or p7zip)...") # Try 7z first, then 7zz (homebrew) for cmd in ["7z", "7zz", "7za"]: try: result = subprocess.run( [cmd, "x", "-y", "-o" + str(CACHE_DIR), str(CACHE_DIR / "cache.zip")], capture_output=True, text=True, ) if result.returncode == 0: print(f"Extracted with {cmd}") return True else: print(f" {cmd} failed: {result.stderr[:200]}") except FileNotFoundError: continue print("\nERROR: 7z not found. Install with: brew install p7zip") return False def convert_to_mcp_format() -> bool: """Convert jlcparts cache.sqlite3 to the MCP server's expected format.""" source = CACHE_DIR / "cache.sqlite3" if not source.exists(): print("ERROR: cache.sqlite3 not found") return False print(f"Reading source database...") src = sqlite3.connect(str(source)) src.row_factory = sqlite3.Row # Check schema tables = [ r[0] for r in src.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() ] print(f" Source tables: {tables}") # Find the main components table comp_table = None for t in tables: count = src.execute(f"SELECT COUNT(*) FROM [{t}]").fetchone()[0] print(f" {t}: {count:,} rows") if count > 10000 and comp_table is None: comp_table = t if not comp_table: # Try 'components' specifically comp_table = "components" if "components" in tables else tables[0] # Get column names cols = [r[1] for r in src.execute(f"PRAGMA table_info([{comp_table}])").fetchall()] print(f" Using table '{comp_table}' with columns: {cols[:10]}...") # Remove old target DB if TARGET_DB.exists(): TARGET_DB.unlink() # Create target DB in MCP format dst = sqlite3.connect(str(TARGET_DB)) dst.execute( """ CREATE TABLE components ( lcsc TEXT PRIMARY KEY, category TEXT, subcategory TEXT, mfr_part TEXT, package TEXT, solder_joints INTEGER, manufacturer TEXT, library_type TEXT, description TEXT, datasheet TEXT, stock INTEGER, price_json TEXT, last_updated INTEGER ) """ ) dst.execute("CREATE INDEX idx_category ON components(category, subcategory)") dst.execute("CREATE INDEX idx_package ON components(package)") dst.execute("CREATE INDEX idx_manufacturer ON components(manufacturer)") dst.execute("CREATE INDEX idx_library_type ON components(library_type)") dst.execute("CREATE INDEX idx_mfr_part ON components(mfr_part)") # Map source columns to our schema # jlcparts schema varies but commonly has: # lcsc, mfr, description, joint, manufacturer, basic, preferred, stock, price, url, etc. print(f"\nConverting parts to MCP format...") now = int(time.time()) batch = [] count = 0 for row in src.execute(f"SELECT * FROM [{comp_table}]"): row_dict = dict(row) # Adapt column names (jlcparts uses various schemas) lcsc = row_dict.get("lcsc") or row_dict.get("LCSC_Part") or row_dict.get("lcsc_id") if lcsc is None: continue if isinstance(lcsc, int): lcsc = f"C{lcsc}" elif not str(lcsc).startswith("C"): lcsc = f"C{lcsc}" mfr_part = row_dict.get("mfr") or row_dict.get("MFR_Part") or row_dict.get("mfr_part") or "" package = row_dict.get("package") or row_dict.get("Package") or "" manufacturer = row_dict.get("manufacturer") or row_dict.get("Manufacturer") or "" description = row_dict.get("description") or row_dict.get("Description") or "" stock = row_dict.get("stock") or row_dict.get("Stock") or 0 category = row_dict.get("category") or row_dict.get("First Category") or "" subcategory = row_dict.get("subcategory") or row_dict.get("Second Category") or "" datasheet = row_dict.get("datasheet") or row_dict.get("url") or "" # Library type is_basic = row_dict.get("basic") or row_dict.get("is_basic") or row_dict.get("Basic") is_preferred = ( row_dict.get("preferred") or row_dict.get("is_preferred") or row_dict.get("Preferred") ) if is_basic: lib_type = "Basic" elif is_preferred: lib_type = "Preferred" else: lib_type = "Extended" # Price price = row_dict.get("price") or row_dict.get("Price") or 0 price_json = json.dumps([{"qty": 1, "price": price}] if price else []) batch.append( ( str(lcsc), category, subcategory, mfr_part, package, 0, manufacturer, lib_type, description, datasheet, int(stock) if stock else 0, price_json, now, ) ) if len(batch) >= 10000: dst.executemany( """ INSERT OR REPLACE INTO components (lcsc, category, subcategory, mfr_part, package, solder_joints, manufacturer, library_type, description, datasheet, stock, price_json, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, batch, ) count += len(batch) batch = [] if count % 100000 == 0: print(f" Converted {count:,} parts...") if batch: dst.executemany( """ INSERT OR REPLACE INTO components (lcsc, category, subcategory, mfr_part, package, solder_joints, manufacturer, library_type, description, datasheet, stock, price_json, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, batch, ) count += len(batch) # Build FTS index print(f" Building full-text search index...") dst.execute( """ CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5( lcsc, description, mfr_part, manufacturer, content=components ) """ ) dst.execute("INSERT INTO components_fts(components_fts) VALUES('rebuild')") dst.commit() # Stats total = dst.execute("SELECT COUNT(*) FROM components").fetchone()[0] basic = dst.execute("SELECT COUNT(*) FROM components WHERE library_type='Basic'").fetchone()[0] extended = dst.execute( "SELECT COUNT(*) FROM components WHERE library_type='Extended'" ).fetchone()[0] dst.close() src.close() print("Cleaning up source database...") shutil.rmtree(CACHE_DIR) print(f" Removed {CACHE_DIR}") db_size = TARGET_DB.stat().st_size / (1024 * 1024) print(f"\nDatabase ready: {TARGET_DB}") print(f" Total parts: {total:,}") print(f" Basic parts: {basic:,}") print(f" Extended parts: {extended:,}") print(f" DB size: {db_size:.1f} MB") return True def main() -> None: print("=" * 60) print("JLCPCB Parts Database Downloader (jlcparts method)") print("=" * 60) start = time.time() if not download_files(): sys.exit(1) if not extract_database(): sys.exit(1) if not convert_to_mcp_format(): sys.exit(1) elapsed = time.time() - start print(f"\nTotal time: {elapsed/60:.1f} minutes") print("Done! Restart the MCP server (/mcp) to use the new database.") if __name__ == "__main__": main()