wechat-publish 1.0.0__tar.gz
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.
- wechat_publish-1.0.0/LICENSE +21 -0
- wechat_publish-1.0.0/PKG-INFO +161 -0
- wechat_publish-1.0.0/README.md +136 -0
- wechat_publish-1.0.0/pyproject.toml +43 -0
- wechat_publish-1.0.0/setup.cfg +4 -0
- wechat_publish-1.0.0/tests/test_publish.py +110 -0
- wechat_publish-1.0.0/wechat_publish/__init__.py +46 -0
- wechat_publish-1.0.0/wechat_publish/cli.py +62 -0
- wechat_publish-1.0.0/wechat_publish/core.py +241 -0
- wechat_publish-1.0.0/wechat_publish/py.typed +0 -0
- wechat_publish-1.0.0/wechat_publish.egg-info/PKG-INFO +161 -0
- wechat_publish-1.0.0/wechat_publish.egg-info/SOURCES.txt +14 -0
- wechat_publish-1.0.0/wechat_publish.egg-info/dependency_links.txt +1 -0
- wechat_publish-1.0.0/wechat_publish.egg-info/entry_points.txt +2 -0
- wechat_publish-1.0.0/wechat_publish.egg-info/requires.txt +1 -0
- wechat_publish-1.0.0/wechat_publish.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wechat-publish contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wechat-publish
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Programmatically push rendered HTML articles (with auto-rehosted images) to a WeChat Official Account (公众号) draft box, and optionally publish them.
|
|
5
|
+
Author: 程序员白大力
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/baiyigali/wechat-publish
|
|
8
|
+
Project-URL: Repository, https://github.com/baiyigali/wechat-publish
|
|
9
|
+
Keywords: wechat,wechat-mp,公众号,publish,draft,article,cms,api
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: requests>=2.25
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# wechat-publish
|
|
27
|
+
|
|
28
|
+
把渲染好的 **HTML 文章一键发布到微信公众号**:自动获取 access_token、把正文里的外链图片转存到微信 CDN、上传封面、创建草稿,可选择立即群发。
|
|
29
|
+
|
|
30
|
+
支持一次推送 **1–8 篇图文**(微信草稿上限),同时提供 Python API 和命令行两种用法。
|
|
31
|
+
|
|
32
|
+
## 安装
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install wechat-publish
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
或从源码安装:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cd wechat-publish
|
|
42
|
+
pip install .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
依赖仅 [`requests`](https://docs.python-requests.org/)。
|
|
46
|
+
|
|
47
|
+
## 快速开始
|
|
48
|
+
|
|
49
|
+
### Python API
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from wechat_publish import push_articles
|
|
53
|
+
|
|
54
|
+
draft_id = push_articles(
|
|
55
|
+
appid="wx你的AppID",
|
|
56
|
+
secret="你的AppSecret",
|
|
57
|
+
articles=[
|
|
58
|
+
{"html_path": "a1.html", "title": "标题1", "cover_path": "c1.jpg"},
|
|
59
|
+
{"html_path": "a2.html", "title": "标题2", "cover_path": "c2.jpg"},
|
|
60
|
+
],
|
|
61
|
+
author="程序员白大力",
|
|
62
|
+
open_comment=True,
|
|
63
|
+
publish_now=True, # True=立即群发;False=只存草稿箱
|
|
64
|
+
)
|
|
65
|
+
print("draft media_id =", draft_id)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 命令行
|
|
69
|
+
|
|
70
|
+
安装后会得到 `wechat-publish` 命令。AppID / AppSecret 优先读环境变量:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
export WECHAT_APPID="wx你的AppID"
|
|
74
|
+
export WECHAT_SECRET="你的AppSecret"
|
|
75
|
+
|
|
76
|
+
wechat-publish \
|
|
77
|
+
--html a1.html --title "标题1" --cover c1.jpg \
|
|
78
|
+
--html a2.html --title "标题2" --cover c2.jpg \
|
|
79
|
+
--author "程序员白大力" --comment --publish
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
不传 `--publish` 则只存草稿箱;也可以用 `--appid` / `--secret` 直接传入。
|
|
83
|
+
|
|
84
|
+
## 文章字段说明
|
|
85
|
+
|
|
86
|
+
`articles` 列表里每一项是一个 dict:
|
|
87
|
+
|
|
88
|
+
| 字段 | 必填 | 说明 |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `html_path` | 是 | 渲染好的 HTML 文件路径(UTF-8) |
|
|
91
|
+
| `title` | 是 | 文章标题,最长 32 字 |
|
|
92
|
+
| `cover_path` | 是 | 封面图路径(jpg/png),会作为永久素材上传 |
|
|
93
|
+
| `digest` | 否 | 摘要,最长 120 字 |
|
|
94
|
+
| `source_url` | 否 | “阅读原文”跳转链接 |
|
|
95
|
+
|
|
96
|
+
公共参数:
|
|
97
|
+
|
|
98
|
+
| 参数 | 类型 | 默认 | 说明 |
|
|
99
|
+
|---|---|---|---|
|
|
100
|
+
| `author` | `str` | `""` | 作者名,最长 16 字 |
|
|
101
|
+
| `open_comment` | `bool` | `False` | 是否打开评论 |
|
|
102
|
+
| `publish_now` | `bool` | `False` | True 立即群发,False 仅存草稿 |
|
|
103
|
+
|
|
104
|
+
## 工作流程
|
|
105
|
+
|
|
106
|
+
调用 `push_articles()` 时内部按顺序执行:
|
|
107
|
+
|
|
108
|
+
1. **获取 access_token** —— `cgi-bin/token`,有效期约 2 小时。
|
|
109
|
+
2. **处理正文图片** —— 扫描 HTML 里所有 `<img src>`,把非微信域名(`mmbiz.qpic.cn` / `mmecoa.com` 除外)的图片下载后通过 `cgi-bin/media/uploadimg` 转存,并把 `src` 改写成微信 CDN 地址(微信会过滤外链图片)。
|
|
110
|
+
3. **上传封面** —— 通过 `cgi-bin/material/add_material` 上传为永久图片素材,拿到 `thumb_media_id`。
|
|
111
|
+
4. **创建草稿** —— `cgi-bin/draft/add`,一次最多 8 篇。
|
|
112
|
+
5. **(可选)群发** —— `cgi-bin/freepublish/submit`。
|
|
113
|
+
|
|
114
|
+
任一接口返回 `errcode != 0` 都会抛出 `wechat_publish.WeChatAPIError`,错误信息里带 errcode / errmsg。
|
|
115
|
+
|
|
116
|
+
## API 一览
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from wechat_publish import (
|
|
120
|
+
push_articles, # 高层入口,从自己代码里调用
|
|
121
|
+
WeChatAPIError, # 接口错误异常
|
|
122
|
+
get_access_token, # 低层:取 token
|
|
123
|
+
upload_cover, # 低层:上传封面永久素材
|
|
124
|
+
upload_content_images, # 低层:转存正文图片并改写 src
|
|
125
|
+
add_draft, # 低层:创建草稿
|
|
126
|
+
publish, # 低层:提交群发
|
|
127
|
+
MAX_ARTICLES, # 8
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
> 常见用法:先 `push_articles(..., publish_now=False)` 存草稿,在公众号后台人工预览确认后,再用返回的 `draft media_id` 调 `publish(token, media_id)` 正式群发。
|
|
132
|
+
|
|
133
|
+
## CI / 自动发布
|
|
134
|
+
|
|
135
|
+
仓库自带 GitHub Actions(`.github/workflows/publish.yml`),与常见开源 Python 包一致:
|
|
136
|
+
|
|
137
|
+
- **push 到 `main`** → 编译 sdist + wheel,自动发布到 **TestPyPI**(官方测试源,验证打包流程不污染正式版);
|
|
138
|
+
- **打 tag `v*`**(如 `v1.0.0`)→ 编译后发布到**正式 PyPI**;
|
|
139
|
+
- 也支持在 Actions 页面手动触发(`workflow_dispatch`),只编译不发布。
|
|
140
|
+
|
|
141
|
+
免密推送:在 PyPI / TestPyPI 后台把本仓库配置为 **Trusted Publisher(OIDC)**,workflow 里 `id-token: write` 自动换取临时凭据,不需要任何 `PYPI_API_TOKEN`。
|
|
142
|
+
|
|
143
|
+
## 技术交流
|
|
144
|
+
|
|
145
|
+
扫码添加微信,交流使用问题、定制与合作:
|
|
146
|
+
|
|
147
|
+
<p align="center">
|
|
148
|
+
<img src="docs/images/wechat-contact-qr.jpg" alt="微信二维码" width="240" />
|
|
149
|
+
</p>
|
|
150
|
+
|
|
151
|
+
## 项目赞助
|
|
152
|
+
|
|
153
|
+
本项目由微信公众号 **「程序员白大力」** 提供赞助,感谢支持:
|
|
154
|
+
|
|
155
|
+
<p align="center">
|
|
156
|
+
<img src="docs/images/wechat-official-account-qr.png" alt="程序员白大力公众号二维码" width="240" />
|
|
157
|
+
</p>
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# wechat-publish
|
|
2
|
+
|
|
3
|
+
把渲染好的 **HTML 文章一键发布到微信公众号**:自动获取 access_token、把正文里的外链图片转存到微信 CDN、上传封面、创建草稿,可选择立即群发。
|
|
4
|
+
|
|
5
|
+
支持一次推送 **1–8 篇图文**(微信草稿上限),同时提供 Python API 和命令行两种用法。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install wechat-publish
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
或从源码安装:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd wechat-publish
|
|
17
|
+
pip install .
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
依赖仅 [`requests`](https://docs.python-requests.org/)。
|
|
21
|
+
|
|
22
|
+
## 快速开始
|
|
23
|
+
|
|
24
|
+
### Python API
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from wechat_publish import push_articles
|
|
28
|
+
|
|
29
|
+
draft_id = push_articles(
|
|
30
|
+
appid="wx你的AppID",
|
|
31
|
+
secret="你的AppSecret",
|
|
32
|
+
articles=[
|
|
33
|
+
{"html_path": "a1.html", "title": "标题1", "cover_path": "c1.jpg"},
|
|
34
|
+
{"html_path": "a2.html", "title": "标题2", "cover_path": "c2.jpg"},
|
|
35
|
+
],
|
|
36
|
+
author="程序员白大力",
|
|
37
|
+
open_comment=True,
|
|
38
|
+
publish_now=True, # True=立即群发;False=只存草稿箱
|
|
39
|
+
)
|
|
40
|
+
print("draft media_id =", draft_id)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 命令行
|
|
44
|
+
|
|
45
|
+
安装后会得到 `wechat-publish` 命令。AppID / AppSecret 优先读环境变量:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
export WECHAT_APPID="wx你的AppID"
|
|
49
|
+
export WECHAT_SECRET="你的AppSecret"
|
|
50
|
+
|
|
51
|
+
wechat-publish \
|
|
52
|
+
--html a1.html --title "标题1" --cover c1.jpg \
|
|
53
|
+
--html a2.html --title "标题2" --cover c2.jpg \
|
|
54
|
+
--author "程序员白大力" --comment --publish
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
不传 `--publish` 则只存草稿箱;也可以用 `--appid` / `--secret` 直接传入。
|
|
58
|
+
|
|
59
|
+
## 文章字段说明
|
|
60
|
+
|
|
61
|
+
`articles` 列表里每一项是一个 dict:
|
|
62
|
+
|
|
63
|
+
| 字段 | 必填 | 说明 |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `html_path` | 是 | 渲染好的 HTML 文件路径(UTF-8) |
|
|
66
|
+
| `title` | 是 | 文章标题,最长 32 字 |
|
|
67
|
+
| `cover_path` | 是 | 封面图路径(jpg/png),会作为永久素材上传 |
|
|
68
|
+
| `digest` | 否 | 摘要,最长 120 字 |
|
|
69
|
+
| `source_url` | 否 | “阅读原文”跳转链接 |
|
|
70
|
+
|
|
71
|
+
公共参数:
|
|
72
|
+
|
|
73
|
+
| 参数 | 类型 | 默认 | 说明 |
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
| `author` | `str` | `""` | 作者名,最长 16 字 |
|
|
76
|
+
| `open_comment` | `bool` | `False` | 是否打开评论 |
|
|
77
|
+
| `publish_now` | `bool` | `False` | True 立即群发,False 仅存草稿 |
|
|
78
|
+
|
|
79
|
+
## 工作流程
|
|
80
|
+
|
|
81
|
+
调用 `push_articles()` 时内部按顺序执行:
|
|
82
|
+
|
|
83
|
+
1. **获取 access_token** —— `cgi-bin/token`,有效期约 2 小时。
|
|
84
|
+
2. **处理正文图片** —— 扫描 HTML 里所有 `<img src>`,把非微信域名(`mmbiz.qpic.cn` / `mmecoa.com` 除外)的图片下载后通过 `cgi-bin/media/uploadimg` 转存,并把 `src` 改写成微信 CDN 地址(微信会过滤外链图片)。
|
|
85
|
+
3. **上传封面** —— 通过 `cgi-bin/material/add_material` 上传为永久图片素材,拿到 `thumb_media_id`。
|
|
86
|
+
4. **创建草稿** —— `cgi-bin/draft/add`,一次最多 8 篇。
|
|
87
|
+
5. **(可选)群发** —— `cgi-bin/freepublish/submit`。
|
|
88
|
+
|
|
89
|
+
任一接口返回 `errcode != 0` 都会抛出 `wechat_publish.WeChatAPIError`,错误信息里带 errcode / errmsg。
|
|
90
|
+
|
|
91
|
+
## API 一览
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from wechat_publish import (
|
|
95
|
+
push_articles, # 高层入口,从自己代码里调用
|
|
96
|
+
WeChatAPIError, # 接口错误异常
|
|
97
|
+
get_access_token, # 低层:取 token
|
|
98
|
+
upload_cover, # 低层:上传封面永久素材
|
|
99
|
+
upload_content_images, # 低层:转存正文图片并改写 src
|
|
100
|
+
add_draft, # 低层:创建草稿
|
|
101
|
+
publish, # 低层:提交群发
|
|
102
|
+
MAX_ARTICLES, # 8
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
> 常见用法:先 `push_articles(..., publish_now=False)` 存草稿,在公众号后台人工预览确认后,再用返回的 `draft media_id` 调 `publish(token, media_id)` 正式群发。
|
|
107
|
+
|
|
108
|
+
## CI / 自动发布
|
|
109
|
+
|
|
110
|
+
仓库自带 GitHub Actions(`.github/workflows/publish.yml`),与常见开源 Python 包一致:
|
|
111
|
+
|
|
112
|
+
- **push 到 `main`** → 编译 sdist + wheel,自动发布到 **TestPyPI**(官方测试源,验证打包流程不污染正式版);
|
|
113
|
+
- **打 tag `v*`**(如 `v1.0.0`)→ 编译后发布到**正式 PyPI**;
|
|
114
|
+
- 也支持在 Actions 页面手动触发(`workflow_dispatch`),只编译不发布。
|
|
115
|
+
|
|
116
|
+
免密推送:在 PyPI / TestPyPI 后台把本仓库配置为 **Trusted Publisher(OIDC)**,workflow 里 `id-token: write` 自动换取临时凭据,不需要任何 `PYPI_API_TOKEN`。
|
|
117
|
+
|
|
118
|
+
## 技术交流
|
|
119
|
+
|
|
120
|
+
扫码添加微信,交流使用问题、定制与合作:
|
|
121
|
+
|
|
122
|
+
<p align="center">
|
|
123
|
+
<img src="docs/images/wechat-contact-qr.jpg" alt="微信二维码" width="240" />
|
|
124
|
+
</p>
|
|
125
|
+
|
|
126
|
+
## 项目赞助
|
|
127
|
+
|
|
128
|
+
本项目由微信公众号 **「程序员白大力」** 提供赞助,感谢支持:
|
|
129
|
+
|
|
130
|
+
<p align="center">
|
|
131
|
+
<img src="docs/images/wechat-official-account-qr.png" alt="程序员白大力公众号二维码" width="240" />
|
|
132
|
+
</p>
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wechat-publish"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Programmatically push rendered HTML articles (with auto-rehosted images) to a WeChat Official Account (公众号) draft box, and optionally publish them."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "程序员白大力" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["wechat", "wechat-mp", "公众号", "publish", "draft", "article", "cms", "api"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"requests>=2.25",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
wechat-publish = "wechat_publish.cli:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/baiyigali/wechat-publish"
|
|
37
|
+
Repository = "https://github.com/baiyigali/wechat-publish"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools]
|
|
40
|
+
packages = ["wechat_publish"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
wechat_publish = ["py.typed"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Offline unit tests for wechat_publish — no real WeChat API calls.
|
|
2
|
+
|
|
3
|
+
Network calls are patched, so this runs in CI with zero credentials.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from wechat_publish import (
|
|
11
|
+
MAX_ARTICLES,
|
|
12
|
+
WeChatAPIError,
|
|
13
|
+
__version__,
|
|
14
|
+
push_articles,
|
|
15
|
+
upload_content_images,
|
|
16
|
+
)
|
|
17
|
+
from wechat_publish.core import _check
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _FakeResp:
|
|
21
|
+
def __init__(self, data):
|
|
22
|
+
self._data = data
|
|
23
|
+
self.headers = {}
|
|
24
|
+
|
|
25
|
+
def json(self):
|
|
26
|
+
return self._data
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_version_exported():
|
|
30
|
+
assert isinstance(__version__, str)
|
|
31
|
+
assert __version__
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_check_raises_on_errcode():
|
|
35
|
+
with pytest.raises(WeChatAPIError):
|
|
36
|
+
_check(_FakeResp({"errcode": 40001, "errmsg": "invalid credential"}), "test")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_check_passes_when_no_errcode():
|
|
40
|
+
data = {"access_token": "abc"}
|
|
41
|
+
assert _check(_FakeResp(data), "test") is data
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_push_articles_requires_at_least_one():
|
|
45
|
+
with pytest.raises(ValueError):
|
|
46
|
+
push_articles("wx", "secret", [])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_push_articles_caps_at_eight():
|
|
50
|
+
articles = [{"html_path": "x", "title": "t", "cover_path": "x"}] * (MAX_ARTICLES + 1)
|
|
51
|
+
with pytest.raises(ValueError):
|
|
52
|
+
push_articles("wx", "secret", articles)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_upload_content_images_no_images_returns_unchanged():
|
|
56
|
+
html = "<p>hello</p>"
|
|
57
|
+
assert upload_content_images("token", html) == html
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_upload_content_images_skips_wechat_hosts(monkeypatch):
|
|
61
|
+
# mmbiz images must NOT be downloaded / re-uploaded
|
|
62
|
+
html = '<p><img src="https://mmbiz.qpic.cn/aaa/640"></p>'
|
|
63
|
+
calls = []
|
|
64
|
+
|
|
65
|
+
def _no_get(*a, **k):
|
|
66
|
+
calls.append(a)
|
|
67
|
+
raise AssertionError("should not download mmbiz image")
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr("wechat_publish.core.requests.get", _no_get)
|
|
70
|
+
out = upload_content_images("token", html)
|
|
71
|
+
assert out == html
|
|
72
|
+
assert calls == []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_upload_content_images_replaces_external_image(monkeypatch):
|
|
76
|
+
html = '<p><img src="https://example.com/a.png"></p>'
|
|
77
|
+
|
|
78
|
+
class _ImgResp:
|
|
79
|
+
headers = {"Content-Type": "image/png"}
|
|
80
|
+
|
|
81
|
+
def raise_for_status(self):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
content = b"\x89PNG fake"
|
|
85
|
+
|
|
86
|
+
def fake_get(url, timeout=0):
|
|
87
|
+
return _ImgResp()
|
|
88
|
+
|
|
89
|
+
def fake_post(url, params=None, files=None, timeout=0):
|
|
90
|
+
return _FakeResp({"url": "https://mmbiz.qpic.cn/wechat/new.png"})
|
|
91
|
+
|
|
92
|
+
monkeypatch.setattr("wechat_publish.core.requests.get", fake_get)
|
|
93
|
+
monkeypatch.setattr("wechat_publish.core.requests.post", fake_post)
|
|
94
|
+
|
|
95
|
+
out = upload_content_images("token", html)
|
|
96
|
+
assert "example.com/a.png" not in out
|
|
97
|
+
assert "mmbiz.qpic.cn/wechat/new.png" in out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_push_articles_missing_html_file_raises(tmp_path, monkeypatch):
|
|
101
|
+
# token fetch succeeds, but html_path does not exist
|
|
102
|
+
monkeypatch.setattr(
|
|
103
|
+
"wechat_publish.core.get_access_token", lambda a, s: "tok"
|
|
104
|
+
)
|
|
105
|
+
missing = tmp_path / "nope.html"
|
|
106
|
+
with pytest.raises(FileNotFoundError):
|
|
107
|
+
push_articles(
|
|
108
|
+
"wx", "secret",
|
|
109
|
+
[{"html_path": str(missing), "title": "t", "cover_path": str(tmp_path / "c.jpg")}],
|
|
110
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""wechat_publish — programmatically publish rendered HTML articles to a
|
|
2
|
+
WeChat Official Account (公众号).
|
|
3
|
+
|
|
4
|
+
Public API::
|
|
5
|
+
|
|
6
|
+
from wechat_publish import push_articles, WeChatAPIError
|
|
7
|
+
|
|
8
|
+
draft_id = push_articles(
|
|
9
|
+
appid="wx...",
|
|
10
|
+
secret="...",
|
|
11
|
+
articles=[
|
|
12
|
+
{"html_path": "a1.html", "title": "标题1", "cover_path": "c1.jpg"},
|
|
13
|
+
],
|
|
14
|
+
author="程序员白大力",
|
|
15
|
+
open_comment=True,
|
|
16
|
+
publish_now=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
A ``wechat-publish`` console script is also installed for quick testing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from .core import (
|
|
25
|
+
MAX_ARTICLES,
|
|
26
|
+
WeChatAPIError,
|
|
27
|
+
add_draft,
|
|
28
|
+
get_access_token,
|
|
29
|
+
publish,
|
|
30
|
+
push_articles,
|
|
31
|
+
upload_content_images,
|
|
32
|
+
upload_cover,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"MAX_ARTICLES",
|
|
37
|
+
"WeChatAPIError",
|
|
38
|
+
"add_draft",
|
|
39
|
+
"get_access_token",
|
|
40
|
+
"publish",
|
|
41
|
+
"push_articles",
|
|
42
|
+
"upload_content_images",
|
|
43
|
+
"upload_cover",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""CLI entry point for wechat_publish.
|
|
2
|
+
|
|
3
|
+
Installs the ``wechat-publish`` console script via ``[project.scripts]``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from .core import push_articles
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> None:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="wechat-publish",
|
|
18
|
+
description="Push rendered HTML files to WeChat MP (1-8 articles per push).",
|
|
19
|
+
epilog="""\
|
|
20
|
+
Examples:
|
|
21
|
+
wechat-publish --html a.html --title "标题" --cover c.jpg
|
|
22
|
+
wechat-publish \\
|
|
23
|
+
--html a1.html --title "标题1" --cover c1.jpg \\
|
|
24
|
+
--html a2.html --title "标题2" --cover c2.jpg \\
|
|
25
|
+
--author "程序员白大力" --comment --publish
|
|
26
|
+
""",
|
|
27
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--html", action="append", required=True,
|
|
30
|
+
help="Path to rendered HTML file (repeat for multiple articles)")
|
|
31
|
+
parser.add_argument("--title", action="append", required=True,
|
|
32
|
+
help="Article title (repeat, one per --html)")
|
|
33
|
+
parser.add_argument("--cover", action="append", required=True,
|
|
34
|
+
help="Cover image path (repeat, one per --html)")
|
|
35
|
+
parser.add_argument("--author", default="", help="Author name (max 16 chars)")
|
|
36
|
+
parser.add_argument("--comment", action="store_true", help="Open comments")
|
|
37
|
+
parser.add_argument("--publish", action="store_true", help="Publish immediately")
|
|
38
|
+
parser.add_argument("--appid", default=os.environ.get("WECHAT_APPID", ""))
|
|
39
|
+
parser.add_argument("--secret", default=os.environ.get("WECHAT_SECRET", ""))
|
|
40
|
+
args = parser.parse_args()
|
|
41
|
+
|
|
42
|
+
if not args.appid or not args.secret:
|
|
43
|
+
sys.exit("Error: set WECHAT_APPID / WECHAT_SECRET env vars or pass --appid/--secret")
|
|
44
|
+
|
|
45
|
+
if not (len(args.html) == len(args.title) == len(args.cover)):
|
|
46
|
+
sys.exit("Error: --html, --title and --cover must be repeated the same number of times")
|
|
47
|
+
|
|
48
|
+
articles = [
|
|
49
|
+
{"html_path": h, "title": t, "cover_path": c}
|
|
50
|
+
for h, t, c in zip(args.html, args.title, args.cover)
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
push_articles(
|
|
54
|
+
args.appid, args.secret, articles,
|
|
55
|
+
author=args.author,
|
|
56
|
+
open_comment=args.comment,
|
|
57
|
+
publish_now=args.publish,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
main()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Low-level and high-level API for publishing to WeChat Official Account (公众号).
|
|
2
|
+
|
|
3
|
+
This module is a faithful refactor of the original ``wechat_publish.py`` script:
|
|
4
|
+
all behavior (error handling, image re-hosting, draft + publish flow) is kept.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
BASE = "https://api.weixin.qq.com/cgi-bin"
|
|
17
|
+
MAX_ARTICLES = 8 # WeChat allows 1-8 articles per draft
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WeChatAPIError(RuntimeError):
|
|
21
|
+
"""Raised when WeChat returns an errcode != 0."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _check(resp: requests.Response, action: str) -> dict:
|
|
25
|
+
"""Parse a WeChat JSON response and raise on API error."""
|
|
26
|
+
data = resp.json()
|
|
27
|
+
if "errcode" in data and data["errcode"] != 0:
|
|
28
|
+
raise WeChatAPIError(
|
|
29
|
+
f"{action} failed: errcode={data['errcode']} errmsg={data.get('errmsg', '')}"
|
|
30
|
+
)
|
|
31
|
+
return data
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Low-level API
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
def get_access_token(appid: str, secret: str) -> str:
|
|
38
|
+
"""Fetch a valid access_token. Valid for ~2 hours."""
|
|
39
|
+
resp = requests.get(
|
|
40
|
+
f"{BASE}/token",
|
|
41
|
+
params={"grant_type": "client_credential", "appid": appid, "secret": secret},
|
|
42
|
+
timeout=15,
|
|
43
|
+
)
|
|
44
|
+
data = _check(resp, "get access_token")
|
|
45
|
+
token = data.get("access_token")
|
|
46
|
+
if not token:
|
|
47
|
+
raise WeChatAPIError(f"no access_token in response: {data}")
|
|
48
|
+
return token
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def upload_cover(token: str, image_path: str | Path) -> str:
|
|
52
|
+
"""Upload an image as a permanent material, returns thumb_media_id."""
|
|
53
|
+
image_path = Path(image_path)
|
|
54
|
+
with open(image_path, "rb") as f:
|
|
55
|
+
resp = requests.post(
|
|
56
|
+
f"{BASE}/material/add_material",
|
|
57
|
+
params={"access_token": token, "type": "image"},
|
|
58
|
+
files={"media": (image_path.name, f, "image/jpeg")},
|
|
59
|
+
timeout=30,
|
|
60
|
+
)
|
|
61
|
+
data = _check(resp, "upload cover")
|
|
62
|
+
media_id = data.get("media_id")
|
|
63
|
+
if not media_id:
|
|
64
|
+
raise WeChatAPIError(f"no media_id in response: {data}")
|
|
65
|
+
return media_id
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def upload_content_images(token: str, html: str) -> str:
|
|
69
|
+
"""Download every external <img> in *html*, upload it to WeChat, and
|
|
70
|
+
rewrite the ``src`` to WeChat's own CDN URL.
|
|
71
|
+
|
|
72
|
+
WeChat filters non-WeChat image URLs out of article content, so we
|
|
73
|
+
must proxy them through ``/cgi-bin/media/uploadimg`` first.
|
|
74
|
+
"""
|
|
75
|
+
img_urls = re.findall(r'<img[^>]+src="([^"]+)"', html)
|
|
76
|
+
if not img_urls:
|
|
77
|
+
return html
|
|
78
|
+
|
|
79
|
+
replaced = html
|
|
80
|
+
for url in img_urls:
|
|
81
|
+
host = urlparse(url).netloc
|
|
82
|
+
if "mmbiz.qpic.cn" in host or "mmecoa.com" in host:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
resp = requests.get(url, timeout=30)
|
|
87
|
+
resp.raise_for_status()
|
|
88
|
+
except Exception as e:
|
|
89
|
+
print(f" [warn] failed to download {url}: {e}")
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
content_type = resp.headers.get("Content-Type", "image/jpeg")
|
|
93
|
+
ext_map = {
|
|
94
|
+
"image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png",
|
|
95
|
+
"image/gif": "gif", "image/webp": "webp",
|
|
96
|
+
}
|
|
97
|
+
ext = ext_map.get(content_type, "jpg")
|
|
98
|
+
fname = f"img.{ext}"
|
|
99
|
+
|
|
100
|
+
up = requests.post(
|
|
101
|
+
f"{BASE}/media/uploadimg",
|
|
102
|
+
params={"access_token": token},
|
|
103
|
+
files={"media": (fname, resp.content, content_type)},
|
|
104
|
+
timeout=30,
|
|
105
|
+
)
|
|
106
|
+
up_data = up.json()
|
|
107
|
+
if "url" not in up_data:
|
|
108
|
+
print(f" [warn] uploadimg failed for {url}: {up_data}")
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
wechat_url = up_data["url"]
|
|
112
|
+
print(f" [image] {url} -> {wechat_url}")
|
|
113
|
+
replaced = replaced.replace(f'src="{url}"', f'src="{wechat_url}"')
|
|
114
|
+
|
|
115
|
+
return replaced
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def add_draft(token: str, articles: list[dict]) -> str:
|
|
119
|
+
"""Create a draft with 1-8 articles. Returns draft media_id."""
|
|
120
|
+
body = json.dumps({"articles": articles}, ensure_ascii=False).encode("utf-8")
|
|
121
|
+
resp = requests.post(
|
|
122
|
+
f"{BASE}/draft/add",
|
|
123
|
+
params={"access_token": token},
|
|
124
|
+
data=body,
|
|
125
|
+
headers={"Content-Type": "application/json; charset=utf-8"},
|
|
126
|
+
timeout=60,
|
|
127
|
+
)
|
|
128
|
+
data = _check(resp, "add draft")
|
|
129
|
+
media_id = data.get("media_id")
|
|
130
|
+
if not media_id:
|
|
131
|
+
raise WeChatAPIError(f"no media_id in draft response: {data}")
|
|
132
|
+
return media_id
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def publish(token: str, media_id: str) -> dict:
|
|
136
|
+
"""Submit a draft for publishing. Returns the publish job info."""
|
|
137
|
+
body = json.dumps({"media_id": media_id}, ensure_ascii=False).encode("utf-8")
|
|
138
|
+
resp = requests.post(
|
|
139
|
+
f"{BASE}/freepublish/submit",
|
|
140
|
+
params={"access_token": token},
|
|
141
|
+
data=body,
|
|
142
|
+
headers={"Content-Type": "application/json; charset=utf-8"},
|
|
143
|
+
timeout=30,
|
|
144
|
+
)
|
|
145
|
+
return _check(resp, "publish")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# High-level API — call this from your own code
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
def push_articles(
|
|
152
|
+
appid: str,
|
|
153
|
+
secret: str,
|
|
154
|
+
articles: list[dict],
|
|
155
|
+
*,
|
|
156
|
+
author: str = "",
|
|
157
|
+
open_comment: bool = False,
|
|
158
|
+
publish_now: bool = False,
|
|
159
|
+
) -> str:
|
|
160
|
+
"""Push one or more articles to WeChat MP.
|
|
161
|
+
|
|
162
|
+
Parameters
|
|
163
|
+
----------
|
|
164
|
+
appid : str
|
|
165
|
+
WeChat official account AppID.
|
|
166
|
+
secret : str
|
|
167
|
+
WeChat official account AppSecret.
|
|
168
|
+
articles : list[dict]
|
|
169
|
+
Each dict must contain:
|
|
170
|
+
- ``html_path`` — path to the rendered HTML file
|
|
171
|
+
- ``title`` — article title (max 32 chars)
|
|
172
|
+
- ``cover_path`` — path to the cover image (jpg/png)
|
|
173
|
+
Optional keys per article:
|
|
174
|
+
- ``digest`` — summary (max 120 chars)
|
|
175
|
+
- ``source_url`` — "read more" URL
|
|
176
|
+
Up to 8 articles allowed.
|
|
177
|
+
author : str
|
|
178
|
+
Author name for all articles (max 16 chars).
|
|
179
|
+
open_comment : bool
|
|
180
|
+
Whether to enable comments on all articles.
|
|
181
|
+
publish_now : bool
|
|
182
|
+
If True, publish immediately after creating the draft.
|
|
183
|
+
If False, only save to draft box.
|
|
184
|
+
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
str
|
|
188
|
+
The draft ``media_id`` (pass to ``publish()`` to publish later).
|
|
189
|
+
"""
|
|
190
|
+
n = len(articles)
|
|
191
|
+
if n < 1:
|
|
192
|
+
raise ValueError("at least one article is required")
|
|
193
|
+
if n > MAX_ARTICLES:
|
|
194
|
+
raise ValueError(f"WeChat allows at most {MAX_ARTICLES} articles per push (got {n})")
|
|
195
|
+
|
|
196
|
+
# Token
|
|
197
|
+
token = get_access_token(appid, secret)
|
|
198
|
+
print(f"[1/3] got access_token")
|
|
199
|
+
|
|
200
|
+
# Build articles
|
|
201
|
+
wechat_articles = []
|
|
202
|
+
for i, art in enumerate(articles):
|
|
203
|
+
title = art["title"]
|
|
204
|
+
print(f"[2/3] article {i + 1}/{n}: {title}")
|
|
205
|
+
|
|
206
|
+
html_path = Path(art["html_path"])
|
|
207
|
+
if not html_path.exists():
|
|
208
|
+
raise FileNotFoundError(f"HTML file not found: {html_path}")
|
|
209
|
+
content = html_path.read_text(encoding="utf-8")
|
|
210
|
+
|
|
211
|
+
# Rehost external images
|
|
212
|
+
content = upload_content_images(token, content)
|
|
213
|
+
|
|
214
|
+
# Upload cover
|
|
215
|
+
cover_path = Path(art["cover_path"])
|
|
216
|
+
if not cover_path.exists():
|
|
217
|
+
raise FileNotFoundError(f"cover image not found: {cover_path}")
|
|
218
|
+
thumb_id = upload_cover(token, cover_path)
|
|
219
|
+
|
|
220
|
+
wechat_articles.append({
|
|
221
|
+
"title": title,
|
|
222
|
+
"author": author[:16],
|
|
223
|
+
"digest": art.get("digest", ""),
|
|
224
|
+
"content": content,
|
|
225
|
+
"content_source_url": art.get("source_url", ""),
|
|
226
|
+
"thumb_media_id": thumb_id,
|
|
227
|
+
"need_open_comment": 1 if open_comment else 0,
|
|
228
|
+
"only_fans_can_comment": 0,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
# Create draft
|
|
232
|
+
draft_id = add_draft(token, wechat_articles)
|
|
233
|
+
print(f"[3/3] draft created -> media_id={draft_id}")
|
|
234
|
+
|
|
235
|
+
if publish_now:
|
|
236
|
+
result = publish(token, draft_id)
|
|
237
|
+
print(f"published! publish_id={result.get('publish_id')}")
|
|
238
|
+
else:
|
|
239
|
+
print("draft saved (not published). pass publish_now=True to publish immediately.")
|
|
240
|
+
|
|
241
|
+
return draft_id
|
|
File without changes
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wechat-publish
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Programmatically push rendered HTML articles (with auto-rehosted images) to a WeChat Official Account (公众号) draft box, and optionally publish them.
|
|
5
|
+
Author: 程序员白大力
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/baiyigali/wechat-publish
|
|
8
|
+
Project-URL: Repository, https://github.com/baiyigali/wechat-publish
|
|
9
|
+
Keywords: wechat,wechat-mp,公众号,publish,draft,article,cms,api
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: requests>=2.25
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# wechat-publish
|
|
27
|
+
|
|
28
|
+
把渲染好的 **HTML 文章一键发布到微信公众号**:自动获取 access_token、把正文里的外链图片转存到微信 CDN、上传封面、创建草稿,可选择立即群发。
|
|
29
|
+
|
|
30
|
+
支持一次推送 **1–8 篇图文**(微信草稿上限),同时提供 Python API 和命令行两种用法。
|
|
31
|
+
|
|
32
|
+
## 安装
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install wechat-publish
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
或从源码安装:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cd wechat-publish
|
|
42
|
+
pip install .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
依赖仅 [`requests`](https://docs.python-requests.org/)。
|
|
46
|
+
|
|
47
|
+
## 快速开始
|
|
48
|
+
|
|
49
|
+
### Python API
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from wechat_publish import push_articles
|
|
53
|
+
|
|
54
|
+
draft_id = push_articles(
|
|
55
|
+
appid="wx你的AppID",
|
|
56
|
+
secret="你的AppSecret",
|
|
57
|
+
articles=[
|
|
58
|
+
{"html_path": "a1.html", "title": "标题1", "cover_path": "c1.jpg"},
|
|
59
|
+
{"html_path": "a2.html", "title": "标题2", "cover_path": "c2.jpg"},
|
|
60
|
+
],
|
|
61
|
+
author="程序员白大力",
|
|
62
|
+
open_comment=True,
|
|
63
|
+
publish_now=True, # True=立即群发;False=只存草稿箱
|
|
64
|
+
)
|
|
65
|
+
print("draft media_id =", draft_id)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 命令行
|
|
69
|
+
|
|
70
|
+
安装后会得到 `wechat-publish` 命令。AppID / AppSecret 优先读环境变量:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
export WECHAT_APPID="wx你的AppID"
|
|
74
|
+
export WECHAT_SECRET="你的AppSecret"
|
|
75
|
+
|
|
76
|
+
wechat-publish \
|
|
77
|
+
--html a1.html --title "标题1" --cover c1.jpg \
|
|
78
|
+
--html a2.html --title "标题2" --cover c2.jpg \
|
|
79
|
+
--author "程序员白大力" --comment --publish
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
不传 `--publish` 则只存草稿箱;也可以用 `--appid` / `--secret` 直接传入。
|
|
83
|
+
|
|
84
|
+
## 文章字段说明
|
|
85
|
+
|
|
86
|
+
`articles` 列表里每一项是一个 dict:
|
|
87
|
+
|
|
88
|
+
| 字段 | 必填 | 说明 |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `html_path` | 是 | 渲染好的 HTML 文件路径(UTF-8) |
|
|
91
|
+
| `title` | 是 | 文章标题,最长 32 字 |
|
|
92
|
+
| `cover_path` | 是 | 封面图路径(jpg/png),会作为永久素材上传 |
|
|
93
|
+
| `digest` | 否 | 摘要,最长 120 字 |
|
|
94
|
+
| `source_url` | 否 | “阅读原文”跳转链接 |
|
|
95
|
+
|
|
96
|
+
公共参数:
|
|
97
|
+
|
|
98
|
+
| 参数 | 类型 | 默认 | 说明 |
|
|
99
|
+
|---|---|---|---|
|
|
100
|
+
| `author` | `str` | `""` | 作者名,最长 16 字 |
|
|
101
|
+
| `open_comment` | `bool` | `False` | 是否打开评论 |
|
|
102
|
+
| `publish_now` | `bool` | `False` | True 立即群发,False 仅存草稿 |
|
|
103
|
+
|
|
104
|
+
## 工作流程
|
|
105
|
+
|
|
106
|
+
调用 `push_articles()` 时内部按顺序执行:
|
|
107
|
+
|
|
108
|
+
1. **获取 access_token** —— `cgi-bin/token`,有效期约 2 小时。
|
|
109
|
+
2. **处理正文图片** —— 扫描 HTML 里所有 `<img src>`,把非微信域名(`mmbiz.qpic.cn` / `mmecoa.com` 除外)的图片下载后通过 `cgi-bin/media/uploadimg` 转存,并把 `src` 改写成微信 CDN 地址(微信会过滤外链图片)。
|
|
110
|
+
3. **上传封面** —— 通过 `cgi-bin/material/add_material` 上传为永久图片素材,拿到 `thumb_media_id`。
|
|
111
|
+
4. **创建草稿** —— `cgi-bin/draft/add`,一次最多 8 篇。
|
|
112
|
+
5. **(可选)群发** —— `cgi-bin/freepublish/submit`。
|
|
113
|
+
|
|
114
|
+
任一接口返回 `errcode != 0` 都会抛出 `wechat_publish.WeChatAPIError`,错误信息里带 errcode / errmsg。
|
|
115
|
+
|
|
116
|
+
## API 一览
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from wechat_publish import (
|
|
120
|
+
push_articles, # 高层入口,从自己代码里调用
|
|
121
|
+
WeChatAPIError, # 接口错误异常
|
|
122
|
+
get_access_token, # 低层:取 token
|
|
123
|
+
upload_cover, # 低层:上传封面永久素材
|
|
124
|
+
upload_content_images, # 低层:转存正文图片并改写 src
|
|
125
|
+
add_draft, # 低层:创建草稿
|
|
126
|
+
publish, # 低层:提交群发
|
|
127
|
+
MAX_ARTICLES, # 8
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
> 常见用法:先 `push_articles(..., publish_now=False)` 存草稿,在公众号后台人工预览确认后,再用返回的 `draft media_id` 调 `publish(token, media_id)` 正式群发。
|
|
132
|
+
|
|
133
|
+
## CI / 自动发布
|
|
134
|
+
|
|
135
|
+
仓库自带 GitHub Actions(`.github/workflows/publish.yml`),与常见开源 Python 包一致:
|
|
136
|
+
|
|
137
|
+
- **push 到 `main`** → 编译 sdist + wheel,自动发布到 **TestPyPI**(官方测试源,验证打包流程不污染正式版);
|
|
138
|
+
- **打 tag `v*`**(如 `v1.0.0`)→ 编译后发布到**正式 PyPI**;
|
|
139
|
+
- 也支持在 Actions 页面手动触发(`workflow_dispatch`),只编译不发布。
|
|
140
|
+
|
|
141
|
+
免密推送:在 PyPI / TestPyPI 后台把本仓库配置为 **Trusted Publisher(OIDC)**,workflow 里 `id-token: write` 自动换取临时凭据,不需要任何 `PYPI_API_TOKEN`。
|
|
142
|
+
|
|
143
|
+
## 技术交流
|
|
144
|
+
|
|
145
|
+
扫码添加微信,交流使用问题、定制与合作:
|
|
146
|
+
|
|
147
|
+
<p align="center">
|
|
148
|
+
<img src="docs/images/wechat-contact-qr.jpg" alt="微信二维码" width="240" />
|
|
149
|
+
</p>
|
|
150
|
+
|
|
151
|
+
## 项目赞助
|
|
152
|
+
|
|
153
|
+
本项目由微信公众号 **「程序员白大力」** 提供赞助,感谢支持:
|
|
154
|
+
|
|
155
|
+
<p align="center">
|
|
156
|
+
<img src="docs/images/wechat-official-account-qr.png" alt="程序员白大力公众号二维码" width="240" />
|
|
157
|
+
</p>
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_publish.py
|
|
5
|
+
wechat_publish/__init__.py
|
|
6
|
+
wechat_publish/cli.py
|
|
7
|
+
wechat_publish/core.py
|
|
8
|
+
wechat_publish/py.typed
|
|
9
|
+
wechat_publish.egg-info/PKG-INFO
|
|
10
|
+
wechat_publish.egg-info/SOURCES.txt
|
|
11
|
+
wechat_publish.egg-info/dependency_links.txt
|
|
12
|
+
wechat_publish.egg-info/entry_points.txt
|
|
13
|
+
wechat_publish.egg-info/requires.txt
|
|
14
|
+
wechat_publish.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.25
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wechat_publish
|