Files
2026-07-13 12:29:17 +08:00

413 lines
16 KiB
Python
Raw Permalink 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.
"""发完笔记后跑一次:抓数据 + 评论 → 生成 report.md。
用法:
python review.py # 交互式选笔记
python review.py login # 仅登录(首次扫码)
python review.py list # 列最近笔记(验证登录态)
python review.py note <note_id> [script.txt] # 直接指定笔记
python review.py archive <notes.json> [output_root] [limit] # 批量归档正文+图片
python review.py summarize [out_dir] # 账号级汇总(基于最近 50 条)
archive / summarize 能力来自 xhs-analytics 的公开页解析,可无登录抓取正文与图片。
"""
from __future__ import annotations
import asyncio
import io
import json
import os
import re
import sys
import time
from collections import defaultdict
from pathlib import Path
import crawler
import renderer
from paths import runtime_project_root, videos_dir
LOG_FILE: io.TextIOWrapper | None = None
def _log(msg: str) -> None:
ts = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}"
try:
print(line)
except Exception:
pass
if LOG_FILE is not None:
try:
LOG_FILE.write(line + "\n")
LOG_FILE.flush()
except Exception:
pass
def _parse_note_arg(arg: str) -> tuple[str, str | None]:
"""接受 note_id 或完整笔记 URL(含 xsec_token)。返回 (note_id, note_url|None)。"""
arg = arg.strip().strip("'").strip('"')
if arg.startswith("http"):
m = re.search(r"/(?:explore|discovery/item)/([0-9a-zA-Z]+)", arg)
note_id = m.group(1) if m else arg
return note_id, arg
return arg, None
def _prompt(msg: str) -> str:
try:
return input(msg).strip()
except EOFError:
return ""
def _pick_note(notes: list[dict]) -> dict | None:
if not notes:
print("未抓到笔记列表。请确认创作者中心已登录,或页面结构已变,需要更新 crawler。")
return None
print("\n最近笔记:")
for i, n in enumerate(notes):
t = renderer._fmt_time(n.get("create_time", 0))
title = (n.get("title") or "").replace("\n", " ")[:40]
print(f" [{i}] {t} | 曝光 {renderer._fmt_num(n.get('view_count'))} | {title}")
choice = _prompt("\n选择序号(回车取消):")
if not choice.isdigit():
return None
idx = int(choice)
if 0 <= idx < len(notes):
return notes[idx]
return None
async def run() -> None:
active_videos_dir = videos_dir()
active_videos_dir.mkdir(parents=True, exist_ok=True)
print("[选笔记] 打开创作者中心拉列表……")
sess = await crawler.Session.open()
try:
notes = await crawler.fetch_recent_notes(sess, limit=10)
finally:
await sess.close()
note = _pick_note(notes)
if not note:
print("已取消。")
return
script_raw = _prompt("把稿子 txt 拖进来(或回车跳过):")
script_path: str | None = None
if script_raw.strip():
p = Path(script_raw.strip().strip("'").strip('"').replace("\\ ", " ")).expanduser()
if p.is_file():
script_path = str(p)
else:
print(f"[警告] 找不到 {p},稿子留空。")
await run_with_id(note["note_id"], script_path)
async def run_with_id(note_arg: str, script_path: str | None) -> None:
active_videos_dir = videos_dir()
active_videos_dir.mkdir(parents=True, exist_ok=True)
note_id, note_url = _parse_note_arg(note_arg)
script = ""
if script_path:
p = Path(script_path).expanduser()
if p.is_file():
script = p.read_text(encoding="utf-8", errors="ignore")
print(f"稿子:{p.name}{len(script)} 字符)")
else:
print(f"[警告] 找不到稿子 {p}")
print(f"[抓取] 笔记 {note_id}" + ("(带 token URL" if note_url else ""))
result = await crawler.fetch_all(note_id, note_url=note_url)
note = result["note"]
comments = result["comments"]
out_dir = renderer.output_dir_for(note, active_videos_dir)
out_dir.mkdir(parents=True, exist_ok=True)
if script:
(out_dir / "script.txt").write_text(script, encoding="utf-8")
# 可选:下载图片到本地(默认关闭;通过环境变量 XHS_DOWNLOAD_IMAGES=1 开启)
if os.environ.get("XHS_DOWNLOAD_IMAGES", "").lower() in ("1", "true", "yes"):
img_dir = out_dir / "images"
img_dir.mkdir(parents=True, exist_ok=True)
paths: list[str] = []
for i, url in enumerate(note.get("images", []), 1):
dest = img_dir / f"{i:02d}"
if await crawler.download_image(url, dest):
# download_image 会根据 Content-Type 修正扩展名,这里找回真实文件名
actual = next(dest.parent.glob(f"{dest.name}.*"), dest)
paths.append(str(actual.relative_to(out_dir)).replace("\\", "/"))
if paths:
note["image_paths"] = paths
md = renderer.render_report(note, script, comments)
report = out_dir / "report.md"
report.write_text(md, encoding="utf-8")
print(f"\n{report}")
# ---------------------------------------------------------------------------
# archive: 批量归档已发布笔记的正文、图片、标签(公开页解析,无登录)
# ---------------------------------------------------------------------------
async def run_archive(input_path: Path, output_root: Path, limit: int | None = None,
max_comments: int = 0) -> None:
global LOG_FILE
output_root.mkdir(parents=True, exist_ok=True)
log_path = output_root / f"archive_{time.strftime('%Y%m%d_%H%M%S')}.log"
LOG_FILE = open(log_path, "w", encoding="utf-8", errors="replace")
try:
_log(f"启动归档: input={input_path}, output={output_root}")
notes = json.loads(input_path.read_text(encoding="utf-8"))
if not isinstance(notes, list):
raise ValueError("输入 JSON 必须是笔记列表")
notes = sorted(notes, key=lambda x: x.get("time", ""), reverse=True)
if limit:
notes = notes[:limit]
results: list[dict] = []
for i, note in enumerate(notes, 1):
note_id = note.get("id") or note.get("note_id")
xsec = note.get("xsecToken") or note.get("xsec_token", "")
title = note.get("title", "")
if not note_id or not xsec:
results.append({
"success": False,
"note_id": note_id,
"error": "缺少 note_id 或 xsec_token",
})
continue
out_dir = output_root / note_id
_log(f"({i}/{len(notes)}) 归档 {note_id} {title[:30]}...")
try:
public = await asyncio.to_thread(crawler.fetch_public_note, note_id, xsec)
if not public.get("success"):
err = public.get("error", "公开页解析失败")
_log(f" -> 失败: {err}")
results.append({"success": False, "note_id": note_id, "error": err})
continue
if max_comments > 0:
comments = await asyncio.to_thread(
crawler.fetch_public_comments, note_id, xsec, max_comments
)
public["comments"] = comments
public["comments_fetched"] = len(comments)
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "note_detail.json").write_text(
json.dumps(public, ensure_ascii=False, indent=2), encoding="utf-8"
)
img_dir = out_dir / "images"
img_dir.mkdir(parents=True, exist_ok=True)
img_count = 0
for idx, url in enumerate(public.get("images", []), 1):
if await crawler.download_image(url, img_dir / f"{idx:02d}"):
img_count += 1
summary = {
"success": True,
"note_id": note_id,
"output_dir": str(out_dir),
"image_count": img_count,
"comment_count": public.get("comments_fetched", 0),
}
_log(f" -> 完成: 图片 {img_count} 张")
results.append(summary)
except Exception as e:
_log(f" -> 异常: {e}")
results.append({"success": False, "note_id": note_id, "error": str(e)})
if i < len(notes):
await asyncio.sleep(0.5)
summary_path = output_root / f"archive_summary_{time.strftime('%Y%m%d_%H%M%S')}.json"
summary_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
success = sum(1 for r in results if r.get("success"))
_log(f"完成: 成功 {success}, 失败 {len(results) - success}, 总计 {len(results)}")
_log(f"汇总: {summary_path}")
finally:
if LOG_FILE is not None:
LOG_FILE.close()
LOG_FILE = None
# ---------------------------------------------------------------------------
# summarize: 账号级汇总(基于创作者中心最近笔记 + 公开页标签)
# ---------------------------------------------------------------------------
def _series_of(note: dict) -> str:
"""按标签/标题关键字给笔记归系列,简单兜底。"""
tags = note.get("tags") or []
if tags:
return tags[0]
title = note.get("title", "")
# 常见系列关键字(可扩展)
for kw in ("Agent", "RAG", "CS336", "Lec", "MCP", "LLM"):
if kw.lower() in title.lower():
return kw
return "其他"
def _summarize(notes: list[dict]) -> str:
lines: list[str] = []
lines.append("# xhs-explore 账号汇总\n")
lines.append(f"生成时间:{time.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"样本数:{len(notes)}\n")
total_view = sum(n.get("view_count", 0) or 0 for n in notes)
total_like = sum(n.get("like_count", 0) or 0 for n in notes)
total_collect = sum(n.get("collect_count", 0) or 0 for n in notes)
total_comment = sum(n.get("comment_count", 0) or 0 for n in notes)
lines.append("## 整体数据\n")
lines.append(f"- 总浏览:{total_view:,}")
lines.append(f"- 总点赞:{total_like:,}")
lines.append(f"- 总收藏:{total_collect:,}")
lines.append(f"- 总评论:{total_comment:,}")
if total_view:
lines.append(f"- 平均赞阅比:{total_like / total_view * 100:.2f}%")
lines.append(f"- 平均藏阅比:{total_collect / total_view * 100:.2f}%")
lines.append("")
# 按浏览 Top 10
sorted_notes = sorted(notes, key=lambda x: x.get("view_count", 0) or 0, reverse=True)[:10]
lines.append("## 浏览量 Top 10\n")
lines.append("| 排名 | 标题 | 浏览 | 点赞 | 收藏 | 评论 |")
lines.append("|------|------|------|------|------|------|")
for idx, n in enumerate(sorted_notes, 1):
title = (n.get("title") or "")[:30]
lines.append(
f"| {idx} | {title} | {n.get('view_count', 0):,} | "
f"{n.get('like_count', 0)} | {n.get('collect_count', 0)} | {n.get('comment_count', 0)} |"
)
lines.append("")
# 系列汇总
series: dict[str, list[dict]] = defaultdict(list)
for n in notes:
series[_series_of(n)].append(n)
lines.append("## 系列汇总(按首标签/标题关键字)\n")
lines.append("| 系列 | 篇数 | 总浏览 | 均浏览 | 赞阅比 | 藏阅比 | 评阅比 |")
lines.append("|------|------|--------|--------|--------|--------|--------|")
for name, items in sorted(series.items(), key=lambda x: sum(i.get("view_count", 0) or 0 for i in x[1]), reverse=True):
views = sum(i.get("view_count", 0) or 0 for i in items)
likes = sum(i.get("like_count", 0) or 0 for i in items)
collects = sum(i.get("collect_count", 0) or 0 for i in items)
comments = sum(i.get("comment_count", 0) or 0 for i in items)
avg_view = views / len(items) if items else 0
like_rate = likes / views * 100 if views else 0
collect_rate = collects / views * 100 if views else 0
comment_rate = comments / views * 100 if views else 0
lines.append(
f"| {name} | {len(items)} | {views:,} | {avg_view:.0f} | "
f"{like_rate:.2f}% | {collect_rate:.2f}% | {comment_rate:.2f}% |"
)
lines.append("")
lines.append(
"> 提示:系列判定优先取公开页标签,无标签时按标题关键字兜底。"
"可通过 `review.py archive` 批量归档后人工校准。\n"
)
return "\n".join(lines)
async def run_summarize(out_dir: Path) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
print("[summarize] 拉取创作者中心最近 50 条笔记……")
# summarize 是批量后台命令,用 headless 避免弹窗打扰
sess = await crawler.Session.open(headless=True)
try:
notes = await crawler.fetch_recent_notes(sess, limit=50)
finally:
await sess.close()
# 用公开页补全标签/正文/图片(失败也不阻塞)
for n in notes:
xsec = n.get("xsec_token") or ""
if not xsec:
continue
try:
public = await asyncio.to_thread(crawler.fetch_public_note, n["note_id"], xsec)
crawler._merge_public_note(n, public)
except Exception:
pass
md = _summarize(notes)
out_path = out_dir / f"xhs_summary_{time.strftime('%Y%m%d_%H%M%S')}.md"
out_path.write_text(md, encoding="utf-8")
print(f"\n{out_path}")
def _usage() -> str:
return """
用法:
python review.py 交互式选笔记
python review.py login 扫码登录
python review.py list 列最近笔记
python review.py note <note_id> [script.txt]
python review.py archive <notes.json> [output_root] [limit]
python review.py summarize [out_dir]
""".strip()
def main() -> None:
# Windows Git Bash 等默认 GBK 控制台,emoji/中文 print 会崩溃,强制 UTF-8 输出
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
if len(sys.argv) > 1 and sys.argv[1] == "login":
asyncio.run(crawler.ensure_login())
return
if len(sys.argv) > 1 and sys.argv[1] == "note":
if len(sys.argv) < 3:
print("用法:python review.py note <note_id> [script.txt]")
sys.exit(2)
note_id = sys.argv[2]
script_path = sys.argv[3] if len(sys.argv) > 3 else None
asyncio.run(run_with_id(note_id, script_path))
return
if len(sys.argv) > 1 and sys.argv[1] == "list":
async def _list() -> None:
sess = await crawler.Session.open()
try:
notes = await crawler.fetch_recent_notes(sess, limit=20)
finally:
await sess.close()
for i, n in enumerate(notes):
t = renderer._fmt_time(n.get("create_time", 0))
title = (n.get("title") or "").replace("\n", " ")[:50]
print(f"[{i}] {n['note_id']} {t} 曝光{renderer._fmt_num(n.get('view_count'))} {title}")
asyncio.run(_list())
return
if len(sys.argv) > 1 and sys.argv[1] == "archive":
if len(sys.argv) < 3:
print(_usage())
sys.exit(2)
input_path = Path(sys.argv[2].strip('"').strip("'")).expanduser().resolve()
output_root = Path(sys.argv[3]).expanduser().resolve() if len(sys.argv) > 3 else runtime_project_root() / "data" / "raw" / "notes" / "archive"
limit = int(sys.argv[4]) if len(sys.argv) > 4 else None
asyncio.run(run_archive(input_path, output_root, limit=limit))
return
if len(sys.argv) > 1 and sys.argv[1] == "summarize":
out_dir = Path(sys.argv[2]).expanduser().resolve() if len(sys.argv) > 2 else runtime_project_root() / "reports"
asyncio.run(run_summarize(out_dir))
return
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
print(_usage())
return
asyncio.run(run())
if __name__ == "__main__":
main()