🔍 核心机关与修改指南
🔍 匿名搜索与鉴权自查 search & my
search 命令无需读取本地 Token 就能搜全网技能;my 命令调用经实测验证的 queryPageByUserCode 查自家在架列表。
🚀 双模式发版参数 --metadata-file
既支持直接传 JSON 字符串,也支持传 --metadata-file Tools/dist/publish_universal_q1.json,跨平台敲命令再也不怕引号转义报错!
💻 完整源码与配置内容(共 127 行 · 支持行号定位与一键复制)
| 1 | #!/usr/bin/env python3 |
| 2 | """PrimeHub 发布客户端(curl transport 版) |
| 3 | |
| 4 | 2026-10-19 实测:本机 python urllib 对 primehub SSL 握手会被服务端掐断 |
| 5 | (UNEXPECTED_EOF_WHILE_READING),curl 正常 → 全链路改走 subprocess curl。 |
| 6 | |
| 7 | 关键端点(均 https://primehub.primebot.com): |
| 8 | POST /app-api/content-server/file/upload multipart 明文 + Bearer → fileUrl |
| 9 | POST /app-api/content-server/skillCenter/publishSkill SM4 加密体(本模块 call()) |
| 10 | POST /app-api/search-server/portal/querySkillList SM4 加密体(免登录可查) |
| 11 | · 参数名是 PC 端约定:keywords / queryType / page / rows(pageNum/pageSize 会 9999) |
| 12 | POST /app-api/content-server/skillCenter/queryPageByUserCode 我的发布列表 |
| 13 | |
| 14 | 用法: |
| 15 | python3 primehub_publish.py search '<keywords>' |
| 16 | python3 primehub_publish.py publish <rpk路径> '<publishJson>' |
| 17 | """ |
| 18 | import json, uuid, time, subprocess, urllib.parse, sys, os |
| 19 | import argparse |
| 20 | from pathlib import Path |
| 21 | |
| 22 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| 23 | from primehub_client import KEY, IV # SM4 key/iv + unscramble |
| 24 | from extract_token import DEFAULT_TOKEN_FILE |
| 25 | from gmssl.sm4 import CryptSM4, SM4_ENCRYPT, SM4_DECRYPT |
| 26 | |
| 27 | BASE = "https://primehub.primebot.com" |
| 28 | TOKEN_FILE = DEFAULT_TOKEN_FILE # 提取器与发布器共用 workspace/.local,支持 PRIMEHUB_TOKEN_FILE |
| 29 | |
| 30 | |
| 31 | def call(path, params, token=None, ns="content-server"): |
| 32 | params = dict(params); params["timestamp"] = int(time.time() * 1000) |
| 33 | params["requestId"] = str(uuid.uuid4()) |
| 34 | inner = urllib.parse.quote(json.dumps(params, separators=(",", ":")), safe="") |
| 35 | c = CryptSM4(); c.set_key(KEY, SM4_ENCRYPT) |
| 36 | ct = c.crypt_cbc(IV, inner.encode()).hex() |
| 37 | result = subprocess.run( |
| 38 | ["curl", "-sS", "--max-time", "40", "-X", "POST", |
| 39 | BASE + "/app-api/" + ns + "/" + path, |
| 40 | "-H", "Content-Type: application/json", "-H", "request-t: true", |
| 41 | "-H", "Origin: " + BASE, "-H", "Referer: " + BASE + "/", |
| 42 | ] + (["-H", "Authorization: Bearer " + token] if token else []) |
| 43 | + ["-d", json.dumps({"data": ct})], |
| 44 | capture_output=True, text=True, encoding="utf-8") |
| 45 | if result.returncode: |
| 46 | raise RuntimeError("PrimeHub 网络请求失败,curl exit=%d" % result.returncode) |
| 47 | d = json.loads(result.stdout) |
| 48 | if isinstance(d.get("data"), str) and d.get("code") == 1000: |
| 49 | c2 = CryptSM4(); c2.set_key(KEY, SM4_DECRYPT) |
| 50 | d["data"] = json.loads(urllib.parse.unquote( |
| 51 | c2.crypt_cbc(IV, bytes.fromhex(d["data"])).decode())) |
| 52 | return d |
| 53 | |
| 54 | |
| 55 | def upload(rpk_path, token): |
| 56 | boundary = "----" + uuid.uuid4().hex |
| 57 | fn = os.path.basename(rpk_path) |
| 58 | part = ("--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; " |
| 59 | "filename=\"" + fn + "\"\r\nContent-Type: application/octet-stream\r\n\r\n") |
| 60 | body = part.encode() + open(rpk_path, "rb").read() + ("\r\n--" + boundary + "--\r\n").encode() |
| 61 | out = subprocess.run( |
| 62 | ["curl", "-s", "--max-time", "120", "-X", "POST", |
| 63 | BASE + "/app-api/content-server/file/upload", |
| 64 | "-H", "Content-Type: multipart/form-data; boundary=" + boundary, |
| 65 | "-H", "Authorization: Bearer " + token, "-H", "request-t: true", |
| 66 | "-H", "Origin: " + BASE, "-H", "Referer: " + BASE + "/", |
| 67 | "--data-binary", "@-"], input=body, capture_output=True).stdout.decode() |
| 68 | d = json.loads(out) |
| 69 | if isinstance(d.get("data"), str): |
| 70 | c = CryptSM4(); c.set_key(KEY, SM4_DECRYPT) |
| 71 | d["data"] = json.loads(urllib.parse.unquote( |
| 72 | c.crypt_cbc(IV, bytes.fromhex(d["data"])).decode())) |
| 73 | return d["data"]["fileUrl"] |
| 74 | |
| 75 | |
| 76 | def my_skills(token): |
| 77 | r = call("skillCenter/queryPageByUserCode", {"page": 1, "rows": 50}, token) |
| 78 | if r.get("code") != 1000: |
| 79 | raise RuntimeError("PrimeHub 我的技能查询失败:%s %s" % (r.get("code"), r.get("msg"))) |
| 80 | return (r.get("data") or {}).get("records") or [] |
| 81 | |
| 82 | |
| 83 | def delete(skill_id, token): |
| 84 | return call("skillCenter/deleteSkillById", {"id": skill_id}, token) |
| 85 | |
| 86 | |
| 87 | def search(keywords, token=None): |
| 88 | r = call("portal/querySkillList", {"keywords": keywords, "queryType": 1, "page": 1, "rows": 30}, |
| 89 | token, ns="search-server") |
| 90 | if r.get("code") != 1000: |
| 91 | raise RuntimeError("PrimeHub 搜索失败:%s %s" % (r.get("code"), r.get("msg"))) |
| 92 | recs = (r.get("data") or {}).get("records") or [] |
| 93 | return [{"id": s.get("id"), "name": s.get("skillName"), "models": s.get("robotModels")} for s in recs] |
| 94 | |
| 95 | |
| 96 | def main(): |
| 97 | parser = argparse.ArgumentParser(description="PrimeHub 搜索、发布和管理") |
| 98 | commands = parser.add_subparsers(dest="command", required=True) |
| 99 | commands.add_parser("search").add_argument("keywords") |
| 100 | commands.add_parser("my") |
| 101 | commands.add_parser("delete").add_argument("skill_id", type=int) |
| 102 | publish_parser = commands.add_parser("publish") |
| 103 | publish_parser.add_argument("rpk") |
| 104 | metadata = publish_parser.add_mutually_exclusive_group(required=True) |
| 105 | metadata.add_argument("publish_json", nargs="?") |
| 106 | metadata.add_argument("--metadata-file", type=Path) |
| 107 | args = parser.parse_args() |
| 108 | |
| 109 | if args.command == "search": |
| 110 | for s in search(args.keywords): |
| 111 | print(s["id"], s["name"], s["models"]) |
| 112 | return |
| 113 | token = Path(TOKEN_FILE).read_text(encoding="utf-8").strip() |
| 114 | if args.command == "my": |
| 115 | for s in my_skills(token): |
| 116 | print(s.get("id"), s.get("skillName")) |
| 117 | elif args.command == "delete": |
| 118 | print(delete(args.skill_id, token)) |
| 119 | elif args.command == "publish": |
| 120 | meta = json.loads(args.metadata_file.read_text(encoding="utf-8") |
| 121 | if args.metadata_file else args.publish_json) |
| 122 | meta["packageUrl"] = upload(args.rpk, token) |
| 123 | print(call("skillCenter/publishSkill", meta, token)) |
| 124 | |
| 125 | |
| 126 | if __name__ == "__main__": |
| 127 | main() |