commit 124a5f019231d6d19fafd4a51192ce82957ad326 Author: Daniel Date: Wed Apr 1 14:21:10 2026 +0800 feat:完成微信公众号的自动化工具 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..89a6a46 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +OPENAI_API_KEY= +OPENAI_BASE_URL= +OPENAI_MODEL=gpt-4.1-mini + +WECHAT_APPID= +WECHAT_SECRET= +WECHAT_AUTHOR=AI 编辑部 + +# 可填飞书/Slack/企微等 webhook +IM_WEBHOOK_URL= +# 若 webhook 需要签名可填 +IM_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06c00d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d6caff3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..816b0e4 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..ef2c537 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..ced0844 --- /dev/null +++ b/app/schemas.py @@ -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 diff --git a/app/services/ai_rewriter.py b/app/services/ai_rewriter.py new file mode 100644 index 0000000..2f5a0c2 --- /dev/null +++ b/app/services/ai_rewriter.py @@ -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) diff --git a/app/services/im.py b/app/services/im.py new file mode 100644 index 0000000..791f19e --- /dev/null +++ b/app/services/im.py @@ -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)}" diff --git a/app/services/wechat.py b/app/services/wechat.py new file mode 100644 index 0000000..f90502c --- /dev/null +++ b/app/services/wechat.py @@ -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 diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..b64d2ff --- /dev/null +++ b/app/static/app.js @@ -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); + } +}); diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..a14d679 --- /dev/null +++ b/app/static/style.css @@ -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; + } +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..e80cb36 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,68 @@ + + + + + + {{ app_name }} + + + +
+
+

{{ app_name }}

+

粘贴 X 上的优质内容,生成公众号可发布版本,并支持同步到 IM。

+ + + + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + + + + +

+
+ +
+

发布内容

+ + + + + + + + + + +
+ + +
+
+
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2845d8e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + x2wechat: + build: . + container_name: x2wechat-studio + ports: + - "18000:8000" + env_file: + - .env + restart: unless-stopped diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..91b2378 --- /dev/null +++ b/readme.md @@ -0,0 +1,36 @@ +# X2WeChat Studio + +把 X 上的优质文章快速改写为公众号可发布版本,并支持同步推送到 IM。 + +## 1. 启动 + +```bash +cp .env.example .env +# 填写 .env 中的 OPENAI / 微信 / IM 参数 + +docker compose up --build +``` + +启动后访问:`http://localhost:8000` + +## 2. 使用流程 + +1. 在页面左侧粘贴 X 文章内容。 +2. 点击 `AI 改写`,自动生成标题、摘要、正文。 +3. 点击 `发布到公众号草稿箱`。 +4. 可选点击 `发送到 IM` 同步到团队群。 + +## 3. 环境变量说明 + +- `OPENAI_API_KEY`:AI 改写能力。 +- `OPENAI_BASE_URL`:可选,兼容第三方网关。 +- `OPENAI_MODEL`:默认 `gpt-4.1-mini`。 +- `WECHAT_APPID` / `WECHAT_SECRET`:公众号发布必填。 +- `WECHAT_AUTHOR`:草稿默认作者名。 +- `IM_WEBHOOK_URL`:IM 推送地址(飞书/Slack/企微等)。 +- `IM_SECRET`:可选签名。 + +## 4. 说明 + +- 未配置 `OPENAI_API_KEY` 时,系统会使用本地降级改写模板,便于你先跑通流程。 +- 建议发布前人工复核事实与引用,避免版权和失真风险。 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..462af6b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.116.0 +uvicorn[standard]==0.35.0 +jinja2==3.1.6 +pydantic-settings==2.11.0 +httpx==0.28.1 +openai==1.108.2 +markdown2==2.5.4 +python-multipart==0.0.20 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..359ac80 --- /dev/null +++ b/start.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT" + +if [[ ! -f ".env" ]]; then + if [[ -f ".env.example" ]]; then + cp ".env.example" ".env" + echo "Created .env from .env.example. Please fill required values if needed." + else + echo "Error: .env and .env.example are both missing." + exit 1 + fi +fi + +if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" +else + echo "Error: docker compose is not available." + echo "Install Docker Desktop first, then run this script again." + exit 1 +fi + +echo "Starting X2WeChat Studio..." +$COMPOSE_CMD up --build -d + +echo "Service is starting in background." +echo "Open: http://localhost:18000" +echo +echo "Useful commands:" +echo " $COMPOSE_CMD logs -f" +echo " $COMPOSE_CMD down"