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()
|