wechat-media-writer 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 微信公众号 API 封装模块
4
+ 支持:获取token、上传图片、创建草稿
5
+ """
6
+
7
+ import http.client
8
+ import json
9
+ import mimetypes
10
+ import os
11
+ import socket
12
+ import ssl
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ import uuid
17
+
18
+ CONFIG_PATH = os.path.expanduser("~/.wechat/config.json")
19
+ TOKEN_CACHE_PATH = os.path.expanduser("~/.wechat/token_cache.json")
20
+ WX_API_HOST = "api.weixin.qq.com"
21
+
22
+
23
+ def _resolve_wx_ip():
24
+ """通过外部DNS解析微信API真实IP,绕过本地DNS劫持"""
25
+ try:
26
+ r = subprocess.run(
27
+ ["dig", "+short", WX_API_HOST, "@8.8.8.8"],
28
+ capture_output=True,
29
+ text=True,
30
+ timeout=5,
31
+ )
32
+ ips = [
33
+ ip.strip()
34
+ for ip in r.stdout.strip().split("\n")
35
+ if ip.strip() and not ip.startswith(";")
36
+ ]
37
+ return ips[0] if ips else WX_API_HOST
38
+ except Exception:
39
+ return WX_API_HOST
40
+
41
+
42
+ _WX_REAL_IP = _resolve_wx_ip()
43
+
44
+
45
+ def _wx_request(path, method="GET", data=None, headers=None, timeout=30):
46
+ """用真实IP直连微信API,SNI用域名保证SSL验证通过"""
47
+ ctx = ssl.create_default_context()
48
+ ctx.check_hostname = True
49
+ ctx.verify_mode = ssl.CERT_REQUIRED
50
+ sock = socket.create_connection((_WX_REAL_IP, 443), timeout=timeout)
51
+ ssl_sock = ctx.wrap_socket(sock, server_hostname=WX_API_HOST)
52
+ conn = http.client.HTTPSConnection(WX_API_HOST, timeout=timeout, context=ctx)
53
+ conn.sock = ssl_sock
54
+ req_h = {"Host": WX_API_HOST}
55
+ if headers:
56
+ req_h.update(headers)
57
+ conn.request(method, f"/{path}", body=data, headers=req_h)
58
+ resp = conn.getresponse()
59
+ body = resp.read().decode()
60
+ conn.close()
61
+ return resp.status, body
62
+
63
+
64
+ def _wx_upload(path, filepath, extra_fields=None, timeout=60):
65
+ """multipart/form-data 上传文件到微信"""
66
+ ctx = ssl.create_default_context()
67
+ sock = socket.create_connection((_WX_REAL_IP, 443), timeout=timeout)
68
+ ssl_sock = ctx.wrap_socket(sock, server_hostname=WX_API_HOST)
69
+ conn = http.client.HTTPSConnection(WX_API_HOST, timeout=timeout, context=ctx)
70
+ conn.sock = ssl_sock
71
+
72
+ boundary = uuid.uuid4().hex
73
+ body_parts = []
74
+
75
+ if extra_fields:
76
+ for k, v in extra_fields.items():
77
+ body_parts.append(
78
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
79
+ )
80
+
81
+ with open(filepath, "rb") as f:
82
+ file_data = f.read()
83
+ filename = os.path.basename(filepath)
84
+ mime_type = mimetypes.guess_type(filepath)[0] or "image/jpeg"
85
+ body_parts.append(
86
+ f'--{boundary}\r\nContent-Disposition: form-data; name="media"; filename="{filename}"\r\nContent-Type: {mime_type}\r\n\r\n'.encode()
87
+ )
88
+ body_parts.append(file_data)
89
+ body_parts.append(f"\r\n--{boundary}--\r\n".encode())
90
+
91
+ body = b"".join(body_parts)
92
+ req_headers = {
93
+ "Host": WX_API_HOST,
94
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
95
+ "Content-Length": str(len(body)),
96
+ }
97
+ conn.request("POST", f"/{path}", body=body, headers=req_headers)
98
+ resp = conn.getresponse()
99
+ resp_body = resp.read().decode()
100
+ conn.close()
101
+ return resp.status, resp_body
102
+
103
+
104
+ def load_config():
105
+ """加载微信配置"""
106
+ if not os.path.exists(CONFIG_PATH):
107
+ print(f"错误: 配置文件不存在 {CONFIG_PATH}", file=sys.stderr)
108
+ sys.exit(1)
109
+ with open(CONFIG_PATH, "r") as f:
110
+ return json.load(f)
111
+
112
+
113
+ def get_access_token(config):
114
+ """获取 access_token,带缓存(提前5分钟刷新)"""
115
+ if os.path.exists(TOKEN_CACHE_PATH):
116
+ try:
117
+ with open(TOKEN_CACHE_PATH, "r") as f:
118
+ cache = json.load(f)
119
+ if cache.get("expires_at", 0) > time.time() + 300:
120
+ return cache["access_token"]
121
+ except (json.JSONDecodeError, KeyError):
122
+ pass
123
+
124
+ status, body = _wx_request(
125
+ f"cgi-bin/token?grant_type=client_credential&appid={config['appid']}&secret={config['appsecret']}",
126
+ timeout=15,
127
+ )
128
+ data = json.loads(body)
129
+ if "errcode" in data:
130
+ print(
131
+ f"获取token失败: [{data['errcode']}] {data.get('errmsg', '')}",
132
+ file=sys.stderr,
133
+ )
134
+ sys.exit(1)
135
+
136
+ token = data["access_token"]
137
+ expires_in = data.get("expires_in", 7200)
138
+ os.makedirs(os.path.dirname(TOKEN_CACHE_PATH), exist_ok=True)
139
+ with open(TOKEN_CACHE_PATH, "w") as f:
140
+ json.dump({"access_token": token, "expires_at": time.time() + expires_in}, f)
141
+ return token
142
+
143
+
144
+ def upload_cover(token, image_path):
145
+ """上传封面图(永久素材),返回 media_id"""
146
+ if not os.path.exists(image_path):
147
+ print(f"错误: 封面图不存在 {image_path}", file=sys.stderr)
148
+ sys.exit(1)
149
+
150
+ status, body = _wx_upload(
151
+ f"cgi-bin/material/add_material?access_token={token}&type=image",
152
+ image_path,
153
+ )
154
+ result = json.loads(body)
155
+ if "errcode" in result:
156
+ print(
157
+ f"封面上传失败: [{result['errcode']}] {result.get('errmsg', '')}",
158
+ file=sys.stderr,
159
+ )
160
+ sys.exit(1)
161
+
162
+ print(f"封面上传成功: media_id={result['media_id']}")
163
+ return result["media_id"]
164
+
165
+
166
+ def upload_content_image(token, image_path):
167
+ """上传内容图,返回 mmbiz URL"""
168
+ if not os.path.exists(image_path):
169
+ print(f"错误: 图片不存在 {image_path}", file=sys.stderr)
170
+ return None
171
+
172
+ status, body = _wx_upload(
173
+ f"cgi-bin/media/uploadimg?access_token={token}",
174
+ image_path,
175
+ )
176
+ result = json.loads(body)
177
+ if "url" not in result:
178
+ print(f"图片上传失败: {body[:200]}", file=sys.stderr)
179
+ return None
180
+
181
+ print(f" ✓ {os.path.basename(image_path)}: {result['url']}")
182
+ return result["url"]
183
+
184
+
185
+ def create_draft(token, title, author, digest, content_html, thumb_media_id):
186
+ """创建草稿"""
187
+ article = {
188
+ "title": title,
189
+ "author": author,
190
+ "digest": digest or "",
191
+ "content": content_html,
192
+ "thumb_media_id": thumb_media_id,
193
+ "need_open_comment": 1,
194
+ "only_fans_can_comment": 0,
195
+ }
196
+ payload = json.dumps({"articles": [article]}, ensure_ascii=False).encode()
197
+
198
+ status, body = _wx_request(
199
+ f"cgi-bin/draft/add?access_token={token}",
200
+ method="POST",
201
+ data=payload,
202
+ headers={"Content-Type": "application/json"},
203
+ timeout=30,
204
+ )
205
+ result = json.loads(body)
206
+ if "errcode" in result:
207
+ print(
208
+ f"创建草稿失败: [{result['errcode']}] {result.get('errmsg', '')}",
209
+ file=sys.stderr,
210
+ )
211
+ sys.exit(1)
212
+ return result["media_id"]
213
+
214
+
215
+ if __name__ == "__main__":
216
+ print("微信API模块 - 请作为模块导入使用")