Files
2026-01-12 18:58:23 +08:00

154 lines
4.5 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.
"""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