237 lines
8.5 KiB
Python
237 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
微信小程序:生成 URL Link / Short Link(官方 HTTPS 接口,无第三方依赖)。
|
||
|
||
放置位置任意;凭证读取顺序:
|
||
1) 环境变量 WECHAT_APPID、WECHAT_APPSECRET(已设置则直接用)
|
||
2) 环境变量 WECHAT_ENV_FILE 指向的 .env 文件
|
||
3) 与本脚本同目录的 .env 或 .env.local
|
||
4) 若本脚本仍在「一场soul的创业实验-永平」目录结构下:上一级/soul-api/.env 或 .env.production
|
||
|
||
官方文档:
|
||
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
|
||
"""
|
||
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 _script_dir() -> Path:
|
||
return Path(__file__).resolve().parent
|
||
|
||
|
||
def _load_partial_env_file(path: Path, keys: tuple[str, ...]) -> bool:
|
||
if not path.is_file():
|
||
return False
|
||
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
|
||
return bool(os.environ.get("WECHAT_APPID") and os.environ.get("WECHAT_APPSECRET"))
|
||
|
||
|
||
def ensure_wechat_env() -> None:
|
||
if os.environ.get("WECHAT_APPID") and os.environ.get("WECHAT_APPSECRET"):
|
||
return
|
||
ef = (os.environ.get("WECHAT_ENV_FILE") or "").strip()
|
||
if ef and _load_partial_env_file(Path(ef), ("WECHAT_APPID", "WECHAT_APPSECRET")):
|
||
return
|
||
base = _script_dir()
|
||
for name in (".env", ".env.local"):
|
||
if _load_partial_env_file(base / name, ("WECHAT_APPID", "WECHAT_APPSECRET")):
|
||
return
|
||
# 兼容:脚本在「仓库根/soul-api」的上一级目录下任意子目录时,尝试读 soul-api/.env
|
||
repo_like = base.parent.parent
|
||
if (repo_like / "soul-api").is_dir():
|
||
for name in (".env.production", ".env"):
|
||
if _load_partial_env_file(
|
||
repo_like / "soul-api" / name, ("WECHAT_APPID", "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:
|
||
s = (secret or "").strip().strip("'").strip('"')
|
||
if re.search(r"[\u4e00-\u9fff]", s):
|
||
return (
|
||
"WECHAT_APPSECRET 中含汉字,说明把「说明文字」当成了密钥。\n"
|
||
"请到 mp.weixin.qq.com → 该小程序 → 开发管理 → 开发设置 →\n"
|
||
"「AppSecret(小程序密钥)」查看/重置后复制约 32 位密钥。"
|
||
)
|
||
if len(s) < 28:
|
||
return "WECHAT_APPSECRET 长度过短,请从公众平台复制完整 AppSecret。"
|
||
return None
|
||
|
||
|
||
def _urllink_args_placeholder_hint(path: str, query: str) -> str | None:
|
||
p, q = (path or "").strip(), (query or "").strip()
|
||
for frag in (
|
||
"真实路径",
|
||
"真实参数",
|
||
"你的文章",
|
||
"你的参数",
|
||
"pages/真实",
|
||
"id=真实",
|
||
"这里粘贴",
|
||
):
|
||
if frag in p or frag in q:
|
||
return "检测到 path/query 中含教程占位词,请换成 app.json 真实路径与 onLoad 参数。"
|
||
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)
|
||
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"):
|
||
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()
|
||
ap = argparse.ArgumentParser(description="微信小程序 URL Link / Short Link 生成器")
|
||
ap.add_argument("--mode", choices=("urllink", "shortlink"), default="urllink")
|
||
ap.add_argument("--path", default="", help="urllink:页面路径")
|
||
ap.add_argument("--query", default="", help="urllink:query,无则传空字符串")
|
||
ap.add_argument("--page-url", default="", help="shortlink:page?query")
|
||
ap.add_argument("--page-title", default="", help="shortlink:标题")
|
||
ap.add_argument("--env-version", choices=("release", "trial", "develop"), default="release")
|
||
ap.add_argument("--expire-days", type=int, default=30)
|
||
ap.add_argument("--permanent", action="store_true")
|
||
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"
|
||
"做法:在本目录放置 .env(见 .env.example),或 export 两个变量,\n"
|
||
"或设置 WECHAT_ENV_FILE=/绝对路径/你的.env",
|
||
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()
|