- 后端: users表新增VIP字段, 4个VIP API (purchase/status/profile/members) - 后端: hot接口改按user_tracks阅读量排序 - 后端: orders表支持vip产品类型, migrate新增vip_fields迁移 - 小程序「我的」: 推广中心改为我的收益, 头像VIP标识, VIP入口卡片 - 小程序「我的」: 最近阅读显示真实章节名称 - 小程序首页: 去掉内容概览, 新增创业老板排行(4列网格) - 小程序首页: 精选推荐从hot接口获取, goToRead增加track记录 - 新增页面: VIP详情页, 会员详情页 - 开发文档精简为10个标准目录, 创建SKILL.md, 需求日志规范化 Co-authored-by: Cursor <cursoragent@cursor.com>
272 lines
8.8 KiB
Python
272 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Soul 文章海报发飞书:生成海报图片(含小程序码)并发送,不发链接
|
||
|
||
流程:解析文章 → 调 Soul API 获取小程序码 → 合成海报图 → 上传飞书 → 以图片形式发送
|
||
|
||
用法:
|
||
python3 scripts/send_poster_to_feishu.py <文章md路径>
|
||
python3 scripts/send_poster_to_feishu.py --id 9.15
|
||
|
||
环境: pip install Pillow requests
|
||
飞书图片上传需配置: FEISHU_APP_ID, FEISHU_APP_SECRET(与 webhook 同租户的飞书应用)
|
||
"""
|
||
|
||
import argparse
|
||
import base64
|
||
import io
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
|
||
# 可选:从脚本同目录 .env.feishu 加载 FEISHU_APP_ID, FEISHU_APP_SECRET
|
||
_env = Path(__file__).resolve().parent / ".env.feishu"
|
||
if _env.exists():
|
||
for line in _env.read_text().strip().split("\n"):
|
||
if "=" in line and not line.strip().startswith("#"):
|
||
k, v = line.split("=", 1)
|
||
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||
import os
|
||
import re
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
from urllib.request import Request, urlopen
|
||
from urllib.error import URLError, HTTPError
|
||
|
||
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/8b7f996e-2892-4075-989f-aa5593ea4fbc"
|
||
SOUL_API = "https://soul.quwanzhi.com/api/miniprogram/qrcode"
|
||
POSTER_W, POSTER_H = 600, 900 # 2x 小程序 300x450
|
||
|
||
|
||
def parse_article(filepath: Path) -> dict:
|
||
"""从 md 解析:标题、金句、日期、section id"""
|
||
text = filepath.read_text(encoding="utf-8")
|
||
lines = [l.strip() for l in text.split("\n")]
|
||
|
||
title = ""
|
||
date_line = ""
|
||
quote = ""
|
||
section_id = ""
|
||
|
||
for line in lines:
|
||
if line.startswith("# "):
|
||
title = line.lstrip("# ").strip()
|
||
m = re.match(r"^(\d+\.\d+)\s", title)
|
||
if m:
|
||
section_id = m.group(1)
|
||
elif re.match(r"^\d{4}年\d{1,2}月\d{1,2}日", line):
|
||
date_line = line
|
||
elif title and not quote and line and not line.startswith("-") and "---" not in line:
|
||
if 10 <= len(line) <= 120:
|
||
quote = line
|
||
|
||
if not section_id and filepath.stem:
|
||
m = re.match(r"^(\d+\.\d+)", filepath.stem)
|
||
if m:
|
||
section_id = m.group(1)
|
||
|
||
return {
|
||
"title": title or filepath.stem,
|
||
"quote": quote or title or "来自 Soul 创业派对的真实故事",
|
||
"date": date_line,
|
||
"section_id": section_id or "9.1",
|
||
}
|
||
|
||
|
||
def fetch_qrcode(section_id: str) -> bytes | None:
|
||
"""从 Soul 后端获取小程序码图片"""
|
||
scene = f"id={section_id}"
|
||
body = json.dumps({"scene": scene, "page": "pages/read/read", "width": 280}).encode()
|
||
req = Request(SOUL_API, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||
try:
|
||
with urlopen(req, timeout=15) as resp:
|
||
data = json.loads(resp.read().decode())
|
||
if not data.get("success") or not data.get("image"):
|
||
return None
|
||
b64 = data["image"].split(",", 1)[-1] if "," in data["image"] else data["image"]
|
||
return base64.b64decode(b64)
|
||
except Exception as e:
|
||
print("获取小程序码失败:", e)
|
||
return None
|
||
|
||
|
||
def draw_poster(data: dict, qr_bytes: bytes | None) -> bytes:
|
||
"""合成海报图,返回 PNG bytes"""
|
||
try:
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
except ImportError:
|
||
print("需要安装 Pillow: pip install Pillow")
|
||
sys.exit(1)
|
||
|
||
img = Image.new("RGB", (POSTER_W, POSTER_H), color=(26, 26, 46))
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# 顶部装饰条
|
||
draw.rectangle([0, 0, POSTER_W, 8], fill=(0, 206, 209))
|
||
|
||
# 字体(系统 fallback)
|
||
try:
|
||
font_sm = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||
font_md = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 32)
|
||
font_lg = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 40)
|
||
except OSError:
|
||
font_sm = font_md = font_lg = ImageFont.load_default()
|
||
|
||
y = 50
|
||
draw.text((40, y), "Soul创业派对", fill=(255, 255, 255), font=font_sm)
|
||
y += 60
|
||
|
||
# 标题(多行)
|
||
title = data["title"]
|
||
for i in range(0, len(title), 18):
|
||
draw.text((40, y), title[i : i + 18], fill=(255, 255, 255), font=font_lg)
|
||
y += 50
|
||
y += 20
|
||
|
||
draw.line([(40, y), (POSTER_W - 40, y)], fill=(255, 255, 255, 40), width=1)
|
||
y += 40
|
||
|
||
# 金句
|
||
quote = data["quote"]
|
||
for i in range(0, min(len(quote), 80), 20):
|
||
draw.text((40, y), quote[i : i + 20], fill=(255, 255, 255, 200), font=font_md)
|
||
y += 40
|
||
if data["date"]:
|
||
draw.text((40, y), data["date"], fill=(255, 255, 255, 180), font=font_sm)
|
||
y += 40
|
||
y += 20
|
||
|
||
# 底部提示 + 小程序码
|
||
draw.text((40, POSTER_H - 120), "长按识别小程序码", fill=(255, 255, 255), font=font_sm)
|
||
draw.text((40, POSTER_H - 90), "阅读全文", fill=(255, 255, 255, 180), font=font_sm)
|
||
|
||
if qr_bytes:
|
||
qr_img = Image.open(io.BytesIO(qr_bytes))
|
||
qr_img = qr_img.resize((160, 160))
|
||
img.paste(qr_img, (POSTER_W - 200, POSTER_H - 180))
|
||
|
||
buf = io.BytesIO()
|
||
img.save(buf, format="PNG")
|
||
return buf.getvalue()
|
||
|
||
|
||
def get_feishu_token() -> str | None:
|
||
app_id = os.environ.get("FEISHU_APP_ID")
|
||
app_secret = os.environ.get("FEISHU_APP_SECRET")
|
||
if not app_id or not app_secret:
|
||
return None
|
||
req = Request(
|
||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
||
data=json.dumps({"app_id": app_id, "app_secret": app_secret}).encode(),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urlopen(req, timeout=10) as resp:
|
||
data = json.loads(resp.read().decode())
|
||
return data.get("tenant_access_token")
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def upload_image_to_feishu(png_bytes: bytes, token: str) -> str | None:
|
||
"""上传图片到飞书,返回 image_key"""
|
||
try:
|
||
import requests
|
||
except ImportError:
|
||
print("需要安装: pip install requests")
|
||
return None
|
||
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
files = {"image": ("poster.png", png_bytes, "image/png")}
|
||
data = {"image_type": "message"}
|
||
try:
|
||
r = requests.post(
|
||
"https://open.feishu.cn/open-apis/im/v1/images",
|
||
headers=headers,
|
||
data=data,
|
||
files=files,
|
||
timeout=15,
|
||
)
|
||
j = r.json()
|
||
if j.get("code") == 0 and j.get("data", {}).get("image_key"):
|
||
return j["data"]["image_key"]
|
||
except Exception as e:
|
||
print("上传飞书失败:", e)
|
||
return None
|
||
|
||
|
||
def send_image_to_feishu(image_key: str) -> bool:
|
||
payload = {"msg_type": "image", "content": {"image_key": image_key}}
|
||
req = Request(
|
||
FEISHU_WEBHOOK,
|
||
data=json.dumps(payload).encode(),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urlopen(req, timeout=10) as resp:
|
||
r = json.loads(resp.read().decode())
|
||
return r.get("code") == 0
|
||
except Exception as e:
|
||
print("发送失败:", e)
|
||
return False
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("file", nargs="?", help="文章 md 路径")
|
||
parser.add_argument("--id", help="章节 id")
|
||
parser.add_argument("--save", help="仅保存海报到本地路径,不发飞书")
|
||
args = parser.parse_args()
|
||
|
||
base = Path("/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例")
|
||
|
||
if args.id:
|
||
candidates = list(base.glob(f"{args.id}*.md"))
|
||
if not candidates:
|
||
print(f"未找到 id={args.id} 的文章")
|
||
sys.exit(1)
|
||
filepath = candidates[0]
|
||
elif args.file:
|
||
filepath = Path(args.file)
|
||
if not filepath.exists():
|
||
print("文件不存在:", filepath)
|
||
sys.exit(1)
|
||
else:
|
||
parser.print_help()
|
||
sys.exit(1)
|
||
|
||
data = parse_article(filepath)
|
||
print("生成海报:", data["title"])
|
||
|
||
qr_bytes = fetch_qrcode(data["section_id"])
|
||
png_bytes = draw_poster(data, qr_bytes)
|
||
|
||
if args.save:
|
||
Path(args.save).write_bytes(png_bytes)
|
||
print("已保存:", args.save)
|
||
return
|
||
|
||
token = get_feishu_token()
|
||
if not token:
|
||
out = Path(tempfile.gettempdir()) / f"soul_poster_{data['section_id']}.png"
|
||
out.write_bytes(png_bytes)
|
||
print("未配置 FEISHU_APP_ID / FEISHU_APP_SECRET,无法上传。海报已保存到:", out)
|
||
sys.exit(1)
|
||
|
||
image_key = upload_image_to_feishu(png_bytes, token)
|
||
if not image_key:
|
||
print("上传图片失败")
|
||
sys.exit(1)
|
||
|
||
if send_image_to_feishu(image_key):
|
||
print("已发送到飞书(图片)")
|
||
else:
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|