400 lines
15 KiB
Python
400 lines
15 KiB
Python
#!/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()
|