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