Files
soul/开发文档/小程序管理/scripts/mp_api.py

636 lines
20 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
# -*- coding: utf-8 -*-
"""
微信小程序管理API封装
支持:注册、配置、代码管理、审核、发布、数据分析
"""
import os
import json
import time
import httpx
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
from pathlib import Path
# 尝试加载dotenv可选依赖
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass # dotenv不是必需的
@dataclass
class MiniProgramInfo:
"""小程序基础信息"""
appid: str
nickname: str
head_image_url: str
signature: str
principal_name: str
realname_status: int # 1=已认证
@dataclass
class AuditStatus:
"""审核状态"""
auditid: int
status: int # 0=成功1=被拒2=审核中3=已撤回4=延后
reason: Optional[str] = None
screenshot: Optional[str] = None
@property
def status_text(self) -> str:
status_map = {
0: "✅ 审核成功",
1: "❌ 审核被拒",
2: "⏳ 审核中",
3: "↩️ 已撤回",
4: "⏸️ 审核延后"
}
return status_map.get(self.status, "未知状态")
class MiniProgramAPI:
"""微信小程序管理API"""
BASE_URL = "https://api.weixin.qq.com"
def __init__(
self,
component_appid: Optional[str] = None,
component_appsecret: Optional[str] = None,
authorizer_appid: Optional[str] = None,
access_token: Optional[str] = None
):
"""
初始化API
Args:
component_appid: 第三方平台AppID
component_appsecret: 第三方平台密钥
authorizer_appid: 授权小程序AppID
access_token: 直接使用的access_token如已获取
"""
self.component_appid = component_appid or os.getenv("COMPONENT_APPID")
self.component_appsecret = component_appsecret or os.getenv("COMPONENT_APPSECRET")
self.authorizer_appid = authorizer_appid or os.getenv("AUTHORIZER_APPID")
self._access_token = access_token or os.getenv("ACCESS_TOKEN")
self._token_expires_at = 0
self.client = httpx.Client(timeout=30.0)
@property
def access_token(self) -> str:
"""获取access_token如果过期则刷新"""
if self._access_token and time.time() < self._token_expires_at:
return self._access_token
# 如果没有配置刷新token的信息直接返回现有token
if not self.component_appid:
return self._access_token or ""
# TODO: 实现token刷新逻辑
return self._access_token or ""
def set_access_token(self, token: str, expires_in: int = 7200):
"""手动设置access_token"""
self._access_token = token
self._token_expires_at = time.time() + expires_in - 300 # 提前5分钟过期
def _request(
self,
method: str,
path: str,
params: Optional[Dict] = None,
json_data: Optional[Dict] = None,
**kwargs
) -> Dict[str, Any]:
"""发起API请求"""
url = f"{self.BASE_URL}{path}"
# 添加access_token
if params is None:
params = {}
if "access_token" not in params:
params["access_token"] = self.access_token
if method.upper() == "GET":
resp = self.client.get(url, params=params, **kwargs)
else:
resp = self.client.post(url, params=params, json=json_data, **kwargs)
# 解析响应
try:
result = resp.json()
except json.JSONDecodeError:
# 可能是二进制数据(如图片)
return {"_binary": resp.content}
# 检查错误
if result.get("errcode", 0) != 0:
raise APIError(result.get("errcode"), result.get("errmsg", "Unknown error"))
return result
# ==================== 基础信息 ====================
def get_basic_info(self) -> MiniProgramInfo:
"""获取小程序基础信息"""
result = self._request("POST", "/cgi-bin/account/getaccountbasicinfo")
return MiniProgramInfo(
appid=result.get("appid", ""),
nickname=result.get("nickname", ""),
head_image_url=result.get("head_image_url", ""),
signature=result.get("signature", ""),
principal_name=result.get("principal_name", ""),
realname_status=result.get("realname_status", 0)
)
def modify_signature(self, signature: str) -> bool:
"""修改简介4-120字"""
self._request("POST", "/cgi-bin/account/modifysignature", json_data={
"signature": signature
})
return True
# ==================== 域名配置 ====================
def get_domain(self) -> Dict[str, List[str]]:
"""获取服务器域名配置"""
result = self._request("POST", "/wxa/modify_domain", json_data={
"action": "get"
})
return {
"requestdomain": result.get("requestdomain", []),
"wsrequestdomain": result.get("wsrequestdomain", []),
"uploaddomain": result.get("uploaddomain", []),
"downloaddomain": result.get("downloaddomain", [])
}
def set_domain(
self,
requestdomain: Optional[List[str]] = None,
wsrequestdomain: Optional[List[str]] = None,
uploaddomain: Optional[List[str]] = None,
downloaddomain: Optional[List[str]] = None
) -> bool:
"""设置服务器域名"""
data = {"action": "set"}
if requestdomain:
data["requestdomain"] = requestdomain
if wsrequestdomain:
data["wsrequestdomain"] = wsrequestdomain
if uploaddomain:
data["uploaddomain"] = uploaddomain
if downloaddomain:
data["downloaddomain"] = downloaddomain
self._request("POST", "/wxa/modify_domain", json_data=data)
return True
def get_webview_domain(self) -> List[str]:
"""获取业务域名"""
result = self._request("POST", "/wxa/setwebviewdomain", json_data={
"action": "get"
})
return result.get("webviewdomain", [])
def set_webview_domain(self, webviewdomain: List[str]) -> bool:
"""设置业务域名"""
self._request("POST", "/wxa/setwebviewdomain", json_data={
"action": "set",
"webviewdomain": webviewdomain
})
return True
# ==================== 隐私协议 ====================
def get_privacy_setting(self, privacy_ver: int = 2) -> Dict[str, Any]:
"""获取隐私协议设置"""
result = self._request("POST", "/cgi-bin/component/getprivacysetting", json_data={
"privacy_ver": privacy_ver
})
return result
def set_privacy_setting(
self,
setting_list: List[Dict[str, str]],
contact_email: Optional[str] = None,
contact_phone: Optional[str] = None,
notice_method: str = "弹窗提示"
) -> bool:
"""
设置隐私协议
Args:
setting_list: 隐私配置列表,如 [{"privacy_key": "UserInfo", "privacy_text": "用于展示头像"}]
contact_email: 联系邮箱
contact_phone: 联系电话
notice_method: 告知方式
"""
data = {
"privacy_ver": 2,
"setting_list": setting_list
}
owner_setting = {"notice_method": notice_method}
if contact_email:
owner_setting["contact_email"] = contact_email
if contact_phone:
owner_setting["contact_phone"] = contact_phone
data["owner_setting"] = owner_setting
self._request("POST", "/cgi-bin/component/setprivacysetting", json_data=data)
return True
# ==================== 类目管理 ====================
def get_all_categories(self) -> List[Dict]:
"""获取可选类目列表"""
result = self._request("GET", "/cgi-bin/wxopen/getallcategories")
return result.get("categories_list", {}).get("categories", [])
def get_category(self) -> List[Dict]:
"""获取已设置的类目"""
result = self._request("GET", "/cgi-bin/wxopen/getcategory")
return result.get("categories", [])
def add_category(self, categories: List[Dict]) -> bool:
"""
添加类目
Args:
categories: 类目列表,如 [{"first": 1, "second": 2}]
"""
self._request("POST", "/cgi-bin/wxopen/addcategory", json_data={
"categories": categories
})
return True
def delete_category(self, first: int, second: int) -> bool:
"""删除类目"""
self._request("POST", "/cgi-bin/wxopen/deletecategory", json_data={
"first": first,
"second": second
})
return True
# ==================== 代码管理 ====================
def commit_code(
self,
template_id: int,
user_version: str,
user_desc: str,
ext_json: Optional[str] = None
) -> bool:
"""
上传代码
Args:
template_id: 代码模板ID
user_version: 版本号
user_desc: 版本描述
ext_json: 扩展配置JSON字符串
"""
data = {
"template_id": template_id,
"user_version": user_version,
"user_desc": user_desc
}
if ext_json:
data["ext_json"] = ext_json
self._request("POST", "/wxa/commit", json_data=data)
return True
def get_page(self) -> List[str]:
"""获取已上传代码的页面列表"""
result = self._request("GET", "/wxa/get_page")
return result.get("page_list", [])
def get_qrcode(self, path: Optional[str] = None) -> bytes:
"""
获取体验版二维码
Args:
path: 页面路径,如 "pages/index/index"
Returns:
二维码图片二进制数据
"""
params = {"access_token": self.access_token}
if path:
params["path"] = path
resp = self.client.get(f"{self.BASE_URL}/wxa/get_qrcode", params=params)
return resp.content
# ==================== 审核管理 ====================
def submit_audit(
self,
item_list: Optional[List[Dict]] = None,
version_desc: Optional[str] = None,
feedback_info: Optional[str] = None
) -> int:
"""
提交审核
Args:
item_list: 页面审核信息列表
version_desc: 版本说明
feedback_info: 反馈内容
Returns:
审核单ID
"""
data = {}
if item_list:
data["item_list"] = item_list
if version_desc:
data["version_desc"] = version_desc
if feedback_info:
data["feedback_info"] = feedback_info
result = self._request("POST", "/wxa/submit_audit", json_data=data)
return result.get("auditid", 0)
def get_audit_status(self, auditid: int) -> AuditStatus:
"""查询审核状态"""
result = self._request("POST", "/wxa/get_auditstatus", json_data={
"auditid": auditid
})
return AuditStatus(
auditid=auditid,
status=result.get("status", -1),
reason=result.get("reason"),
screenshot=result.get("screenshot")
)
def get_latest_audit_status(self) -> AuditStatus:
"""查询最新审核状态"""
result = self._request("GET", "/wxa/get_latest_auditstatus")
return AuditStatus(
auditid=result.get("auditid", 0),
status=result.get("status", -1),
reason=result.get("reason"),
screenshot=result.get("screenshot")
)
def undo_code_audit(self) -> bool:
"""撤回审核每天限1次"""
self._request("GET", "/wxa/undocodeaudit")
return True
# ==================== 发布管理 ====================
def release(self) -> bool:
"""发布已审核通过的版本"""
self._request("POST", "/wxa/release", json_data={})
return True
def revert_code_release(self) -> bool:
"""版本回退(只能回退到上一版本)"""
self._request("GET", "/wxa/revertcoderelease")
return True
def get_revert_history(self) -> List[Dict]:
"""获取可回退版本历史"""
result = self._request("GET", "/wxa/revertcoderelease", params={
"action": "get_history_version"
})
return result.get("version_list", [])
def gray_release(self, gray_percentage: int) -> bool:
"""
分阶段发布
Args:
gray_percentage: 灰度比例 1-100
"""
self._request("POST", "/wxa/grayrelease", json_data={
"gray_percentage": gray_percentage
})
return True
# ==================== 小程序码 ====================
def get_wxacode(
self,
path: str,
width: int = 430,
auto_color: bool = False,
line_color: Optional[Dict[str, int]] = None,
is_hyaline: bool = False
) -> bytes:
"""
获取小程序码有限制每个path最多10万个
Args:
path: 页面路径,如 "pages/index/index?id=123"
width: 宽度 280-1280
auto_color: 自动配置线条颜色
line_color: 线条颜色 {"r": 0, "g": 0, "b": 0}
is_hyaline: 是否透明背景
Returns:
二维码图片二进制数据
"""
data = {
"path": path,
"width": width,
"auto_color": auto_color,
"is_hyaline": is_hyaline
}
if line_color:
data["line_color"] = line_color
resp = self.client.post(
f"{self.BASE_URL}/wxa/getwxacode",
params={"access_token": self.access_token},
json=data
)
return resp.content
def get_wxacode_unlimit(
self,
scene: str,
page: Optional[str] = None,
width: int = 430,
auto_color: bool = False,
line_color: Optional[Dict[str, int]] = None,
is_hyaline: bool = False
) -> bytes:
"""
获取无限小程序码(推荐)
Args:
scene: 场景值最长32字符"user_id=123&from=share"
page: 页面路径,必须是已发布的页面
width: 宽度 280-1280
auto_color: 自动配置线条颜色
line_color: 线条颜色 {"r": 0, "g": 0, "b": 0}
is_hyaline: 是否透明背景
Returns:
二维码图片二进制数据
"""
data = {
"scene": scene,
"width": width,
"auto_color": auto_color,
"is_hyaline": is_hyaline
}
if page:
data["page"] = page
if line_color:
data["line_color"] = line_color
resp = self.client.post(
f"{self.BASE_URL}/wxa/getwxacodeunlimit",
params={"access_token": self.access_token},
json=data
)
return resp.content
def gen_short_link(
self,
page_url: str,
page_title: str,
is_permanent: bool = False
) -> str:
"""
生成小程序短链接
Args:
page_url: 页面路径,如 "pages/index/index?id=123"
page_title: 页面标题
is_permanent: 是否永久有效
Returns:
短链接
"""
result = self._request("POST", "/wxa/genwxashortlink", json_data={
"page_url": page_url,
"page_title": page_title,
"is_permanent": is_permanent
})
return result.get("link", "")
# ==================== 数据分析 ====================
def get_daily_visit_trend(self, begin_date: str, end_date: str) -> List[Dict]:
"""
获取每日访问趋势
Args:
begin_date: 开始日期 YYYYMMDD
end_date: 结束日期 YYYYMMDD
"""
result = self._request(
"POST",
"/datacube/getweanalysisappiddailyvisittrend",
json_data={"begin_date": begin_date, "end_date": end_date}
)
return result.get("list", [])
def get_user_portrait(self, begin_date: str, end_date: str) -> Dict:
"""
获取用户画像
Args:
begin_date: 开始日期 YYYYMMDD
end_date: 结束日期 YYYYMMDD
"""
result = self._request(
"POST",
"/datacube/getweanalysisappiduserportrait",
json_data={"begin_date": begin_date, "end_date": end_date}
)
return result
# ==================== API配额 ====================
def get_api_quota(self, cgi_path: str) -> Dict:
"""
查询接口调用额度
Args:
cgi_path: 接口路径,如 "/wxa/getwxacode"
"""
result = self._request("POST", "/cgi-bin/openapi/quota/get", json_data={
"cgi_path": cgi_path
})
return result.get("quota", {})
def clear_quota(self, appid: Optional[str] = None) -> bool:
"""重置接口调用次数每月限10次"""
self._request("POST", "/cgi-bin/clear_quota", json_data={
"appid": appid or self.authorizer_appid
})
return True
def close(self):
"""关闭连接"""
self.client.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
class APIError(Exception):
"""API错误"""
ERROR_CODES = {
-1: "系统繁忙",
40001: "access_token无效",
40002: "grant_type不正确",
40013: "appid不正确",
40029: "code无效",
40125: "appsecret不正确",
41002: "缺少appid参数",
41004: "缺少appsecret参数",
42001: "access_token过期",
42007: "refresh_token过期",
45009: "调用超过限制",
61039: "代码检测任务未完成,请稍后再试",
85006: "标签格式错误",
85007: "页面路径错误",
85009: "已有审核版本,请先撤回",
85010: "版本输入错误",
85011: "当前版本不能回退",
85012: "无效的版本",
85015: "该账号已有发布中的版本",
85019: "没有审核版本",
85020: "审核状态异常",
85064: "找不到模板",
85085: "该小程序不能被操作",
85086: "小程序没有绑定任何类目",
87013: "每天只能撤回1次审核",
89020: "该小程序尚未认证",
89248: "隐私协议内容不完整",
}
def __init__(self, code: int, message: str):
self.code = code
self.message = message
super().__init__(f"[{code}] {self.ERROR_CODES.get(code, message)}")
# 便捷函数
def create_api_from_env() -> MiniProgramAPI:
"""从环境变量创建API实例"""
return MiniProgramAPI()
if __name__ == "__main__":
# 测试
api = create_api_from_env()
print("API初始化成功")