From 1401f0230ea602a8e7b7b51d53b82cf5eaea3f22 Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 18 Mar 2026 00:53:37 -0500 Subject: [PATCH] 's' --- app/routers/salon_meetup.py | 176 ++++++++++++++++++++++++++++++------ 1 file changed, 147 insertions(+), 29 deletions(-) diff --git a/app/routers/salon_meetup.py b/app/routers/salon_meetup.py index d17e4b8..f626097 100644 --- a/app/routers/salon_meetup.py +++ b/app/routers/salon_meetup.py @@ -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 URL(m/ 为主页短链)""" 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():