Add standalone Selenium Part Request submitter (altium365_part_request.py) with live-captured form selectors; token-free bulk end task
parent
6a2f6a553b
commit
0b2a8ccb92
8
SKILL.md
8
SKILL.md
|
|
@ -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
|
`.SchLib`: the parameter set, where each value comes from, and how the generated Altium script
|
||||||
stamps them onto the symbol.
|
stamps them onto the symbol.
|
||||||
- `references/part_request_web.md` — submitting finished components to a managed Altium 365
|
- `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
|
Workspace as **Part Requests** (no token/admin): prerequisites, per-component field mapping,
|
||||||
per-component field mapping, the `part_requests.json` manifest, and the looped browser steps.
|
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/template/versions.json` — per-typeid `template_version` + `skill_version`.
|
||||||
- `assets/CHANGELOG.xlsx` — global version/parameter changelog (created on first add;
|
- `assets/CHANGELOG.xlsx` — global version/parameter changelog (created on first add;
|
||||||
merged into the skill repo's copy in Gitea by `push-skill`).
|
merged into the skill repo's copy in Gitea by `push-skill`).
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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**.
|
6. Review, then **Save**. Record the auto-assigned **Request Id**.
|
||||||
7. Move to the next entry.
|
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
|
## Safety and auditing
|
||||||
|
|
||||||
- On the **first component of a run**, fill everything and **stop at Save** for the operator to
|
- On the **first component of a run**, fill everything and **stop at Save** for the operator to
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue