wp.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997
  1. #!/usr/bin/env python3
  2. """WikiPali 写入客户端。
  3. 以「AI 模型身份」把句子写入 WikiPali 句子库。子命令:
  4. endpoint 查看 / 切换 API 地址
  5. whoami 显示当前凭据状态
  6. ensure-model 幂等地建立模型记录并取模型身份 token
  7. revoke 撤销该模型已签出的全部 token
  8. channels 列出当前账号可编辑的 channel
  9. grant 为某个 channel 签发 access token 并缓存
  10. write 写入句子(分批 + 确认 + count 核对)
  11. 约束:只用 Python 标准库,目录自包含,可整体复制到任意项目。
  12. 密码只由 wp_login.py 接触,本脚本永不读密码。
  13. """
  14. import argparse
  15. import base64
  16. import json
  17. import os
  18. import stat
  19. import sys
  20. import time
  21. import urllib.error
  22. import urllib.parse
  23. import urllib.request
  24. from datetime import datetime, timezone
  25. # ---------------------------------------------------------------------------
  26. # 站点清单
  27. # ---------------------------------------------------------------------------
  28. # 四个线上地址共享同一个数据库和同一把 jwt 密钥,凭据完全通用;
  29. # .org / .cc 是地区可达性,www / next 是代码版本(不是数据环境)。
  30. # 开发机是另一个库、另一把密钥,故单独一桶,且永不作为自动 fallback 目标。
  31. SITES = [
  32. {"key": "www", "url": "https://www.wikipali.org/api", "version": "稳定版", "domain": ".org"},
  33. {"key": "www.cc", "url": "https://www.wikipali.cc/api", "version": "稳定版", "domain": ".cc"},
  34. {"key": "next", "url": "https://next.wikipali.org/api", "version": "最新版", "domain": ".org"},
  35. {"key": "next.cc", "url": "https://next.wikipali.cc/api", "version": "最新版", "domain": ".cc"},
  36. {"key": "local", "url": "http://127.0.0.1:8000/api", "version": "开发机", "domain": "本机"},
  37. ]
  38. ONLINE_URLS = [s["url"] for s in SITES if s["key"] != "local"]
  39. LOCAL_URL = SITES[-1]["url"]
  40. DEFAULT_API_URL = SITES[0]["url"]
  41. CREDS_DIR = os.path.join(os.path.expanduser("~"), ".wikipali")
  42. CREDS_PATH = os.path.join(CREDS_DIR, "credentials.json")
  43. # access token 剩余不足这么多秒就重新签发,避免写到一半过期
  44. TOKEN_REFRESH_MARGIN = 3600
  45. DEFAULT_TIMEOUT = 30
  46. WRITE_TIMEOUT = 120
  47. DEFAULT_BATCH = 50
  48. # ---------------------------------------------------------------------------
  49. # 错误类型
  50. # ---------------------------------------------------------------------------
  51. class WpError(Exception):
  52. """面向用户的错误:main() 捕获后只打印 message,不打印堆栈。"""
  53. class ApiError(WpError):
  54. def __init__(self, status, message, url=None, body=None):
  55. self.status = status
  56. self.url = url
  57. self.body = body
  58. super().__init__(message)
  59. # ---------------------------------------------------------------------------
  60. # 凭据文件
  61. # ---------------------------------------------------------------------------
  62. def load_creds():
  63. if not os.path.exists(CREDS_PATH):
  64. return {"current": "online"}
  65. try:
  66. with open(CREDS_PATH, "r", encoding="utf-8") as fh:
  67. data = json.load(fh)
  68. except (OSError, ValueError) as exc:
  69. raise WpError(f"凭据文件无法读取({CREDS_PATH}):{exc}")
  70. if not isinstance(data, dict):
  71. raise WpError(f"凭据文件格式不对({CREDS_PATH}),应为 JSON 对象")
  72. data.setdefault("current", "online")
  73. return data
  74. def save_creds(creds):
  75. os.makedirs(CREDS_DIR, mode=0o700, exist_ok=True)
  76. tmp = CREDS_PATH + ".tmp"
  77. flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
  78. fd = os.open(tmp, flags, 0o600)
  79. try:
  80. with os.fdopen(fd, "w", encoding="utf-8") as fh:
  81. json.dump(creds, fh, ensure_ascii=False, indent=2)
  82. fh.write("\n")
  83. except Exception:
  84. os.unlink(tmp)
  85. raise
  86. os.replace(tmp, CREDS_PATH)
  87. os.chmod(CREDS_PATH, stat.S_IRUSR | stat.S_IWUSR)
  88. def bucket_name_for(api_url):
  89. """凭据桶名。线上四地址共用 online 桶;开发机 local;其余地址自成一桶。"""
  90. if api_url in ONLINE_URLS:
  91. return "online"
  92. if api_url == LOCAL_URL:
  93. return "local"
  94. return "site:" + api_url
  95. def get_bucket(creds, name, api_url=None):
  96. bucket = creds.setdefault(name, {})
  97. bucket.setdefault("api_url", api_url or (DEFAULT_API_URL if name == "online" else LOCAL_URL))
  98. bucket.setdefault("user", {})
  99. bucket.setdefault("model", {})
  100. bucket.setdefault("access_tokens", {})
  101. return bucket
  102. def normalize_api_url(url):
  103. url = url.rstrip("/")
  104. parsed = urllib.parse.urlparse(url)
  105. if parsed.scheme not in ("http", "https"):
  106. raise WpError(f"API 地址必须以 http:// 或 https:// 开头:{url}")
  107. host = (parsed.hostname or "").lower()
  108. if parsed.scheme == "http" and host not in ("127.0.0.1", "localhost", "::1"):
  109. raise WpError(f"只有 127.0.0.1 / localhost 允许用 http://,其余必须 https://:{url}")
  110. return url
  111. def resolve_api_url(cli_api, creds):
  112. """地址来源优先级:--api > 环境变量 > 凭据文件 > 内置默认。
  113. 前两者是一次性覆盖,不写回凭据文件——否则「上周试了一次 next」会一直粘着。
  114. """
  115. if cli_api:
  116. return normalize_api_url(expand_site_alias(cli_api)), "cli"
  117. env = os.environ.get("WIKIPALI_API_URL")
  118. if env:
  119. return normalize_api_url(expand_site_alias(env)), "env"
  120. current = creds.get("current", "online")
  121. bucket = creds.get(current)
  122. if isinstance(bucket, dict) and bucket.get("api_url"):
  123. return normalize_api_url(bucket["api_url"]), "creds"
  124. return DEFAULT_API_URL, "default"
  125. def expand_site_alias(value):
  126. """把序号 / 简称展开成完整 url;已是 url 则原样返回。"""
  127. value = value.strip()
  128. if value.isdigit():
  129. idx = int(value) - 1
  130. if 0 <= idx < len(SITES):
  131. return SITES[idx]["url"]
  132. raise WpError(f"站点序号超出范围:{value}(可选 1-{len(SITES)})")
  133. for site in SITES:
  134. if value == site["key"]:
  135. return site["url"]
  136. if "://" in value:
  137. return value
  138. raise WpError(
  139. f"无法识别的站点:{value}。可用简称:" + " / ".join(s["key"] for s in SITES) + ",或直接给完整 url"
  140. )
  141. def site_label(api_url):
  142. for site in SITES:
  143. if site["url"] == api_url:
  144. return f"{site['version']} · {site['domain']}"
  145. return "自定义地址"
  146. # ---------------------------------------------------------------------------
  147. # HTTP
  148. # ---------------------------------------------------------------------------
  149. def mask(token):
  150. if not token:
  151. return "(无)"
  152. if len(token) <= 16:
  153. return token[:4] + "…"
  154. return token[:8] + "…" + token[-4:]
  155. def jwt_payload(token):
  156. """不验签地读出 JWT payload,仅用于显示有效期。"""
  157. try:
  158. part = token.split(".")[1]
  159. part += "=" * (-len(part) % 4)
  160. return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))
  161. except Exception:
  162. return {}
  163. def token_expiry(token):
  164. exp = jwt_payload(token).get("exp")
  165. return int(exp) if isinstance(exp, (int, float)) else None
  166. def fmt_ts(ts):
  167. if not ts:
  168. return "未知"
  169. return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
  170. def note(msg):
  171. print(msg, file=sys.stderr)
  172. def http_json(api_url, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
  173. """发一个 JSON 请求,返回解析后的响应体(dict)。
  174. 网络层失败抛 urllib 的异常(由 Client 决定是否 fallback);
  175. HTTP 层失败抛 ApiError,带上服务端 message。
  176. """
  177. url = api_url + "/" + path.lstrip("/")
  178. if query:
  179. url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
  180. data = None
  181. headers = {"Accept": "application/json", "User-Agent": "wikipali-write-skill"}
  182. if body is not None:
  183. data = json.dumps(body, ensure_ascii=False).encode("utf-8")
  184. headers["Content-Type"] = "application/json"
  185. if token:
  186. headers["Authorization"] = "Bearer " + token
  187. req = urllib.request.Request(url, data=data, headers=headers, method=method)
  188. try:
  189. with urllib.request.urlopen(req, timeout=timeout) as resp:
  190. raw = resp.read().decode("utf-8", "replace")
  191. status = resp.status
  192. except urllib.error.HTTPError as exc:
  193. raw = exc.read().decode("utf-8", "replace")
  194. status = exc.code
  195. payload = safe_json(raw)
  196. message = payload.get("message") if isinstance(payload, dict) else None
  197. raise ApiError(status, message or f"HTTP {status}", url=url, body=payload or raw)
  198. payload = safe_json(raw)
  199. if not isinstance(payload, dict):
  200. raise ApiError(status, f"响应不是 JSON:{raw[:200]}", url=url, body=raw)
  201. if not payload.get("ok", False):
  202. raise ApiError(status, payload.get("message") or "请求失败", url=url, body=payload)
  203. return payload.get("data")
  204. def safe_json(raw):
  205. try:
  206. return json.loads(raw)
  207. except ValueError:
  208. return None
  209. class Client:
  210. """按站点收发请求,并在线上地址之间做出声的 fallback。"""
  211. def __init__(self, api_url, source, creds, allow_fallback=True):
  212. self.api_url = api_url
  213. self.source = source
  214. self.creds = creds
  215. self.bucket_name = bucket_name_for(api_url)
  216. self.bucket = get_bucket(creds, self.bucket_name, api_url)
  217. self.allow_fallback = allow_fallback and api_url in ONLINE_URLS
  218. # -- 凭据 ---------------------------------------------------------------
  219. @property
  220. def user_token(self):
  221. token = (self.bucket.get("user") or {}).get("token")
  222. if not token:
  223. raise WpError(
  224. "尚未登录。请自己执行(Claude Code 里用 ! 前缀):\n"
  225. " ! python3 " + os.path.join(os.path.dirname(os.path.abspath(__file__)), "wp_login.py")
  226. )
  227. return token
  228. @property
  229. def model(self):
  230. model = self.bucket.get("model") or {}
  231. if not model.get("token"):
  232. raise WpError("尚未取得模型身份 token。请先跑:python3 wp.py ensure-model --name <模型名>")
  233. return model
  234. def save(self):
  235. save_creds(self.creds)
  236. # -- 请求 ---------------------------------------------------------------
  237. def fallback_order(self):
  238. """同版本的另一域名 → 另一版本的同域名 → 其余。绝不含 local。"""
  239. cur = next((s for s in SITES if s["url"] == self.api_url), None)
  240. if not cur:
  241. return []
  242. others = [s for s in SITES if s["key"] != "local" and s["url"] != self.api_url]
  243. others.sort(
  244. key=lambda s: (
  245. 0 if s["version"] == cur["version"] else 1,
  246. 0 if s["domain"] == cur["domain"] else 1,
  247. )
  248. )
  249. return [s["url"] for s in others]
  250. def call(self, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
  251. urls = [self.api_url] + (self.fallback_order() if self.allow_fallback else [])
  252. last = None
  253. for idx, url in enumerate(urls):
  254. try:
  255. data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
  256. except (urllib.error.URLError, TimeoutError, OSError) as exc:
  257. # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
  258. last = exc
  259. reason = getattr(exc, "reason", exc)
  260. if idx + 1 < len(urls):
  261. note(f"⚠ {url} 连接失败({reason}),改用 {urls[idx + 1]}")
  262. continue
  263. if url != self.api_url:
  264. # fallback 成功后本次会话都用它,但不写回凭据文件
  265. note(f"⚠ 本次请求实际发往 {url}({site_label(url)})")
  266. self.api_url = url
  267. return data
  268. raise WpError(f"所有可用站点都连不上,最后一次错误:{last}")
  269. def api_note(self):
  270. src = {"cli": "--api", "env": "环境变量", "creds": "凭据文件", "default": "内置默认"}[self.source]
  271. return f"{self.api_url}({site_label(self.api_url)},来源:{src})"
  272. def make_client(args, allow_fallback=True):
  273. creds = load_creds()
  274. api_url, source = resolve_api_url(getattr(args, "api", None), creds)
  275. return Client(api_url, source, creds, allow_fallback=allow_fallback)
  276. def explain_api_error(exc, what):
  277. """把 HTTP 状态翻译成对操作者有意义的话(见 references/api.md 的错误约定)。"""
  278. if exc.status == 401:
  279. return WpError(
  280. f"{what}:401 凭据失效或已被撤销。\n"
  281. " · 用户 token 失效 → 重新登录:! python3 scripts/wp_login.py\n"
  282. " · 模型 token 失效或被撤销 → 重跑:python3 wp.py ensure-model\n"
  283. " 不要自动重试。"
  284. )
  285. if exc.status == 403:
  286. return WpError(f"{what}:403 无权限(不是 channel 的 owner/协作者,或不是模型 owner 本人)。")
  287. if exc.status == 404:
  288. return WpError(
  289. f"{what}:404。若这是较新的端点,可能是当前站点跑的是稳定版代码、端点尚未上线;\n"
  290. " 可切到最新版试试:python3 wp.py endpoint next\n"
  291. " 否则才是资源真的不存在。"
  292. )
  293. if exc.status == 409:
  294. return WpError(f"{what}:409 同名记录已存在。")
  295. if exc.status == 422:
  296. return WpError(f"{what}:422 参数校验失败——{exc}")
  297. return WpError(f"{what}:HTTP {exc.status} {exc}")
  298. # ---------------------------------------------------------------------------
  299. # 子命令:endpoint
  300. # ---------------------------------------------------------------------------
  301. def cmd_endpoint(args):
  302. creds = load_creds()
  303. current_url, source = resolve_api_url(getattr(args, "api", None), creds)
  304. if not args.target:
  305. for idx, site in enumerate(SITES, 1):
  306. mark = " ← 当前" if site["url"] == current_url else ""
  307. print(f" {idx}) {site['url']:<32} {site['version']} · {site['domain']}{mark}")
  308. if source in ("cli", "env"):
  309. src = "--api" if source == "cli" else "WIKIPALI_API_URL"
  310. note(f"注意:当前地址来自 {src},是一次性覆盖,未写入凭据文件。")
  311. if current_url not in [s["url"] for s in SITES]:
  312. print(f" *) {current_url:<32} 自定义地址 ← 当前")
  313. print("\n切换:python3 wp.py endpoint <序号|www|www.cc|next|next.cc|local|完整url>")
  314. return 0
  315. url = normalize_api_url(expand_site_alias(args.target))
  316. name = bucket_name_for(url)
  317. bucket = get_bucket(creds, name, url)
  318. bucket["api_url"] = url
  319. creds["current"] = name
  320. save_creds(creds)
  321. print(f"已切换到 {url}({site_label(url)})")
  322. if name != "online":
  323. note("提示:该地址与线上四站不共用数据库/密钥,凭据是独立的一桶,可能需要重新登录。")
  324. return 0
  325. # ---------------------------------------------------------------------------
  326. # 子命令:whoami
  327. # ---------------------------------------------------------------------------
  328. def cmd_whoami(args):
  329. client = make_client(args)
  330. print(f"API : {client.api_note()}")
  331. print(f"凭据文件 : {CREDS_PATH}(桶:{client.bucket_name})")
  332. user = client.bucket.get("user") or {}
  333. if user.get("token"):
  334. exp = token_expiry(user["token"])
  335. expired = exp is not None and exp < time.time()
  336. print(f"用户 : {user.get('username', '?')} uid={user.get('uid', '?')}")
  337. print(f" token {mask(user['token'])} 到期 {fmt_ts(exp)}{' ⚠ 已过期' if expired else ''}")
  338. else:
  339. print("用户 : 未登录(! python3 scripts/wp_login.py)")
  340. model = client.bucket.get("model") or {}
  341. if model.get("token"):
  342. exp = token_expiry(model["token"])
  343. expired = exp is not None and exp < time.time()
  344. print(f"模型 : {model.get('name', '?')} uid={model.get('uid', '?')}")
  345. print(f" token {mask(model['token'])} 到期 {fmt_ts(exp)}{' ⚠ 已过期' if expired else ''}")
  346. else:
  347. print("模型 : 未建立(python3 wp.py ensure-model --name <模型名>)")
  348. tokens = client.bucket.get("access_tokens") or {}
  349. if tokens:
  350. print("access token:")
  351. for uid, item in tokens.items():
  352. exp = item.get("exp") or token_expiry(item.get("token", ""))
  353. expired = exp is not None and exp < time.time()
  354. book = item.get("book", 0)
  355. scope = "全部 book" if book == 0 else f"book {book}"
  356. name = item.get("channel_name") or ""
  357. print(f" {uid[:8]}… {name:<24} {scope:<10} 到期 {fmt_ts(exp)}{' ⚠ 已过期' if expired else ''}")
  358. else:
  359. print("access token:无(python3 wp.py grant <channel>)")
  360. if args.check:
  361. try:
  362. data = client.call("GET", "v2/auth/current", token=client.user_token)
  363. except ApiError as exc:
  364. raise explain_api_error(exc, "校验用户 token")
  365. print(f"\n服务端确认:{data.get('nickName')} / realName={data.get('realName')}(studio_name 用它)")
  366. return 0
  367. # ---------------------------------------------------------------------------
  368. # 子命令:ensure-model
  369. # ---------------------------------------------------------------------------
  370. def cmd_ensure_model(args):
  371. client = make_client(args)
  372. token = client.user_token
  373. name = args.name or (client.bucket.get("model") or {}).get("name") or os.environ.get("WIKIPALI_MODEL_NAME")
  374. if not name:
  375. raise WpError(
  376. "必须指定模型名:--name <模型标识>(如 claude-opus-5)。\n"
  377. "该名字会成为句子的作者署名,不要用别的模型的名字。"
  378. )
  379. try:
  380. current = client.call("GET", "v2/auth/current", token=token)
  381. except ApiError as exc:
  382. raise explain_api_error(exc, "取当前用户信息")
  383. studio_name = current.get("realName")
  384. if not studio_name:
  385. raise WpError("服务端没有返回 realName,无法确定 studio_name。")
  386. # 1) 按 studio + keyword 查,keyword 是模糊匹配,客户端自己做精确比对
  387. try:
  388. listed = client.call(
  389. "GET", "v2/ai-model", token=token,
  390. query={"view": "studio", "name": studio_name, "keyword": name},
  391. )
  392. except ApiError as exc:
  393. raise explain_api_error(exc, "查询模型列表")
  394. rows = (listed or {}).get("rows") or []
  395. found = next((r for r in rows if r.get("name") == name), None)
  396. if found:
  397. print(f"已存在模型记录:{name} uid={found['uid']}")
  398. else:
  399. body = {"name": name, "studio_name": studio_name, "privacy": args.privacy}
  400. for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
  401. if value is not None:
  402. body[field] = value
  403. try:
  404. found = client.call("POST", "v2/ai-model", token=token, body=body)
  405. print(f"已创建模型记录:{name} uid={found['uid']}")
  406. except ApiError as exc:
  407. if exc.status != 409:
  408. raise explain_api_error(exc, "创建模型记录")
  409. # 并发或模糊匹配漏网:重查一次
  410. listed = client.call(
  411. "GET", "v2/ai-model", token=token,
  412. query={"view": "studio", "name": studio_name, "keyword": name},
  413. )
  414. rows = (listed or {}).get("rows") or []
  415. found = next((r for r in rows if r.get("name") == name), None)
  416. if not found:
  417. raise WpError(f"服务端说 {name} 已存在(409),但列表里查不到,无法继续。")
  418. print(f"已存在模型记录:{name} uid={found['uid']}")
  419. # 2) 增量补字段(update 是增量的,未提交的字段保持原值)
  420. patch = {}
  421. for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
  422. if value is not None and found.get(field) != value:
  423. patch[field] = value
  424. if args.privacy and found.get("privacy") != args.privacy:
  425. patch["privacy"] = args.privacy
  426. if patch:
  427. try:
  428. found = client.call("PUT", f"v2/ai-model/{found['uid']}", token=token, body=patch)
  429. print(f"已更新字段:{', '.join(patch)}")
  430. except ApiError as exc:
  431. raise explain_api_error(exc, "更新模型记录")
  432. # 3) 取模型身份 token
  433. try:
  434. issued = client.call("GET", f"v2/ai-model-token/{found['uid']}", token=token)
  435. except ApiError as exc:
  436. raise explain_api_error(exc, "签发模型身份 token")
  437. client.bucket["model"] = {
  438. "uid": issued["uid"],
  439. "name": issued["name"],
  440. "token": issued["token"],
  441. "issued_at": iso_now(),
  442. }
  443. client.save()
  444. exp = token_expiry(issued["token"])
  445. print(f"模型身份 token 已缓存:{mask(issued['token'])} 到期 {fmt_ts(exp)}")
  446. print(f"写入的句子将署名为该模型(editor_uid={issued['uid']})。")
  447. return 0
  448. def cmd_revoke(args):
  449. client = make_client(args)
  450. model = client.bucket.get("model") or {}
  451. uid = args.uid or model.get("uid")
  452. if not uid:
  453. raise WpError("没有可撤销的模型:请给 --uid <模型 uid>,或先跑 ensure-model。")
  454. if not args.yes and not confirm(f"将撤销模型 {model.get('name', uid)} 已签出的全部 token,继续?"):
  455. print("已取消。")
  456. return 1
  457. try:
  458. data = client.call("DELETE", f"v2/ai-model-token/{uid}", token=client.user_token)
  459. except ApiError as exc:
  460. raise explain_api_error(exc, "撤销模型 token")
  461. if model.get("uid") == uid:
  462. client.bucket["model"] = {"uid": uid, "name": model.get("name")}
  463. client.save()
  464. print(f"已撤销 {data.get('name')} 的全部 token(token_version={data.get('token_version')})。")
  465. print("本地缓存的模型 token 已清除,需要写入时请重跑 ensure-model。")
  466. return 0
  467. # ---------------------------------------------------------------------------
  468. # 子命令:channels
  469. # ---------------------------------------------------------------------------
  470. def fetch_channels(client, search=None):
  471. try:
  472. data = client.call(
  473. "GET", "v2/channel", token=client.user_token,
  474. query={"view": "user-edit", "order": "updated_at", "dir": "desc", "limit": 200, "search": search},
  475. )
  476. except ApiError as exc:
  477. raise explain_api_error(exc, "获取可编辑 channel 列表")
  478. return (data or {}).get("rows") or []
  479. def cmd_channels(args):
  480. client = make_client(args)
  481. rows = fetch_channels(client, args.search)
  482. if args.json:
  483. print(json.dumps(rows, ensure_ascii=False, indent=2))
  484. return 0
  485. if not rows:
  486. print("当前账号没有任何可编辑的 channel。")
  487. return 1
  488. print(f"可编辑 channel({len(rows)} 个,按更新时间倒序):")
  489. for idx, ch in enumerate(rows, 1):
  490. print(
  491. f" {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} "
  492. f"{ch.get('uid', '')[:8]}… {ch.get('role', '')}"
  493. )
  494. return 0
  495. def pick_channel(client, given, interactive=True):
  496. """返回 (uid, name)。given 可以是 uid、序号或名字片段;为空则交互选择。"""
  497. rows = fetch_channels(client)
  498. if not rows:
  499. raise WpError("当前账号没有任何可编辑的 channel,无法继续。")
  500. if given:
  501. for ch in rows:
  502. if ch.get("uid") == given:
  503. return ch["uid"], ch.get("name")
  504. if given.isdigit() and 1 <= int(given) <= len(rows):
  505. ch = rows[int(given) - 1]
  506. return ch["uid"], ch.get("name")
  507. matched = [c for c in rows if given.lower() in (c.get("name") or "").lower()]
  508. if len(matched) == 1:
  509. return matched[0]["uid"], matched[0].get("name")
  510. if len(matched) > 1:
  511. names = ", ".join(c.get("name", "") for c in matched[:5])
  512. raise WpError(f"「{given}」匹配到多个 channel:{names}…… 请给完整 uid。")
  513. # 不在可编辑列表里的 uid:直接用,但回显不出名字
  514. if len(given) >= 32:
  515. note(f"⚠ {given} 不在可编辑列表中,仍按 uid 使用——签发 access token 时可能返回 count: 0。")
  516. return given, None
  517. raise WpError(f"找不到 channel:{given}")
  518. if not (interactive and sys.stdin.isatty()):
  519. raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wp.py channels` 再用 --channel 指定。")
  520. print("可编辑 channel:")
  521. for idx, ch in enumerate(rows, 1):
  522. print(f" {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} {ch.get('uid', '')[:8]}…")
  523. raw = input("选择序号:").strip()
  524. if not raw.isdigit() or not (1 <= int(raw) <= len(rows)):
  525. raise WpError("输入无效。")
  526. ch = rows[int(raw) - 1]
  527. return ch["uid"], ch.get("name")
  528. # ---------------------------------------------------------------------------
  529. # 子命令:grant
  530. # ---------------------------------------------------------------------------
  531. def cached_access_token(client, channel_uid, book):
  532. item = (client.bucket.get("access_tokens") or {}).get(channel_uid)
  533. if not item or not item.get("token"):
  534. return None
  535. # book 0 是「不限 book」,能覆盖任何请求;否则必须完全一致
  536. if item.get("book", 0) != 0 and item.get("book") != book:
  537. return None
  538. exp = item.get("exp") or token_expiry(item["token"])
  539. if exp and exp - time.time() < TOKEN_REFRESH_MARGIN:
  540. return None
  541. return item
  542. def grant_access_token(client, channel_uid, channel_name, book, force=False):
  543. if not force:
  544. cached = cached_access_token(client, channel_uid, book)
  545. if cached:
  546. return cached
  547. # book 必须是整数:服务端用 !== 严格比较,"1" !== 1 恒真会导致鉴权失败
  548. payload = [{"res_type": "channel", "res_id": channel_uid, "power": "edit", "book": int(book)}]
  549. try:
  550. data = client.call("POST", "v2/access-token", token=client.user_token, body={"payload": payload})
  551. except ApiError as exc:
  552. raise explain_api_error(exc, "签发 access token")
  553. rows = (data or {}).get("rows") or []
  554. if not rows:
  555. # 无权时服务端静默跳过该条,rows 为空——等同 403,绝不能继续写
  556. raise WpError(
  557. f"签发 access token 返回 count: 0,说明当前账号对 channel {channel_uid} 没有编辑权。\n"
  558. "不要继续写入。请确认选对了 channel,或让 owner 授予 ≥ editor 权限。"
  559. )
  560. row = rows[0]
  561. item = {
  562. "token": row["token"],
  563. "book": int(book),
  564. "exp": (row.get("payload") or {}).get("exp"),
  565. "granted_at": iso_now(),
  566. }
  567. if channel_name:
  568. item["channel_name"] = channel_name
  569. client.bucket.setdefault("access_tokens", {})[channel_uid] = item
  570. client.save()
  571. return item
  572. def cmd_grant(args):
  573. client = make_client(args)
  574. uid, name = pick_channel(client, args.channel)
  575. item = grant_access_token(client, uid, name, args.book, force=args.force)
  576. scope = "全部 book" if item["book"] == 0 else f"book {item['book']}"
  577. print(f"channel : {name or '(未知)'} {uid}")
  578. print(f"范围 : {scope}")
  579. print(f"token : {mask(item['token'])} 到期 {fmt_ts(item.get('exp'))}")
  580. return 0
  581. # ---------------------------------------------------------------------------
  582. # 子命令:write
  583. # ---------------------------------------------------------------------------
  584. SENT_REQUIRED = ("book_id", "paragraph", "word_start", "word_end", "content")
  585. def load_sentences(args):
  586. if args.file == "-":
  587. raw = sys.stdin.read()
  588. else:
  589. try:
  590. with open(args.file, "r", encoding="utf-8") as fh:
  591. raw = fh.read()
  592. except OSError as exc:
  593. raise WpError(f"读不了输入文件:{exc}")
  594. try:
  595. data = json.loads(raw)
  596. except ValueError as exc:
  597. raise WpError(f"输入不是合法 JSON:{exc}")
  598. default_channel = None
  599. if isinstance(data, dict):
  600. default_channel = data.get("channel_uid") or data.get("channel")
  601. data = data.get("sentences")
  602. if not isinstance(data, list) or not data:
  603. raise WpError('输入必须是句子数组,或 {"channel_uid": ..., "sentences": [...]},且非空。')
  604. return data, default_channel
  605. def normalize_sentences(rows, channel_uid, default_content_type):
  606. out = []
  607. for idx, row in enumerate(rows):
  608. if not isinstance(row, dict):
  609. raise WpError(f"第 {idx + 1} 条不是对象。")
  610. missing = [f for f in SENT_REQUIRED if row.get(f) is None]
  611. if missing:
  612. raise WpError(f"第 {idx + 1} 条缺字段:{', '.join(missing)}")
  613. try:
  614. sent = {
  615. "book_id": int(row["book_id"]),
  616. "paragraph": int(row["paragraph"]),
  617. "word_start": int(row["word_start"]),
  618. "word_end": int(row["word_end"]),
  619. "content": str(row["content"]),
  620. "content_type": row.get("content_type") or default_content_type,
  621. "channel_uid": row.get("channel_uid") or channel_uid,
  622. }
  623. except (TypeError, ValueError) as exc:
  624. raise WpError(f"第 {idx + 1} 条字段类型不对:{exc}")
  625. if not sent["channel_uid"]:
  626. raise WpError(f"第 {idx + 1} 条没有 channel_uid,且未通过 --channel 指定。")
  627. out.append(sent)
  628. return out
  629. def sent_key(sent):
  630. return (
  631. int(sent["book_id"]),
  632. int(sent["paragraph"]),
  633. int(sent["word_start"]),
  634. int(sent["word_end"]),
  635. sent["channel_uid"],
  636. )
  637. def row_key(row):
  638. channel = row.get("channel") or {}
  639. return (
  640. int(row.get("book", -1)),
  641. int(row.get("paragraph", -1)),
  642. int(row.get("word_start", -1)),
  643. int(row.get("word_end", -1)),
  644. channel.get("uid"),
  645. )
  646. def confirm(question):
  647. if not sys.stdin.isatty():
  648. return False
  649. answer = input(f"{question} [y/N] ").strip().lower()
  650. return answer in ("y", "yes")
  651. def iso_now():
  652. return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
  653. def cmd_write(args):
  654. client = make_client(args)
  655. # 先确认凭据齐备再解析输入:缺 token 时不该让用户看半张回显
  656. client.user_token
  657. model = client.model
  658. rows, file_channel = load_sentences(args)
  659. channel_hint = args.channel or file_channel
  660. uid = name = None
  661. if channel_hint or not any(r.get("channel_uid") for r in rows if isinstance(r, dict)):
  662. uid, name = pick_channel(client, channel_hint)
  663. sentences = normalize_sentences(rows, uid, args.content_type)
  664. channels = sorted({s["channel_uid"] for s in sentences})
  665. books = sorted({s["book_id"] for s in sentences})
  666. names = {uid: name} if uid else {}
  667. for cuid in channels:
  668. if cuid not in names:
  669. names[cuid] = channel_display_name(client, cuid)
  670. # 写入前的确认:出问题时必须知道是哪一版代码、写进了哪个 channel
  671. print("=" * 72)
  672. print(f"API : {client.api_note()}")
  673. for cuid in channels:
  674. print(f"channel : {names.get(cuid) or '(未知)'} {cuid}")
  675. print(f"book : {', '.join(str(b) for b in books)}")
  676. print(f"模型身份 : {model.get('name')} uid={model.get('uid')}")
  677. print(f"句子数 : {len(sentences)}(每批 {args.batch})")
  678. print("-" * 72)
  679. for sent in sentences[: args.preview]:
  680. summary = sent["content"].replace("\n", " ")
  681. if len(summary) > 50:
  682. summary = summary[:50] + "…"
  683. print(f" {sent['book_id']}-{sent['paragraph']}-{sent['word_start']}-{sent['word_end']} {summary}")
  684. if len(sentences) > args.preview:
  685. print(f" …… 其余 {len(sentences) - args.preview} 条")
  686. print("-" * 72)
  687. print("⚠ 相同位置(book/paragraph/word_start/word_end/channel)的已有句子将被覆盖。")
  688. print("=" * 72)
  689. if args.dry_run:
  690. print("--dry-run:未发送任何请求。")
  691. return 0
  692. if not args.yes and not confirm("确认写入?"):
  693. print("已取消,未写入任何内容。")
  694. return 1
  695. # 每个 channel 一张 access token(缓存命中就不重签)
  696. tokens = {}
  697. for cuid in channels:
  698. book_scope = 0 if len(books) > 1 else books[0]
  699. if args.book is not None:
  700. book_scope = args.book
  701. item = grant_access_token(client, cuid, names.get(cuid), book_scope)
  702. tokens[cuid] = item["token"]
  703. written = {}
  704. failed = []
  705. model_token = model["token"]
  706. for start in range(0, len(sentences), args.batch):
  707. batch = sentences[start : start + args.batch]
  708. body = {
  709. "sentences": [
  710. {
  711. "book_id": s["book_id"],
  712. "paragraph": s["paragraph"],
  713. "word_start": s["word_start"],
  714. "word_end": s["word_end"],
  715. "channel_uid": s["channel_uid"],
  716. "content": s["content"],
  717. "content_type": s["content_type"],
  718. "access_token": tokens[s["channel_uid"]],
  719. }
  720. for s in batch
  721. ]
  722. }
  723. try:
  724. data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
  725. except ApiError as exc:
  726. if exc.status != 401:
  727. raise explain_api_error(exc, "写入句子")
  728. # 模型 token 过期或被撤销:重取一次再试,仍失败才提示重新登录
  729. model_token = refresh_model_token(client)
  730. try:
  731. data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
  732. except ApiError as retry_exc:
  733. raise explain_api_error(retry_exc, "写入句子(已重签模型 token 后重试)")
  734. returned = (data or {}).get("rows") or []
  735. for row in returned:
  736. written[row_key(row)] = row
  737. got = len(returned)
  738. print(f"批次 {start // args.batch + 1}: 提交 {len(batch)},服务端确认 {got}")
  739. if got < len(batch):
  740. # HTTP 200 不等于全部写入:逐句鉴权失败是静默 continue 掉的
  741. for s in batch:
  742. if sent_key(s) not in written:
  743. failed.append(s)
  744. print("-" * 72)
  745. print(f"合计提交 {len(sentences)} 条,确认写入 {len(written)} 条。")
  746. sample = next(iter(written.values()), None)
  747. if sample:
  748. editor = (sample.get("editor") or {}).get("nickName") or (sample.get("editor") or {}).get("name")
  749. print(f"署名核对:第一条的 editor = {editor}")
  750. if failed:
  751. print(f"⚠ 有 {len(failed)} 条未写入(服务端逐句鉴权失败会静默跳过):")
  752. for s in failed[:10]:
  753. print(f" {s['book_id']}-{s['paragraph']}-{s['word_start']}-{s['word_end']} channel={s['channel_uid'][:8]}…")
  754. if len(failed) > 10:
  755. print(f" …… 其余 {len(failed) - 10} 条")
  756. return 1
  757. return 0
  758. def channel_display_name(client, uid):
  759. try:
  760. data = client.call("GET", f"v2/channel/{uid}", token=client.user_token)
  761. except (ApiError, WpError):
  762. return None
  763. if isinstance(data, dict):
  764. return data.get("name")
  765. return None
  766. def refresh_model_token(client):
  767. note("⚠ 模型 token 被拒(过期或已撤销),正在重新签发……")
  768. model = client.bucket.get("model") or {}
  769. if not model.get("uid"):
  770. raise WpError("缓存里没有模型 uid,无法重签。请跑:python3 wp.py ensure-model --name <模型名>")
  771. try:
  772. issued = client.call("GET", f"v2/ai-model-token/{model['uid']}", token=client.user_token)
  773. except ApiError as exc:
  774. raise explain_api_error(exc, "重新签发模型 token")
  775. model.update({"uid": issued["uid"], "name": issued["name"], "token": issued["token"], "issued_at": iso_now()})
  776. client.bucket["model"] = model
  777. client.save()
  778. return issued["token"]
  779. # ---------------------------------------------------------------------------
  780. # CLI
  781. # ---------------------------------------------------------------------------
  782. def build_parser():
  783. parser = argparse.ArgumentParser(
  784. prog="wp.py",
  785. description="WikiPali 写入客户端(以 AI 模型身份写入句子)",
  786. formatter_class=argparse.RawDescriptionHelpFormatter,
  787. )
  788. parser.add_argument("--api", help="本次调用使用的 API 地址(序号/简称/完整 url),不写回凭据文件")
  789. sub = parser.add_subparsers(dest="command", required=True)
  790. p = sub.add_parser("endpoint", help="查看 / 切换 API 地址")
  791. p.add_argument("target", nargs="?", help="序号、简称(www/www.cc/next/next.cc/local)或完整 url")
  792. p.set_defaults(func=cmd_endpoint)
  793. p = sub.add_parser("whoami", help="显示当前凭据状态")
  794. p.add_argument("--check", action="store_true", help="额外向服务端校验用户 token")
  795. p.set_defaults(func=cmd_whoami)
  796. p = sub.add_parser("ensure-model", help="幂等地建立模型记录并取模型身份 token")
  797. p.add_argument("--name", help="模型标识,如 claude-opus-5(会成为句子作者署名)")
  798. p.add_argument("--model", help="底层模型 id")
  799. p.add_argument("--url", dest="url", help="模型服务地址")
  800. p.add_argument("--description", help="描述")
  801. p.add_argument("--privacy", choices=["private", "public"], default="private")
  802. p.set_defaults(func=cmd_ensure_model)
  803. p = sub.add_parser("revoke", help="撤销该模型已签出的全部 token")
  804. p.add_argument("--uid", help="模型 uid,缺省用缓存里的")
  805. p.add_argument("-y", "--yes", action="store_true")
  806. p.set_defaults(func=cmd_revoke)
  807. p = sub.add_parser("channels", help="列出当前账号可编辑的 channel")
  808. p.add_argument("--search", help="按名字过滤")
  809. p.add_argument("--json", action="store_true", help="输出原始 JSON")
  810. p.set_defaults(func=cmd_channels)
  811. p = sub.add_parser("grant", help="为某个 channel 签发 access token 并缓存")
  812. p.add_argument("channel", nargs="?", help="channel uid / 列表序号 / 名字片段;省略则交互选择")
  813. p.add_argument("--book", type=int, default=0, help="限定 book,0 表示不限(默认)")
  814. p.add_argument("--force", action="store_true", help="即使缓存未过期也重新签发")
  815. p.set_defaults(func=cmd_grant)
  816. p = sub.add_parser("write", help="写入句子")
  817. p.add_argument("file", help="句子 JSON 文件,- 表示从 stdin 读")
  818. p.add_argument("--channel", help="目标 channel(uid / 序号 / 名字片段)")
  819. p.add_argument("--book", type=int, help="access token 的 book 范围,缺省按句子推断")
  820. p.add_argument("--batch", type=int, default=DEFAULT_BATCH, help=f"每批条数,默认 {DEFAULT_BATCH}")
  821. p.add_argument("--content-type", default="markdown")
  822. p.add_argument("--preview", type=int, default=5, help="确认时预览几条")
  823. p.add_argument("--dry-run", action="store_true", help="只做校验与回显,不发请求")
  824. p.add_argument("-y", "--yes", action="store_true", help="跳过交互确认(非交互环境必须显式给)")
  825. p.set_defaults(func=cmd_write)
  826. return parser
  827. def main(argv=None):
  828. args = build_parser().parse_args(argv)
  829. try:
  830. return args.func(args)
  831. except WpError as exc:
  832. print(f"错误:{exc}", file=sys.stderr)
  833. return 1
  834. except KeyboardInterrupt:
  835. print("\n已中断。", file=sys.stderr)
  836. return 130
  837. if __name__ == "__main__":
  838. sys.exit(main())