#!/usr/bin/env python3 """ render_x_like_posts.py — 将多条帖子渲染为一张 X-like 风格长图 PNG 推荐用法: python3 render_x_like_posts.py \ --author "OpenAI" \ --handle "@OpenAI" \ --post "第一条帖子" \ --post "第二条帖子" \ --out /path/to/workspace/tmp/x-like-posts.png 也支持: --posts-file posts.json posts.json 格式: ["post one", "post two"] 或 [{"text": "post one"}, {"text": "post two"}] """ import argparse import json import math import shutil import subprocess import sys import tempfile from datetime import datetime from html import escape from pathlib import Path from typing import Iterable from zoneinfo import ZoneInfo SKILL_DIR = Path(__file__).parent.parent TEMPLATE_PATH = SKILL_DIR / "assets" / "templates" / "x-like-posts.html" ICONS_DIR = SKILL_DIR / "assets" / "icons" FONTS_DIR = SKILL_DIR / "assets" / "fonts" CHROME_PATHS = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "google-chrome", "chromium", ] WIDTH = 900 MIN_HEIGHT = 1200 MAX_HEIGHT = 16000 TEXT_WIDTH_CHARS = 26 DISPLAY_TZ = ZoneInfo("Asia/Shanghai") DISPLAY_TZ_LABEL = "UTC+8" def find_chrome(): for path in CHROME_PATHS: if Path(path).exists() or shutil.which(path): return path return None def weighted_length(text: str) -> float: total = 0.0 for ch in text: if ch == "\n": total += 8.0 elif ord(ch) < 128: total += 0.55 else: total += 1.0 return total def estimate_post_height(text: str) -> int: paragraphs = [p for p in text.splitlines() if p.strip()] or [text] wrapped_lines = 0 for paragraph in paragraphs: logical_lines = max(1, math.ceil(weighted_length(paragraph) / TEXT_WIDTH_CHARS)) wrapped_lines += logical_lines wrapped_lines += max(0, len(paragraphs) - 1) return 120 + wrapped_lines * 48 def estimate_canvas_height(posts: Iterable[str]) -> int: total = 210 for post in posts: total += estimate_post_height(post) + 20 return max(MIN_HEIGHT, min(MAX_HEIGHT, total + 120)) def load_posts(args) -> list[dict]: posts = [] raw_posts = list(args.post) + list(args.tweet) if raw_posts: posts.extend([{"text": text.strip()} for text in raw_posts if text.strip()]) input_file = args.posts_file or args.tweets_file if input_file: raw = Path(input_file).read_text(encoding="utf-8") parsed = json.loads(raw) if not isinstance(parsed, list): sys.exit("posts file must be a JSON array") for item in parsed: if isinstance(item, str) and item.strip(): posts.append({"text": item.strip()}) elif isinstance(item, dict) and str(item.get("text", "")).strip(): posts.append( { "text": str(item["text"]).strip(), "created_at": str(item.get("created_at", "")).strip(), "url": str(item.get("url", "")).strip(), "favorite_count": item.get("favorite_count", 0), "retweet_count": item.get("retweet_count", 0), } ) if not posts: sys.exit("需要至少一条帖子:传 --post/--tweet 或 --posts-file/--tweets-file") return posts def text_to_html(text: str) -> str: parts = [] for block in text.splitlines(): if not block.strip(): continue parts.append(f"
{escape(block)}
") return "".join(parts) if parts else f"{escape(text)}
" def parse_created_at(created_at: str): if not created_at: return None try: return datetime.fromisoformat(created_at.replace("Z", "+00:00")) except ValueError: return None def format_created_at(created_at: str) -> str: if not created_at: return "" dt = parse_created_at(created_at) if not dt: return created_at local_dt = dt.astimezone(DISPLAY_TZ) return local_dt.strftime(f"%Y-%m-%d %H:%M {DISPLAY_TZ_LABEL}") def build_date_label(posts: list[dict]) -> str: created_times = [] for post in posts: dt = parse_created_at(str(post.get("created_at", "")).strip()) if dt: created_times.append(dt.astimezone(DISPLAY_TZ)) if not created_times: return "日期未知" created_times.sort(reverse=True) return created_times[0].strftime("%Y-%m-%d") def to_int(value) -> int: try: return int(value) except (TypeError, ValueError): return 0 def build_stats(post: dict) -> str: parts = [] created_at = format_created_at(str(post.get("created_at", "")).strip()) if created_at: parts.append(f'{escape(created_at)}') repost_count = to_int(post.get("retweet_count", 0)) favorite_count = to_int(post.get("favorite_count", 0)) if repost_count > 0: parts.append(f'{repost_count} RT') if favorite_count > 0: parts.append(f'{favorite_count} Likes') if str(post.get("url", "")).strip(): parts.append('x.com') return "".join(parts) if parts else 'Forwarded from X' def build_post_items(posts: list[dict], author: str, handle: str, avatar_path: str) -> str: total = len(posts) items = [] for index, post in enumerate(posts, 1): text = str(post.get("text", "")).strip() items.append( f"""