This commit is contained in:
eric
2026-03-18 00:53:37 -05:00
parent 4d1c3448d2
commit 1401f0230e

View File

@@ -23,6 +23,7 @@ _SALON_ADMIN_TOKEN_CACHE: tuple[str, float] | None = None
_TOKEN_CACHE_TTL = 300
_XHS_PROFILE_PATTERN = re.compile(r"^https?://(www\.)?(xiaohongshu\.com|xhslink\.com)/user/profile/[a-zA-Z0-9_-]+", re.I)
_XHS_SHORT_PATTERN = re.compile(r"^https?://xhslink\.com/m/[a-zA-Z0-9_-]+", re.I)
_XHS_NOTE_PATTERN = re.compile(r"^https?://(xhslink\.com/o/|(www\.)?xiaohongshu\.com/explore/)[a-zA-Z0-9_-]+", re.I)
_XHS_REQUEST_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Accept-Language": "zh-CN,zh;q=0.9",
@@ -625,7 +626,11 @@ async def join_volunteers():
class XiaohongshuProfileRequest(BaseModel):
url: str
url: str | None = None
id: str | None = None
def get_url_or_id(self) -> str:
return (self.url or self.id or "").strip()
def _normalize_xiaohongshu_profile_url(url: str, depth: int = 0) -> str:
@@ -654,7 +659,7 @@ def _normalize_xiaohongshu_profile_url(url: str, depth: int = 0) -> str:
def _resolve_xhslink(url: str) -> str:
"""解析 xhslink.com 短链为真实 profile URL"""
"""解析 xhslink.com 短链为真实 profile URLm/ 为主页短链)"""
if not _XHS_SHORT_PATTERN.match(url):
return _normalize_xiaohongshu_profile_url(url)
try:
@@ -669,6 +674,54 @@ def _resolve_xhslink(url: str) -> str:
return _normalize_xiaohongshu_profile_url(url)
def _extract_profile_url_from_note_state(state: dict) -> str | None:
"""从笔记页 __INITIAL_STATE__ 提取作者 profile URL"""
if not state or not isinstance(state, dict):
return None
def find_user_id(obj, depth=0):
if depth > 18:
return None
if isinstance(obj, dict):
uid = obj.get("userId") or obj.get("user_id") or obj.get("id")
if uid and len(str(uid)) > 10:
return str(uid).strip()
for k in ("note", "noteCard", "user", "author", "creator", "note_card", "basicInfo"):
v = obj.get(k)
if isinstance(v, dict):
found = find_user_id(v, depth + 1)
if found:
return found
for v in obj.values():
found = find_user_id(v, depth + 1)
if found:
return found
elif isinstance(obj, list) and obj:
return find_user_id(obj[0], depth + 1)
return None
uid = find_user_id(state)
return f"https://www.xiaohongshu.com/user/profile/{uid}" if uid else None
def _resolve_note_url_to_profile_url(note_url: str) -> str | None:
"""从笔记链接解析出作者 profile URL支持 xhslink.com/o/xxx、xiaohongshu.com/explore/xxx"""
if not _XHS_NOTE_PATTERN.match(note_url):
return None
try:
r = requests.get(
note_url,
allow_redirects=True,
headers=_XHS_REQUEST_HEADERS,
timeout=12,
)
state = _extract_state_from_html(r.text)
return _extract_profile_url_from_note_state(state) if state else None
except Exception as e:
logging.debug(f"[xiaohongshu] note resolve failed: {e}")
return None
def _unwrap_ref(value):
if isinstance(value, dict) and value.get("__v_isRef"):
raw = value.get("_rawValue")
@@ -955,9 +1008,26 @@ def _parse_dom_text(text: str) -> dict:
if not text:
return out
# 优先匹配「数字万?+? 关注/粉丝/获赞与收藏」格式,如 10+关注、1万+粉丝、10+ 关注
for pattern, key in [
(r"(\d+万?\+?)\s*关注", "following"),
(r"(\d+万?\+?)\s*粉丝", "followers"),
(r"(\d+万?\+?)\s*获赞与收藏", "likesAndCollects"),
(r"关注\s*(\d+万?\+?)", "following"),
(r"粉丝\s*(\d+万?\+?)", "followers"),
(r"获赞与收藏\s*(\d+万?\+?)", "likesAndCollects"),
]:
m = re.search(pattern, text)
if m:
val = m.group(1).strip()
if not out.get(key) and not out.get(f"{key}Text"):
_set_count_fields(out, key, val)
lines = [line.strip() for line in text.splitlines() if line.strip()]
def pick_count(label: str, key: str) -> None:
if out.get(key) or out.get(f"{key}Text"):
return
for idx, line in enumerate(lines):
if line != label or idx == 0:
continue
@@ -971,20 +1041,21 @@ def _parse_dom_text(text: str) -> dict:
pick_count("\u83b7\u8d5e\u4e0e\u6536\u85cf", "likesAndCollects")
# 格式0 关注 559 粉丝 536 获赞与收藏。找「关注」之后第一个「粉丝」,避免匹配到推荐区的 10+粉丝
i_guan = text.find("关注")
i_fen = text.find("粉丝", i_guan) if i_guan >= 0 else text.find("粉丝")
i_huo = text.find("获赞与收藏", i_fen) if i_fen >= 0 else text.find("获赞与收藏")
if i_fen >= 0:
before = text[max(0, i_fen - 100) : i_fen]
after = text[i_fen + 2 : i_huo + 80] if i_huo >= 0 else text[i_fen + 2 : i_fen + 80]
nums_before = [int(m.group()) for m in re.finditer(r"\d+", before)]
nums_after = [int(m.group()) for m in re.finditer(r"\d+", after)]
if nums_before:
out["followers"] = max(nums_before) if max(nums_before) > 50 else nums_before[-1]
if len(nums_before) >= 2:
out["following"] = nums_before[0]
if nums_after:
out["likesAndCollects"] = nums_after[0]
if not out.get("followers") and not out.get("followersText"):
i_guan = text.find("关注")
i_fen = text.find("粉丝", i_guan) if i_guan >= 0 else text.find("粉丝")
i_huo = text.find("获赞与收藏", i_fen) if i_fen >= 0 else text.find("获赞与收藏")
if i_fen >= 0:
before = text[max(0, i_fen - 100) : i_fen]
after = text[i_fen + 2 : i_huo + 80] if i_huo >= 0 else text[i_fen + 2 : i_fen + 80]
nums_before = [int(m.group()) for m in re.finditer(r"\d+", before)]
nums_after = [int(m.group()) for m in re.finditer(r"\d+", after)]
if nums_before:
out["followers"] = max(nums_before) if max(nums_before) > 50 else nums_before[-1]
if len(nums_before) >= 2 and not out.get("following"):
out["following"] = nums_before[0]
if nums_after and not out.get("likesAndCollects"):
out["likesAndCollects"] = nums_after[0]
# 笔记/发帖
m = re.search(r"笔记\s*(\d+)|(\d+)\s*篇|共\s*(\d+)\s*篇", text)
@@ -1294,8 +1365,8 @@ async def _fetch_xiaohongshu_profile(url: str) -> dict | None:
await route.continue_()
await page.route("**/*", handle_route)
await page.goto(resolved_url, wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(5000)
await page.goto(resolved_url, wait_until="domcontentloaded", timeout=20000)
await page.wait_for_timeout(2500)
state = await page.evaluate("""() => {
const unwrap = (value) => {
@@ -1316,6 +1387,35 @@ async def _fetch_xiaohongshu_profile(url: str) -> dict | None:
body_text = await page.evaluate("""() => document.body ? document.body.innerText : ''""")
dom_parsed = _parse_dom_text(body_text or "")
dom_stats = await page.evaluate("""() => {
const body = document.body?.innerText || '';
const extractNum = (label) => {
const r = new RegExp('(\\\\d+万?\\\\+?)\\\\s*' + label + '|' + label + '\\\\s*(\\\\d+万?\\\\+?)');
const m = body.match(r);
return m ? (m[1] || m[2] || '').trim() : null;
};
const toNum = (s) => {
if (!s) return null;
const m = s.match(/([\\d.]+)/);
if (!m) return null;
let n = parseFloat(m[1]);
if (/万/.test(s)) n *= 10000;
return Math.floor(n);
};
return {
followersText: extractNum('粉丝'),
followingText: extractNum('关注'),
likesAndCollectsText: extractNum('获赞与收藏'),
followers: toNum(extractNum('粉丝')),
following: toNum(extractNum('关注')),
likesAndCollects: toNum(extractNum('获赞与收藏'))
};
}""")
for k, v in (dom_stats or {}).items():
if v is not None and v != "":
dom_parsed[k] = v
title = await page.title()
nick_from_title = title.split(" - ")[0].strip() if title and " - " in title else None
@@ -1346,23 +1446,32 @@ async def _fetch_xiaohongshu_profile(url: str) -> dict | None:
@router.post("/xiaohongshu/profile")
async def xiaohongshu_profile(item: XiaohongshuProfileRequest):
"""获取小红书用户资料(昵称/粉丝/发帖数/性别),支持 xhslink 短链"""
"""获取小红书用户资料支持主页链接、xhslink.com/m/ 短链、笔记链接 xhslink.com/o/"""
logging.info("[xiaohongshu] profile request received")
url = (item.url or "").strip()
if not url:
url_raw = (item.get_url_or_id() or "").strip()
if not url_raw:
logging.warning("[xiaohongshu] 400: 请输入小红书链接")
raise HTTPException(status_code=400, detail="请输入小红书链接")
raise HTTPException(status_code=400, detail="请输入小红书主页链接或笔记链接")
xh_profile = re.compile(r"^https?://(www\.)?(xiaohongshu\.com|xhslink\.com)/user/profile/[a-zA-Z0-9_-]+", re.I)
xh_short = re.compile(r"^https?://xhslink\.com/m/[a-zA-Z0-9_-]+", re.I)
if not xh_profile.match(url) and not xh_short.match(url):
logging.warning(f"[xiaohongshu] 400: 链接格式异常 url={url[:80]}")
raise HTTPException(status_code=400, detail="小红书link异常")
url = _resolve_xhslink(url)
if _XHS_NOTE_PATTERN.match(url_raw):
url = _resolve_note_url_to_profile_url(url_raw)
if not url:
raise HTTPException(
status_code=400,
detail="无法从笔记链接解析用户请使用主页链接xhslink.com/m/xxx 或 xiaohongshu.com/user/profile/xxx",
)
logging.info(f"[xiaohongshu] 从笔记链接解析得到 profile URL")
elif xh_profile.match(url_raw) or xh_short.match(url_raw):
url = _resolve_xhslink(url_raw)
else:
raise HTTPException(status_code=400, detail="请输入有效的小红书主页链接或笔记链接xhslink.com/o/xxx")
if not xh_profile.match(url):
logging.warning(f"[xiaohongshu] 400: 短链解析失败 resolved={url[:80]}")
raise HTTPException(status_code=400, detail="链解析失败")
logging.warning(f"[xiaohongshu] 400: 解析失败 resolved={url[:80]}")
raise HTTPException(status_code=400, detail="解析失败,请确认链接有效")
parsed = await _fetch_xiaohongshu_profile(url)
if not parsed:
@@ -1379,6 +1488,13 @@ async def submit_join(item: dict):
user_id = item.get("user_id")
if not user_id:
raise HTTPException(status_code=400, detail="missing user_id")
if not (item.get("xiaohongshu_url") or "").strip():
raise HTTPException(status_code=400, detail="请填写小红书主页链接或笔记链接")
reason = (item.get("reason") or item.get("intro") or "").strip()
if not reason:
raise HTTPException(status_code=400, detail="请填写自我介绍")
if len(reason) < 20:
raise HTTPException(status_code=400, detail="自我介绍不少于20字")
try:
from ..services import get_pb_client
@@ -1389,11 +1505,12 @@ async def submit_join(item: dict):
pb_type = raw_type if raw_type in ("session-1", "session-2") else ""
qualify = bool(item.get("qualify", False)) # 女性且小红书帖子>10 时自动通过
user_info_val = (item.get("userInfo") or "").strip()
pb_data = {
"user_id": user_id,
"nickname": item.get("nickname", ""),
"occupation": item.get("occupation", ""),
"reason": item.get("reason", ""),
"reason": reason,
"wechatId": item.get("wechatId", ""),
"gender": item.get("gender", ""),
"education": item.get("education", ""),
@@ -1410,6 +1527,7 @@ async def submit_join(item: dict):
"is_approved": qualify, # 符合条件时自动通过审核
"age_range": item.get("age_range", ""),
"first_time": item.get("first_time", ""),
"userInfo": user_info_val,
}
phone_val = item.get("phone", "")
if phone_val and str(phone_val).strip():