Files
soul/scripts/send_poster_to_feishu.py
卡若 afc2376e96 v1.19 全面改版:VIP会员系统、我的收益、创业老板排行、阅读量排序
- 后端: 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>
2026-02-23 14:07:41 +08:00

272 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()