"""user_resolver.py 将“数字小红书号(例如 11432840954)”解析成 profile_id(/user/profile/{profile_id} 里的那串)。 - 使用 Playwright 打开搜索结果页(更接近真实浏览器,风险更低) - 解析 HTML:根据 '小红书号:' 定位,再提取附近的 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/ ... - /user/profile/ ... - 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