|
|
@@ -1,997 +0,0 @@
|
|
|
-#!/usr/bin/env python3
|
|
|
-"""WikiPali 写入客户端。
|
|
|
-
|
|
|
-以「AI 模型身份」把句子写入 WikiPali 句子库。子命令:
|
|
|
-
|
|
|
- endpoint 查看 / 切换 API 地址
|
|
|
- whoami 显示当前凭据状态
|
|
|
- ensure-model 幂等地建立模型记录并取模型身份 token
|
|
|
- revoke 撤销该模型已签出的全部 token
|
|
|
- channels 列出当前账号可编辑的 channel
|
|
|
- grant 为某个 channel 签发 access token 并缓存
|
|
|
- write 写入句子(分批 + 确认 + count 核对)
|
|
|
-
|
|
|
-约束:只用 Python 标准库,目录自包含,可整体复制到任意项目。
|
|
|
-密码只由 wp_login.py 接触,本脚本永不读密码。
|
|
|
-"""
|
|
|
-
|
|
|
-import argparse
|
|
|
-import base64
|
|
|
-import json
|
|
|
-import os
|
|
|
-import stat
|
|
|
-import sys
|
|
|
-import time
|
|
|
-import urllib.error
|
|
|
-import urllib.parse
|
|
|
-import urllib.request
|
|
|
-from datetime import datetime, timezone
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# 站点清单
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# 四个线上地址共享同一个数据库和同一把 jwt 密钥,凭据完全通用;
|
|
|
-# .org / .cc 是地区可达性,www / next 是代码版本(不是数据环境)。
|
|
|
-# 开发机是另一个库、另一把密钥,故单独一桶,且永不作为自动 fallback 目标。
|
|
|
-
|
|
|
-SITES = [
|
|
|
- {"key": "www", "url": "https://www.wikipali.org/api", "version": "稳定版", "domain": ".org"},
|
|
|
- {"key": "www.cc", "url": "https://www.wikipali.cc/api", "version": "稳定版", "domain": ".cc"},
|
|
|
- {"key": "next", "url": "https://next.wikipali.org/api", "version": "最新版", "domain": ".org"},
|
|
|
- {"key": "next.cc", "url": "https://next.wikipali.cc/api", "version": "最新版", "domain": ".cc"},
|
|
|
- {"key": "local", "url": "http://127.0.0.1:8000/api", "version": "开发机", "domain": "本机"},
|
|
|
-]
|
|
|
-
|
|
|
-ONLINE_URLS = [s["url"] for s in SITES if s["key"] != "local"]
|
|
|
-LOCAL_URL = SITES[-1]["url"]
|
|
|
-DEFAULT_API_URL = SITES[0]["url"]
|
|
|
-
|
|
|
-CREDS_DIR = os.path.join(os.path.expanduser("~"), ".wikipali")
|
|
|
-CREDS_PATH = os.path.join(CREDS_DIR, "credentials.json")
|
|
|
-
|
|
|
-# access token 剩余不足这么多秒就重新签发,避免写到一半过期
|
|
|
-TOKEN_REFRESH_MARGIN = 3600
|
|
|
-
|
|
|
-DEFAULT_TIMEOUT = 30
|
|
|
-WRITE_TIMEOUT = 120
|
|
|
-DEFAULT_BATCH = 50
|
|
|
-
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# 错误类型
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
-class WpError(Exception):
|
|
|
- """面向用户的错误:main() 捕获后只打印 message,不打印堆栈。"""
|
|
|
-
|
|
|
-
|
|
|
-class ApiError(WpError):
|
|
|
- def __init__(self, status, message, url=None, body=None):
|
|
|
- self.status = status
|
|
|
- self.url = url
|
|
|
- self.body = body
|
|
|
- super().__init__(message)
|
|
|
-
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# 凭据文件
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
-def load_creds():
|
|
|
- if not os.path.exists(CREDS_PATH):
|
|
|
- return {"current": "online"}
|
|
|
- try:
|
|
|
- with open(CREDS_PATH, "r", encoding="utf-8") as fh:
|
|
|
- data = json.load(fh)
|
|
|
- except (OSError, ValueError) as exc:
|
|
|
- raise WpError(f"凭据文件无法读取({CREDS_PATH}):{exc}")
|
|
|
- if not isinstance(data, dict):
|
|
|
- raise WpError(f"凭据文件格式不对({CREDS_PATH}),应为 JSON 对象")
|
|
|
- data.setdefault("current", "online")
|
|
|
- return data
|
|
|
-
|
|
|
-
|
|
|
-def save_creds(creds):
|
|
|
- os.makedirs(CREDS_DIR, mode=0o700, exist_ok=True)
|
|
|
- tmp = CREDS_PATH + ".tmp"
|
|
|
- flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
|
- fd = os.open(tmp, flags, 0o600)
|
|
|
- try:
|
|
|
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
|
- json.dump(creds, fh, ensure_ascii=False, indent=2)
|
|
|
- fh.write("\n")
|
|
|
- except Exception:
|
|
|
- os.unlink(tmp)
|
|
|
- raise
|
|
|
- os.replace(tmp, CREDS_PATH)
|
|
|
- os.chmod(CREDS_PATH, stat.S_IRUSR | stat.S_IWUSR)
|
|
|
-
|
|
|
-
|
|
|
-def bucket_name_for(api_url):
|
|
|
- """凭据桶名。线上四地址共用 online 桶;开发机 local;其余地址自成一桶。"""
|
|
|
- if api_url in ONLINE_URLS:
|
|
|
- return "online"
|
|
|
- if api_url == LOCAL_URL:
|
|
|
- return "local"
|
|
|
- return "site:" + api_url
|
|
|
-
|
|
|
-
|
|
|
-def get_bucket(creds, name, api_url=None):
|
|
|
- bucket = creds.setdefault(name, {})
|
|
|
- bucket.setdefault("api_url", api_url or (DEFAULT_API_URL if name == "online" else LOCAL_URL))
|
|
|
- bucket.setdefault("user", {})
|
|
|
- bucket.setdefault("model", {})
|
|
|
- bucket.setdefault("access_tokens", {})
|
|
|
- return bucket
|
|
|
-
|
|
|
-
|
|
|
-def normalize_api_url(url):
|
|
|
- url = url.rstrip("/")
|
|
|
- parsed = urllib.parse.urlparse(url)
|
|
|
- if parsed.scheme not in ("http", "https"):
|
|
|
- raise WpError(f"API 地址必须以 http:// 或 https:// 开头:{url}")
|
|
|
- host = (parsed.hostname or "").lower()
|
|
|
- if parsed.scheme == "http" and host not in ("127.0.0.1", "localhost", "::1"):
|
|
|
- raise WpError(f"只有 127.0.0.1 / localhost 允许用 http://,其余必须 https://:{url}")
|
|
|
- return url
|
|
|
-
|
|
|
-
|
|
|
-def resolve_api_url(cli_api, creds):
|
|
|
- """地址来源优先级:--api > 环境变量 > 凭据文件 > 内置默认。
|
|
|
-
|
|
|
- 前两者是一次性覆盖,不写回凭据文件——否则「上周试了一次 next」会一直粘着。
|
|
|
- """
|
|
|
- if cli_api:
|
|
|
- return normalize_api_url(expand_site_alias(cli_api)), "cli"
|
|
|
- env = os.environ.get("WIKIPALI_API_URL")
|
|
|
- if env:
|
|
|
- return normalize_api_url(expand_site_alias(env)), "env"
|
|
|
- current = creds.get("current", "online")
|
|
|
- bucket = creds.get(current)
|
|
|
- if isinstance(bucket, dict) and bucket.get("api_url"):
|
|
|
- return normalize_api_url(bucket["api_url"]), "creds"
|
|
|
- return DEFAULT_API_URL, "default"
|
|
|
-
|
|
|
-
|
|
|
-def expand_site_alias(value):
|
|
|
- """把序号 / 简称展开成完整 url;已是 url 则原样返回。"""
|
|
|
- value = value.strip()
|
|
|
- if value.isdigit():
|
|
|
- idx = int(value) - 1
|
|
|
- if 0 <= idx < len(SITES):
|
|
|
- return SITES[idx]["url"]
|
|
|
- raise WpError(f"站点序号超出范围:{value}(可选 1-{len(SITES)})")
|
|
|
- for site in SITES:
|
|
|
- if value == site["key"]:
|
|
|
- return site["url"]
|
|
|
- if "://" in value:
|
|
|
- return value
|
|
|
- raise WpError(
|
|
|
- f"无法识别的站点:{value}。可用简称:" + " / ".join(s["key"] for s in SITES) + ",或直接给完整 url"
|
|
|
- )
|
|
|
-
|
|
|
-
|
|
|
-def site_label(api_url):
|
|
|
- for site in SITES:
|
|
|
- if site["url"] == api_url:
|
|
|
- return f"{site['version']} · {site['domain']}"
|
|
|
- return "自定义地址"
|
|
|
-
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# HTTP
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
-def mask(token):
|
|
|
- if not token:
|
|
|
- return "(无)"
|
|
|
- if len(token) <= 16:
|
|
|
- return token[:4] + "…"
|
|
|
- return token[:8] + "…" + token[-4:]
|
|
|
-
|
|
|
-
|
|
|
-def jwt_payload(token):
|
|
|
- """不验签地读出 JWT payload,仅用于显示有效期。"""
|
|
|
- try:
|
|
|
- part = token.split(".")[1]
|
|
|
- part += "=" * (-len(part) % 4)
|
|
|
- return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))
|
|
|
- except Exception:
|
|
|
- return {}
|
|
|
-
|
|
|
-
|
|
|
-def token_expiry(token):
|
|
|
- exp = jwt_payload(token).get("exp")
|
|
|
- return int(exp) if isinstance(exp, (int, float)) else None
|
|
|
-
|
|
|
-
|
|
|
-def fmt_ts(ts):
|
|
|
- if not ts:
|
|
|
- return "未知"
|
|
|
- return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
|
|
-
|
|
|
-
|
|
|
-def note(msg):
|
|
|
- print(msg, file=sys.stderr)
|
|
|
-
|
|
|
-
|
|
|
-def http_json(api_url, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
|
|
|
- """发一个 JSON 请求,返回解析后的响应体(dict)。
|
|
|
-
|
|
|
- 网络层失败抛 urllib 的异常(由 Client 决定是否 fallback);
|
|
|
- HTTP 层失败抛 ApiError,带上服务端 message。
|
|
|
- """
|
|
|
- url = api_url + "/" + path.lstrip("/")
|
|
|
- if query:
|
|
|
- url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
|
|
|
- data = None
|
|
|
- headers = {"Accept": "application/json", "User-Agent": "wikipali-write-skill"}
|
|
|
- if body is not None:
|
|
|
- data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
|
- headers["Content-Type"] = "application/json"
|
|
|
- if token:
|
|
|
- headers["Authorization"] = "Bearer " + token
|
|
|
- req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
|
- try:
|
|
|
- with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
|
- raw = resp.read().decode("utf-8", "replace")
|
|
|
- status = resp.status
|
|
|
- except urllib.error.HTTPError as exc:
|
|
|
- raw = exc.read().decode("utf-8", "replace")
|
|
|
- status = exc.code
|
|
|
- payload = safe_json(raw)
|
|
|
- message = payload.get("message") if isinstance(payload, dict) else None
|
|
|
- raise ApiError(status, message or f"HTTP {status}", url=url, body=payload or raw)
|
|
|
- payload = safe_json(raw)
|
|
|
- if not isinstance(payload, dict):
|
|
|
- raise ApiError(status, f"响应不是 JSON:{raw[:200]}", url=url, body=raw)
|
|
|
- if not payload.get("ok", False):
|
|
|
- raise ApiError(status, payload.get("message") or "请求失败", url=url, body=payload)
|
|
|
- return payload.get("data")
|
|
|
-
|
|
|
-
|
|
|
-def safe_json(raw):
|
|
|
- try:
|
|
|
- return json.loads(raw)
|
|
|
- except ValueError:
|
|
|
- return None
|
|
|
-
|
|
|
-
|
|
|
-class Client:
|
|
|
- """按站点收发请求,并在线上地址之间做出声的 fallback。"""
|
|
|
-
|
|
|
- def __init__(self, api_url, source, creds, allow_fallback=True):
|
|
|
- self.api_url = api_url
|
|
|
- self.source = source
|
|
|
- self.creds = creds
|
|
|
- self.bucket_name = bucket_name_for(api_url)
|
|
|
- self.bucket = get_bucket(creds, self.bucket_name, api_url)
|
|
|
- self.allow_fallback = allow_fallback and api_url in ONLINE_URLS
|
|
|
-
|
|
|
- # -- 凭据 ---------------------------------------------------------------
|
|
|
-
|
|
|
- @property
|
|
|
- def user_token(self):
|
|
|
- token = (self.bucket.get("user") or {}).get("token")
|
|
|
- if not token:
|
|
|
- raise WpError(
|
|
|
- "尚未登录。请自己执行(Claude Code 里用 ! 前缀):\n"
|
|
|
- " ! python3 " + os.path.join(os.path.dirname(os.path.abspath(__file__)), "wp_login.py")
|
|
|
- )
|
|
|
- return token
|
|
|
-
|
|
|
- @property
|
|
|
- def model(self):
|
|
|
- model = self.bucket.get("model") or {}
|
|
|
- if not model.get("token"):
|
|
|
- raise WpError("尚未取得模型身份 token。请先跑:python3 wp.py ensure-model --name <模型名>")
|
|
|
- return model
|
|
|
-
|
|
|
- def save(self):
|
|
|
- save_creds(self.creds)
|
|
|
-
|
|
|
- # -- 请求 ---------------------------------------------------------------
|
|
|
-
|
|
|
- def fallback_order(self):
|
|
|
- """同版本的另一域名 → 另一版本的同域名 → 其余。绝不含 local。"""
|
|
|
- cur = next((s for s in SITES if s["url"] == self.api_url), None)
|
|
|
- if not cur:
|
|
|
- return []
|
|
|
- others = [s for s in SITES if s["key"] != "local" and s["url"] != self.api_url]
|
|
|
- others.sort(
|
|
|
- key=lambda s: (
|
|
|
- 0 if s["version"] == cur["version"] else 1,
|
|
|
- 0 if s["domain"] == cur["domain"] else 1,
|
|
|
- )
|
|
|
- )
|
|
|
- return [s["url"] for s in others]
|
|
|
-
|
|
|
- def call(self, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
|
|
|
- urls = [self.api_url] + (self.fallback_order() if self.allow_fallback else [])
|
|
|
- last = None
|
|
|
- for idx, url in enumerate(urls):
|
|
|
- try:
|
|
|
- data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
|
|
|
- except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
|
- # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
|
|
|
- last = exc
|
|
|
- reason = getattr(exc, "reason", exc)
|
|
|
- if idx + 1 < len(urls):
|
|
|
- note(f"⚠ {url} 连接失败({reason}),改用 {urls[idx + 1]}")
|
|
|
- continue
|
|
|
- if url != self.api_url:
|
|
|
- # fallback 成功后本次会话都用它,但不写回凭据文件
|
|
|
- note(f"⚠ 本次请求实际发往 {url}({site_label(url)})")
|
|
|
- self.api_url = url
|
|
|
- return data
|
|
|
- raise WpError(f"所有可用站点都连不上,最后一次错误:{last}")
|
|
|
-
|
|
|
- def api_note(self):
|
|
|
- src = {"cli": "--api", "env": "环境变量", "creds": "凭据文件", "default": "内置默认"}[self.source]
|
|
|
- return f"{self.api_url}({site_label(self.api_url)},来源:{src})"
|
|
|
-
|
|
|
-
|
|
|
-def make_client(args, allow_fallback=True):
|
|
|
- creds = load_creds()
|
|
|
- api_url, source = resolve_api_url(getattr(args, "api", None), creds)
|
|
|
- return Client(api_url, source, creds, allow_fallback=allow_fallback)
|
|
|
-
|
|
|
-
|
|
|
-def explain_api_error(exc, what):
|
|
|
- """把 HTTP 状态翻译成对操作者有意义的话(见 references/api.md 的错误约定)。"""
|
|
|
- if exc.status == 401:
|
|
|
- return WpError(
|
|
|
- f"{what}:401 凭据失效或已被撤销。\n"
|
|
|
- " · 用户 token 失效 → 重新登录:! python3 scripts/wp_login.py\n"
|
|
|
- " · 模型 token 失效或被撤销 → 重跑:python3 wp.py ensure-model\n"
|
|
|
- " 不要自动重试。"
|
|
|
- )
|
|
|
- if exc.status == 403:
|
|
|
- return WpError(f"{what}:403 无权限(不是 channel 的 owner/协作者,或不是模型 owner 本人)。")
|
|
|
- if exc.status == 404:
|
|
|
- return WpError(
|
|
|
- f"{what}:404。若这是较新的端点,可能是当前站点跑的是稳定版代码、端点尚未上线;\n"
|
|
|
- " 可切到最新版试试:python3 wp.py endpoint next\n"
|
|
|
- " 否则才是资源真的不存在。"
|
|
|
- )
|
|
|
- if exc.status == 409:
|
|
|
- return WpError(f"{what}:409 同名记录已存在。")
|
|
|
- if exc.status == 422:
|
|
|
- return WpError(f"{what}:422 参数校验失败——{exc}")
|
|
|
- return WpError(f"{what}:HTTP {exc.status} {exc}")
|
|
|
-
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# 子命令:endpoint
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
-def cmd_endpoint(args):
|
|
|
- creds = load_creds()
|
|
|
- current_url, source = resolve_api_url(getattr(args, "api", None), creds)
|
|
|
-
|
|
|
- if not args.target:
|
|
|
- for idx, site in enumerate(SITES, 1):
|
|
|
- mark = " ← 当前" if site["url"] == current_url else ""
|
|
|
- print(f" {idx}) {site['url']:<32} {site['version']} · {site['domain']}{mark}")
|
|
|
- if source in ("cli", "env"):
|
|
|
- src = "--api" if source == "cli" else "WIKIPALI_API_URL"
|
|
|
- note(f"注意:当前地址来自 {src},是一次性覆盖,未写入凭据文件。")
|
|
|
- if current_url not in [s["url"] for s in SITES]:
|
|
|
- print(f" *) {current_url:<32} 自定义地址 ← 当前")
|
|
|
- print("\n切换:python3 wp.py endpoint <序号|www|www.cc|next|next.cc|local|完整url>")
|
|
|
- return 0
|
|
|
-
|
|
|
- url = normalize_api_url(expand_site_alias(args.target))
|
|
|
- name = bucket_name_for(url)
|
|
|
- bucket = get_bucket(creds, name, url)
|
|
|
- bucket["api_url"] = url
|
|
|
- creds["current"] = name
|
|
|
- save_creds(creds)
|
|
|
- print(f"已切换到 {url}({site_label(url)})")
|
|
|
- if name != "online":
|
|
|
- note("提示:该地址与线上四站不共用数据库/密钥,凭据是独立的一桶,可能需要重新登录。")
|
|
|
- return 0
|
|
|
-
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# 子命令:whoami
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
-def cmd_whoami(args):
|
|
|
- client = make_client(args)
|
|
|
- print(f"API : {client.api_note()}")
|
|
|
- print(f"凭据文件 : {CREDS_PATH}(桶:{client.bucket_name})")
|
|
|
-
|
|
|
- user = client.bucket.get("user") or {}
|
|
|
- if user.get("token"):
|
|
|
- exp = token_expiry(user["token"])
|
|
|
- expired = exp is not None and exp < time.time()
|
|
|
- print(f"用户 : {user.get('username', '?')} uid={user.get('uid', '?')}")
|
|
|
- print(f" token {mask(user['token'])} 到期 {fmt_ts(exp)}{' ⚠ 已过期' if expired else ''}")
|
|
|
- else:
|
|
|
- print("用户 : 未登录(! python3 scripts/wp_login.py)")
|
|
|
-
|
|
|
- model = client.bucket.get("model") or {}
|
|
|
- if model.get("token"):
|
|
|
- exp = token_expiry(model["token"])
|
|
|
- expired = exp is not None and exp < time.time()
|
|
|
- print(f"模型 : {model.get('name', '?')} uid={model.get('uid', '?')}")
|
|
|
- print(f" token {mask(model['token'])} 到期 {fmt_ts(exp)}{' ⚠ 已过期' if expired else ''}")
|
|
|
- else:
|
|
|
- print("模型 : 未建立(python3 wp.py ensure-model --name <模型名>)")
|
|
|
-
|
|
|
- tokens = client.bucket.get("access_tokens") or {}
|
|
|
- if tokens:
|
|
|
- print("access token:")
|
|
|
- for uid, item in tokens.items():
|
|
|
- exp = item.get("exp") or token_expiry(item.get("token", ""))
|
|
|
- expired = exp is not None and exp < time.time()
|
|
|
- book = item.get("book", 0)
|
|
|
- scope = "全部 book" if book == 0 else f"book {book}"
|
|
|
- name = item.get("channel_name") or ""
|
|
|
- print(f" {uid[:8]}… {name:<24} {scope:<10} 到期 {fmt_ts(exp)}{' ⚠ 已过期' if expired else ''}")
|
|
|
- else:
|
|
|
- print("access token:无(python3 wp.py grant <channel>)")
|
|
|
-
|
|
|
- if args.check:
|
|
|
- try:
|
|
|
- data = client.call("GET", "v2/auth/current", token=client.user_token)
|
|
|
- except ApiError as exc:
|
|
|
- raise explain_api_error(exc, "校验用户 token")
|
|
|
- print(f"\n服务端确认:{data.get('nickName')} / realName={data.get('realName')}(studio_name 用它)")
|
|
|
- return 0
|
|
|
-
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# 子命令:ensure-model
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
-def cmd_ensure_model(args):
|
|
|
- client = make_client(args)
|
|
|
- token = client.user_token
|
|
|
-
|
|
|
- name = args.name or (client.bucket.get("model") or {}).get("name") or os.environ.get("WIKIPALI_MODEL_NAME")
|
|
|
- if not name:
|
|
|
- raise WpError(
|
|
|
- "必须指定模型名:--name <模型标识>(如 claude-opus-5)。\n"
|
|
|
- "该名字会成为句子的作者署名,不要用别的模型的名字。"
|
|
|
- )
|
|
|
-
|
|
|
- try:
|
|
|
- current = client.call("GET", "v2/auth/current", token=token)
|
|
|
- except ApiError as exc:
|
|
|
- raise explain_api_error(exc, "取当前用户信息")
|
|
|
- studio_name = current.get("realName")
|
|
|
- if not studio_name:
|
|
|
- raise WpError("服务端没有返回 realName,无法确定 studio_name。")
|
|
|
-
|
|
|
- # 1) 按 studio + keyword 查,keyword 是模糊匹配,客户端自己做精确比对
|
|
|
- try:
|
|
|
- listed = client.call(
|
|
|
- "GET", "v2/ai-model", token=token,
|
|
|
- query={"view": "studio", "name": studio_name, "keyword": name},
|
|
|
- )
|
|
|
- except ApiError as exc:
|
|
|
- raise explain_api_error(exc, "查询模型列表")
|
|
|
- rows = (listed or {}).get("rows") or []
|
|
|
- found = next((r for r in rows if r.get("name") == name), None)
|
|
|
-
|
|
|
- if found:
|
|
|
- print(f"已存在模型记录:{name} uid={found['uid']}")
|
|
|
- else:
|
|
|
- body = {"name": name, "studio_name": studio_name, "privacy": args.privacy}
|
|
|
- for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
|
|
|
- if value is not None:
|
|
|
- body[field] = value
|
|
|
- try:
|
|
|
- found = client.call("POST", "v2/ai-model", token=token, body=body)
|
|
|
- print(f"已创建模型记录:{name} uid={found['uid']}")
|
|
|
- except ApiError as exc:
|
|
|
- if exc.status != 409:
|
|
|
- raise explain_api_error(exc, "创建模型记录")
|
|
|
- # 并发或模糊匹配漏网:重查一次
|
|
|
- listed = client.call(
|
|
|
- "GET", "v2/ai-model", token=token,
|
|
|
- query={"view": "studio", "name": studio_name, "keyword": name},
|
|
|
- )
|
|
|
- rows = (listed or {}).get("rows") or []
|
|
|
- found = next((r for r in rows if r.get("name") == name), None)
|
|
|
- if not found:
|
|
|
- raise WpError(f"服务端说 {name} 已存在(409),但列表里查不到,无法继续。")
|
|
|
- print(f"已存在模型记录:{name} uid={found['uid']}")
|
|
|
-
|
|
|
- # 2) 增量补字段(update 是增量的,未提交的字段保持原值)
|
|
|
- patch = {}
|
|
|
- for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
|
|
|
- if value is not None and found.get(field) != value:
|
|
|
- patch[field] = value
|
|
|
- if args.privacy and found.get("privacy") != args.privacy:
|
|
|
- patch["privacy"] = args.privacy
|
|
|
- if patch:
|
|
|
- try:
|
|
|
- found = client.call("PUT", f"v2/ai-model/{found['uid']}", token=token, body=patch)
|
|
|
- print(f"已更新字段:{', '.join(patch)}")
|
|
|
- except ApiError as exc:
|
|
|
- raise explain_api_error(exc, "更新模型记录")
|
|
|
-
|
|
|
- # 3) 取模型身份 token
|
|
|
- try:
|
|
|
- issued = client.call("GET", f"v2/ai-model-token/{found['uid']}", token=token)
|
|
|
- except ApiError as exc:
|
|
|
- raise explain_api_error(exc, "签发模型身份 token")
|
|
|
-
|
|
|
- client.bucket["model"] = {
|
|
|
- "uid": issued["uid"],
|
|
|
- "name": issued["name"],
|
|
|
- "token": issued["token"],
|
|
|
- "issued_at": iso_now(),
|
|
|
- }
|
|
|
- client.save()
|
|
|
- exp = token_expiry(issued["token"])
|
|
|
- print(f"模型身份 token 已缓存:{mask(issued['token'])} 到期 {fmt_ts(exp)}")
|
|
|
- print(f"写入的句子将署名为该模型(editor_uid={issued['uid']})。")
|
|
|
- return 0
|
|
|
-
|
|
|
-
|
|
|
-def cmd_revoke(args):
|
|
|
- client = make_client(args)
|
|
|
- model = client.bucket.get("model") or {}
|
|
|
- uid = args.uid or model.get("uid")
|
|
|
- if not uid:
|
|
|
- raise WpError("没有可撤销的模型:请给 --uid <模型 uid>,或先跑 ensure-model。")
|
|
|
- if not args.yes and not confirm(f"将撤销模型 {model.get('name', uid)} 已签出的全部 token,继续?"):
|
|
|
- print("已取消。")
|
|
|
- return 1
|
|
|
- try:
|
|
|
- data = client.call("DELETE", f"v2/ai-model-token/{uid}", token=client.user_token)
|
|
|
- except ApiError as exc:
|
|
|
- raise explain_api_error(exc, "撤销模型 token")
|
|
|
- if model.get("uid") == uid:
|
|
|
- client.bucket["model"] = {"uid": uid, "name": model.get("name")}
|
|
|
- client.save()
|
|
|
- print(f"已撤销 {data.get('name')} 的全部 token(token_version={data.get('token_version')})。")
|
|
|
- print("本地缓存的模型 token 已清除,需要写入时请重跑 ensure-model。")
|
|
|
- return 0
|
|
|
-
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# 子命令:channels
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
-def fetch_channels(client, search=None):
|
|
|
- try:
|
|
|
- data = client.call(
|
|
|
- "GET", "v2/channel", token=client.user_token,
|
|
|
- query={"view": "user-edit", "order": "updated_at", "dir": "desc", "limit": 200, "search": search},
|
|
|
- )
|
|
|
- except ApiError as exc:
|
|
|
- raise explain_api_error(exc, "获取可编辑 channel 列表")
|
|
|
- return (data or {}).get("rows") or []
|
|
|
-
|
|
|
-
|
|
|
-def cmd_channels(args):
|
|
|
- client = make_client(args)
|
|
|
- rows = fetch_channels(client, args.search)
|
|
|
- if args.json:
|
|
|
- print(json.dumps(rows, ensure_ascii=False, indent=2))
|
|
|
- return 0
|
|
|
- if not rows:
|
|
|
- print("当前账号没有任何可编辑的 channel。")
|
|
|
- return 1
|
|
|
- print(f"可编辑 channel({len(rows)} 个,按更新时间倒序):")
|
|
|
- for idx, ch in enumerate(rows, 1):
|
|
|
- print(
|
|
|
- f" {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} "
|
|
|
- f"{ch.get('uid', '')[:8]}… {ch.get('role', '')}"
|
|
|
- )
|
|
|
- return 0
|
|
|
-
|
|
|
-
|
|
|
-def pick_channel(client, given, interactive=True):
|
|
|
- """返回 (uid, name)。given 可以是 uid、序号或名字片段;为空则交互选择。"""
|
|
|
- rows = fetch_channels(client)
|
|
|
- if not rows:
|
|
|
- raise WpError("当前账号没有任何可编辑的 channel,无法继续。")
|
|
|
-
|
|
|
- if given:
|
|
|
- for ch in rows:
|
|
|
- if ch.get("uid") == given:
|
|
|
- return ch["uid"], ch.get("name")
|
|
|
- if given.isdigit() and 1 <= int(given) <= len(rows):
|
|
|
- ch = rows[int(given) - 1]
|
|
|
- return ch["uid"], ch.get("name")
|
|
|
- matched = [c for c in rows if given.lower() in (c.get("name") or "").lower()]
|
|
|
- if len(matched) == 1:
|
|
|
- return matched[0]["uid"], matched[0].get("name")
|
|
|
- if len(matched) > 1:
|
|
|
- names = ", ".join(c.get("name", "") for c in matched[:5])
|
|
|
- raise WpError(f"「{given}」匹配到多个 channel:{names}…… 请给完整 uid。")
|
|
|
- # 不在可编辑列表里的 uid:直接用,但回显不出名字
|
|
|
- if len(given) >= 32:
|
|
|
- note(f"⚠ {given} 不在可编辑列表中,仍按 uid 使用——签发 access token 时可能返回 count: 0。")
|
|
|
- return given, None
|
|
|
- raise WpError(f"找不到 channel:{given}")
|
|
|
-
|
|
|
- if not (interactive and sys.stdin.isatty()):
|
|
|
- raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wp.py channels` 再用 --channel 指定。")
|
|
|
-
|
|
|
- print("可编辑 channel:")
|
|
|
- for idx, ch in enumerate(rows, 1):
|
|
|
- print(f" {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} {ch.get('uid', '')[:8]}…")
|
|
|
- raw = input("选择序号:").strip()
|
|
|
- if not raw.isdigit() or not (1 <= int(raw) <= len(rows)):
|
|
|
- raise WpError("输入无效。")
|
|
|
- ch = rows[int(raw) - 1]
|
|
|
- return ch["uid"], ch.get("name")
|
|
|
-
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# 子命令:grant
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
-def cached_access_token(client, channel_uid, book):
|
|
|
- item = (client.bucket.get("access_tokens") or {}).get(channel_uid)
|
|
|
- if not item or not item.get("token"):
|
|
|
- return None
|
|
|
- # book 0 是「不限 book」,能覆盖任何请求;否则必须完全一致
|
|
|
- if item.get("book", 0) != 0 and item.get("book") != book:
|
|
|
- return None
|
|
|
- exp = item.get("exp") or token_expiry(item["token"])
|
|
|
- if exp and exp - time.time() < TOKEN_REFRESH_MARGIN:
|
|
|
- return None
|
|
|
- return item
|
|
|
-
|
|
|
-
|
|
|
-def grant_access_token(client, channel_uid, channel_name, book, force=False):
|
|
|
- if not force:
|
|
|
- cached = cached_access_token(client, channel_uid, book)
|
|
|
- if cached:
|
|
|
- return cached
|
|
|
-
|
|
|
- # book 必须是整数:服务端用 !== 严格比较,"1" !== 1 恒真会导致鉴权失败
|
|
|
- payload = [{"res_type": "channel", "res_id": channel_uid, "power": "edit", "book": int(book)}]
|
|
|
- try:
|
|
|
- data = client.call("POST", "v2/access-token", token=client.user_token, body={"payload": payload})
|
|
|
- except ApiError as exc:
|
|
|
- raise explain_api_error(exc, "签发 access token")
|
|
|
- rows = (data or {}).get("rows") or []
|
|
|
- if not rows:
|
|
|
- # 无权时服务端静默跳过该条,rows 为空——等同 403,绝不能继续写
|
|
|
- raise WpError(
|
|
|
- f"签发 access token 返回 count: 0,说明当前账号对 channel {channel_uid} 没有编辑权。\n"
|
|
|
- "不要继续写入。请确认选对了 channel,或让 owner 授予 ≥ editor 权限。"
|
|
|
- )
|
|
|
- row = rows[0]
|
|
|
- item = {
|
|
|
- "token": row["token"],
|
|
|
- "book": int(book),
|
|
|
- "exp": (row.get("payload") or {}).get("exp"),
|
|
|
- "granted_at": iso_now(),
|
|
|
- }
|
|
|
- if channel_name:
|
|
|
- item["channel_name"] = channel_name
|
|
|
- client.bucket.setdefault("access_tokens", {})[channel_uid] = item
|
|
|
- client.save()
|
|
|
- return item
|
|
|
-
|
|
|
-
|
|
|
-def cmd_grant(args):
|
|
|
- client = make_client(args)
|
|
|
- uid, name = pick_channel(client, args.channel)
|
|
|
- item = grant_access_token(client, uid, name, args.book, force=args.force)
|
|
|
- scope = "全部 book" if item["book"] == 0 else f"book {item['book']}"
|
|
|
- print(f"channel : {name or '(未知)'} {uid}")
|
|
|
- print(f"范围 : {scope}")
|
|
|
- print(f"token : {mask(item['token'])} 到期 {fmt_ts(item.get('exp'))}")
|
|
|
- return 0
|
|
|
-
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# 子命令:write
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-SENT_REQUIRED = ("book_id", "paragraph", "word_start", "word_end", "content")
|
|
|
-
|
|
|
-
|
|
|
-def load_sentences(args):
|
|
|
- if args.file == "-":
|
|
|
- raw = sys.stdin.read()
|
|
|
- else:
|
|
|
- try:
|
|
|
- with open(args.file, "r", encoding="utf-8") as fh:
|
|
|
- raw = fh.read()
|
|
|
- except OSError as exc:
|
|
|
- raise WpError(f"读不了输入文件:{exc}")
|
|
|
- try:
|
|
|
- data = json.loads(raw)
|
|
|
- except ValueError as exc:
|
|
|
- raise WpError(f"输入不是合法 JSON:{exc}")
|
|
|
-
|
|
|
- default_channel = None
|
|
|
- if isinstance(data, dict):
|
|
|
- default_channel = data.get("channel_uid") or data.get("channel")
|
|
|
- data = data.get("sentences")
|
|
|
- if not isinstance(data, list) or not data:
|
|
|
- raise WpError('输入必须是句子数组,或 {"channel_uid": ..., "sentences": [...]},且非空。')
|
|
|
- return data, default_channel
|
|
|
-
|
|
|
-
|
|
|
-def normalize_sentences(rows, channel_uid, default_content_type):
|
|
|
- out = []
|
|
|
- for idx, row in enumerate(rows):
|
|
|
- if not isinstance(row, dict):
|
|
|
- raise WpError(f"第 {idx + 1} 条不是对象。")
|
|
|
- missing = [f for f in SENT_REQUIRED if row.get(f) is None]
|
|
|
- if missing:
|
|
|
- raise WpError(f"第 {idx + 1} 条缺字段:{', '.join(missing)}")
|
|
|
- try:
|
|
|
- sent = {
|
|
|
- "book_id": int(row["book_id"]),
|
|
|
- "paragraph": int(row["paragraph"]),
|
|
|
- "word_start": int(row["word_start"]),
|
|
|
- "word_end": int(row["word_end"]),
|
|
|
- "content": str(row["content"]),
|
|
|
- "content_type": row.get("content_type") or default_content_type,
|
|
|
- "channel_uid": row.get("channel_uid") or channel_uid,
|
|
|
- }
|
|
|
- except (TypeError, ValueError) as exc:
|
|
|
- raise WpError(f"第 {idx + 1} 条字段类型不对:{exc}")
|
|
|
- if not sent["channel_uid"]:
|
|
|
- raise WpError(f"第 {idx + 1} 条没有 channel_uid,且未通过 --channel 指定。")
|
|
|
- out.append(sent)
|
|
|
- return out
|
|
|
-
|
|
|
-
|
|
|
-def sent_key(sent):
|
|
|
- return (
|
|
|
- int(sent["book_id"]),
|
|
|
- int(sent["paragraph"]),
|
|
|
- int(sent["word_start"]),
|
|
|
- int(sent["word_end"]),
|
|
|
- sent["channel_uid"],
|
|
|
- )
|
|
|
-
|
|
|
-
|
|
|
-def row_key(row):
|
|
|
- channel = row.get("channel") or {}
|
|
|
- return (
|
|
|
- int(row.get("book", -1)),
|
|
|
- int(row.get("paragraph", -1)),
|
|
|
- int(row.get("word_start", -1)),
|
|
|
- int(row.get("word_end", -1)),
|
|
|
- channel.get("uid"),
|
|
|
- )
|
|
|
-
|
|
|
-
|
|
|
-def confirm(question):
|
|
|
- if not sys.stdin.isatty():
|
|
|
- return False
|
|
|
- answer = input(f"{question} [y/N] ").strip().lower()
|
|
|
- return answer in ("y", "yes")
|
|
|
-
|
|
|
-
|
|
|
-def iso_now():
|
|
|
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
-
|
|
|
-
|
|
|
-def cmd_write(args):
|
|
|
- client = make_client(args)
|
|
|
- # 先确认凭据齐备再解析输入:缺 token 时不该让用户看半张回显
|
|
|
- client.user_token
|
|
|
- model = client.model
|
|
|
- rows, file_channel = load_sentences(args)
|
|
|
-
|
|
|
- channel_hint = args.channel or file_channel
|
|
|
- uid = name = None
|
|
|
- if channel_hint or not any(r.get("channel_uid") for r in rows if isinstance(r, dict)):
|
|
|
- uid, name = pick_channel(client, channel_hint)
|
|
|
- sentences = normalize_sentences(rows, uid, args.content_type)
|
|
|
-
|
|
|
- channels = sorted({s["channel_uid"] for s in sentences})
|
|
|
- books = sorted({s["book_id"] for s in sentences})
|
|
|
- names = {uid: name} if uid else {}
|
|
|
- for cuid in channels:
|
|
|
- if cuid not in names:
|
|
|
- names[cuid] = channel_display_name(client, cuid)
|
|
|
-
|
|
|
- # 写入前的确认:出问题时必须知道是哪一版代码、写进了哪个 channel
|
|
|
- print("=" * 72)
|
|
|
- print(f"API : {client.api_note()}")
|
|
|
- for cuid in channels:
|
|
|
- print(f"channel : {names.get(cuid) or '(未知)'} {cuid}")
|
|
|
- print(f"book : {', '.join(str(b) for b in books)}")
|
|
|
- print(f"模型身份 : {model.get('name')} uid={model.get('uid')}")
|
|
|
- print(f"句子数 : {len(sentences)}(每批 {args.batch})")
|
|
|
- print("-" * 72)
|
|
|
- for sent in sentences[: args.preview]:
|
|
|
- summary = sent["content"].replace("\n", " ")
|
|
|
- if len(summary) > 50:
|
|
|
- summary = summary[:50] + "…"
|
|
|
- print(f" {sent['book_id']}-{sent['paragraph']}-{sent['word_start']}-{sent['word_end']} {summary}")
|
|
|
- if len(sentences) > args.preview:
|
|
|
- print(f" …… 其余 {len(sentences) - args.preview} 条")
|
|
|
- print("-" * 72)
|
|
|
- print("⚠ 相同位置(book/paragraph/word_start/word_end/channel)的已有句子将被覆盖。")
|
|
|
- print("=" * 72)
|
|
|
-
|
|
|
- if args.dry_run:
|
|
|
- print("--dry-run:未发送任何请求。")
|
|
|
- return 0
|
|
|
- if not args.yes and not confirm("确认写入?"):
|
|
|
- print("已取消,未写入任何内容。")
|
|
|
- return 1
|
|
|
-
|
|
|
- # 每个 channel 一张 access token(缓存命中就不重签)
|
|
|
- tokens = {}
|
|
|
- for cuid in channels:
|
|
|
- book_scope = 0 if len(books) > 1 else books[0]
|
|
|
- if args.book is not None:
|
|
|
- book_scope = args.book
|
|
|
- item = grant_access_token(client, cuid, names.get(cuid), book_scope)
|
|
|
- tokens[cuid] = item["token"]
|
|
|
-
|
|
|
- written = {}
|
|
|
- failed = []
|
|
|
- model_token = model["token"]
|
|
|
- for start in range(0, len(sentences), args.batch):
|
|
|
- batch = sentences[start : start + args.batch]
|
|
|
- body = {
|
|
|
- "sentences": [
|
|
|
- {
|
|
|
- "book_id": s["book_id"],
|
|
|
- "paragraph": s["paragraph"],
|
|
|
- "word_start": s["word_start"],
|
|
|
- "word_end": s["word_end"],
|
|
|
- "channel_uid": s["channel_uid"],
|
|
|
- "content": s["content"],
|
|
|
- "content_type": s["content_type"],
|
|
|
- "access_token": tokens[s["channel_uid"]],
|
|
|
- }
|
|
|
- for s in batch
|
|
|
- ]
|
|
|
- }
|
|
|
- try:
|
|
|
- data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
|
|
|
- except ApiError as exc:
|
|
|
- if exc.status != 401:
|
|
|
- raise explain_api_error(exc, "写入句子")
|
|
|
- # 模型 token 过期或被撤销:重取一次再试,仍失败才提示重新登录
|
|
|
- model_token = refresh_model_token(client)
|
|
|
- try:
|
|
|
- data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
|
|
|
- except ApiError as retry_exc:
|
|
|
- raise explain_api_error(retry_exc, "写入句子(已重签模型 token 后重试)")
|
|
|
- returned = (data or {}).get("rows") or []
|
|
|
- for row in returned:
|
|
|
- written[row_key(row)] = row
|
|
|
- got = len(returned)
|
|
|
- print(f"批次 {start // args.batch + 1}: 提交 {len(batch)},服务端确认 {got}")
|
|
|
- if got < len(batch):
|
|
|
- # HTTP 200 不等于全部写入:逐句鉴权失败是静默 continue 掉的
|
|
|
- for s in batch:
|
|
|
- if sent_key(s) not in written:
|
|
|
- failed.append(s)
|
|
|
-
|
|
|
- print("-" * 72)
|
|
|
- print(f"合计提交 {len(sentences)} 条,确认写入 {len(written)} 条。")
|
|
|
- sample = next(iter(written.values()), None)
|
|
|
- if sample:
|
|
|
- editor = (sample.get("editor") or {}).get("nickName") or (sample.get("editor") or {}).get("name")
|
|
|
- print(f"署名核对:第一条的 editor = {editor}")
|
|
|
- if failed:
|
|
|
- print(f"⚠ 有 {len(failed)} 条未写入(服务端逐句鉴权失败会静默跳过):")
|
|
|
- for s in failed[:10]:
|
|
|
- print(f" {s['book_id']}-{s['paragraph']}-{s['word_start']}-{s['word_end']} channel={s['channel_uid'][:8]}…")
|
|
|
- if len(failed) > 10:
|
|
|
- print(f" …… 其余 {len(failed) - 10} 条")
|
|
|
- return 1
|
|
|
- return 0
|
|
|
-
|
|
|
-
|
|
|
-def channel_display_name(client, uid):
|
|
|
- try:
|
|
|
- data = client.call("GET", f"v2/channel/{uid}", token=client.user_token)
|
|
|
- except (ApiError, WpError):
|
|
|
- return None
|
|
|
- if isinstance(data, dict):
|
|
|
- return data.get("name")
|
|
|
- return None
|
|
|
-
|
|
|
-
|
|
|
-def refresh_model_token(client):
|
|
|
- note("⚠ 模型 token 被拒(过期或已撤销),正在重新签发……")
|
|
|
- model = client.bucket.get("model") or {}
|
|
|
- if not model.get("uid"):
|
|
|
- raise WpError("缓存里没有模型 uid,无法重签。请跑:python3 wp.py ensure-model --name <模型名>")
|
|
|
- try:
|
|
|
- issued = client.call("GET", f"v2/ai-model-token/{model['uid']}", token=client.user_token)
|
|
|
- except ApiError as exc:
|
|
|
- raise explain_api_error(exc, "重新签发模型 token")
|
|
|
- model.update({"uid": issued["uid"], "name": issued["name"], "token": issued["token"], "issued_at": iso_now()})
|
|
|
- client.bucket["model"] = model
|
|
|
- client.save()
|
|
|
- return issued["token"]
|
|
|
-
|
|
|
-
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-# CLI
|
|
|
-# ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
-def build_parser():
|
|
|
- parser = argparse.ArgumentParser(
|
|
|
- prog="wp.py",
|
|
|
- description="WikiPali 写入客户端(以 AI 模型身份写入句子)",
|
|
|
- formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
- )
|
|
|
- parser.add_argument("--api", help="本次调用使用的 API 地址(序号/简称/完整 url),不写回凭据文件")
|
|
|
- sub = parser.add_subparsers(dest="command", required=True)
|
|
|
-
|
|
|
- p = sub.add_parser("endpoint", help="查看 / 切换 API 地址")
|
|
|
- p.add_argument("target", nargs="?", help="序号、简称(www/www.cc/next/next.cc/local)或完整 url")
|
|
|
- p.set_defaults(func=cmd_endpoint)
|
|
|
-
|
|
|
- p = sub.add_parser("whoami", help="显示当前凭据状态")
|
|
|
- p.add_argument("--check", action="store_true", help="额外向服务端校验用户 token")
|
|
|
- p.set_defaults(func=cmd_whoami)
|
|
|
-
|
|
|
- p = sub.add_parser("ensure-model", help="幂等地建立模型记录并取模型身份 token")
|
|
|
- p.add_argument("--name", help="模型标识,如 claude-opus-5(会成为句子作者署名)")
|
|
|
- p.add_argument("--model", help="底层模型 id")
|
|
|
- p.add_argument("--url", dest="url", help="模型服务地址")
|
|
|
- p.add_argument("--description", help="描述")
|
|
|
- p.add_argument("--privacy", choices=["private", "public"], default="private")
|
|
|
- p.set_defaults(func=cmd_ensure_model)
|
|
|
-
|
|
|
- p = sub.add_parser("revoke", help="撤销该模型已签出的全部 token")
|
|
|
- p.add_argument("--uid", help="模型 uid,缺省用缓存里的")
|
|
|
- p.add_argument("-y", "--yes", action="store_true")
|
|
|
- p.set_defaults(func=cmd_revoke)
|
|
|
-
|
|
|
- p = sub.add_parser("channels", help="列出当前账号可编辑的 channel")
|
|
|
- p.add_argument("--search", help="按名字过滤")
|
|
|
- p.add_argument("--json", action="store_true", help="输出原始 JSON")
|
|
|
- p.set_defaults(func=cmd_channels)
|
|
|
-
|
|
|
- p = sub.add_parser("grant", help="为某个 channel 签发 access token 并缓存")
|
|
|
- p.add_argument("channel", nargs="?", help="channel uid / 列表序号 / 名字片段;省略则交互选择")
|
|
|
- p.add_argument("--book", type=int, default=0, help="限定 book,0 表示不限(默认)")
|
|
|
- p.add_argument("--force", action="store_true", help="即使缓存未过期也重新签发")
|
|
|
- p.set_defaults(func=cmd_grant)
|
|
|
-
|
|
|
- p = sub.add_parser("write", help="写入句子")
|
|
|
- p.add_argument("file", help="句子 JSON 文件,- 表示从 stdin 读")
|
|
|
- p.add_argument("--channel", help="目标 channel(uid / 序号 / 名字片段)")
|
|
|
- p.add_argument("--book", type=int, help="access token 的 book 范围,缺省按句子推断")
|
|
|
- p.add_argument("--batch", type=int, default=DEFAULT_BATCH, help=f"每批条数,默认 {DEFAULT_BATCH}")
|
|
|
- p.add_argument("--content-type", default="markdown")
|
|
|
- p.add_argument("--preview", type=int, default=5, help="确认时预览几条")
|
|
|
- p.add_argument("--dry-run", action="store_true", help="只做校验与回显,不发请求")
|
|
|
- p.add_argument("-y", "--yes", action="store_true", help="跳过交互确认(非交互环境必须显式给)")
|
|
|
- p.set_defaults(func=cmd_write)
|
|
|
-
|
|
|
- return parser
|
|
|
-
|
|
|
-
|
|
|
-def main(argv=None):
|
|
|
- args = build_parser().parse_args(argv)
|
|
|
- try:
|
|
|
- return args.func(args)
|
|
|
- except WpError as exc:
|
|
|
- print(f"错误:{exc}", file=sys.stderr)
|
|
|
- return 1
|
|
|
- except KeyboardInterrupt:
|
|
|
- print("\n已中断。", file=sys.stderr)
|
|
|
- return 130
|
|
|
-
|
|
|
-
|
|
|
-if __name__ == "__main__":
|
|
|
- sys.exit(main())
|