feat:完成微信公众号的自动化工具

This commit is contained in:
Daniel
2026-04-01 14:21:10 +08:00
commit 124a5f0192
16 changed files with 675 additions and 0 deletions

21
app/config.py Normal file
View File

@@ -0,0 +1,21 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "X2WeChat Studio"
openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")
openai_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL")
openai_model: str = Field(default="gpt-4.1-mini", alias="OPENAI_MODEL")
wechat_appid: str | None = Field(default=None, alias="WECHAT_APPID")
wechat_secret: str | None = Field(default=None, alias="WECHAT_SECRET")
wechat_author: str = Field(default="AI 编辑部", alias="WECHAT_AUTHOR")
im_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL")
im_secret: str | None = Field(default=None, alias="IM_SECRET")
settings = Settings()

40
app/main.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config import settings
from app.schemas import IMPublishRequest, RewriteRequest, WechatPublishRequest
from app.services.ai_rewriter import AIRewriter
from app.services.im import IMPublisher
from app.services.wechat import WechatPublisher
app = FastAPI(title=settings.app_name)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
rewriter = AIRewriter()
wechat = WechatPublisher()
im = IMPublisher()
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request, "app_name": settings.app_name})
@app.post("/api/rewrite")
async def rewrite(req: RewriteRequest):
return rewriter.rewrite(req)
@app.post("/api/publish/wechat")
async def publish_wechat(req: WechatPublishRequest):
return await wechat.publish_draft(req)
@app.post("/api/publish/im")
async def publish_im(req: IMPublishRequest):
return await im.publish(req)

34
app/schemas.py Normal file
View File

@@ -0,0 +1,34 @@
from pydantic import BaseModel, Field
class RewriteRequest(BaseModel):
source_text: str = Field(..., min_length=20)
title_hint: str = ""
tone: str = "专业、可信、可读性强"
audience: str = "公众号读者"
keep_points: str = ""
avoid_words: str = ""
class RewriteResponse(BaseModel):
title: str
summary: str
body_markdown: str
class WechatPublishRequest(BaseModel):
title: str
summary: str = ""
body_markdown: str
author: str = ""
class IMPublishRequest(BaseModel):
title: str
body_markdown: str
class PublishResponse(BaseModel):
ok: bool
detail: str
data: dict | None = None

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import re
from textwrap import shorten
from openai import OpenAI
from app.config import settings
from app.schemas import RewriteRequest, RewriteResponse
SYSTEM_PROMPT = """
你是中文内容编辑与合规顾问。请把输入内容进行“原创改写”,要求:
1) 保留核心事实,但避免逐句复述;
2) 结构清晰导语、3-5个小节、结尾行动建议
3) 风格适合微信公众号表达自然避免AI腔
4) 如果原文存在未经核实结论,请使用“可能/有待验证”等措辞;
5) 输出必须是 JSON字段title, summary, body_markdown。
""".strip()
class AIRewriter:
def __init__(self) -> None:
self._client = None
if settings.openai_api_key:
self._client = OpenAI(
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
)
def rewrite(self, req: RewriteRequest) -> RewriteResponse:
if not self._client:
return self._fallback_rewrite(req)
user_prompt = f"""
原始内容:
{req.source_text}
改写约束:
- 标题参考:{req.title_hint or '自动生成'}
- 目标语气:{req.tone}
- 目标读者:{req.audience}
- 必须保留观点:{req.keep_points or ''}
- 避免词汇:{req.avoid_words or ''}
""".strip()
completion = self._client.responses.create(
model=settings.openai_model,
input=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
text={"format": {"type": "json_object"}},
)
text = completion.output_text
import json
data = json.loads(text)
return RewriteResponse(**data)
def _fallback_rewrite(self, req: RewriteRequest) -> RewriteResponse:
clean_text = re.sub(r"\n{2,}", "\n", req.source_text.strip())
lines = [line.strip() for line in clean_text.split("\n") if line.strip()]
head = lines[0] if lines else clean_text[:50]
title = req.title_hint.strip() or f"{shorten(head, width=26, placeholder='')}:可执行解读"
summary = shorten(clean_text, width=90, placeholder="...")
body = (
f"## 导语\n"
f"这篇内容值得关注的核心在于:{summary}\n\n"
f"## 重点拆解\n"
f"1. 背景与问题:从原文可以看到关键矛盾已出现。\n"
f"2. 方法与动作:建议按“目标-路径-验证”三步推进。\n"
f"3. 风险与边界:避免绝对化表述,必要时补充数据来源。\n\n"
f"## 公众号改写正文\n"
f"{clean_text}\n\n"
f"## 结尾\n"
f"以上为原创重组版本,可继续补充案例与数据后发布。"
)
return RewriteResponse(title=title, summary=summary, body_markdown=body)

54
app/services/im.py Normal file
View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import hashlib
import hmac
import base64
import time
from urllib.parse import quote_plus
import httpx
from app.config import settings
from app.schemas import IMPublishRequest, PublishResponse
class IMPublisher:
async def publish(self, req: IMPublishRequest) -> PublishResponse:
if not settings.im_webhook_url:
return PublishResponse(ok=False, detail="缺少 IM_WEBHOOK_URL 配置")
webhook = self._with_signature(settings.im_webhook_url, settings.im_secret)
payload = {
"msg_type": "post",
"content": {
"post": {
"zh_cn": {
"title": req.title,
"content": [[{"tag": "text", "text": req.body_markdown[:3800]}]],
}
}
},
}
async with httpx.AsyncClient(timeout=20) as client:
r = await client.post(webhook, json=payload)
try:
data = r.json()
except Exception:
data = {"status_code": r.status_code, "text": r.text}
if r.status_code >= 400:
return PublishResponse(ok=False, detail=f"IM 推送失败: {data}", data=data)
return PublishResponse(ok=True, detail="IM 推送成功", data=data)
def _with_signature(self, webhook: str, secret: str | None) -> str:
# 兼容飞书 webhook 签名参数timestamp/sign
if not secret:
return webhook
ts = str(int(time.time()))
string_to_sign = f"{ts}\n{secret}".encode("utf-8")
sign = base64.b64encode(hmac.new(string_to_sign, b"", hashlib.sha256).digest()).decode("utf-8")
connector = "&" if "?" in webhook else "?"
return f"{webhook}{connector}timestamp={ts}&sign={quote_plus(sign)}"

72
app/services/wechat.py Normal file
View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import time
import httpx
import markdown2
from app.config import settings
from app.schemas import PublishResponse, WechatPublishRequest
class WechatPublisher:
def __init__(self) -> None:
self._access_token = None
self._expires_at = 0
async def publish_draft(self, req: WechatPublishRequest) -> PublishResponse:
if not settings.wechat_appid or not settings.wechat_secret:
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
token = await self._get_access_token()
if not token:
return PublishResponse(ok=False, detail="获取微信 access_token 失败")
html = markdown2.markdown(req.body_markdown)
payload = {
"articles": [
{
"title": req.title,
"author": req.author or settings.wechat_author,
"digest": req.summary,
"content": html,
"content_source_url": "",
"need_open_comment": 0,
"only_fans_can_comment": 0,
}
]
}
async with httpx.AsyncClient(timeout=25) as client:
url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
r = await client.post(url, json=payload)
data = r.json()
if data.get("errcode", 0) != 0:
return PublishResponse(ok=False, detail=f"微信发布失败: {data}", data=data)
return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data)
async def _get_access_token(self) -> str | None:
now = int(time.time())
if self._access_token and now < self._expires_at - 60:
return self._access_token
async with httpx.AsyncClient(timeout=20) as client:
r = await client.get(
"https://api.weixin.qq.com/cgi-bin/token",
params={
"grant_type": "client_credential",
"appid": settings.wechat_appid,
"secret": settings.wechat_secret,
},
)
data = r.json()
token = data.get("access_token")
if not token:
return None
self._access_token = token
self._expires_at = now + int(data.get("expires_in", 7200))
return token

74
app/static/app.js Normal file
View File

@@ -0,0 +1,74 @@
const $ = (id) => document.getElementById(id);
const statusEl = $("status");
function setStatus(msg, danger = false) {
statusEl.style.color = danger ? "#b42318" : "#0f5f3d";
statusEl.textContent = msg;
}
async function postJSON(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "请求失败");
return data;
}
$("rewriteBtn").addEventListener("click", async () => {
const sourceText = $("sourceText").value.trim();
if (sourceText.length < 20) {
setStatus("原始内容太短,至少 20 个字符", true);
return;
}
setStatus("AI 改写中...");
try {
const data = await postJSON("/api/rewrite", {
source_text: sourceText,
title_hint: $("titleHint").value,
tone: $("tone").value,
audience: $("audience").value,
keep_points: $("keepPoints").value,
avoid_words: $("avoidWords").value,
});
$("title").value = data.title || "";
$("summary").value = data.summary || "";
$("body").value = data.body_markdown || "";
setStatus("改写完成,可直接发布。");
} catch (e) {
setStatus(`改写失败: ${e.message}`, true);
}
});
$("wechatBtn").addEventListener("click", async () => {
setStatus("正在发布到公众号草稿箱...");
try {
const data = await postJSON("/api/publish/wechat", {
title: $("title").value,
summary: $("summary").value,
body_markdown: $("body").value,
});
if (!data.ok) throw new Error(data.detail);
setStatus("公众号草稿发布成功");
} catch (e) {
setStatus(`公众号发布失败: ${e.message}`, true);
}
});
$("imBtn").addEventListener("click", async () => {
setStatus("正在发送到 IM...");
try {
const data = await postJSON("/api/publish/im", {
title: $("title").value,
body_markdown: $("body").value,
});
if (!data.ok) throw new Error(data.detail);
setStatus("IM 发送成功");
} catch (e) {
setStatus(`IM 发送失败: ${e.message}`, true);
}
});

117
app/static/style.css Normal file
View File

@@ -0,0 +1,117 @@
:root {
--bg: #f3f7f5;
--panel: #ffffff;
--line: #d7e3dd;
--text: #1a3128;
--muted: #5e7a6f;
--accent: #18794e;
--accent-2: #0f5f3d;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: radial-gradient(circle at 10% 20%, #e6f4ec, transparent 35%),
radial-gradient(circle at 90% 80%, #dff0ff, transparent 30%),
var(--bg);
color: var(--text);
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
}
.layout {
max-width: 1280px;
margin: 24px auto;
padding: 0 16px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 18px;
box-shadow: 0 8px 24px rgba(32, 84, 55, 0.07);
}
h1,
h2 {
margin-top: 0;
}
.muted {
color: var(--muted);
margin-top: -6px;
}
label {
display: block;
margin-top: 10px;
margin-bottom: 6px;
font-size: 14px;
font-weight: 600;
}
input,
textarea,
button {
width: 100%;
border-radius: 10px;
border: 1px solid var(--line);
padding: 10px 12px;
font-size: 14px;
}
textarea {
resize: vertical;
line-height: 1.5;
}
button {
cursor: pointer;
margin-top: 12px;
font-weight: 700;
}
button:hover {
filter: brightness(0.98);
}
button.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
button.primary:hover {
background: var(--accent-2);
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.status {
min-height: 22px;
margin-top: 8px;
color: var(--accent-2);
font-weight: 600;
}
@media (max-width: 960px) {
.layout {
grid-template-columns: 1fr;
}
}

68
app/templates/index.html Normal file
View File

@@ -0,0 +1,68 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }}</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<main class="layout">
<section class="panel input-panel">
<h1>{{ app_name }}</h1>
<p class="muted">粘贴 X 上的优质内容,生成公众号可发布版本,并支持同步到 IM。</p>
<label>原始内容</label>
<textarea id="sourceText" rows="14" placeholder="粘贴 X 长文/线程内容..."></textarea>
<div class="grid2">
<div>
<label>标题提示</label>
<input id="titleHint" type="text" placeholder="如AI Agent 商业化路径" />
</div>
<div>
<label>目标读者</label>
<input id="audience" type="text" value="公众号运营者/产品经理" />
</div>
</div>
<div class="grid2">
<div>
<label>语气风格</label>
<input id="tone" type="text" value="专业、有观点、口语自然" />
</div>
<div>
<label>避免词汇</label>
<input id="avoidWords" type="text" placeholder="如:颠覆、闭环、赋能" />
</div>
</div>
<label>必须保留观点</label>
<input id="keepPoints" type="text" placeholder="逗号分隔" />
<button id="rewriteBtn" class="primary">AI 改写</button>
<p id="status" class="status"></p>
</section>
<section class="panel output-panel">
<h2>发布内容</h2>
<label>标题</label>
<input id="title" type="text" />
<label>摘要</label>
<textarea id="summary" rows="3"></textarea>
<label>Markdown 正文</label>
<textarea id="body" rows="16"></textarea>
<div class="actions">
<button id="wechatBtn">发布到公众号草稿箱</button>
<button id="imBtn">发送到 IM</button>
</div>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>