357 lines
13 KiB
Python
357 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate a complete Threads page from one compact high-level JSON spec."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import copy
|
|
import json
|
|
import sys
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
|
|
import render_threads_page as renderer
|
|
|
|
REPLY_NAMES = ("林夏", "周野", "陈默", "顾念", "沈言", "宋可心", "许知夏", "苏小鹿")
|
|
REPLY_COLORS = (
|
|
("#5662f6", "#9f45d8"),
|
|
("#15a88b", "#2e88cc"),
|
|
("#f97316", "#db2777"),
|
|
("#0f7d78", "#51b35e"),
|
|
)
|
|
REPLY_TEMPLATES = (
|
|
"关于“{topic}”,这个表达很有意思。",
|
|
"我也在关注“{topic}”,期待看到后续更新。",
|
|
"从另一个角度看,“{topic}”还有很多值得讨论的细节。",
|
|
"这条串文把“{topic}”说得很清楚。",
|
|
"先收藏,也想听听大家对“{topic}”的看法。",
|
|
"内容不长,但“{topic}”这个重点已经交代得很完整。",
|
|
"这正是我最近在思考的“{topic}”问题。",
|
|
"希望评论区可以继续理性讨论“{topic}”。",
|
|
)
|
|
|
|
|
|
def _read_spec(path: Path) -> dict[str, object]:
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise renderer.DataValidationError(f"Invalid spec JSON: {exc}") from exc
|
|
if not isinstance(value, dict):
|
|
raise renderer.DataValidationError("Spec JSON must be an object")
|
|
return value
|
|
|
|
|
|
def _string(value: object, path: str, *, required: bool = False) -> str:
|
|
if value is None and not required:
|
|
return ""
|
|
if not isinstance(value, str) or (required and not value.strip()):
|
|
suffix = "a non-empty string" if required else "a string"
|
|
raise renderer.DataValidationError(f"{path} must be {suffix}")
|
|
return value.strip()
|
|
|
|
|
|
def _integer(value: object, path: str, *, default: int = 0) -> int:
|
|
if value is None:
|
|
return default
|
|
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
raise renderer.DataValidationError(f"{path} must be a non-negative integer")
|
|
return value
|
|
|
|
|
|
def _boolean(value: object, path: str, *, default: bool = False) -> bool:
|
|
if value is None:
|
|
return default
|
|
if not isinstance(value, bool):
|
|
raise renderer.DataValidationError(f"{path} must be a boolean")
|
|
return value
|
|
|
|
|
|
def _strings(value: object, path: str) -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
|
|
raise renderer.DataValidationError(f"{path} must be an array of non-empty strings")
|
|
return [item.strip() for item in value]
|
|
|
|
|
|
def _require_chinese(value: str, path: str) -> None:
|
|
if not any("\u3400" <= char <= "\u9fff" for char in value):
|
|
raise renderer.DataValidationError(f"{path} must contain Simplified Chinese reader-visible text")
|
|
|
|
|
|
def _reply_objects(
|
|
count: int,
|
|
topics: list[str],
|
|
custom_replies: list[object],
|
|
) -> list[dict[str, object]]:
|
|
if not 0 <= count <= 8:
|
|
raise renderer.DataValidationError("displayReplyCount must be between 0 and 8")
|
|
if len(custom_replies) > count:
|
|
raise renderer.DataValidationError("displayReplyCount cannot be smaller than replies.length")
|
|
replies: list[dict[str, object]] = []
|
|
for index in range(count):
|
|
name = REPLY_NAMES[index % len(REPLY_NAMES)]
|
|
reply: dict[str, object] = {
|
|
"author": {
|
|
"name": name,
|
|
"initial": name[:1],
|
|
"colors": list(REPLY_COLORS[index % len(REPLY_COLORS)]),
|
|
},
|
|
"time": f"{index + 1} 分钟",
|
|
"text": REPLY_TEMPLATES[index % len(REPLY_TEMPLATES)].format(topic=topics[index % len(topics)]),
|
|
"metrics": {
|
|
"likes": max(0, (count - index) * 9),
|
|
"replies": index % 4,
|
|
"reposts": index % 2,
|
|
},
|
|
}
|
|
if index < len(custom_replies):
|
|
custom = custom_replies[index]
|
|
if isinstance(custom, str):
|
|
reply["text"] = custom.strip()
|
|
elif isinstance(custom, Mapping):
|
|
reply["text"] = _string(
|
|
custom.get("text"),
|
|
f"replies[{index}].text",
|
|
required=True,
|
|
)
|
|
if "author" in custom:
|
|
author_name = _string(
|
|
custom.get("author"),
|
|
f"replies[{index}].author",
|
|
required=True,
|
|
)
|
|
reply["author"]["name"] = author_name
|
|
reply["author"]["initial"] = author_name[:1]
|
|
if "time" in custom:
|
|
reply["time"] = _string(
|
|
custom.get("time"),
|
|
f"replies[{index}].time",
|
|
required=True,
|
|
)
|
|
metrics = reply["metrics"]
|
|
for key in ("likes", "replies", "reposts"):
|
|
if key in custom:
|
|
metrics[key] = _integer(
|
|
custom.get(key),
|
|
f"replies[{index}].{key}",
|
|
)
|
|
else:
|
|
raise renderer.DataValidationError(f"replies[{index}] must be a string or object")
|
|
_require_chinese(str(reply["text"]), f"replies[{index}].text")
|
|
replies.append(reply)
|
|
return replies
|
|
|
|
|
|
def _media(value: object, path: str) -> dict[str, object] | None:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, Mapping):
|
|
raise renderer.DataValidationError(f"{path} must be an object or null")
|
|
media_type = _string(value.get("type"), f"{path}.type") or "image"
|
|
if media_type not in {"image", "video", "webpage"}:
|
|
raise renderer.DataValidationError(f"{path}.type must be image, video, or webpage")
|
|
media: dict[str, object] = {"type": media_type}
|
|
if media_type == "webpage":
|
|
media["pageUrl"] = _string(
|
|
value.get("pageUrl"),
|
|
f"{path}.pageUrl",
|
|
required=True,
|
|
)
|
|
media["title"] = _string(
|
|
value.get("title"),
|
|
f"{path}.title",
|
|
required=True,
|
|
)
|
|
media["description"] = _string(
|
|
value.get("description"),
|
|
f"{path}.description",
|
|
required=True,
|
|
)
|
|
else:
|
|
media["mediaUrl"] = _string(
|
|
value.get("url"),
|
|
f"{path}.url",
|
|
required=True,
|
|
)
|
|
media["alt"] = _string(value.get("alt"), f"{path}.alt") or "用户提供的媒体"
|
|
if value.get("pageUrl"):
|
|
media["pageUrl"] = _string(value.get("pageUrl"), f"{path}.pageUrl")
|
|
return media
|
|
|
|
|
|
def build_document(spec: dict[str, object]) -> dict[str, object]:
|
|
allowed = {
|
|
"accountName",
|
|
"handle",
|
|
"verified",
|
|
"description",
|
|
"pageTitle",
|
|
"topic",
|
|
"replyTopics",
|
|
"displayReplyCount",
|
|
"totalReplyCount",
|
|
"replies",
|
|
"postText",
|
|
"posts",
|
|
"media",
|
|
"likes",
|
|
"reposts",
|
|
"following",
|
|
"followers",
|
|
"threadCount",
|
|
}
|
|
unknown = sorted(set(spec) - allowed)
|
|
if unknown:
|
|
raise renderer.DataValidationError("Unknown spec keys: " + ", ".join(unknown))
|
|
account_name = _string(spec.get("accountName"), "accountName", required=True)
|
|
handle = _string(spec.get("handle"), "handle") or "@threads_user"
|
|
topic = _string(spec.get("topic"), "topic") or account_name
|
|
reply_topics = _strings(spec.get("replyTopics"), "replyTopics") or [topic]
|
|
raw_posts = spec.get("posts")
|
|
if raw_posts is None:
|
|
raw_posts = [
|
|
{
|
|
"text": _string(spec.get("postText"), "postText", required=True),
|
|
"media": spec.get("media") if "media" in spec else "__example__",
|
|
"likes": spec.get("likes", 0),
|
|
"reposts": spec.get("reposts", 0),
|
|
}
|
|
]
|
|
if not isinstance(raw_posts, list) or not 1 <= len(raw_posts) <= 10:
|
|
raise renderer.DataValidationError("posts must contain between 1 and 10 objects")
|
|
if not all(isinstance(item, Mapping) for item in raw_posts):
|
|
raise renderer.DataValidationError("posts must contain only objects")
|
|
|
|
data = renderer._read_json(renderer.DEFAULT_DATA_PATH)
|
|
defaults = data["threads"]["items"]
|
|
custom_replies = spec.get("replies", [])
|
|
if not isinstance(custom_replies, list):
|
|
raise renderer.DataValidationError("replies must be an array")
|
|
display_reply_count = _integer(
|
|
spec.get("displayReplyCount"),
|
|
"displayReplyCount",
|
|
default=max(3, len(custom_replies)),
|
|
)
|
|
total_reply_count = _integer(
|
|
spec.get("totalReplyCount"),
|
|
"totalReplyCount",
|
|
default=display_reply_count,
|
|
)
|
|
if total_reply_count < display_reply_count:
|
|
raise renderer.DataValidationError("totalReplyCount cannot be smaller than displayReplyCount")
|
|
|
|
items: list[dict[str, object]] = []
|
|
for index, raw_post in enumerate(raw_posts):
|
|
post = copy.deepcopy(defaults[index % len(defaults)])
|
|
text = _string(
|
|
raw_post.get("text"),
|
|
f"posts[{index}].text",
|
|
required=True,
|
|
)
|
|
_require_chinese(text, f"posts[{index}].text")
|
|
author_name = _string(raw_post.get("authorName"), f"posts[{index}].authorName") or account_name
|
|
post["id"] = f"thread-{index + 1:03d}"
|
|
post["author"] = {
|
|
"name": author_name,
|
|
"handle": _string(raw_post.get("handle"), f"posts[{index}].handle") or handle,
|
|
"initial": author_name[:1],
|
|
"colors": post["author"]["colors"],
|
|
"verified": _boolean(
|
|
raw_post.get("verified"),
|
|
f"posts[{index}].verified",
|
|
default=_boolean(spec.get("verified"), "verified"),
|
|
),
|
|
}
|
|
post["time"] = _string(raw_post.get("time"), f"posts[{index}].time") or "刚刚"
|
|
post["text"] = text
|
|
post["followable"] = _boolean(
|
|
raw_post.get("followable"),
|
|
f"posts[{index}].followable",
|
|
)
|
|
post["quote"] = None
|
|
if "media" in raw_post and raw_post.get("media") != "__example__":
|
|
post["media"] = _media(raw_post.get("media"), f"posts[{index}].media")
|
|
if index == 0:
|
|
replies = _reply_objects(display_reply_count, reply_topics, custom_replies)
|
|
replies_metric = total_reply_count
|
|
else:
|
|
replies = []
|
|
replies_metric = _integer(
|
|
raw_post.get("totalReplyCount"),
|
|
f"posts[{index}].totalReplyCount",
|
|
)
|
|
post["replies"] = replies
|
|
post["metrics"] = {
|
|
"likes": _integer(raw_post.get("likes"), f"posts[{index}].likes"),
|
|
"replies": replies_metric,
|
|
"reposts": _integer(raw_post.get("reposts"), f"posts[{index}].reposts"),
|
|
}
|
|
post["preview"] = f"查看全部 {replies_metric} 条回复"
|
|
items.append(post)
|
|
|
|
data["site"]["pageTitle"] = _string(spec.get("pageTitle"), "pageTitle") or f"Threads · {account_name}"
|
|
data["site"]["description"] = _string(spec.get("description"), "description") or f"{account_name} 的 Threads 中文页面。"
|
|
data["site"]["currentUser"].update(
|
|
{
|
|
"name": account_name,
|
|
"handle": handle,
|
|
"initial": account_name[:1],
|
|
"stats": {
|
|
"following": str(spec.get("following", "0")),
|
|
"followers": str(spec.get("followers", "0")),
|
|
"threads": str(spec.get("threadCount", len(items))),
|
|
},
|
|
}
|
|
)
|
|
data["threads"]["items"] = items
|
|
return data
|
|
|
|
|
|
def _next_paths(output_dir: Path) -> tuple[Path, Path]:
|
|
for index in range(1, 1001):
|
|
stem = "threads-page" if index == 1 else f"threads-page-{index}"
|
|
data_path = output_dir / f"{stem}-data.json"
|
|
html_path = output_dir / f"{stem}.html"
|
|
if not data_path.exists() and not html_path.exists():
|
|
return data_path, html_path
|
|
raise renderer.DataValidationError("Unable to allocate output filenames")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--spec", type=Path, required=True)
|
|
parser.add_argument("--output-dir", type=Path, required=True)
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
data = build_document(_read_spec(args.spec))
|
|
summary = renderer.validate_document(data)
|
|
rendered = renderer.render_document(
|
|
data,
|
|
renderer.TEMPLATE_PATH.read_text(encoding="utf-8"),
|
|
renderer.RUNTIME_PATH.read_text(encoding="utf-8"),
|
|
)
|
|
data_path, html_path = _next_paths(args.output_dir)
|
|
renderer._write_new(
|
|
data_path,
|
|
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
)
|
|
renderer._write_new(html_path, rendered)
|
|
except (renderer.DataValidationError, OSError, UnicodeError) as exc:
|
|
renderer._emit({"status": "error", "action": "fast-threads-page", "error": str(exc)})
|
|
return 2
|
|
renderer._emit(
|
|
{
|
|
"status": "ok",
|
|
"action": "fast-threads-page",
|
|
"data": str(data_path),
|
|
"html": str(html_path),
|
|
**summary,
|
|
}
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|