Add standalone Selenium Part Request submitter (altium365_part_request.py) with live-captured form selectors; token-free bulk end task

main
admin 2026-07-15 06:12:12 +00:00
parent 6a2f6a553b
commit 0b2a8ccb92
No known key found for this signature in database
4 changed files with 231 additions and 2 deletions

View File

@ -536,8 +536,12 @@ plain flat push, but it does not merge the changelog or blank the token, so pref
`.SchLib`: the parameter set, where each value comes from, and how the generated Altium script
stamps them onto the symbol.
- `references/part_request_web.md` — submitting finished components to a managed Altium 365
Workspace as **Part Requests** via browser automation (no token/admin): prerequisites, the
per-component field mapping, the `part_requests.json` manifest, and the looped browser steps.
Workspace as **Part Requests** (no token/admin): prerequisites, per-component field mapping,
the `part_requests.json` manifest, the captured form selectors, and both routes (live
Claude-in-Chrome, or the standalone Selenium script).
- `scripts/altium365_part_request.py` — standalone **Selenium** submitter: attaches to the
operator's signed-in Chrome and loops the `part_requests.json` manifest, filling and saving each
Part Request. Token-free per component; the token-economical end task for a whole library.
- `assets/template/versions.json` — per-typeid `template_version` + `skill_version`.
- `assets/CHANGELOG.xlsx` — global version/parameter changelog (created on first add;
merged into the skill repo's copy in Gitea by `push-skill`).

Binary file not shown.

View File

@ -69,6 +69,46 @@ Start the browser session with `tabs_context_mcp`, then for each entry in the ma
6. Review, then **Save**. Record the auto-assigned **Request Id**.
7. Move to the next entry.
## Standalone Selenium path (token-free at scale) — `scripts/altium365_part_request.py`
Live browser-driving costs Claude tokens per component. For a whole library, use the Selenium
script instead: it's authored once, then runs on the operator's machine over any number of
components with **zero Claude tokens per part**. It attaches to the operator's already-signed-in
Chrome (remote-debugging port), so it reuses the Altium 365 session — no credentials handled.
Captured selectors (live form at `<workspace>/partrequestsmanagement/Tasks/Add`; Altium "afs"
design system + Selectize.js dropdowns):
- Manufacturer → `input#Manufacturer`
- Manufacturer Part Numbers → `textarea#ManufacturerPartNumbers`
- Description → `textarea#Description`
- Component Type / Assign to → Selectize: click the `.selectize-input` after the label, then the
`.selectize-dropdown-content .option` whose text matches.
- Parameters → afs-table under the "Parameters" label; each "Add" (`a.afs-link`) click adds a
`div.afs-table__tr` with two `input.afs-input__control` (name, value).
- Attachments → `input#fileupload` (Selenium `send_keys` the local file path(s)).
- Save → `button.afs-btn_primary` (text "Save").
Component Type options (must match one exactly): Test Points, Clock&Timing, Memory, Wireless,
Transformers, Mechanicals, Capacitors, Transistors, Drivers, Optoelectronics, Power Supply,
Audio, Fuses, Switches, Integrated Circuits, Logic, Diodes, Interface, Miscellaneous, Radio&RF,
Sensors, Processors, Mechanical, Inductors, LED, Amplifiers, Resistors, Data Converters, Relays,
Batteries, Connectors, Crystals & Oscillators. (Map each typeid to one of these when building the
manifest — e.g. `CER → Capacitors`.)
Run it:
```bash
# 1) close Chrome, relaunch on the operator's own profile with a debug port:
# chrome.exe --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\Google\Chrome\User Data"
# 2) sign into the Workspace in that Chrome, then:
python scripts/altium365_part_request.py --manifest part_requests.json \
--base https://<workspace>.365.altium.com [--review-first | --no-submit]
```
`--no-submit` fills every form without saving (dry run); `--review-first` pauses after the first
form so the operator can eyeball the mapping before the batch submits. The skill writes the
`part_requests.json` manifest and commits each component's files to the operator's disk (device
bridge) so the local `files` paths resolve.
## Safety and auditing
- On the **first component of a run**, fill everything and **stop at Save** for the operator to

View File

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Submit Altium 365 **Part Requests** with Selenium, looping over a manifest — the skill's
end task when the central library is a managed Altium 365 Workspace and the operator is not an
admin.
It runs on the OPERATOR'S machine and **attaches to their already-signed-in Chrome** via the
remote-debugging port, so it reuses the existing Altium 365 session no credentials are ever
handled here. It then fills and submits the "Add new request" web form for each component.
Selectors were captured from the live form at
`<workspace>/partrequestsmanagement/Tasks/Add` (Altium's "afs" design system + Selectize.js
dropdowns):
- Manufacturer : input#Manufacturer
- Manufacturer Part Numbers: textarea#ManufacturerPartNumbers
- Description : textarea#Description
- Component Type / Assign to: Selectize dropdowns (click control after the label, click the
option div by text)
- Parameters : an afs-table under the "Parameters" label; each "Add" click adds a
row of two inputs (name, value)
- Attachments : input#fileupload (Selenium send_keys the local file path)
- Save : button.afs-btn_primary with text "Save"
--------------------------------------------------------------------------------------------
SETUP (once, on the operator's machine)
1. pip install selenium (Selenium 4+ auto-manages chromedriver; or pass --chromedriver)
2. Fully close Chrome, then start it with a debugging port on the operator's own profile so
the Altium 365 login is reused, e.g. on Windows:
chrome.exe --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\\Google\\Chrome\\User Data"
Sign in to the Workspace in that Chrome if not already.
3. Run:
python altium365_part_request.py --manifest part_requests.json \
--base https://vecmocon-technologies-pvt-ltd.365.altium.com
Add --no-submit to fill every form but NOT click Save (dry run), or --review-first to
pause after filling the first request so you can eyeball it before the batch proceeds.
MANIFEST (part_requests.json) one entry per component:
{"requests":[
{"manufacturer":"Taiyo Yuden","mpn":"JMK105BJ105KV-F",
"description":"CHIP_CAP_1uF_6.3v_±10%_0402_x5r","component_type":"Capacitors",
"assignee":"", // optional: exact name as shown in the Assign-to list
"parameters":{"Value":"1u","Voltage(V)":"6.3","Tolerance":"±10%"},
"files":["C:\\\\lib\\\\JMK105BJ105KV-F.SchLib","C:\\\\lib\\\\<fp>.PcbLib","C:\\\\lib\\\\<mpn>.pdf"]}
]}
Component Type must be one of the Workspace's list (captured live):
Test Points, Clock&Timing, Memory, Wireless, Transformers, Mechanicals, Capacitors,
Transistors, Drivers, Optoelectronics, Power Supply, Audio, Fuses, Switches,
Integrated Circuits, Logic, Diodes, Interface, Miscellaneous, Radio&RF, Sensors, Processors,
Mechanical, Inductors, LED, Amplifiers, Resistors, Data Converters, Relays, Batteries,
Connectors, Crystals & Oscillators
"""
import argparse, json, os, sys, time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def make_driver(debugger_address, chromedriver):
opts = Options()
opts.add_experimental_option("debuggerAddress", debugger_address)
return webdriver.Chrome(service=Service(chromedriver), options=opts) if chromedriver \
else webdriver.Chrome(options=opts)
def _wait(driver, cond, t=25):
return WebDriverWait(driver, t).until(cond)
def _set_text(driver, by, sel, value):
el = _wait(driver, EC.presence_of_element_located((by, sel)))
driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
el.clear()
el.send_keys(value or "")
def _select_dropdown(driver, label, value):
"""Selectize: click the control after `label`, then the visible option whose text == value."""
control = _wait(driver, EC.element_to_be_clickable((By.XPATH,
f"//*[normalize-space(text())='{label}']/following::div[contains(@class,'selectize-input')][1]")))
driver.execute_script("arguments[0].scrollIntoView({block:'center'});", control)
control.click()
opt = _wait(driver, EC.element_to_be_clickable((By.XPATH,
"//div[contains(@class,'selectize-dropdown') and not(contains(@style,'display: none'))]"
f"//div[contains(@class,'option') and normalize-space()=\"{value}\"]")))
opt.click()
def _add_parameters(driver, params):
"""Click the Parameters 'Add' link once per parameter, then fill each row's (name,value)."""
if not params:
return
add_link_xpath = ("//*[normalize-space(text())='Parameters']/descendant-or-self::*"
"/a[contains(@class,'afs-link')] | "
"//*[normalize-space(text())='Parameters']/following::a[contains(@class,'afs-link')][1]")
for _ in params:
link = _wait(driver, EC.element_to_be_clickable((By.XPATH, add_link_xpath)))
driver.execute_script("arguments[0].click();", link)
time.sleep(0.25)
# the Parameters afs-table tbody that sits under the "Parameters" label
tbody = _wait(driver, EC.presence_of_element_located((By.XPATH,
"//*[normalize-space(text())='Parameters']/following::div[contains(@class,'afs-table__tbody')][1]")))
rows = tbody.find_elements(By.XPATH, ".//div[contains(@class,'afs-table__tr')]")
for row, (name, val) in zip(rows, params.items()):
cells = row.find_elements(By.XPATH, ".//input[contains(@class,'afs-input__control')]")
if len(cells) >= 2:
cells[0].clear(); cells[0].send_keys(str(name))
cells[1].clear(); cells[1].send_keys(str(val))
def _attach_files(driver, files):
files = [f for f in (files or []) if f and os.path.exists(f)]
if not files:
return [], [f for f in (files or []) if f and not os.path.exists(f)]
inp = _wait(driver, EC.presence_of_element_located((By.ID, "fileupload")))
inp.send_keys("\n".join(os.path.abspath(f) for f in files)) # multi-file input
time.sleep(1.0)
return files, []
def submit_request(driver, base, req, no_submit=False):
driver.get(f"{base}/partrequestsmanagement/Tasks/Add")
_wait(driver, EC.presence_of_element_located((By.ID, "Manufacturer")))
_set_text(driver, By.ID, "Manufacturer", req.get("manufacturer", ""))
_set_text(driver, By.ID, "ManufacturerPartNumbers", req.get("mpn", ""))
_set_text(driver, By.ID, "Description", req.get("description", ""))
if req.get("component_type"):
_select_dropdown(driver, "Component Type", req["component_type"])
if req.get("assignee"):
_select_dropdown(driver, "Assign to", req["assignee"])
_add_parameters(driver, req.get("parameters") or {})
attached, missing = _attach_files(driver, req.get("files"))
if no_submit:
return {"mpn": req.get("mpn"), "status": "filled_not_submitted",
"attached": attached, "missing_files": missing}
save = _wait(driver, EC.element_to_be_clickable((By.XPATH,
"//button[contains(@class,'afs-btn_primary') and normalize-space()='Save']")))
driver.execute_script("arguments[0].click();", save)
time.sleep(2.5) # let the SPA post and return to the list
return {"mpn": req.get("mpn"), "status": "submitted",
"attached": attached, "missing_files": missing}
def main():
ap = argparse.ArgumentParser(description="Submit Altium 365 Part Requests via Selenium.")
ap.add_argument("--manifest", required=True, help="part_requests.json")
ap.add_argument("--base", required=True, help="Workspace base URL, e.g. https://<ws>.365.altium.com")
ap.add_argument("--debugger-address", default="127.0.0.1:9222")
ap.add_argument("--chromedriver", default=None, help="path to chromedriver (optional in Selenium 4+)")
ap.add_argument("--no-submit", action="store_true", help="fill every form but do not click Save")
ap.add_argument("--review-first", action="store_true",
help="after filling the first request, pause for Enter before submitting the rest")
ap.add_argument("--out", default="part_requests_result.json")
a = ap.parse_args()
data = json.load(open(a.manifest, encoding="utf-8"))
requests = data.get("requests", [])
driver = make_driver(a.debugger_address, a.chromedriver)
results = []
try:
for i, req in enumerate(requests):
first = (i == 0)
no_submit = a.no_submit or (a.review_first and first)
try:
r = submit_request(driver, a.base.rstrip("/"), req, no_submit=no_submit)
except Exception as e:
r = {"mpn": req.get("mpn"), "status": "error", "error": str(e)[:300]}
results.append(r)
print(f"[{i+1}/{len(requests)}] {req.get('mpn')}: {r['status']}"
+ (f" MISSING {r['missing_files']}" if r.get("missing_files") else ""))
if a.review_first and first and not a.no_submit:
input("Review the first request in the browser, then press Enter to submit the rest...")
# re-submit the first for real now that it's been reviewed
results[-1] = submit_request(driver, a.base.rstrip("/"), req, no_submit=False)
finally:
json.dump({"results": results}, open(a.out, "w"), indent=2, ensure_ascii=False)
print(f"\nwrote {a.out} ({sum(1 for r in results if r['status']=='submitted')} submitted, "
f"{sum(1 for r in results if r['status']=='error')} errors)")
# NOTE: attaching to your Chrome — the script does not close your browser.
if __name__ == "__main__":
main()