项目文件夹

文件
2026-07-13 12:29:17 +08:00

726 行
29 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""小红书创作者中心 + 前台评论抓取。
登录一次后,Cookie 持久化在 .auth-xhs/,之后直接复用。
一次抓取共享一个 Chromium 会话,稳定性优于每步一个进程。
设计原则(和 douyin-session 一致):
- 不逆向 x-s / x-t 签名、不伪造请求——用登录态浏览器,让页面自己发带签名的请求,
我们只被动拦截返回的 JSON。
- 创作者**自己的**笔记数据走 galaxy 接口(不需要 xsec_token),是最稳的主路。
- 评论走前台 web API(需要 xsec_token),让页面自己导航触发带 token 的请求;
拿不到就优雅降级(report.md 标 comments_unavailable,cheat-retro 回落到 manual 粘评论)。
本文件新增的能力(来自 xhs-analytics 的公开页解析):
- fetch_public_note: 无登录解析 explore 页面 __INITIAL_STATE__,拿正文 / 图片 / 标签。
- fetch_public_comments: 公开页兜底 top 评论。
- download_image: 下载笔记图片到本地。
"""
from __future__ import annotations
import asyncio
import json
import re
import urllib.parse
from pathlib import Path
from typing import Any
import requests
from playwright.async_api import BrowserContext, Page, Response, async_playwright
from paths import auth_dir, debug_dir
CREATOR_HOME = "https://creator.xiaohongshu.com/new/home"
CREATOR_NOTE_MANAGER = "https://creator.xiaohongshu.com/new/note-manager"
# galaxy 接口路径片段——宽松匹配,接口偶有版本号变化
GALAXY_NOTE_LIST_KEYS = (
# 松匹配后缀,兼容 /api/galaxy/creator/... 与 /api/galaxy/v2/creator/...(实测是 v2
"/creator/note/user/posted",
)
GALAXY_NOTE_STATS_KEYS = (
"/api/galaxy/creator/data/note_stats",
"/api/galaxy/creator/data/note_detail",
)
# 前台 web API
FEED_KEY = "/api/sns/web/v1/feed"
COMMENT_KEY = "/api/sns/web/v2/comment/page"
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
)
class Session:
"""单浏览器会话,按顺序跑多步抓取。"""
def __init__(self, ctx: BrowserContext, pw: Any) -> None:
self.ctx = ctx
self.pw = pw
@classmethod
async def open(cls, headless: bool = False) -> "Session":
pw = await async_playwright().start()
auth_path = auth_dir()
auth_path.mkdir(parents=True, exist_ok=True)
common_kwargs = {
"user_data_dir": str(auth_path),
"headless": headless,
"viewport": {"width": 1440, "height": 900},
"args": ["--disable-blink-features=AutomationControlled"],
}
try:
ctx = await pw.chromium.launch_persistent_context(**common_kwargs)
except Exception as exc:
# 部分环境只装了系统 Chrome,没下载 Playwright Chromium,fallback 到 channel=chrome
try:
ctx = await pw.chromium.launch_persistent_context(
**common_kwargs, channel="chrome"
)
except Exception:
raise exc
return cls(ctx, pw)
async def close(self) -> None:
try:
await self.ctx.close()
finally:
await self.pw.stop()
# 创作者中心登录凭证——扫码登 creator.xiaohongshu.com 产生的就是这些(galaxy 主路只需它们)。
# 注意:创作者中心登录 *不* 产生 web_session(那是主站 www 前台 cookie),早期版本只认
# web_session 是个 bug,会导致登录成功却一直检测不到、白等超时。
CREATOR_LOGIN_COOKIES = (
"access-token-creator.xiaohongshu.com",
"galaxy_creator_session_id",
"customer-sso-sid",
)
# 主站前台凭证——feed / 评论 web API 需要;只在登录后访问过 www 才会下发。
WEB_LOGIN_COOKIE = "web_session"
async def _cookie_map(ctx: BrowserContext, host: str) -> dict[str, str]:
try:
return {c["name"]: c.get("value", "") for c in await ctx.cookies(host)}
except Exception:
return {}
async def _creator_logged_in(ctx: BrowserContext) -> bool:
names = await _cookie_map(ctx, "https://creator.xiaohongshu.com")
return any(names.get(n) for n in CREATOR_LOGIN_COOKIES)
async def _has_web_session(ctx: BrowserContext) -> bool:
for host in ("https://www.xiaohongshu.com", "https://creator.xiaohongshu.com"):
if (await _cookie_map(ctx, host)).get(WEB_LOGIN_COOKIE):
return True
return False
async def _acquire_web_session(page: Page) -> None:
"""创作者中心登录后,访问主站让 SSO 下发 web_session(前台 feed/评论需要)。best-effort。"""
try:
await page.goto("https://www.xiaohongshu.com/explore",
wait_until="domcontentloaded", timeout=30000)
await asyncio.sleep(3)
if await _has_web_session(page.context):
print("[登录] ✓ 已获取主站 web_session(前台评论/互动可用)")
else:
print("[登录] 注意:未拿到 web_session,前台评论可能要 manual;galaxy 主路不受影响。")
except Exception:
pass
async def ensure_login(timeout_s: int = 180, max_refresh: int = 5) -> bool:
"""扫码登录创作者中心;检测到创作者登录态后顺便换取 web_session,然后自动关闭。
二维码本身会过期,所以用 timeout_s 作为单次等待上限,超时后自动刷新页面重新出码,
最多刷新 max_refresh 次。用户有充足时间扫码。
"""
sess = await Session.open()
try:
page = await sess.ctx.new_page()
await page.goto(CREATOR_HOME)
print(f"[登录] 在弹出的 Chromium 窗口里扫码登录小红书创作者中心。每次二维码有效期约 {timeout_s} 秒,超时自动刷新。")
for refresh in range(max_refresh + 1):
for i in range(timeout_s):
try:
if await _creator_logged_in(sess.ctx) and "login" not in page.url:
print(f"[登录] ✓ 创作者中心登录态已确认(总用时 {refresh * timeout_s + i}s")
await _acquire_web_session(page)
await asyncio.sleep(1)
return True
except Exception:
pass
# 每 30 秒提醒一次,避免用户以为卡死
if i > 0 and i % 30 == 0:
print(f"[登录] 已等待 {i} 秒,请用小红书 App 扫码(或等待自动刷新二维码)……")
await asyncio.sleep(1)
if refresh < max_refresh:
print(f"[登录] 本次二维码未扫码或已过期,正在刷新页面重新出码(第 {refresh + 1}/{max_refresh} 次刷新)……")
try:
await page.reload(wait_until="domcontentloaded", timeout=30000)
except Exception:
await page.goto(CREATOR_HOME)
print("[登录] 超过最大刷新次数仍未检测到登录态,已停止。如需继续请重新运行本命令。")
return False
finally:
await sess.close()
async def fetch_recent_notes(sess: Session, limit: int = 50) -> list[dict]:
"""创作者中心笔记管理页 → 拦截 galaxy 笔记列表 + 单篇运营数据(含曝光/浏览)。"""
captured: list[dict] = []
all_urls: list[str] = []
page = await sess.ctx.new_page()
async def on_response(resp: Response) -> None:
all_urls.append(resp.url)
if any(k in resp.url for k in GALAXY_NOTE_LIST_KEYS + GALAXY_NOTE_STATS_KEYS):
try:
data = await resp.json()
captured.append({"url": resp.url, "data": data})
if len(captured) == 1 and isinstance(data, dict):
print(f"[诊断] galaxy 接口 keys: {list(data.keys())[:8]}")
except Exception:
pass
page.on("response", on_response)
try:
await page.goto(CREATOR_NOTE_MANAGER, wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(8)
# cookie 过期时会被 302 到登录页
if "login" in page.url or "redirectReason=401" in page.url:
print("[登录] 创作者中心已跳转登录页,cookie 可能已过期。请运行:python crawler.py login")
return []
for _ in range(4):
await page.evaluate("window.scrollBy(0, 1200)")
await asyncio.sleep(1.5)
notes = _parse_note_list(captured, limit)
if not notes:
_dump(all_urls, "creator_urls.txt", captured, "creator_captured.json")
print(f"[诊断] 笔记列表为空,{len(all_urls)} 个请求已 dump 到 .cheat-cache/xhs-explore-debug/。")
return notes
finally:
await page.close()
def _iter_candidates(data: Any) -> list:
"""从任意 galaxy response 里挖出"笔记数组"。结构多变,宽松找。"""
out: list = []
if isinstance(data, dict):
# 常见包装:{data: {...}} / {data: [...]} / 顶层直接 list 字段
inner = data.get("data") if isinstance(data.get("data"), (dict, list)) else data
targets = [inner] if not isinstance(inner, list) else []
if isinstance(inner, list):
out.extend(inner)
for t in targets:
if isinstance(t, dict):
for key in ("notes", "note_list", "list", "items", "note_stats", "result"):
val = t.get(key)
if isinstance(val, list):
out.extend(val)
return out
def _parse_note_list(captured: list[dict], limit: int) -> list[dict]:
by_id: dict[str, dict] = {}
for item in captured:
for raw in _iter_candidates(item["data"]):
if not isinstance(raw, dict):
continue
note = _normalize_note(raw)
if not note["note_id"]:
continue
# 同一 note 可能在 list 接口和 stats 接口各出现一次——合并,非空字段优先
existing = by_id.get(note["note_id"])
if existing:
for k, v in note.items():
if v and not existing.get(k):
existing[k] = v
else:
by_id[note["note_id"]] = note
return list(by_id.values())[:limit]
def _first(d: dict, *keys: str) -> Any:
for k in keys:
if k in d and d[k] is not None and d[k] != "":
return d[k]
for k in keys:
if k in d and d[k] is not None:
return d[k]
return 0
def _normalize_note(v: dict) -> dict:
note_id = v.get("note_id") or v.get("id") or v.get("noteId") or ""
# 字段名已用真实返回校准(2026-05 /api/galaxy/v2/creator/note/user/posted):
# 观看 view_count | 点赞 likes | 收藏 collected_count | 评论 comments_count
# 分享 shared_count | 发布时间 visible_time(unix秒) | 单篇 token xsec_token
# 确认名放首位,旧候选留作兜底以防接口再次改版。
return {
"note_id": str(note_id),
"title": v.get("display_title") or v.get("title") or v.get("desc") or v.get("name") or "",
"create_time": _to_int(_first(v, "visible_time", "create_time", "post_time", "publish_time")),
"view_count": _to_int(_first(v, "view_count", "view", "imp", "impression", "read_count", "pv")),
"like_count": _to_int(_first(v, "likes", "like_count", "liked_count", "like")),
"collect_count": _to_int(_first(v, "collected_count", "collect_count", "collect", "fav_count")),
"comment_count": _to_int(_first(v, "comments_count", "comment_count", "comment", "cmt_count")),
"share_count": _to_int(_first(v, "shared_count", "share_count", "share")),
"fans_inc": _to_int(_first(v, "fans", "fans_inc", "new_fans", "follow_count")),
"post_time_str": v.get("time") or "", # galaxy 自带本地时间串,比 epoch 省去时区换算
"xsec_token": v.get("xsec_token") or "",
"note_type": v.get("type") or "",
"raw": v,
}
def _to_int(x: Any) -> int:
try:
if isinstance(x, str):
x = x.replace(",", "").strip()
return int(float(x))
except (ValueError, TypeError):
return 0
# ---------------------------------------------------------------------------
# 公开页解析(来自 xhs-analytics 的核心能力):无登录拿正文、图片、标签、评论兜底
# ---------------------------------------------------------------------------
def _extract_initial_state(html: str) -> dict | None:
"""从小红书 explore 页面 HTML 中解析 window.__INITIAL_STATE__。"""
marker = "window.__INITIAL_STATE__="
start = html.find(marker)
if start == -1:
return None
script_start = html.rfind("<script", 0, start)
script_end = html.find("</script>", start)
if script_start == -1 or script_end == -1:
return None
script = html[script_start:script_end]
assign_start = script.find(marker) + len(marker)
json_str = script[assign_start:]
json_str = re.sub(r":\s*undefined\s*([,}\]])", r":null\1", json_str)
json_str = json_str.rstrip().rstrip(';').rstrip()
try:
return json.loads(json_str)
except Exception:
return None
def _image_url(img: dict) -> str:
"""从 imageList 元素中提取可用的图片 URL。"""
if not isinstance(img, dict):
return ""
for key in ("urlDefault", "url"):
if img.get(key):
return img[key]
for info in img.get("infoList", []) or []:
if isinstance(info, dict) and info.get("imageScene") == "WB_DFT" and info.get("url"):
return info["url"]
for info in img.get("infoList", []) or []:
if isinstance(info, dict) and info.get("url"):
return info["url"]
return ""
def _xsec_token_from_url(url: str | None) -> str:
"""从笔记 URL 的 query 中拆出 xsec_token(保留原编码)。"""
if not url:
return ""
parsed = urllib.parse.urlparse(url)
return urllib.parse.unquote(urllib.parse.parse_qs(parsed.query).get("xsec_token", [""])[0])
def fetch_public_note(note_id: str, xsec_token: str) -> dict:
"""无登录抓取 explore 公开页,解析 __INITIAL_STATE__。
返回 dictsuccess, note_id, title, desc/body, images, tags, time, counts, raw。
"""
token = urllib.parse.unquote(xsec_token)
url = (
f"https://www.xiaohongshu.com/explore/{note_id}"
f"?xsec_token={urllib.parse.quote(token)}"
f"&xsec_source=pc_creatormng"
)
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
r = requests.get(url, headers=headers, timeout=20)
r.raise_for_status()
state = _extract_initial_state(r.text)
if not state:
raise ValueError("无法解析页面初始状态")
note_detail_map = state.get("note", {}).get("noteDetailMap", {})
if note_id not in note_detail_map:
raise ValueError("页面未返回该笔记数据")
raw = note_detail_map[note_id]
note = raw.get("note", {})
interact = note.get("interactInfo", {})
return {
"success": True,
"note_id": note_id,
"title": note.get("title", ""),
"desc": note.get("desc", ""),
"body": note.get("desc", ""),
"type": note.get("type", ""),
"time": note.get("time", 0),
"images": [_image_url(img) for img in note.get("imageList", [])],
"tags": [tag.get("name", "") for tag in note.get("tagList", []) if tag.get("name")],
"counts": {
"liked": _to_int(interact.get("likedCount")),
"collected": _to_int(interact.get("collectedCount")),
"comment": _to_int(interact.get("commentCount")),
"shared": _to_int(interact.get("shareCount")),
},
"raw": raw,
}
def _normalize_public_comment(c: dict) -> dict:
"""把 __INITIAL_STATE__ / edith 接口里的评论字段统一成 adapter 格式。"""
user = c.get("user_info") or c.get("userInfo") or {}
like_count = c.get("like_count") or c.get("likeCount") or 0
sub_count = c.get("sub_comment_count") or c.get("subCommentCount") or 0
return {
"cid": str(c.get("id") or c.get("comment_id") or ""),
"text": c.get("content") or "",
"like_count": _to_int(like_count),
"sub_comment_count": _to_int(sub_count),
"create_time": c.get("create_time") or c.get("createTime") or 0,
"user_name": user.get("nickname") or "",
"ip_label": c.get("ip_location") or c.get("ipLocation") or "",
}
def fetch_public_comments(note_id: str, xsec_token: str, max_comments: int = 20) -> list[dict]:
"""公开页 __INITIAL_STATE__ 兜底 top 评论(通常 ~10 条)。"""
token = urllib.parse.unquote(xsec_token)
url = (
f"https://www.xiaohongshu.com/explore/{note_id}"
f"?xsec_token={urllib.parse.quote(token)}"
f"&xsec_source=pc_creatormng"
)
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
r = requests.get(url, headers=headers, timeout=20)
r.raise_for_status()
state = _extract_initial_state(r.text)
if not state:
return []
raw = state.get("note", {}).get("noteDetailMap", {}).get(note_id, {})
comments = []
# __INITIAL_STATE__ 里的评论路径可能是 comments.list 或 commentsList
for key in ("comments", "commentsList"):
container = raw.get(key) if isinstance(raw, dict) else None
if isinstance(container, dict):
arr = container.get("list") or container.get("comments") or []
if isinstance(arr, list):
comments.extend([_normalize_public_comment(c) for c in arr])
elif isinstance(container, list):
comments.extend([_normalize_public_comment(c) for c in container])
seen = set()
dedup = []
for c in comments:
if not c["cid"] or c["cid"] in seen:
continue
seen.add(c["cid"])
dedup.append(c)
dedup.sort(key=lambda x: x["like_count"], reverse=True)
return dedup[:max_comments]
async def download_image(url: str, dest: Path, timeout: int = 30) -> bool:
"""下载单张图片到 dest(会根据 Content-Type 修正扩展名)。异步封装。"""
if not url:
return False
dest = Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
def _download() -> bool:
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://www.xiaohongshu.com/",
}
r = requests.get(url, headers=headers, timeout=timeout, stream=True)
r.raise_for_status()
parsed = urllib.parse.urlparse(url)
path = urllib.parse.unquote(parsed.path)
ext = Path(path).suffix.lower()
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"}:
ct = r.headers.get("Content-Type", "").lower()
if "webp" in ct:
ext = ".webp"
elif "png" in ct:
ext = ".png"
elif "gif" in ct:
ext = ".gif"
else:
ext = ".jpg"
dest_final = dest.with_suffix(ext)
with open(dest_final, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
return True
try:
return await asyncio.to_thread(_download)
except Exception as exc:
print(f"[下载图片] 失败 {url}: {exc}")
return False
async def fetch_note_frontend(
sess: Session,
note_id: str,
note_url: str | None = None,
xsec_token: str | None = None,
) -> dict:
"""打开前台笔记页 → 拦截 feedinteract_info 确认字段)+ comment/page。
前台需要 xsec_token + 登录态(web_session)。
- 若传入 note_url(含 xsec_token,如从创作者后台复制的 ?xsec_token=...&xsec_source=pc_creatormng
→ 直接用它导航,最稳。
- 否则退回裸 explore URL(仅对已登录账号访问自己笔记可能可行)。
token 缺失 / 未登录 → dump 并降级(评论留给 manual)。
拦截不到评论时,会用公开页 __INITIAL_STATE__ 里的 top 评论兜底。
"""
feed: dict = {}
comments: list[dict] = []
all_urls: list[str] = []
page = await sess.ctx.new_page()
async def on_response(resp: Response) -> None:
all_urls.append(resp.url)
if FEED_KEY in resp.url:
try:
data = await resp.json()
ii = _extract_interact(data)
if ii:
feed.update(ii)
except Exception:
pass
elif COMMENT_KEY in resp.url:
try:
data = await resp.json()
for c in _extract_comments(data):
comments.append(c)
except Exception:
pass
page.on("response", on_response)
try:
url = note_url or f"https://www.xiaohongshu.com/explore/{note_id}"
try:
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
except Exception as e:
print(f"[警告] 笔记页加载异常:{e}")
await asyncio.sleep(5)
if "website-login/error" in page.url or "登录" in (await page.title()):
print("[警告] 触发登录墙(安全限制)——cookie 未登录或已过期。先跑 crawler.py login 扫码。")
# 滚动评论区触发分页懒加载
last = 0
stagnant = 0
for _ in range(40):
await page.evaluate("window.scrollBy(0, 1400)")
await asyncio.sleep(1.8)
cur = len({c["cid"] for c in comments})
if cur == last:
stagnant += 1
if stagnant >= 5:
break
else:
stagnant = 0
last = cur
# 兜底:公开页 __INITIAL_STATE__ 里的 top 评论
if not comments:
xsec = xsec_token or _xsec_token_from_url(note_url)
if xsec:
try:
public_comments = await asyncio.to_thread(
fetch_public_comments, note_id, xsec, max_comments=20
)
if public_comments:
comments = public_comments
print(f" 公开页兜底 {len(comments)} 条评论")
except Exception as exc:
print(f"[诊断] 公开页评论兜底失败:{exc}")
if not comments:
dbg = debug_dir()
dbg.mkdir(parents=True, exist_ok=True)
try:
await page.screenshot(path=str(dbg / f"note_{note_id}.png"))
except Exception:
pass
(dbg / "frontend_urls.txt").write_text("\n".join(all_urls), encoding="utf-8")
print("[诊断] 前台未拦到评论(可能 xsec_token 缺失或评论被关),已 dump URL。")
# 去重 + 按赞降序
seen = set()
dedup = []
for c in comments:
if c["cid"] in seen:
continue
seen.add(c["cid"])
dedup.append(c)
dedup.sort(key=lambda x: x["like_count"], reverse=True)
print(f" 前台共 {len(dedup)} 条评论")
return {"interact": feed, "comments": dedup}
finally:
await page.close()
def _extract_interact(data: Any) -> dict:
"""从 feed response 里挖 interact_info(确认字段)。"""
if not isinstance(data, dict):
return {}
items = []
d = data.get("data", data)
if isinstance(d, dict):
items = d.get("items") or d.get("note_list") or []
for it in items if isinstance(items, list) else []:
node = it.get("note_card") or it.get("note") or it
ii = node.get("interact_info") if isinstance(node, dict) else None
if isinstance(ii, dict):
return {
"like_count": _to_int(ii.get("liked_count")),
"collect_count": _to_int(ii.get("collected_count")),
"comment_count": _to_int(ii.get("comment_count")),
"share_count": _to_int(ii.get("share_count")),
"ip_location": node.get("ip_location") or "",
}
return {}
def _extract_comments(data: Any) -> list[dict]:
"""comment/page response → 评论列表(确认字段)。"""
out: list[dict] = []
if not isinstance(data, dict):
return out
d = data.get("data", data)
arr = d.get("comments") if isinstance(d, dict) else None
for c in arr or []:
if not isinstance(c, dict):
continue
user = c.get("user_info") or {}
out.append({
"cid": str(c.get("id") or c.get("comment_id") or ""),
"text": c.get("content") or "",
"like_count": _to_int(c.get("like_count")),
"sub_comment_count": _to_int(c.get("sub_comment_count")),
"create_time": c.get("create_time") or 0,
"user_name": user.get("nickname") or "",
"ip_label": c.get("ip_location") or "",
})
return out
def _dump(urls: list[str], url_file: str, captured: list[dict], cap_file: str) -> None:
dbg = debug_dir()
dbg.mkdir(parents=True, exist_ok=True)
(dbg / url_file).write_text("\n".join(urls), encoding="utf-8")
try:
(dbg / cap_file).write_text(
json.dumps([c["data"] for c in captured][:5], ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
pass
def _merge_public_note(note: dict, public: dict) -> None:
"""用公开页数据补全 note(不覆盖已有的 galaxy 运营数据)。"""
if not public.get("success"):
return
for key in ("title", "desc", "body", "type", "images", "tags"):
if public.get(key) and not note.get(key):
note[key] = public[key]
if public.get("time") and not note.get("create_time"):
note["create_time"] = _to_int(public["time"])
counts = public.get("counts") or {}
mapping = {
"like_count": counts.get("liked"),
"collect_count": counts.get("collected"),
"comment_count": counts.get("comment"),
"share_count": counts.get("shared"),
}
for k, v in mapping.items():
if v and not note.get(k):
note[k] = _to_int(v)
async def fetch_all(note_id: str, note_url: str | None = None) -> dict:
"""一个会话跑完笔记列表(含 galaxy 指标)+ 前台 interact + 评论 + 公开页正文/图片兜底。"""
sess = await Session.open()
try:
print(" → 打开创作者中心,拉笔记列表 + 运营数据")
notes = await fetch_recent_notes(sess, limit=50)
note = next((n for n in notes if n["note_id"] == note_id), None)
if not note:
print(f" 未在最近 {len(notes)} 条里找到 {note_id},用最小元数据继续。")
note = _normalize_note({"note_id": note_id})
else:
print(f" ✓ {(note.get('title') or '')[:40]}(曝光 {note.get('view_count')}")
# 抓自己的笔记时,galaxy 列表已带每条的 xsec_token——自动拼前台 URL,免得手动粘 token 链接
front_url = note_url
if not front_url and note.get("xsec_token"):
front_url = (f"https://www.xiaohongshu.com/explore/{note_id}"
f"?xsec_token={note['xsec_token']}&xsec_source=pc_creatormng")
xsec = _xsec_token_from_url(front_url) or note.get("xsec_token", "")
# 先用公开页补正文/图片/标签(无登录、低成本),不覆盖 galaxy 已确认的计数
if xsec:
try:
public = await asyncio.to_thread(fetch_public_note, note_id, xsec)
_merge_public_note(note, public)
print(" ✓ 公开页正文/图片/标签已补全")
except Exception as exc:
print(f"[诊断] 公开页兜底失败:{exc}")
print(" → 打开前台笔记页抓 interact + 评论")
front = await fetch_note_frontend(sess, note_id, note_url=front_url, xsec_token=xsec)
# 前台 interact 字段是确认的——用它补全/覆盖 galaxy 里可能缺的计数
for k in ("like_count", "collect_count", "comment_count", "share_count"):
if front["interact"].get(k):
note[k] = front["interact"][k]
if front["interact"].get("ip_location"):
note["ip_location"] = front["interact"]["ip_location"]
return {"note": note, "comments": front["comments"]}
finally:
await sess.close()
if __name__ == "__main__":
asyncio.run(ensure_login())