Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Search, analyze, and interact with Xiaohongshu (RedNote/小红书) content via a local MCP server and shell scripts.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/export-long-image.py
1#!/usr/bin/env python32"""3小红书帖子长图导出工具45用法:6python3 export-long-image.py --posts '<json>' --output output.jpg7python3 export-long-image.py --posts-file posts.json --output output.jpg89posts JSON 格式:10[11{12"title": "帖子标题",13"author": "作者名",14"stats": "1.3万赞 5171收藏",15"desc": "正文摘要,支持\\n换行",16"images": ["url1", "url2", ...],17"per_image_text": {18"1": "第2张图的说明文字(0-indexed)",19"3": "第4张图的说明文字"20}21},22...23]2425per_image_text 可选:如果原帖文字明确指向某张图,可以把说明放在对应图片上。26未指定 per_image_text 时,所有文字放在该帖第一张图前的文字块中。27"""2829import argparse30import json31import os32import sys33import tempfile34import urllib.request35from PIL import Image, ImageDraw, ImageFont3637# --- 配置 ---38WIDTH = 80039PAD = 2440LINE_SPACE = 1041FONT_CANDIDATES = [42"/System/Library/Fonts/STHeiti Medium.ttc",43"/System/Library/Fonts/Hiragino Sans GB.ttc",44"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",45"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",46"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",47]484950def find_font():51for path in FONT_CANDIDATES:52if os.path.exists(path):53return path54return None555657def load_font(path, size):58if path:59try:60return ImageFont.truetype(path, size, index=0)61except Exception:62pass63return ImageFont.load_default()646566def wrap_text(text, font, max_width, draw):67lines = []68for paragraph in text.split("\n"):69paragraph = paragraph.strip()70if not paragraph:71continue72current = ""73for char in paragraph:74test = current + char75bbox = draw.textbbox((0, 0), test, font=font)76if bbox[2] - bbox[0] > max_width:77if current:78lines.append(current)79current = char80else:81current = test82if current:83lines.append(current)84return lines858687def draw_lines(draw, lines, font, x, y, fill):88for line in lines:89draw.text((x, y), line, font=font, fill=fill)90bbox = draw.textbbox((0, 0), line, font=font)91y += (bbox[3] - bbox[1]) + LINE_SPACE92return y939495def measure_lines(lines, font, draw):96h = 097for line in lines:98bbox = draw.textbbox((0, 0), line if line else " ", font=font)99h += (bbox[3] - bbox[1]) + LINE_SPACE100return h101102103def make_text_block(title, author_line, desc, font_path, width):104"""白底黑字文字块,模仿小红书原样"""105title_font = load_font(font_path, 32)106author_font = load_font(font_path, 20)107body_font = load_font(font_path, 24)108109tmp = Image.new("RGB", (width, 10))110draw = ImageDraw.Draw(tmp)111max_w = width - PAD * 2112113title_lines = wrap_text(title, title_font, max_w, draw)114author_lines = [author_line] if author_line else []115desc_lines = wrap_text(desc, body_font, max_w, draw) if desc else []116117# 计算高度118total_h = PAD119total_h += measure_lines(title_lines, title_font, draw)120if author_lines:121total_h += 4122total_h += measure_lines(author_lines, author_font, draw)123if desc_lines:124total_h += 8125total_h += measure_lines(desc_lines, body_font, draw)126total_h += PAD127128# 绘制129block = Image.new("RGB", (width, total_h), (255, 255, 255))130draw = ImageDraw.Draw(block)131132y = PAD133y = draw_lines(draw, title_lines, title_font, PAD, y, (33, 33, 33))134if author_lines:135y += 4136y = draw_lines(draw, author_lines, author_font, PAD, y, (153, 153, 153))137if desc_lines:138y += 8139y = draw_lines(draw, desc_lines, body_font, PAD, y, (66, 66, 66))140141return block142143144def make_image_caption(text, font_path, width):145"""图片上方的小说明文字块"""146font = load_font(font_path, 20)147tmp = Image.new("RGB", (width, 10))148draw = ImageDraw.Draw(tmp)149lines = wrap_text(text, font, width - PAD * 2, draw)150151h = PAD + measure_lines(lines, font, draw) + 8152block = Image.new("RGB", (width, h), (245, 245, 245))153draw = ImageDraw.Draw(block)154draw_lines(draw, lines, font, PAD, PAD // 2, (100, 100, 100))155return block156157158def download_image(url, tmpdir, idx):159"""下载图片到临时目录"""160ext = ".webp"161path = os.path.join(tmpdir, f"img_{idx}{ext}")162try:163req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})164with urllib.request.urlopen(req, timeout=30) as resp:165with open(path, "wb") as f:166f.write(resp.read())167return path168except Exception as e:169print(f" 警告: 下载失败 {url[:60]}... ({e})", file=sys.stderr)170return None171172173def main():174parser = argparse.ArgumentParser(description="小红书帖子长图导出")175parser.add_argument("--posts", help="Posts JSON string")176parser.add_argument("--posts-file", help="Posts JSON file path")177parser.add_argument("--output", "-o", required=True, help="Output JPG path")178parser.add_argument("--width", type=int, default=800, help="Image width (default 800)")179parser.add_argument("--quality", type=int, default=88, help="JPEG quality (default 88)")180args = parser.parse_args()181182global WIDTH183WIDTH = args.width184185# 读取 posts 数据186if args.posts:187posts = json.loads(args.posts)188elif args.posts_file:189with open(args.posts_file, "r") as f:190posts = json.load(f)191else:192print("错误: 需要 --posts 或 --posts-file", file=sys.stderr)193sys.exit(1)194195font_path = find_font()196if not font_path:197print("警告: 未找到中文字体,文字可能显示异常", file=sys.stderr)198199sep = Image.new("RGB", (WIDTH, 3), (230, 230, 230))200pieces = []201202with tempfile.TemporaryDirectory() as tmpdir:203img_counter = 0204for pi, post in enumerate(posts):205title = post.get("title", "")206author = post.get("author", "")207stats = post.get("stats", "")208desc = post.get("desc", "")209images = post.get("images", [])210per_image_text = post.get("per_image_text", {})211212# 作者行213author_line = author214if stats:215author_line = f"{author} · {stats}" if author else stats216217# 主文字块218text_block = make_text_block(title, author_line, desc, font_path, WIDTH)219pieces.append(text_block)220221# 图片222for i, url in enumerate(images):223# 是否有针对这张图的说明224img_key = str(i)225if img_key in per_image_text:226caption_block = make_image_caption(per_image_text[img_key], font_path, WIDTH)227pieces.append(caption_block)228229img_path = download_image(url, tmpdir, img_counter)230img_counter += 1231if img_path:232try:233im = Image.open(img_path).convert("RGB")234ratio = WIDTH / im.width235im = im.resize((WIDTH, int(im.height * ratio)), Image.LANCZOS)236pieces.append(im)237except Exception as e:238print(f" 警告: 图片处理失败 ({e})", file=sys.stderr)239240# 帖子间分隔线241if pi < len(posts) - 1:242pieces.append(sep)243244if not pieces:245print("错误: 没有内容可拼接", file=sys.stderr)246sys.exit(1)247248total_h = sum(p.height for p in pieces)249long_img = Image.new("RGB", (WIDTH, total_h), (255, 255, 255))250y = 0251for p in pieces:252long_img.paste(p, (0, y))253y += p.height254255long_img.save(args.output, "JPEG", quality=args.quality)256print(f"完成: {args.output} ({WIDTH}x{total_h})")257258259if __name__ == "__main__":260main()261