ts
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
|
||||
"""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
|
||||
Reference in New Issue
Block a user