feat:完成微信公众号的自动化工具
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -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=
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -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"]
|
||||
21
app/config.py
Normal file
21
app/config.py
Normal 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
40
app/main.py
Normal 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
34
app/schemas.py
Normal 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
|
||||
80
app/services/ai_rewriter.py
Normal file
80
app/services/ai_rewriter.py
Normal 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
54
app/services/im.py
Normal 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
72
app/services/wechat.py
Normal 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
74
app/static/app.js
Normal 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
117
app/static/style.css
Normal 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
68
app/templates/index.html
Normal 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>
|
||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
x2wechat:
|
||||
build: .
|
||||
container_name: x2wechat-studio
|
||||
ports:
|
||||
- "18000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
36
readme.md
Normal file
36
readme.md
Normal file
@@ -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` 时,系统会使用本地降级改写模板,便于你先跑通流程。
|
||||
- 建议发布前人工复核事实与引用,避免版权和失真风险。
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -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
|
||||
35
start.sh
Executable file
35
start.sh
Executable file
@@ -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"
|
||||
Reference in New Issue
Block a user