Files
xiaohongshu-wiki/.agents/skills/xiaohongshu/scripts/export-long-image.py
2026-04-15 09:40:15 +08:00

261 lines
8.0 KiB
Python
Raw 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
"""
小红书帖子长图导出工具
用法:
python3 export-long-image.py --posts '<json>' --output output.jpg
python3 export-long-image.py --posts-file posts.json --output output.jpg
posts JSON 格式:
[
{
"title": "帖子标题",
"author": "作者名",
"stats": "1.3万赞 5171收藏",
"desc": "正文摘要,支持\\n换行",
"images": ["url1", "url2", ...],
"per_image_text": {
"1": "第2张图的说明文字0-indexed",
"3": "第4张图的说明文字"
}
},
...
]
per_image_text 可选:如果原帖文字明确指向某张图,可以把说明放在对应图片上。
未指定 per_image_text 时,所有文字放在该帖第一张图前的文字块中。
"""
import argparse
import json
import os
import sys
import tempfile
import urllib.request
from PIL import Image, ImageDraw, ImageFont
# --- 配置 ---
WIDTH = 800
PAD = 24
LINE_SPACE = 10
FONT_CANDIDATES = [
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
]
def find_font():
for path in FONT_CANDIDATES:
if os.path.exists(path):
return path
return None
def load_font(path, size):
if path:
try:
return ImageFont.truetype(path, size, index=0)
except Exception:
pass
return ImageFont.load_default()
def wrap_text(text, font, max_width, draw):
lines = []
for paragraph in text.split("\n"):
paragraph = paragraph.strip()
if not paragraph:
continue
current = ""
for char in paragraph:
test = current + char
bbox = draw.textbbox((0, 0), test, font=font)
if bbox[2] - bbox[0] > max_width:
if current:
lines.append(current)
current = char
else:
current = test
if current:
lines.append(current)
return lines
def draw_lines(draw, lines, font, x, y, fill):
for line in lines:
draw.text((x, y), line, font=font, fill=fill)
bbox = draw.textbbox((0, 0), line, font=font)
y += (bbox[3] - bbox[1]) + LINE_SPACE
return y
def measure_lines(lines, font, draw):
h = 0
for line in lines:
bbox = draw.textbbox((0, 0), line if line else " ", font=font)
h += (bbox[3] - bbox[1]) + LINE_SPACE
return h
def make_text_block(title, author_line, desc, font_path, width):
"""白底黑字文字块,模仿小红书原样"""
title_font = load_font(font_path, 32)
author_font = load_font(font_path, 20)
body_font = load_font(font_path, 24)
tmp = Image.new("RGB", (width, 10))
draw = ImageDraw.Draw(tmp)
max_w = width - PAD * 2
title_lines = wrap_text(title, title_font, max_w, draw)
author_lines = [author_line] if author_line else []
desc_lines = wrap_text(desc, body_font, max_w, draw) if desc else []
# 计算高度
total_h = PAD
total_h += measure_lines(title_lines, title_font, draw)
if author_lines:
total_h += 4
total_h += measure_lines(author_lines, author_font, draw)
if desc_lines:
total_h += 8
total_h += measure_lines(desc_lines, body_font, draw)
total_h += PAD
# 绘制
block = Image.new("RGB", (width, total_h), (255, 255, 255))
draw = ImageDraw.Draw(block)
y = PAD
y = draw_lines(draw, title_lines, title_font, PAD, y, (33, 33, 33))
if author_lines:
y += 4
y = draw_lines(draw, author_lines, author_font, PAD, y, (153, 153, 153))
if desc_lines:
y += 8
y = draw_lines(draw, desc_lines, body_font, PAD, y, (66, 66, 66))
return block
def make_image_caption(text, font_path, width):
"""图片上方的小说明文字块"""
font = load_font(font_path, 20)
tmp = Image.new("RGB", (width, 10))
draw = ImageDraw.Draw(tmp)
lines = wrap_text(text, font, width - PAD * 2, draw)
h = PAD + measure_lines(lines, font, draw) + 8
block = Image.new("RGB", (width, h), (245, 245, 245))
draw = ImageDraw.Draw(block)
draw_lines(draw, lines, font, PAD, PAD // 2, (100, 100, 100))
return block
def download_image(url, tmpdir, idx):
"""下载图片到临时目录"""
ext = ".webp"
path = os.path.join(tmpdir, f"img_{idx}{ext}")
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
with open(path, "wb") as f:
f.write(resp.read())
return path
except Exception as e:
print(f" 警告: 下载失败 {url[:60]}... ({e})", file=sys.stderr)
return None
def main():
parser = argparse.ArgumentParser(description="小红书帖子长图导出")
parser.add_argument("--posts", help="Posts JSON string")
parser.add_argument("--posts-file", help="Posts JSON file path")
parser.add_argument("--output", "-o", required=True, help="Output JPG path")
parser.add_argument("--width", type=int, default=800, help="Image width (default 800)")
parser.add_argument("--quality", type=int, default=88, help="JPEG quality (default 88)")
args = parser.parse_args()
global WIDTH
WIDTH = args.width
# 读取 posts 数据
if args.posts:
posts = json.loads(args.posts)
elif args.posts_file:
with open(args.posts_file, "r") as f:
posts = json.load(f)
else:
print("错误: 需要 --posts 或 --posts-file", file=sys.stderr)
sys.exit(1)
font_path = find_font()
if not font_path:
print("警告: 未找到中文字体,文字可能显示异常", file=sys.stderr)
sep = Image.new("RGB", (WIDTH, 3), (230, 230, 230))
pieces = []
with tempfile.TemporaryDirectory() as tmpdir:
img_counter = 0
for pi, post in enumerate(posts):
title = post.get("title", "")
author = post.get("author", "")
stats = post.get("stats", "")
desc = post.get("desc", "")
images = post.get("images", [])
per_image_text = post.get("per_image_text", {})
# 作者行
author_line = author
if stats:
author_line = f"{author} · {stats}" if author else stats
# 主文字块
text_block = make_text_block(title, author_line, desc, font_path, WIDTH)
pieces.append(text_block)
# 图片
for i, url in enumerate(images):
# 是否有针对这张图的说明
img_key = str(i)
if img_key in per_image_text:
caption_block = make_image_caption(per_image_text[img_key], font_path, WIDTH)
pieces.append(caption_block)
img_path = download_image(url, tmpdir, img_counter)
img_counter += 1
if img_path:
try:
im = Image.open(img_path).convert("RGB")
ratio = WIDTH / im.width
im = im.resize((WIDTH, int(im.height * ratio)), Image.LANCZOS)
pieces.append(im)
except Exception as e:
print(f" 警告: 图片处理失败 ({e})", file=sys.stderr)
# 帖子间分隔线
if pi < len(posts) - 1:
pieces.append(sep)
if not pieces:
print("错误: 没有内容可拼接", file=sys.stderr)
sys.exit(1)
total_h = sum(p.height for p in pieces)
long_img = Image.new("RGB", (WIDTH, total_h), (255, 255, 255))
y = 0
for p in pieces:
long_img.paste(p, (0, y))
y += p.height
long_img.save(args.output, "JPEG", quality=args.quality)
print(f"完成: {args.output} ({WIDTH}x{total_h})")
if __name__ == "__main__":
main()