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.
- package/README.md +108 -0
- package/SKILL.md +376 -0
- package/bin/cli.js +110 -0
- package/package.json +40 -0
- package/references/publish_template.py +155 -0
- package/scripts/book_cover.py +182 -0
- package/scripts/image_downloader.py +66 -0
- package/scripts/install.js +29 -0
- package/scripts/publish.py +118 -0
- package/scripts/wechat_api.py +216 -0
|
@@ -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模块 - 请作为模块导入使用")
|