156 lines
7.7 KiB
Python
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()
|