#!/usr/bin/env python3 """Download Altium footprints/symbols by driving the user's ALREADY-LOGGED-IN Chrome. Runs on the USER'S machine (where the signed-in browser windows live) - NOT in the sandbox. The user starts Chrome once with a remote-debugging port and logs into the CAD sites (SnapEDA, Ultra Librarian, Component Search Engine, DigiKey, ...). This script ATTACHES to that running Chrome and reuses those live sessions, so NO password is ever typed, stored, or seen here. # user, once: fully quit Chrome, then relaunch with a debug port + own profile # macOS: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ # --remote-debugging-port=9222 --user-data-dir="$HOME/cad-chrome" # Linux: google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/cad-chrome" # then sign into the CAD sites in that window, then: python fetch_cad.py --mpn BAT46WJ --dest --sites snapeda,ultralibrarian SAFETY: this NEVER solves CAPTCHAs or defeats bot-detection. If a site shows a login wall, CAPTCHA, or bot-check, the adapter STOPS and returns ('manual', url) so the caller can fall back to asking the user to grab the file by hand. It also does not create accounts or enter passwords. Per-site selectors live in SITES below and may need tweaking when a site changes its layout (they can't be verified from the sandbox). """ import argparse, glob, os, re, shutil, sys, time # ----- pure helpers (no browser) ------------------------------------------------- BLOCK_MARKERS=("recaptcha","g-recaptcha","hcaptcha","captcha","are you a robot", "please sign in","log in to download","sign in to download","cf-challenge") def looks_blocked(page_source_lower): """Heuristic: does the page look like a CAPTCHA / login wall / bot-check? If so we DEFER to the human - we never try to get past it.""" return any(m in page_source_lower for m in BLOCK_MARKERS) def newest_download(dl_dir, known_before, timeout=60): """Return the path of a file that appeared in dl_dir after `known_before`, once it has finished downloading (no .crdownload/.part). None on timeout.""" end=time.time()+timeout while time.time()0: return f time.sleep(1) return None def place(downloaded, dest, mpn, kind): """Move a downloaded CAD file into the DFS folder named _ (kind = 'fp' | 'sym' | 'cad' for a bundle/archive). Returns the new path.""" os.makedirs(dest, exist_ok=True) ext=os.path.splitext(downloaded)[1] or "" out=os.path.join(dest, f"{mpn}_{kind}{ext}") shutil.move(downloaded, out); return out # ----- browser attach ------------------------------------------------------------ def attach(port, dl_dir): """Attach to a Chrome already running with --remote-debugging-port= and point downloads at dl_dir. Selenium is imported lazily so this file stays importable without a browser present.""" from selenium import webdriver from selenium.webdriver.chrome.options import Options opts=Options(); opts.add_experimental_option("debuggerAddress", f"127.0.0.1:{port}") drv=webdriver.Chrome(options=opts) try: # route downloads to our folder via CDP drv.execute_cdp_cmd("Page.setDownloadBehavior", {"behavior":"allow","downloadPath":os.path.abspath(dl_dir)}) except Exception: pass return drv # ----- per-site adapters --------------------------------------------------------- # Each returns ('ok', path) | ('manual', url) | ('notfound', url). Adapters open the site's # search for the MPN, prefer the Altium export, click download, and wait for the file. # Selectors are best-effort starting points - verify/adjust on the first real run. def _snapeda(drv, mpn, dl_dir): from selenium.webdriver.common.by import By url=f"https://www.snapeda.com/search/?q={mpn}&has_symbol=true" drv.get(url); time.sleep(3) if looks_blocked(drv.page_source.lower()): return ("manual", drv.current_url) hits=drv.find_elements(By.CSS_SELECTOR, "a[href*='/parts/']") if not hits: return ("notfound", url) hits[0].click(); time.sleep(3) if looks_blocked(drv.page_source.lower()): return ("manual", drv.current_url) before=set(glob.glob(os.path.join(dl_dir,"*"))) # try to pick an Altium download control; fall back to any 'download' button btns=drv.find_elements(By.XPATH, "//*[contains(translate(text(),'ALTIUM','altium'),'altium')]" "//ancestor-or-self::a | //a[contains(translate(.,'DOWNLOAD','download'),'download')]") if not btns: return ("manual", drv.current_url) btns[0].click() f=newest_download(dl_dir, before) return ("ok", f) if f else ("manual", drv.current_url) def _searchonly(base): """Adapters not yet wired for auto-download: land on the site's search and defer to the user (returns 'manual' with the URL). Fill in the click/download flow to automate.""" def adapter(drv, mpn, dl_dir): url=base.format(mpn=mpn) try: drv.get(url); time.sleep(2) except Exception: pass return ("manual", url) return adapter SITES={ "snapeda": _snapeda, "ultralibrarian": _searchonly("https://app.ultralibrarian.com/search?queryText={mpn}"), "componentsearchengine": _searchonly("https://componentsearchengine.com/search?term={mpn}"), "digikey": _searchonly("https://www.digikey.com/en/products/result?keywords={mpn}"), } def main(): ap=argparse.ArgumentParser() ap.add_argument("--mpn", required=True) ap.add_argument("--dest", required=True, help="the part's DFS folder (MPN_make_typeid/)") ap.add_argument("--sites", default="snapeda", help="comma list, tried in order: "+", ".join(SITES)) ap.add_argument("--port", type=int, default=9222, help="Chrome remote-debugging port") ap.add_argument("--kind", default="cad", choices=["fp","sym","cad"], help="name the file _; 'cad' for a bundle/archive") ap.add_argument("--download-dir", default=None, help="Chrome's download folder (default: /.dl)") a=ap.parse_args() dl_dir=a.download_dir or os.path.join(a.dest, ".dl"); os.makedirs(dl_dir, exist_ok=True) try: drv=attach(a.port, dl_dir) except Exception as e: sys.exit(f"Could not attach to Chrome on port {a.port}. Start Chrome with " f"--remote-debugging-port={a.port} and sign in first.\n{e}") manual=[] for site in [s.strip() for s in a.sites.split(",") if s.strip()]: adapter=SITES.get(site) if not adapter: print(f" ! unknown site '{site}' - skipping"); continue try: status,info=adapter(drv, a.mpn, dl_dir) except Exception as e: print(f" {site}: error ({e}) -> manual"); manual.append((site, drv.current_url)); continue if status=="ok": out=place(info, a.dest, a.mpn, a.kind) print(f" {site}: downloaded -> {out}") print("\nDONE. Cross-check the model's pad/pin count and dimensions vs the datasheet.") return if status=="manual": print(f" {site}: login/CAPTCHA/manual -> {info}"); manual.append((site, info)) else: print(f" {site}: not found ({info})") print("\nNo automatic download. These need you to grab the file by hand (then hand it " "back and I'll place it as the footprint/symbol):") for site,url in manual: print(f" - {site}: {url}") sys.exit(2) if __name__=="__main__": main()