"""小红书创作者中心 + 前台评论抓取。 登录一次后,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("", 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__。 返回 dict:success, 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: """打开前台笔记页 → 拦截 feed(interact_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())