Skill/scripts/fetch_cad.py

156 lines
7.7 KiB
Python

#!/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 <DFS_folder> --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()<end:
now=set(glob.glob(os.path.join(dl_dir,"*")))
new=[f for f in now-known_before if not f.endswith((".crdownload",".part",".tmp"))]
if new:
f=max(new, key=os.path.getmtime)
if os.path.getsize(f)>0: return f
time.sleep(1)
return None
def place(downloaded, dest, mpn, kind):
"""Move a downloaded CAD file into the DFS folder named <mpn>_<kind><ext>
(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=<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 <mpn>_<kind>; 'cad' for a bundle/archive")
ap.add_argument("--download-dir", default=None,
help="Chrome's download folder (default: <dest>/.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()