From 0b2a8ccb92198ab883f9e7ef1cc5253b93484e96 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 15 Jul 2026 06:12:12 +0000 Subject: [PATCH] Add standalone Selenium Part Request submitter (altium365_part_request.py) with live-captured form selectors; token-free bulk end task --- SKILL.md | 8 +- assets/CHANGELOG.xlsx | Bin 5653 -> 5655 bytes references/part_request_web.md | 40 +++++++ scripts/altium365_part_request.py | 185 ++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 scripts/altium365_part_request.py diff --git a/SKILL.md b/SKILL.md index c079676..5255812 100644 --- a/SKILL.md +++ b/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 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`). diff --git a/assets/CHANGELOG.xlsx b/assets/CHANGELOG.xlsx index 3b953316be59a0c51fde82ade24c654eb9d28a25..39139a0dfe58a638ab764b72b77930bfc5cee441 100644 GIT binary patch delta 2290 zcmZ8jc{J307oR~WOA}+4Wz>vp5n~Nwtt=7awT)e7tYaxE@ndY6P#UBnJ0T=X6j{dB z^N>BMPz{;MI+l?Py*%gC^PczKKkm8b`?=qH&gY!Zxt~mh`wHCF=B#W`5D3HxN>oYb z?%+&RS;6Qfb0nvaO95tG%8*1ilTbXux513cD7btJCzuNUfDxbR@x zxn;POq6LPYc9r}@gwsGe@G{)Qm}c6xgHm&ST+Bpo(){JAenvXFMx~YbpOFqqVlKQb z<3+@huKy8Nv`E`qHvFOc_Y)NG(>@cD(O3@+;a3g6$UiFPGNC5gGbvUCOP;npll8-Y z7-8L~q>cgBXa9$k%9d(6NtY82;PGEIAw6xxO#3P}OU~FOX_OpLvEUn-l;y%`xWN9E zoLtvv`&ZOYF$8$iDK4_bm(n%p{cXFOq5O%f+4fWnyhdbjywAKriT!oWSLm6FWn-|G z-X!OPOQ}Z9n|9K%@Jv}sv@lzgev!gf5BdAFVOmn*SkBzDyT+N0rgI(u3uf`0Yp>4q zkDkVIbqyx{S-!IaVegW{i8Q8dM*QWw^6*fYhw7Syc3ki42X2-QD(@>bCc4ni1wFCs zR&V;a&hl$wWK7s|Dj zU-L7jzvPEwC3zo<+V)M;gI_69 zer&@!2EQ<7t)|wQ%M%UuJp)flDFx6-*g!yvkCNP$|A@?;xo?0N#xL1Q0 z^o*B&5SsLN#Yz&3gXgg2>4hwv9xcaMwY+C0dSNYs&k0_gLa_eha|Gf^`Fa=THOI4B zOnXYv1cFQ^eepP6904~Ij^7PmEtxueK3Jb>s=jqxS^}Z^Xp48;92dqE9c~f4I*60F z7f*!-i-dhDg#zSUnLDvemv7_Y!|w_$>yU96{!hkl*o(b6uLd?+i)GfGijEo5HK0a| z8X|;^8)1kvh#MA|l+r|pb57j)=7NZy%=lbuoAI!PvzmUsBT7r@Wa`mOx&wG^GOMXL zzFr$puQ~em9Ads`P)Cx75cz054Ps?pxz3w8we4@b4)oD(N;9M`bY^%SLP|#c-3Z&m z>DO}(@YfYcUXYf&juEj)ewcdth@$5uLGi_W@ykPOcha~X*{>MaYys4Swn?g%;o05h z=fh!77B{Mo4MbkNVRd`Hv1|ET0*uoR^|UXwD-5AE;+u{XqNmM$r2OS~tO2WiaiA(S z-tFWpy#N@881KD64q?2^yBgVpcayzgr?ea%XcNO!mh!S0`ft==xy$)t@3XZHE%M6L zOL;SMX~@FsGjFcE&PmU+DD$D6^tQ8oHCYu@%L>m1x)Fkoi5yTv@N*9g#g80qQ6|t7 zxO+Gol>mK7i^6{niacjrE#>9C$GkF=?1~T6L?6GJ+f8fpUG5(wVC7yY*vESnr7G@D z38ly`gf~gJpL5$(zN+u)QhKePpvJ$tZR)-@&|ZJHY(|Fyfd6QFU)n2};b^-zhgd-% z9s!avw-kU=u#mQSbZVqHuwrcQy&DI&!x?qei}yEZ^L4Rz-`SUt;DNR%0mf8R#7yoT z_lR+tOxYH)L|ao0bH&lYe=0hqP^re(=w-z7l1`jixoF`4+V0YXoEec9#dAln&XErp zpB&uEx8w7Xs?;~#{77`|a=7III)5TRUJTmW>)d~s}&&RK%iH5Pj?sa;%<*?Z;! zIHLrgFXAVrNVbDh+zyOrQFt+n$1JRc4?D4w?eOs&);eW9JxE-;Ev4EO?i{F_3oWjPZUB+Udp?n_IjlsmZ(Mf}!N*&YVUe84zvcr~y z1BIK!Q5&%)PdgqfMQ2&Un3KAXwX)3xg8)xfPa<~!$-AI>2{w=Y)EMj>+lx4=>Asx`-y$hmpCw{Re}(nzF9Zx4gw;usPp520U5YHI|s#FkFy ztdxJ8eZgc`jLi5Mj<?9D< zzDeY6%FiO|KESP-&Y7a@gwNWPIO?UNl6mJo-d@Gx3@i64PsHUB>yK{aC(YEfJ*H>Q z{%>;KyVT~Gvv_`xQB9h@%`%y=PGq%P4HJmf!L^)NeVBAI{)otf&{LXR_5as9bOzF1bXb> z$XN4NP8r=s=99m=b1SD9d4%kh-OR%2T}%pbzJWA{))}kt&W#2vrgqc4Y{IrxEk`vk z@;xnllzP8|9jJ7^*Pb^c*24pF5Wlt2=tQYZjp$p5im9UcA17QUM!>R8x%v5hNmq>tWIvpioUiTrb*^6xLKPY`PT@LY>v*e>>ocJhzEGTk{%zU z9JFzKX8*K<{Ad681QI_~;{aj-)&KWQlAwCL|0l2+sNw;PS3n$mo+K|IiaB^^sFk#- zgFzrT3kY;{--P^*7_bTi1iBV*IszZ+?}^9zD_si=ur>#?Kv{o#l@-hhiARN1`x8I* hKM?&rxnE9xwM{NPi(>VNa-6s-?R@Yf*KR{?8=%y2wtG~$ zWbT)Bf*Hs<;Ow%gu_j%$?3ZzdX%?6tnu^A$bW?l#V6a--@ zB3ENK{R|k1OwO(i$V8%#GD0mqzK=b|l8fiX3{7>8!^k=YW815eOB}x)p*Xjs@&{0r z`X5tPOIB+MFj^6`<%TLcR3NEDAvC&J$xsdFArH4VK^eOT zm}m;i?tD`SkBBA5)Vd7z2_3p`ey5%0y?0ida0(RO+wud$U z87EShx=@A|u_DRx5=JIhQ7f^DWoyG}*Hh@v2*QfIJ_md{Pt^6^H%6S22E9jR$TrQU z`^n!oDtMvLWco^{wv;;^JIAU4ZpyF3Xg|}oXfZc%XgE+#YYhfSt!1)k#PIuyu4U>K z5owEyPWGq77VS-j$p(=yyuGZJ3{&EX+&aGVc_o@U*?hqQs|eF#0R_b1^(SL_MwdQ( zJIMkhAzlvm)-L0o-8bX!XRYaSzzx97p6VaML;gb5zP0J5cjHif%>ID!*XZHee)sma ztv7-u;2^& z*r2_iFG*u*1z4Wqy*)LAWTDKx7O>_3cX1MAElLUOX4Z3#M zsOv7D7qxM={bCe63XwoXqja@X)-mW|XJ~7QS2poPb+2XCbq^}=<_tN$cH6|$ml(bR z6Rj_3e_>(s=PsNjT3MKML{b`HKVjx%02 zGo&1rM2CyUed!*Jj<|c4SiHl~tyqyGG?V73LJcskoWxI)<$S|VRPGj1K(@j-_^oOZ z!;~8-ybEF*-ItlUr8Klr%1~f}dhGD**dtPDw{kAEN1_)pk8nikpbw~s5$}5EYXczx zrmZ9U-uo#Fqx4@uO$R)I-M1Kw;cs4%qJL5S&h~kg<+*@Z_MQp*ok?xo5A13XCicolV3Uy1Gka67(Hf)D zH5pffjeJR zEcT~9W0vO%Y^vkxZ4#juVWh^u4W8|kv+a8qSUC6GLxAbucmIL zc?fjBYWPH!qOJ<61i{%Ox-Hn1EZfE{_sFN4Up}R(-7Q$jN+pH#6)In=Mnl}pUa#o3 z3XAD_UmsFq2{Fq45*&?(kF*JSs_En3IK@|&=~i~F(|wef!t$grN=s^RTBxtWF*VBD z4b0Xtq7e)fmSqXg&j9xumQh116Q-GdHLuw5&sY>cNpVU{^)e*Gj+M<+c-dL*w$hLNRATqr#lMCZj?b zI3Bj)xUH3Eu9ioF85SWF@oEvrKQ}cGVX|4Kzf9w2m~_(aPL#Bgh*krKx;y{vKs*Q= z@YS}`>%%Bd9v_r#?Chb!@-_r@Gq_qf&9RaEW{@u z3`IVj`Yx|O8Ai`ITQqmr*xvu(A{Vs()&PPD)9R)3*9T*^2A*HSTb9KO7|N>q4;*4s z{@oG?5b&)F%6+ive@`+Itit$z@tOxqT+o;yP@on;0>XU(bk2bk!5+fLe?b=FOV2Jn o?;(dk0D$}a{yP|8JPV/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://.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 diff --git a/scripts/altium365_part_request.py b/scripts/altium365_part_request.py new file mode 100644 index 0000000..d757b3a --- /dev/null +++ b/scripts/altium365_part_request.py @@ -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 +`/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\\\\.PcbLib","C:\\\\lib\\\\.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://.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()