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

726 lines
29 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.
"""小红书创作者中心 + 前台评论抓取。
登录一次后,Cookie 持久化在 .auth-xhs/,之后直接复用。
一次抓取共享一个 Chromium 会话,稳定性优于每步一个进程。
设计原则(和 douyin-session 一致):
- 不逆向 x-s / x-t 签名、不伪造请求——用登录态浏览器,让页面自己发带签名的请求,
我们只被动拦截返回的 JSON。
- 创作者**自己的**笔记数据走 galaxy 接口(不需要 xsec_token),是最稳的主路。
- 评论走前台 web API(需要 xsec_token),让页面自己导航触发带 token 的请求;
拿不到就优雅降级(report.md 标 comments_unavailablecheat-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 Chromiumfallback 到 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,前台评论可能要 manualgalaxy 主路不受影响。")
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())