Files
xhs_crawler_claude/app/user_resolver.py
T

154 lines
4.5 KiB
Python
Raw Normal View History

2026-01-12 18:58:23 +08:00
"""user_resolver.py
将“数字小红书号(例如 11432840954)”解析成 profile_id(/user/profile/{profile_id} 里的那串)。
- 使用 Playwright 打开搜索结果页(更接近真实浏览器,风险更低)
- 解析 HTML:根据 '小红书号:<keyword>' 定位,再提取附近的 profile href
- 强制缓存:data/profile_map.json,避免重复搜索造成风控压力
"""
from __future__ import annotations
import json
import os
import random
import re
from html import unescape
from typing import Dict, Optional, Tuple
from playwright.async_api import Browser
from app import config
PROFILE_MAP_FILE = os.path.join(config.DATA_DIR, "profile_map.json")
def _load_map() -> Dict[str, str]:
if not os.path.exists(PROFILE_MAP_FILE):
return {}
try:
with open(PROFILE_MAP_FILE, "r", encoding="utf-8") as f:
return json.load(f) or {}
except Exception:
return {}
def _save_map(m: Dict[str, str]) -> None:
tmp = PROFILE_MAP_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(m, f, ensure_ascii=False, indent=2)
os.replace(tmp, PROFILE_MAP_FILE)
def extract_profile_id_from_search_html(html: str, keyword: str) -> Optional[str]:
"""从 search_result HTML 中解析出对应 keyword 的 profile_id。"""
if not html:
return None
html = unescape(html)
marker = f"小红书号:{keyword}"
idx = html.find(marker)
if idx == -1:
return None
start = max(0, idx - 5000)
end = min(len(html), idx + 5000)
chunk = html[start:end]
m = re.search(r'href="\/user\/profile\/([0-9a-fA-F]+)', chunk)
if not m:
return None
return m.group(1)
def parse_line_to_keyword_or_profile(line: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""解析一行 txt,返回 (keyword数字, profile_id, label备注) 三者之一。
允许格式:
- 11432840954
- 11432840954 张三
- https://www.xiaohongshu.com/user/profile/<profile_id> ...
- /user/profile/<profile_id> ...
- profile_id(十六进制串)
- 支持行尾 # 备注
"""
raw = line.strip()
if not raw:
return None, None, None
# 整行注释
if raw.startswith("#") or raw.startswith("//"):
return None, None, None
# 拆备注:支持 # 以及连续空格
label = None
if "#" in raw:
raw, label = raw.split("#", 1)
raw = raw.strip()
label = label.strip() or None
parts = raw.split()
core = parts[0].strip() if parts else ""
if len(parts) > 1 and not label:
label = " ".join(parts[1:]).strip() or None
# URL/相对路径中直接提取 profile_id
m = re.search(r"/user/profile/([0-9a-fA-F]+)", core)
if m:
return None, m.group(1), label
# 可能直接就是 profile_id(十六进制串,长度>=8)
if re.fullmatch(r"[0-9a-fA-F]{8,}", core):
return None, core, label
# 纯数字:小红书号
if re.fullmatch(r"\d{5,20}", core):
return core, None, label
return None, None, label
async def resolve_keyword_to_profile_id(browser: Browser, keyword: str, *, slow_mode: bool = True) -> Tuple[Optional[str], str]:
"""将数字 keyword 解析成 profile_id,并写入缓存。返回 (profile_id, message)。"""
m = _load_map()
if keyword in m:
return m[keyword], "cache"
# 使用新的 context(不强制登录)
context = await browser.new_context()
page = await context.new_page()
try:
url = f"{config.XIAOHONGSHU_BASE_URL}/search_result?keyword={keyword}&source=unknown"
await page.goto(url, wait_until="domcontentloaded", timeout=20000)
# 轻微等待/滚动(更像真人浏览)
if slow_mode:
await page.wait_for_timeout(int(random.uniform(800, 1600)))
# 小幅滚动一下
try:
await page.mouse.wheel(0, random.randint(200, 600))
except Exception:
pass
await page.wait_for_timeout(int(random.uniform(400, 1000)))
html = await page.content()
pid = extract_profile_id_from_search_html(html, keyword)
if not pid:
return None, "not_found"
m[keyword] = pid
_save_map(m)
return pid, "ok"
except Exception as e:
return None, f"error:{e}"
finally:
try:
await page.close()
except Exception:
pass
try:
await context.close()
except Exception:
pass