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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ wechat-publish = wechat_publish.cli:main
@@ -0,0 +1 @@
1
+ requests>=2.25
@@ -0,0 +1 @@
1
+ wechat_publish