266 lines
9.8 KiB
Python
266 lines
9.8 KiB
Python
#!/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()
|