Files
Mycontent/scripts/wechat_generate_share_link.py
2026-04-15 19:07:02 +08:00

266 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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="urllinkquery 字符串,不含问号")
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()