Files
soul-yongping/scripts/send_chapter_poster_to_feishu.py
2026-03-07 22:58:43 +08:00

400 lines
15 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
# -*- coding: utf-8 -*-
"""
生成章节海报(标题=章节标题、摘要+小程序码),上传到飞书并发送到 Soul 彩民团队飞书群(默认 webhook
海报样式:深蓝背景、顶部装饰条、主标题为章节标题、摘要、底部「长按识别小程序码」+ 二维码。
用法:
python3 send_chapter_poster_to_feishu.py 9.24 "第112场一个人起头维权挣了大半套房"
python3 send_chapter_poster_to_feishu.py 9.24 --excerpt "起头难。起完头,后面都是顺势。..."
依赖: pip install requests Pillow
"""
import argparse
import base64
import io
import os
import re
import sys
from pathlib import Path
try:
import requests
except ImportError:
print("请安装 requests: pip install requests")
sys.exit(1)
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("请安装 Pillow: pip install Pillow")
sys.exit(1)
# 与 post_to_feishu 保持一致
SCRIPT_DIR = Path(__file__).resolve().parent
# 默认发到 Soul 彩民团队飞书群
WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/14a7e0d3-864d-4709-ad40-0def6edba566"
BACKEND_QRCODE_URL = "https://soul.quwanzhi.com/api/miniprogram/qrcode"
MINIPROGRAM_READ_BASE = "https://soul.quwanzhi.com/read"
# 海报尺寸(与小程序内一致,便于统一风格)
POSTER_W = 300
POSTER_H = 450
# 高级感配色:深蓝渐变 + 青金点缀
COLOR_BG_TOP = (26, 26, 46) # #1a1a2e
COLOR_BG_BOTTOM = (22, 33, 62) # #16213e
COLOR_ACCENT = (0, 206, 209) # #00CED1 顶部条
COLOR_BOTTOM_BG = (28, 42, 62) # 底部区深色块
COLOR_TITLE = (255, 255, 255)
COLOR_SUMMARY = (220, 225, 235) # 略柔和的摘要灰
COLOR_HINT = (180, 190, 210) # 底部提示文字
FONT_PATHS = [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Supplemental/PingFang.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
def excerpt_from_article(md_path: Path, percent: float = 0.06) -> str:
"""从文章 md 中取前 percent默认 6%)字数作为摘要,去掉标题和分隔线。"""
if not md_path.exists():
return ""
raw = md_path.read_text(encoding="utf-8")
lines = []
for line in raw.splitlines():
s = line.strip()
if not s or s == "---" or s.startswith("# "):
continue
lines.append(s)
text = " ".join(lines)
total = len(text)
take = max(1, int(total * percent))
out = text[:take].strip()
return re.sub(r"\s+", " ", out) + ("..." if take < total else "")
def load_env():
env_path = SCRIPT_DIR / ".env.feishu"
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().replace('"', "").replace("'", ""))
def _font(size: int, bold: bool = False):
"""中文字体,优先 PingFang标题用 Semibold正文用 Regular更好看。"""
for path in FONT_PATHS:
if Path(path).exists():
try:
# PingFang.ttc: 0=Regular, 1=Medium, 2=Semibold
idx = 2 if bold else 0
return ImageFont.truetype(path, size, index=idx)
except Exception:
try:
return ImageFont.truetype(path, size, index=1 if bold else 0)
except Exception:
try:
return ImageFont.truetype(path, size)
except Exception:
continue
return ImageFont.load_default()
def _text_width(draw, text: str, font) -> int:
"""获取文本像素宽度(兼容不同 Pillow 版本)。"""
try:
b = draw.textbbox((0, 0), text, font=font)
return b[2] - b[0]
except AttributeError:
w, _ = draw.textsize(text, font=font)
return w
def _wrap_text(draw, text: str, font, max_width: int) -> list[str]:
"""按像素宽度换行。"""
lines = []
for char in text:
if not lines:
lines.append(char)
continue
test = lines[-1] + char
if _text_width(draw, test, font) <= max_width:
lines[-1] = test
else:
lines.append(char)
return lines
def _split_sentences(text: str) -> list[str]:
"""将文本按句子切分,保留结尾标点;用于“每句空一行”的海报排版。"""
s = re.sub(r"\s+", " ", (text or "")).strip()
if not s:
return []
parts = re.split(r"(?<=[。!?!?;])\s*", s)
out = [p.strip() for p in parts if p and p.strip()]
return out
def _excerpt_to_poster_lines(draw, excerpt: str, font, max_width: int, max_lines: int) -> list[str]:
"""把摘要排成“每句空一行”的行列表,超出行数自动省略。"""
sentences = _split_sentences(excerpt)
if not sentences:
return ["扫码阅读全文…"]
lines: list[str] = []
for si, sent in enumerate(sentences):
sent_lines = _wrap_text(draw, sent, font, max_width)
for ln in sent_lines:
if len(lines) >= max_lines:
# 把最后一行改成省略号收尾
if lines:
lines[-1] = (lines[-1].rstrip("").rstrip(".") + "")[: max(1, len(lines[-1]))]
return lines
lines.append(ln)
# 句子之间空一行(除非已经到上限)
if si != len(sentences) - 1 and len(lines) < max_lines:
lines.append("")
return lines
def _excerpt_to_feishu_text(excerpt: str) -> str:
"""飞书群文本:一句一行,行间空一行(即句子之间用 \\n\\n"""
sentences = _split_sentences(excerpt)
if not sentences:
return ""
return "\n\n".join(sentences)
def build_poster(title: str, excerpt: str, qrcode_bytes: bytes) -> bytes:
"""绘制海报图PNG bytes。标题=章节标题,样式与小程序内一致并略提升质感。"""
img = Image.new("RGB", (POSTER_W, POSTER_H), COLOR_BG_TOP)
draw = ImageDraw.Draw(img)
# 背景渐变(从上到下)
for y in range(POSTER_H):
t = y / POSTER_H
r = int(COLOR_BG_TOP[0] * (1 - t) + COLOR_BG_BOTTOM[0] * t)
g = int(COLOR_BG_TOP[1] * (1 - t) + COLOR_BG_BOTTOM[1] * t)
b = int(COLOR_BG_TOP[2] * (1 - t) + COLOR_BG_BOTTOM[2] * t)
draw.line([(0, y), (POSTER_W, y)], fill=(r, g, b))
# 顶部装饰条(加高一点更醒目)
draw.rectangle([0, 0, POSTER_W, 5], fill=COLOR_ACCENT)
# 小品牌标识(可选,弱化)
font_small = _font(11)
draw.text((20, 22), "Soul创业派对", fill=(150, 160, 180), font=font_small)
# 主标题:章节标题(字体加粗、略大,更易读)
font_title = _font(19, bold=True)
title_lines = _wrap_text(draw, title, font_title, POSTER_W - 40)
y = 50
for line in title_lines[:2]:
draw.text((20, y), line, fill=COLOR_TITLE, font=font_title)
y += 26
# 分隔线
y += 8
draw.line([(20, y), (POSTER_W - 20, y)], fill=(100, 110, 130), width=1)
# 摘要(严格限制在摘要框内;每句空一行,避免挤/溢出)
y += 28
font_summary = _font(13)
excerpt = (excerpt or "扫码阅读全文").strip()
# 摘要框(增加边界感,但不抢视觉)
bottom_h = 100
box_x1, box_y1 = 16, y - 6
box_x2, box_y2 = POSTER_W - 16, POSTER_H - bottom_h - 14
try:
draw.rounded_rectangle(
[box_x1, box_y1, box_x2, box_y2],
radius=10,
fill=(18, 26, 46),
outline=(40, 55, 80),
width=1,
)
except Exception:
draw.rectangle(
[box_x1, box_y1, box_x2, box_y2],
fill=(18, 26, 46),
outline=(40, 55, 80),
width=1,
)
text_x = 20
text_y = y
max_w = POSTER_W - 40
line_h = 18 # 12px 字体更舒服的行高
max_lines = max(1, int((box_y2 - text_y) / line_h))
summary_lines = _excerpt_to_poster_lines(draw, excerpt, font_summary, max_w, max_lines)
for line in summary_lines:
if line:
draw.text((text_x, text_y), line, fill=COLOR_SUMMARY, font=font_summary)
text_y += line_h
# 底部区域背景(深色块)
draw.rectangle([0, POSTER_H - bottom_h, POSTER_W, POSTER_H], fill=COLOR_BOTTOM_BG)
# 底部文字
font_hint = _font(13)
draw.text((20, POSTER_H - 62), "长按识别小程序码", fill=COLOR_TITLE, font=font_hint)
font_hint2 = _font(11)
draw.text((20, POSTER_H - 40), "长按小程序码阅读全文", fill=COLOR_HINT, font=font_hint2)
# 二维码(右下角,略留白;不放置手指图标)
qr_size = 72
qr_x = POSTER_W - 20 - qr_size
qr_y = POSTER_H - 20 - qr_size
try:
qr_img = Image.open(io.BytesIO(qrcode_bytes)).convert("RGB")
qr_img = qr_img.resize((qr_size, qr_size), Image.Resampling.LANCZOS)
img.paste(qr_img, (qr_x, qr_y))
except Exception:
draw.rectangle([qr_x, qr_y, qr_x + qr_size, qr_y + qr_size], outline=COLOR_ACCENT, width=2)
draw.text((qr_x + 18, qr_y + 26), "扫码", fill=COLOR_ACCENT, font=_font(12))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def get_tenant_access_token():
"""获取飞书 tenant_access_token用于上传图片"""
load_env()
app_id = os.environ.get("FEISHU_APP_ID")
app_secret = os.environ.get("FEISHU_APP_SECRET")
if not app_id or not app_secret:
print("未配置 FEISHU_APP_ID / FEISHU_APP_SECRET请填写 scripts/.env.feishu")
return None
r = requests.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret},
timeout=10,
)
data = r.json() or {}
if data.get("code") != 0:
print("获取飞书 token 失败:", data)
return None
return data.get("tenant_access_token")
def fetch_qrcode_image(section_id: str) -> bytes | None:
"""从后端获取章节小程序码图片PNG 二进制)。"""
scene = f"id={section_id}"
payload = {"scene": scene, "page": "pages/read/read", "width": 280}
try:
r = requests.post(BACKEND_QRCODE_URL, json=payload, timeout=15)
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else None
if body and body.get("success") and body.get("image"):
raw = body["image"]
if isinstance(raw, str) and raw.startswith("data:image"):
raw = raw.split(",", 1)[-1]
return base64.b64decode(raw)
# 有些后端直接返回 PNG
if r.status_code == 200 and r.headers.get("content-type", "").startswith("image/"):
return r.content
print("后端返回异常:", r.status_code, body or r.text[:200])
return None
except Exception as e:
print("请求小程序码失败:", e)
return None
def upload_image_to_feishu(token: str, image_bytes: bytes) -> str | None:
"""上传图片到飞书,返回 image_key。"""
url = "https://open.feishu.cn/open-apis/im/v1/images"
headers = {"Authorization": f"Bearer {token}"}
files = {"image": ("qrcode.png", image_bytes, "image/png")}
data = {"image_type": "message"}
try:
r = requests.post(url, headers=headers, files=files, data=data, timeout=15)
out = r.json() or {}
if out.get("code") != 0:
print("飞书上传图片失败:", out)
return None
return (out.get("data") or {}).get("image_key")
except Exception as e:
print("上传飞书失败:", e)
return None
def send_webhook_text(webhook_url: str, text: str) -> bool:
"""发送纯文本到 webhook不发小程序链接"""
payload = {"msg_type": "text", "content": {"text": text}}
try:
r = requests.post(webhook_url, json=payload, timeout=10)
data = r.json() or {}
if data.get("code") != 0:
print("飞书 webhook 文本发送失败:", data)
return False
return True
except Exception as e:
print("发送 webhook 文本失败:", e)
return False
def send_webhook_image(webhook_url: str, image_key: str, title: str) -> bool:
"""发送图片消息到 webhook不发小程序链接仅海报图。"""
payload = {"msg_type": "image", "content": {"image_key": image_key}}
try:
r = requests.post(webhook_url, json=payload, timeout=10)
data = r.json() or {}
if data.get("code") != 0:
# 兜底:只发文字,不发链接
text = f"📚 {title}\n\n长按海报中的小程序码阅读全文。"
send_webhook_text(webhook_url, text)
print("已发送文本到飞书群(图片类型可能未开放)")
return True
print("已发送海报图到飞书群")
return True
except Exception as e:
print("发送 webhook 失败:", e)
return False
def main():
parser = argparse.ArgumentParser(description="生成章节海报图并发到飞书群")
parser.add_argument("section_id", help="章节 id如 9.24")
parser.add_argument("title", nargs="?", default="", help="章节标题(即海报主标题)")
parser.add_argument("--excerpt", default="", help="摘要文案;若同时给 --md 则忽略,改用文章前 6%% 字数")
parser.add_argument("--md", default="", help="文章 md 路径,摘要取正文前 6%% 字数")
parser.add_argument("--webhook", default=WEBHOOK, help="飞书 webhook 地址")
args = parser.parse_args()
section_id = args.section_id.strip()
title = (args.title or section_id).strip()
if args.md:
excerpt = excerpt_from_article(Path(args.md), percent=0.06)
else:
excerpt = (args.excerpt or "长按识别小程序码,阅读本章节全文。").strip()
qrcode_bytes = fetch_qrcode_image(section_id)
if not qrcode_bytes:
print("无法获取小程序码,请确认后端 soul.quwanzhi.com 可访问且已配置小程序码接口")
sys.exit(1)
poster_bytes = build_poster(title, excerpt, qrcode_bytes)
token = get_tenant_access_token()
if not token:
sys.exit(1)
image_key = upload_image_to_feishu(token, poster_bytes)
if not image_key:
sys.exit(1)
# 先发正文前 6%(一句一行、行间空一行)到群,再发海报图;不发小程序链接
feishu_excerpt = _excerpt_to_feishu_text(excerpt)
text_msg = f"📚 {title}"
if feishu_excerpt:
text_msg = f"{text_msg}\n\n{feishu_excerpt}"
if send_webhook_text(args.webhook, text_msg):
print("已发送前 6%% 正文到飞书群")
ok = send_webhook_image(args.webhook, image_key, title)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()