#!/usr/bin/env python3 """ 生成可粘贴到微信(含朋友圈文案里)的小程序官方外链:URL Link 或 Short Link。 说明(非逆向): - 仅调用微信开放平台文档中的 HTTPS 接口,需该小程序的 AppSecret 换 access_token。 - 「F12 抓包」只能辅助你自己后台调这些接口,不能从他人小程序里抠出可复用的签名链。 - URL Link 文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/url-link/qrcode-link/url-link/api_generateurllink.html - Short Link 文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/url-link/qrcode-link/short-link/api_generateshortlink.html 环境变量(与 wechat_miniprogram_release.py 一致,可读 soul-api/.env): WECHAT_APPID / WECHAT_APPSECRET 用法示例: python3 scripts/wechat_generate_share_link.py --mode urllink --path pages/index/index --query "id=1" python3 scripts/wechat_generate_share_link.py --mode shortlink --page-url "pages/foo/bar?x=1" --page-title "标题" """ from __future__ import annotations import argparse import json import os import re import sys import urllib.error import urllib.parse import urllib.request from pathlib import Path def _repo_root() -> Path: return Path(__file__).resolve().parent.parent def _load_partial_env_file(path: Path, keys: tuple[str, ...]) -> None: if not path.is_file(): return for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) k, v = k.strip(), v.strip().strip('"').strip("'") if k in keys and not os.environ.get(k): os.environ[k] = v def ensure_wechat_env_from_soul_api() -> None: if os.environ.get("WECHAT_APPID") and os.environ.get("WECHAT_APPSECRET"): return root = _repo_root() for name in (".env.production", ".env"): _load_partial_env_file( root / "soul-api" / name, ("WECHAT_APPID", "WECHAT_APPSECRET") ) if os.environ.get("WECHAT_APPID") and os.environ.get("WECHAT_APPSECRET"): return def _get(url: str) -> dict: req = urllib.request.Request(url, method="GET") with urllib.request.urlopen(req, timeout=60) as resp: return json.loads(resp.read().decode()) def _post_json(url: str, body: dict) -> dict: data = json.dumps(body, ensure_ascii=False).encode("utf-8") req = urllib.request.Request( url, data=data, method="POST", headers={"Content-Type": "application/json; charset=utf-8"}, ) with urllib.request.urlopen(req, timeout=60) as resp: return json.loads(resp.read().decode()) def _wechat_appsecret_hint(secret: str) -> str | None: """若明显是占位说明或误粘贴,提前报错,避免只看到 40125。""" s = (secret or "").strip().strip("'").strip('"') if re.search(r"[\u4e00-\u9fff]", s): return ( "WECHAT_APPSECRET 中含汉字,说明把「说明文字」当成了密钥。\n" "请到 mp.weixin.qq.com → 该小程序 → 开发管理 → 开发设置 →\n" "「AppSecret(小程序密钥)」点「重置」或「查看」后复制那一串(约 32 位),\n" "整串粘贴到 export,勿把教程里的整句说明当作值,例如勿写:\n" " export WECHAT_APPSECRET='这里粘贴真实 AppSecret'" ) if len(s) < 28: return ( "WECHAT_APPSECRET 长度过短。真实 AppSecret 一般为 32 位字符;\n" "请从公众平台复制完整密钥,勿留空格或括号说明。" ) return None def _urllink_args_placeholder_hint(path: str, query: str) -> str | None: """--path / --query 仍是教程占位符时提示,避免带着假路径去调微信。""" p, q = (path or "").strip(), (query or "").strip() bad_fragments = ( "真实路径", "真实参数", "你的文章", "你的参数", "pages/真实", "id=真实", "这里粘贴", ) for frag in bad_fragments: if frag in p or frag in q: return ( "检测到 --path 或 --query 里仍是「教程占位词」(如「真实路径」「真实参数」)。\n" "请改成该小程序 app.json 里真实存在的页面路径,例如 pages/index/index,\n" "以及该页 onLoad 里会解析的真实 query(没有就传空字符串:--query '')。" ) return None def get_access_token(appid: str, secret: str) -> str: q = urllib.parse.urlencode( {"grant_type": "client_credential", "appid": appid, "secret": secret} ) url = f"https://api.weixin.qq.com/cgi-bin/token?{q}" data = _get(url) if data.get("errcode"): raise SystemExit(f"获取 access_token 失败: {data}") token = data.get("access_token") if not token: raise SystemExit(f"无 access_token: {data}") return token def normalize_path(path: str) -> str: p = (path or "").strip() if p.startswith("/"): p = p[1:] return p def cmd_urllink(token: str, path: str, query: str, env_version: str, expire_days: int) -> None: path = normalize_path(path) # 与当前开放文档一致的 jump_wxa 结构(若微信侧变更,以官方为准) body: dict = { "jump_wxa": { "path": path, "query": query or "", "env_version": env_version, }, "expire_type": 1, "expire_interval": max(1, min(expire_days, 365)), } url = f"https://api.weixin.qq.com/wxa/generate_urllink?access_token={urllib.parse.quote(token)}" try: data = _post_json(url, body) except urllib.error.HTTPError as e: raw = e.read().decode(errors="ignore") raise SystemExit(f"HTTP {e.code}: {raw}") from e if data.get("errcode"): # 部分历史文档为顶层 path/query,失败时再试一次 flat = { "path": path, "query": query or "", "env_version": env_version, "expire_type": 1, "expire_interval": max(1, min(expire_days, 365)), } data = _post_json(url, flat) if data.get("errcode"): raise SystemExit(f"generate_urllink 失败: {data}") link = data.get("url_link") if not link: raise SystemExit(f"响应无 url_link: {data}") print(link) def cmd_shortlink(token: str, page_url: str, page_title: str, permanent: bool) -> None: page_url_norm = page_url.strip() if page_url_norm.startswith("/"): page_url_norm = page_url_norm[1:] body = { "page_url": page_url_norm, "page_title": page_title or "分享", "is_permanent": permanent, } url = f"https://api.weixin.qq.com/wxa/genwxashortlink?access_token={urllib.parse.quote(token)}" try: data = _post_json(url, body) except urllib.error.HTTPError as e: raw = e.read().decode(errors="ignore") raise SystemExit(f"HTTP {e.code}: {raw}") from e if data.get("errcode"): raise SystemExit(f"genwxashortlink 失败: {data}") link = data.get("link") if not link: raise SystemExit(f"响应无 link: {data}") print(link) def main() -> None: ensure_wechat_env_from_soul_api() ap = argparse.ArgumentParser(description="生成小程序 URL Link / Short Link(官方接口)") ap.add_argument( "--mode", choices=("urllink", "shortlink"), default="urllink", help="urllink=HTTPS 外链(短信/网页/微信内);shortlink=微信内短链", ) ap.add_argument("--path", default="", help="urllink:页面路径,如 pages/index/index") ap.add_argument("--query", default="", help="urllink:query 字符串,不含问号") ap.add_argument( "--page-url", default="", help="shortlink:含路径与 query,如 pages/foo/bar?id=1", ) ap.add_argument("--page-title", default="", help="shortlink:页面标题") ap.add_argument( "--env-version", choices=("release", "trial", "develop"), default="release", help="urllink 打开的小程序版本", ) ap.add_argument("--expire-days", type=int, default=30, help="urllink 有效天数(1~365)") ap.add_argument( "--permanent", action="store_true", help="shortlink:是否申请永久有效(受类目/额度限制)", ) args = ap.parse_args() appid = (os.environ.get("WECHAT_APPID") or "").strip() secret = (os.environ.get("WECHAT_APPSECRET") or "").strip() if not appid or not secret: print( "缺少 WECHAT_APPID / WECHAT_APPSECRET。\n" "请在 shell 中 export,或写入 soul-api/.env(或 .env.production)。\n" "注意:须与要分享的小程序一致;卡若派对仓库内默认是 soul 小程序,不是 wx3d15ed… 等其它 AppID。", file=sys.stderr, ) raise SystemExit(2) hint = _wechat_appsecret_hint(secret) if hint: print(hint, file=sys.stderr) raise SystemExit(2) if args.mode == "urllink": if not args.path.strip(): raise SystemExit("urllink 模式必须提供 --path") ph = _urllink_args_placeholder_hint(args.path, args.query) if ph: print(ph, file=sys.stderr) raise SystemExit(2) token = get_access_token(appid, secret) if args.mode == "urllink": cmd_urllink(token, args.path, args.query, args.env_version, args.expire_days) else: if not args.page_url.strip(): raise SystemExit("shortlink 模式必须提供 --page-url") cmd_shortlink(token, args.page_url, args.page_title, args.permanent) if __name__ == "__main__": main()