271 lines
7.9 KiB
Python
271 lines
7.9 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import requests
|
||
|
||
|
||
class WeChatPublisher:
|
||
"""微信公众号草稿箱发布器"""
|
||
|
||
def __init__(self, appid: str, appsecret: str):
|
||
self.appid = appid
|
||
self.appsecret = appsecret
|
||
self.access_token: Optional[str] = None
|
||
self.token_expires_at: float = 0
|
||
|
||
def _get_access_token(self) -> str:
|
||
"""获取或刷新 access_token"""
|
||
if self.access_token and time.time() < self.token_expires_at - 300:
|
||
return self.access_token
|
||
|
||
url = "https://api.weixin.qq.com/cgi-bin/token"
|
||
params = {
|
||
"grant_type": "client_credential",
|
||
"appid": self.appid,
|
||
"secret": self.appsecret
|
||
}
|
||
|
||
response = requests.get(url, params=params, timeout=30)
|
||
data = response.json()
|
||
|
||
if "access_token" not in data:
|
||
errcode = data.get("errcode")
|
||
errmsg = data.get("errmsg", "")
|
||
|
||
if errcode == 40164 or "not in whitelist" in errmsg.lower() or "ip" in errmsg.lower():
|
||
import re
|
||
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', errmsg)
|
||
current_ip = ip_match.group(1) if ip_match else "当前IP"
|
||
|
||
raise RuntimeError(
|
||
f"获取 access_token 失败: IP 不在白名单中\n"
|
||
f"当前 IP: {current_ip}\n"
|
||
f"解决方法: 登录微信公众平台 → 开发 → 基本配置 → IP白名单 → 添加以上IP地址"
|
||
)
|
||
elif errcode == 40013:
|
||
raise RuntimeError(f"获取 access_token 失败: AppID 无效")
|
||
elif errcode == 40125:
|
||
raise RuntimeError(f"获取 access_token 失败: AppSecret 无效")
|
||
else:
|
||
raise RuntimeError(f"获取 access_token 失败: {data}")
|
||
|
||
self.access_token = data["access_token"]
|
||
self.token_expires_at = time.time() + data.get("expires_in", 7200)
|
||
|
||
return self.access_token
|
||
|
||
def upload_thumb(self, image_path: str) -> str:
|
||
"""
|
||
上传封面图片获取 thumb_media_id
|
||
|
||
Args:
|
||
image_path: 图片文件路径
|
||
|
||
Returns:
|
||
thumb_media_id
|
||
"""
|
||
token = self._get_access_token()
|
||
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image"
|
||
|
||
with open(image_path, 'rb') as f:
|
||
files = {'media': f}
|
||
response = requests.post(url, files=files, timeout=30)
|
||
|
||
result = response.json()
|
||
|
||
if result.get("errcode"):
|
||
raise RuntimeError(f"上传封面图片失败: {result}")
|
||
|
||
media_id = result.get("media_id")
|
||
if not media_id:
|
||
raise RuntimeError(f"上传封面图片失败,未返回 media_id: {result}")
|
||
|
||
return media_id
|
||
|
||
def upload_thumb_from_url(self, image_url: str) -> str:
|
||
"""
|
||
从URL下载图片并上传获取 thumb_media_id
|
||
|
||
Args:
|
||
image_url: 图片URL
|
||
|
||
Returns:
|
||
thumb_media_id
|
||
"""
|
||
import tempfile
|
||
|
||
# 下载图片
|
||
response = requests.get(image_url, timeout=30)
|
||
response.raise_for_status()
|
||
|
||
# 保存到临时文件
|
||
suffix = Path(image_url).suffix or '.jpg'
|
||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
||
f.write(response.content)
|
||
temp_path = f.name
|
||
|
||
try:
|
||
return self.upload_thumb(temp_path)
|
||
finally:
|
||
# 清理临时文件
|
||
Path(temp_path).unlink(missing_ok=True)
|
||
|
||
def add_draft(
|
||
self,
|
||
title: str,
|
||
content: str,
|
||
thumb_media_id: str,
|
||
author: str = "",
|
||
digest: str = "",
|
||
content_source_url: str = ""
|
||
) -> dict:
|
||
"""
|
||
添加草稿到公众号草稿箱
|
||
|
||
Args:
|
||
title: 文章标题
|
||
content: 文章内容(HTML格式)
|
||
thumb_media_id: 封面图片的 media_id(必需)
|
||
author: 作者
|
||
digest: 摘要
|
||
content_source_url: 阅读原文链接
|
||
|
||
Returns:
|
||
API 返回的原始数据
|
||
"""
|
||
token = self._get_access_token()
|
||
url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
|
||
|
||
# 构建图文消息
|
||
article = {
|
||
"title": title,
|
||
"content": content,
|
||
"thumb_media_id": thumb_media_id,
|
||
"show_cover_pic": 1,
|
||
"need_open_comment": 0,
|
||
"only_fans_can_comment": 0
|
||
}
|
||
|
||
if author:
|
||
article["author"] = author
|
||
if digest:
|
||
article["digest"] = digest
|
||
if content_source_url:
|
||
article["content_source_url"] = content_source_url
|
||
|
||
payload = {"articles": [article]}
|
||
|
||
# 调试信息
|
||
payload_str = json.dumps(payload, ensure_ascii=False)
|
||
print(f"[WeChat] 发送内容长度: {len(payload_str)} 字符")
|
||
|
||
response = requests.post(
|
||
url,
|
||
data=payload_str.encode('utf-8'),
|
||
headers={"Content-Type": "application/json; charset=utf-8"},
|
||
timeout=30
|
||
)
|
||
|
||
result = response.json()
|
||
|
||
if result.get("errcode", 0) != 0:
|
||
raise RuntimeError(f"添加草稿失败: {result}")
|
||
|
||
return result
|
||
|
||
|
||
def publish_to_wechat(
|
||
title: str,
|
||
content: str,
|
||
appid: str,
|
||
appsecret: str,
|
||
thumb_image_path: Optional[str] = None,
|
||
thumb_image_url: Optional[str] = None
|
||
) -> bool:
|
||
"""
|
||
便捷函数:发布文章到微信公众号草稿箱
|
||
|
||
Args:
|
||
title: 文章标题
|
||
content: 文章内容(HTML格式)
|
||
appid: 微信公众号 AppID
|
||
appsecret: 微信公众号 AppSecret
|
||
thumb_image_path: 封面图片本地路径(可选,与 thumb_image_url 二选一)
|
||
thumb_image_url: 封面图片URL(可选,与 thumb_image_path 二选一)
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
try:
|
||
publisher = WeChatPublisher(appid, appsecret)
|
||
|
||
# 获取封面图片的 media_id
|
||
if thumb_image_path:
|
||
print(f"[WeChat] 正在上传封面图片: {thumb_image_path}")
|
||
thumb_media_id = publisher.upload_thumb(thumb_image_path)
|
||
elif thumb_image_url:
|
||
print(f"[WeChat] 正在下载并上传封面图片: {thumb_image_url}")
|
||
thumb_media_id = publisher.upload_thumb_from_url(thumb_image_url)
|
||
else:
|
||
raise RuntimeError("必须提供封面图片路径或URL")
|
||
|
||
print(f"[WeChat] 封面图片上传成功,media_id: {thumb_media_id}")
|
||
|
||
# 添加草稿
|
||
result = publisher.add_draft(
|
||
title=title,
|
||
content=content,
|
||
thumb_media_id=thumb_media_id
|
||
)
|
||
|
||
print(f"[WeChat] 草稿添加成功,media_id: {result.get('media_id')}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"[WeChat] 发布失败: {e}")
|
||
return False
|
||
|
||
|
||
def main():
|
||
"""测试发布功能"""
|
||
from config_loader import get_config
|
||
|
||
config = get_config()
|
||
appid = config.wechat_appid
|
||
appsecret = config.wechat_appsecret
|
||
|
||
if not appid or not appsecret:
|
||
print("[ERROR] 未配置微信公众号,请编辑 config.yaml 文件:")
|
||
print(" wechat:")
|
||
print(" appid: \"your_appid\"")
|
||
print(" appsecret: \"your_appsecret\"")
|
||
return
|
||
|
||
# 测试内容
|
||
title = "测试文章"
|
||
content = "<p>这是一篇测试文章</p>"
|
||
|
||
# 使用默认封面图片
|
||
default_thumb_path = r"c:\Users\南音\Desktop\yidaima\assets\img\bg.jpg"
|
||
|
||
success = publish_to_wechat(
|
||
title=title,
|
||
content=content,
|
||
appid=appid,
|
||
appsecret=appsecret,
|
||
thumb_image_path=default_thumb_path
|
||
)
|
||
|
||
if success:
|
||
print("[INFO] 发布成功!")
|
||
else:
|
||
print("[ERROR] 发布失败!")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|