feat: 更新一堆 ai 初始化以及 skill
This commit is contained in:
260
.agents/skills/xiaohongshu/scripts/export-long-image.py
Normal file
260
.agents/skills/xiaohongshu/scripts/export-long-image.py
Normal file
@@ -0,0 +1,260 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user