""" salon 加入流程:check-user、ensure-user、complete-order 克隆 meetup 逻辑,使用 site_id=salon """ import json import logging import re import time from urllib.parse import parse_qs, unquote, urlparse import requests from fastapi import APIRouter, HTTPException from pydantic import BaseModel from ..config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD SITE_ID = "salon" router = APIRouter(prefix="/api/salon", tags=["salon"]) DEFAULT_PASSWORD = "12345678" _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_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", } def _get_admin_token() -> tuple[str | None, str | None]: global _SALON_ADMIN_TOKEN_CACHE now = time.time() if _SALON_ADMIN_TOKEN_CACHE and _SALON_ADMIN_TOKEN_CACHE[1] > now: return _SALON_ADMIN_TOKEN_CACHE[0], None if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD: return None, "PB_ADMIN_EMAIL 或 PB_ADMIN_PASSWORD 未配置" base = (PB_URL or "").rstrip("/") for path in ["/api/collections/_superusers/auth-with-password", "/api/admins/auth-with-password"]: try: r = requests.post( f"{base}{path}", json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, timeout=4, ) if r.status_code == 200: data = r.json() token = data.get("token") if token: _SALON_ADMIN_TOKEN_CACHE = (token, now + _TOKEN_CACHE_TTL) return token, None if r.status_code == 404: continue try: msg = r.json().get("message", r.text[:100]) except Exception: msg = r.text[:100] logging.warning(f"PocketBase admin auth failed: status={r.status_code}, msg={msg}") return None, f"PocketBase 管理员认证失败: {msg}" except requests.exceptions.ConnectionError as e: logging.error(f"PocketBase 连接失败: {e}") return None, f"无法连接 {PB_URL},请检查网络或 PB_URL" except Exception as e: logging.warning(f"PocketBase auth try {path}: {e}") return None, "PocketBase 管理员认证失败: 请确认 PB_URL 及管理员账号密码正确" class CheckUserRequest(BaseModel): email: str class EnsureUserRequest(BaseModel): email: str password: str | None = None def _check_site_vip(user_id: str, token: str | None = None) -> bool: if not token: token, _ = _get_admin_token() if not token: return False try: r = requests.get( f"{PB_URL}/api/collections/site_vip/records", params={ "filter": f'user_id = "{user_id}" && site_id = "{SITE_ID}"', "perPage": 1, "sort": "-expires_at", }, headers={"Authorization": f"Bearer {token}"}, timeout=4, ) if r.status_code != 200: return False data = r.json() items = data.get("items") or [] if not items: return False rec = items[0] exp = rec.get("expires_at") if not exp: return True try: from datetime import datetime as _dt, timezone exp_dt = _dt.fromisoformat(exp.replace("Z", "+00:00")) now = _dt.now(timezone.utc) if exp_dt.tzinfo is None: exp_dt = exp_dt.replace(tzinfo=timezone.utc) return exp_dt > now except Exception: return True except Exception: return False @router.post("/check-user") async def check_user(item: CheckUserRequest): email = (item.email or "").strip() if not email: raise HTTPException(status_code=400, detail="请输入邮箱") token, err = _get_admin_token() if not token: raise HTTPException(status_code=500, detail=f"服务配置错误:{err}") try: filter_val = f'email="{email}"' r = requests.get( f"{PB_URL}/api/collections/users/records", params={"filter": filter_val, "perPage": 1}, headers={"Authorization": f"Bearer {token}"}, timeout=4, ) if r.status_code != 200: raise HTTPException(status_code=500, detail="查询失败") data = r.json() items = data.get("items") or [] exists = len(items) > 0 user_id = items[0].get("id") if items else None vip = _check_site_vip(user_id, token) if user_id else False return {"ok": True, "exists": exists, "vip": vip, "user_id": user_id} except HTTPException: raise except Exception as e: logging.error(f"salon check-user error: {e}") raise HTTPException(status_code=500, detail="操作失败") @router.post("/ensure-user") async def ensure_user(item: EnsureUserRequest): email = (item.email or "").strip() if not email: raise HTTPException(status_code=400, detail="请输入邮箱") password = (item.password or "").strip() or DEFAULT_PASSWORD try: login_r = requests.post( f"{PB_URL}/api/collections/users/auth-with-password", json={"identity": email, "password": password}, timeout=10, ) if login_r.status_code == 200: data = login_r.json() return { "ok": True, "user_id": data.get("record", {}).get("id"), "token": data.get("token"), "record": data.get("record"), "is_new": False, } try: err_data = login_r.json() except Exception: err_data = {} is_not_found = login_r.status_code == 400 and ( "Invalid" in str(err_data.get("message", "")) or "identity" in str(err_data.get("message", "")).lower() ) if not is_not_found and item.password: raise HTTPException(status_code=400, detail="密码错误") token, err = _get_admin_token() if not token: raise HTTPException(status_code=500, detail=f"服务配置错误:{err}") create_r = requests.post( f"{PB_URL}/api/collections/users/records", json={ "email": email, "password": DEFAULT_PASSWORD, "passwordConfirm": DEFAULT_PASSWORD, }, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", }, timeout=10, ) if create_r.status_code != 200: try: create_err = create_r.json() except Exception: create_err = {} data = create_err.get("data", {}) if ( create_r.status_code == 400 and "already" in str(data.get("email", {}).get("message", "")).lower() ): raise HTTPException(status_code=400, detail="该邮箱已注册,请输入密码登录") msg = create_err.get("message", "创建账号失败") logging.warning(f"salon ensure-user create failed: status={create_r.status_code}, msg={msg}") raise HTTPException(status_code=500, detail=msg) final_r = requests.post( f"{PB_URL}/api/collections/users/auth-with-password", json={"identity": email, "password": DEFAULT_PASSWORD}, timeout=10, ) if final_r.status_code != 200: raise HTTPException(status_code=500, detail="登录失败") auth_data = final_r.json() return { "ok": True, "user_id": auth_data.get("record", {}).get("id"), "token": auth_data.get("token"), "record": auth_data.get("record"), "is_new": True, } except HTTPException: raise except Exception as e: logging.error(f"salon ensure-user error: {e}") raise HTTPException(status_code=500, detail="操作失败") def _decode_pending_email(user_id: str) -> str | None: if not user_id.startswith("pending_"): return None try: return bytes.fromhex(user_id[8:]).decode("utf-8") except Exception: return None def _query_zpay_paid(order_id: str) -> bool: try: from ..payment import get_payment_provider from ..payment.zpay import ZPayProvider provider = get_payment_provider() if not isinstance(provider, ZPayProvider): return False result = provider.query_order_status(order_id, timeout=10) return bool(result.get("paid")) except Exception as e: logging.warning(f"salon complete-order zpay query failed: {e}") return False @router.post("/complete-order") async def complete_order(item: dict): order_id = (item.get("order_id") or "").strip() if not order_id: raise HTTPException(status_code=400, detail="缺少 order_id") base = (PB_URL or "").rstrip("/") token, err = _get_admin_token() if not token: raise HTTPException(status_code=500, detail=f"服务配置错误:{err}") from ..services.payment import parse_order_id user_id, pay_type = parse_order_id(order_id) if not user_id or pay_type != "salon": raise HTTPException(status_code=400, detail="订单格式无效") max_retries = 4 for attempt in range(max_retries): r = requests.get( f"{base}/api/collections/site_vip/records", params={"filter": f'order_id = "{order_id}" && site_id = "{SITE_ID}"', "perPage": 1}, headers={"Authorization": f"Bearer {token}"}, timeout=10, ) total = (r.json().get("totalItems") or 0) if r.status_code == 200 else 0 if total > 0: break if attempt < max_retries - 1 and _query_zpay_paid(order_id): time.sleep(2) continue resp_preview = r.text[:200] if r.ok else f"status={r.status_code}" logging.warning(f"salon complete-order 未找到订单: order_id={order_id}, attempt={attempt + 1}, pb_resp={resp_preview}") raise HTTPException(status_code=400, detail="订单不存在或未支付成功") if user_id.startswith("pending_"): email = _decode_pending_email(user_id) if not email: raise HTTPException(status_code=400, detail="订单格式无效") login_r = requests.post( f"{base}/api/collections/users/auth-with-password", json={"identity": email, "password": DEFAULT_PASSWORD}, timeout=10, ) if login_r.status_code != 200: raise HTTPException(status_code=500, detail="登录失败,请稍后重试") auth_data = login_r.json() return { "ok": True, "token": auth_data.get("token"), "record": auth_data.get("record"), "is_new": True, } return {"ok": True, "token": None, "record": None, "is_new": False} @router.get("/vip/check") async def vip_check(user_id: str = ""): """检查用户是否为 salon VIP""" if not user_id: return {"vip": False} token, _ = _get_admin_token() return {"vip": _check_site_vip(user_id, token)} @router.get("/join/status") async def join_status(user_id: str = ""): """检查用户是否已提交 salon 加入申请""" if not user_id: return {"hasRecord": False, "isComplete": False} try: from ..services import get_pb_client pb_client = get_pb_client() solan = pb_client.collection("solanRed") existing = solan.get_list(1, 1, {"filter": f'user_id = "{user_id}"', "sort": "-created"}) has_record = len(existing.items) > 0 rec = existing.items[0] if existing.items else None _get = lambda r, k, d=None: getattr(r, k, d) if r else d is_complete = bool(rec and _get(rec, "order_id") and (_get(rec, "amount", 0) or 0) > 0) return {"hasRecord": has_record, "isComplete": is_complete} except Exception: return {"hasRecord": False, "isComplete": False} @router.get("/join/by-wechat") async def join_by_wechat(wechat_id: str = ""): """按 wechatId 查询 solanRed 记录""" if not wechat_id or not wechat_id.strip(): return {"hasRecord": False, "record": None, "isComplete": False, "isWechatFriend": False, "isApproved": False, "isRejected": False, "vip": False} try: from ..services import get_pb_client pb_client = get_pb_client() solan = pb_client.collection("solanRed") w = wechat_id.strip().replace("\\", "\\\\").replace('"', '\\"') existing = solan.get_list(1, 1, {"filter": f'wechatId = "{w}"', "sort": "-created"}) rec = existing.items[0] if existing.items else None has_record = rec is not None _get = lambda r, k, d=None: getattr(r, k, d) if r else d amount = int(_get(rec, "amount", 0) or 0) if rec else 0 is_complete = has_record and bool(_get(rec, "order_id")) and amount > 0 is_wechat_friend = bool(_get(rec, "is_wechat_friend")) if rec else False is_approved = bool(_get(rec, "is_approved")) if rec else False is_rejected = bool(_get(rec, "is_rejected")) if rec else False vip = bool(_get(rec, "vip")) if rec else False record_dict = None if rec: record_dict = { "user_id": _get(rec, "user_id"), "amount": amount, "order_id": _get(rec, "order_id"), "vip": vip, } return { "hasRecord": has_record, "record": record_dict, "isComplete": is_complete, "isWechatFriend": is_wechat_friend, "isApproved": is_approved, "isRejected": is_rejected, "vip": vip, } except Exception as e: logging.warning(f"salon join by-wechat 查询失败: {e}") return {"hasRecord": False, "record": None, "isComplete": False, "isWechatFriend": False, "isApproved": False, "isRejected": False, "vip": False} class XiaohongshuProfileRequest(BaseModel): url: str def _normalize_xiaohongshu_profile_url(url: str, depth: int = 0) -> str: if not url or depth > 3: return url url = str(url).strip() if _XHS_PROFILE_PATTERN.match(url): return url try: parsed = urlparse(url) host = (parsed.netloc or "").lower() if host.endswith("xiaohongshu.com") and parsed.path.startswith("/login"): query = parse_qs(parsed.query) redirect = query.get("redirectPath", [None])[0] or query.get("redirect_path", [None])[0] if redirect: redirect = unquote(redirect).strip() if redirect.startswith("/"): redirect = f"{parsed.scheme or 'https'}://{parsed.netloc}{redirect}" return _normalize_xiaohongshu_profile_url(redirect, depth + 1) except Exception: return url return url def _resolve_xhslink(url: str) -> str: """解析 xhslink.com 短链为真实 profile URL""" if not _XHS_SHORT_PATTERN.match(url): return _normalize_xiaohongshu_profile_url(url) try: r = requests.get( url, allow_redirects=True, headers=_XHS_REQUEST_HEADERS, timeout=10, ) return _normalize_xiaohongshu_profile_url(r.url or url) except Exception: return _normalize_xiaohongshu_profile_url(url) def _unwrap_ref(value): if isinstance(value, dict) and value.get("__v_isRef"): raw = value.get("_rawValue") if raw is None: raw = value.get("_value") return raw return value def _sanitize_state_json(raw: str) -> str: raw = re.sub(r":\s*undefined\b", ": null", raw) raw = re.sub(r":\s*NaN\b", ": null", raw) raw = re.sub(r":\s*-?Infinity\b", ": null", raw) return raw def _extract_state_from_html(html: str) -> dict | None: if not html: return None m = re.search(r"__INITIAL_STATE__\s*=\s*(\{.*?\})\s*", html, re.S) if not m: return None try: return json.loads(_sanitize_state_json(m.group(1))) except Exception as exc: logging.debug(f"[xiaohongshu] state parse failed: {exc}") return None def _looks_like_personal_link(value) -> bool: if value is None: return False text = str(value).strip() return bool(re.fullmatch(r"[a-zA-Z0-9][-a-zA-Z0-9.]*\.(?:com|cn|net|io|me)", text)) and "xiaohongshu" not in text.lower() def _extract_avatar_url(value) -> str | None: value = _unwrap_ref(value) if not isinstance(value, dict): return None for key in ("imageb", "images", "avatarUrl", "avatar", "image", "headImage"): raw = value.get(key) if not raw: continue text = str(raw).strip() if text.startswith("http://") or text.startswith("https://"): return text return None def _parse_count_value(value) -> tuple[int | None, str | None]: if value is None: return None, None if isinstance(value, (int, float)): num = int(value) return num, str(num) text = str(value).strip() if not text or text == "-": return None, None cleaned = text.replace(",", "") multiplier = 1 if cleaned.endswith(("万", "w", "W")): multiplier = 10000 cleaned = cleaned[:-1] if cleaned.endswith("+"): cleaned = cleaned[:-1] m = re.search(r"\d+(?:\.\d+)?", cleaned) if not m: return None, text num = int(float(m.group()) * multiplier) return num, text def _set_count_fields(out: dict, key: str, raw_value) -> None: num, text = _parse_count_value(raw_value) if num is not None: out[key] = num if text: out[f"{key}Text"] = text def _merge_profile(base: dict | None, extra: dict | None) -> dict: merged = dict(base or {}) if not extra: return merged count_keys = {"followers", "following", "likesAndCollects", "postCount"} for key, value in extra.items(): if value is None or value == "": continue if key in count_keys: current = merged.get(key) if key == "postCount": if not isinstance(current, int) or value > current: merged[key] = value continue current_text = merged.get(f"{key}Text") extra_text = extra.get(f"{key}Text") current_exact = isinstance(current_text, str) and current_text != "" and "+" not in current_text extra_exact = isinstance(extra_text, str) and extra_text != "" and "+" not in extra_text if current is None or current == 0 or (extra_exact and not current_exact): merged[key] = value continue if key.endswith("Text"): current = merged.get(key) current_exact = isinstance(current, str) and current != "" and "+" not in current extra_exact = isinstance(value, str) and value != "" and "+" not in value if not current or (extra_exact and not current_exact): merged[key] = value continue if key == "resolvedUrl": merged[key] = value continue if not merged.get(key): merged[key] = value return merged def _extract_note_stats_from_state(state: dict) -> tuple[int, str | None]: user = _unwrap_ref(state.get("user")) if not isinstance(user, dict): return 0, None notes = _unwrap_ref(user.get("notes")) loaded_count = 0 if isinstance(notes, list): for tab in notes: tab = _unwrap_ref(tab) if isinstance(tab, list): loaded_count = max(loaded_count, len(tab)) note_queries = _unwrap_ref(user.get("noteQueries")) has_more = False if isinstance(note_queries, list): has_more = any(isinstance(item, dict) and bool(item.get("hasMore")) for item in note_queries) if loaded_count <= 0: return 0, None return loaded_count, f"{loaded_count}+" if has_more else str(loaded_count) def _extract_user_from_state(state: dict, target_user_id: str | None) -> dict | None: """从 __INITIAL_STATE__ 提取当前 profile 用户""" def check(u): u = _unwrap_ref(u) if not u or not isinstance(u, dict): return None uid = str(u.get("user_id") or u.get("userId") or u.get("id") or "").strip() if target_user_id and uid and uid != target_user_id: return None return u user = state.get("user") if isinstance(user, dict): ui = user.get("userInfo") if ui: found = check(ui) if found: return found found = check(user) if found: return found for key in ("userInfo", "userDetail"): u = state.get(key) found = check(u) if found: return found return None def _parse_user(user: dict) -> dict | None: """解析用户对象为返回格式""" user = _unwrap_ref(user) if not isinstance(user, dict): return None nickname = str(user.get("nickname") or user.get("nickName") or user.get("name") or user.get("user_name") or "").strip() if not nickname: return None out = {"nickname": nickname} redbook_id = str(user.get("redbook_id") or user.get("red_id") or user.get("redId") or "").strip() if redbook_id: out["redbookId"] = redbook_id avatar_url = _extract_avatar_url(user) if avatar_url: out["avatarUrl"] = avatar_url ip_location = str(user.get("ipLocation") or user.get("ip_location") or "").strip() if ip_location: out["ipLocation"] = ip_location desc = str(user.get("desc") or user.get("bio") or "").strip() if _looks_like_personal_link(desc): out["personalLink"] = desc _set_count_fields(out, "followers", user.get("fans_count") or user.get("fansCount") or user.get("followers")) _set_count_fields(out, "following", user.get("follow_count") or user.get("followCount")) _set_count_fields(out, "likesAndCollects", user.get("collect_count") or user.get("collectCount") or user.get("likes_count") or user.get("likesCount")) _set_count_fields(out, "postCount", user.get("notes_count") or user.get("notesCount") or user.get("post_count") or user.get("postCount")) return out def _parse_profile_from_state(state: dict, target_user_id: str | None) -> dict | None: if not state or not isinstance(state, dict): return None out = {} user = _unwrap_ref(state.get("user")) user_page_data = None if isinstance(user, dict): user_page_data = _unwrap_ref(user.get("userPageData")) if isinstance(user_page_data, dict): basic_info = _unwrap_ref(user_page_data.get("basicInfo")) or {} if isinstance(basic_info, dict): nickname = str(basic_info.get("nickname") or basic_info.get("nickName") or "").strip() if nickname: out["nickname"] = nickname avatar_url = _extract_avatar_url(basic_info) if avatar_url: out["avatarUrl"] = avatar_url redbook_id = str(basic_info.get("redId") or basic_info.get("redbookId") or "").strip() if redbook_id: out["redbookId"] = redbook_id ip_location = str(basic_info.get("ipLocation") or "").strip() if ip_location: out["ipLocation"] = ip_location desc = str(basic_info.get("desc") or "").strip() if _looks_like_personal_link(desc): out["personalLink"] = desc interactions = _unwrap_ref(user_page_data.get("interactions")) if isinstance(interactions, list): for item in interactions: item = _unwrap_ref(item) if not isinstance(item, dict): continue item_type = str(item.get("type") or "").strip().lower() item_name = str(item.get("name") or "").strip() target_key = None if item_type in {"follows", "follow"} or item_name == "\u5173\u6ce8": target_key = "following" elif item_type == "fans" or item_name == "\u7c89\u4e1d": target_key = "followers" elif item_type == "interaction" or item_name == "\u83b7\u8d5e\u4e0e\u6536\u85cf": target_key = "likesAndCollects" if target_key: _set_count_fields(out, target_key, item.get("count")) note_count, note_count_text = _extract_note_stats_from_state(state) if note_count > 0: out["postCount"] = note_count out["postCountText"] = note_count_text or str(note_count) parsed_user = _parse_user(_extract_user_from_state(state, target_user_id) or {}) out = _merge_profile(out, parsed_user) return out or None def _parse_dom_text(text: str) -> dict: """从页面可见文本解析:关注、粉丝、获赞与收藏、小红书号、IP属地、个人链接""" out = {} if not text: return out lines = [line.strip() for line in text.splitlines() if line.strip()] def pick_count(label: str, key: str) -> None: for idx, line in enumerate(lines): if line != label or idx == 0: continue prev = lines[idx - 1] if re.fullmatch(r"[-\d.,+wW\u4e07]+", prev): _set_count_fields(out, key, prev) return pick_count("\u5173\u6ce8", "following") pick_count("\u7c89\u4e1d", "followers") 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] # 笔记/发帖 m = re.search(r"笔记\s*(\d+)|(\d+)\s*篇|共\s*(\d+)\s*篇", text) if m: out["postCount"] = int(m.group(1) or m.group(2) or m.group(3) or 0) out["postCountText"] = str(out["postCount"]) # 小红书号 m = re.search(r"小红书号[::]\s*([a-zA-Z0-9_]+)", text) if m: out["redbookId"] = m.group(1).strip() # IP属地 m = re.search(r"IP属地[::]\s*([^\s\n]+)", text) if m: out["ipLocation"] = m.group(1).strip() # 个人链接 m = re.search( r"(?:小红书号|IP属地)[^\n]*\n\s*([a-zA-Z0-9][-a-zA-Z0-9.]*\.(?:com|cn|net|io|me))\b", text, re.MULTILINE, ) if m: out["personalLink"] = m.group(1).strip() else: m = re.search(r"\b([a-zA-Z0-9][-a-zA-Z0-9.]*\.(?:com|cn|net|io|me))\b", text[:600]) if m and "xiaohongshu" not in m.group(1).lower(): out["personalLink"] = m.group(1).strip() return out def _extract_nickname_from_body(body_text: str) -> str | None: """从页面正文提取昵称(profile 页通常昵称在首屏靠前)""" if not body_text or len(body_text) < 2: return None lines = [ln.strip() for ln in body_text.split("\n") if ln.strip()] skip = {"发现", "发布", "通知", "创作中心", "业务合作", "关注", "笔记", "收藏", "登录", "注册"} for ln in lines: if len(ln) < 2 or len(ln) > 40: continue if ln in skip or ln.startswith("http") or ln.isdigit(): continue if "粉丝" in ln or "关注" in ln or "笔记" in ln or "收藏" in ln: continue if re.match(r"^[\d\s+]+$", ln): continue return ln return None async def _fetch_xiaohongshu_profile(url: str) -> dict | None: """使用 Playwright 获取小红书用户资料""" try: from playwright.async_api import async_playwright except ImportError: logging.warning("playwright 未安装,请执行: pip install playwright && playwright install chromium") return None profile_pattern = re.compile(r"/user/profile/([a-zA-Z0-9_-]+)") m = profile_pattern.search(url) target_user_id = m.group(1) if m else None async with async_playwright() as p: browser = await p.chromium.launch( headless=True, args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-blink-features=AutomationControlled"], ) try: context = await browser.new_context( viewport={"width": 1280, "height": 800}, user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", locale="zh-CN", ) page = await context.new_page() await page.goto(url, wait_until="load", timeout=20000) await page.wait_for_timeout(3000) state = await page.evaluate("""() => { const w = window; return w.__INITIAL_STATE__ || w.__INITIAL_SSR_STATE__ || null; }""") body_text = await page.evaluate("""() => document.body ? document.body.innerText : ''""") # 从 profile 区域提取 dom_stats = await page.evaluate("""() => { const nums = (txt) => { const arr = []; let m; const r = /(\\d+)\\s*粉丝|粉丝\\s*(\\d+)/g; while ((m = r.exec(txt)) !== null) arr.push(+(m[1]||m[2]||0)); return arr; }; const body = document.body?.innerText || ''; const fans = nums(body); const follow = (body.match(/(\\d+)\\s*关注|关注\\s*(\\d+)/g) || []).map(s => parseInt(s.replace(/\\D/g,''))).filter(n=>!isNaN(n)); const likes = (body.match(/(\\d+)\\s*获赞与收藏|获赞与收藏\\s*(\\d+)/g) || []).map(s => parseInt(s.replace(/\\D/g,''))).filter(n=>!isNaN(n)); return { followers: fans.length ? Math.max(...fans) : null, following: follow.length ? Math.max(...follow) : null, likesAndCollects: likes.length ? Math.max(...likes) : null }; }""") dom_stats = dom_stats or {} dom_parsed = _parse_dom_text(body_text or "") if dom_stats.get("followers"): dom_parsed["followers"] = dom_stats["followers"] if dom_stats.get("following") is not None: dom_parsed["following"] = dom_stats["following"] if dom_stats.get("likesAndCollects"): dom_parsed["likesAndCollects"] = dom_stats["likesAndCollects"] title = await page.title() await browser.close() def _make_result(nick: str) -> dict: return { "nickname": nick, "followers": dom_parsed.get("followers", 0), "following": dom_parsed.get("following", 0), "likesAndCollects": dom_parsed.get("likesAndCollects", 0), "postCount": dom_parsed.get("postCount", 0), "gender": None, "redbookId": dom_parsed.get("redbookId"), "ipLocation": dom_parsed.get("ipLocation"), "personalLink": dom_parsed.get("personalLink"), } nick_from_title = title.split(" - ")[0].strip() if title and " - " in title else None nick_from_body = _extract_nickname_from_body(body_text or "") fallback_nick = nick_from_title or nick_from_body if not state or not isinstance(state, dict): if fallback_nick and len(fallback_nick) < 50: return _make_result(fallback_nick) logging.debug(f"[xiaohongshu] no state, title={title!r}, body_preview={body_text[:200] if body_text else ''!r}") return None user = _extract_user_from_state(state, target_user_id) if not user: if fallback_nick and len(fallback_nick) < 50: return _make_result(fallback_nick) return None parsed = _parse_user(user) if not parsed: if fallback_nick and len(fallback_nick) < 50: return _make_result(fallback_nick) return None if parsed.get("followers", 0) == 0 and dom_parsed.get("followers"): parsed["followers"] = dom_parsed["followers"] if parsed.get("following", 0) == 0 and dom_parsed.get("following") is not None: parsed["following"] = dom_parsed["following"] if parsed.get("likesAndCollects", 0) == 0 and dom_parsed.get("likesAndCollects") is not None: parsed["likesAndCollects"] = dom_parsed["likesAndCollects"] if parsed.get("postCount", 0) == 0 and dom_parsed.get("postCount"): parsed["postCount"] = dom_parsed["postCount"] if not parsed.get("redbookId") and dom_parsed.get("redbookId"): parsed["redbookId"] = dom_parsed["redbookId"] if not parsed.get("ipLocation") and dom_parsed.get("ipLocation"): parsed["ipLocation"] = dom_parsed["ipLocation"] if not parsed.get("personalLink") and dom_parsed.get("personalLink"): parsed["personalLink"] = dom_parsed["personalLink"] return parsed except Exception as e: logging.warning(f"[xiaohongshu] fetch error: {e}", exc_info=True) try: await browser.close() except Exception: pass return None def _parse_dom_text(text: str) -> dict: out = {} if not text: return out lines = [line.strip() for line in text.splitlines() if line.strip()] def pick_count(label: str, key: str) -> None: for idx, line in enumerate(lines): if line != label or idx == 0: continue prev = lines[idx - 1] if re.fullmatch(r"[-\d.,+wW\u4e07]+", prev): _set_count_fields(out, key, prev) return pick_count("\u5173\u6ce8", "following") pick_count("\u7c89\u4e1d", "followers") pick_count("\u83b7\u8d5e\u4e0e\u6536\u85cf", "likesAndCollects") m = re.search(r"\u7b14\u8bb0\s*(\d+)|(\d+)\s*\u7bc7|\u5171\s*(\d+)\s*\u7bc7", text) if m: out["postCount"] = int(m.group(1) or m.group(2) or m.group(3) or 0) out["postCountText"] = str(out["postCount"]) m = re.search(r"\u5c0f\u7ea2\u4e66\u53f7[::]\s*([a-zA-Z0-9_]+)", text) if m: out["redbookId"] = m.group(1).strip() m = re.search(r"IP\u5c5e\u5730[::]\s*([^\s\n]+)", text) if m: out["ipLocation"] = m.group(1).strip() m = re.search( r"(?:\u5c0f\u7ea2\u4e66\u53f7|IP\u5c5e\u5730)[^\n]*\n\s*([a-zA-Z0-9][-a-zA-Z0-9.]*\.(?:com|cn|net|io|me))\b", text, re.MULTILINE, ) if m: out["personalLink"] = m.group(1).strip() else: m = re.search(r"\b([a-zA-Z0-9][-a-zA-Z0-9.]*\.(?:com|cn|net|io|me))\b", text[:600]) if m and "xiaohongshu" not in m.group(1).lower(): out["personalLink"] = m.group(1).strip() return out def _extract_nickname_from_body(body_text: str) -> str | None: if not body_text or len(body_text) < 2: return None lines = [ln.strip() for ln in body_text.split("\n") if ln.strip()] skip = { "\u53d1\u73b0", "\u53d1\u5e03", "\u901a\u77e5", "\u521b\u4f5c\u4e2d\u5fc3", "\u4e1a\u52a1\u5408\u4f5c", "\u5173\u6ce8", "\u7b14\u8bb0", "\u6536\u85cf", "\u767b\u5f55", "\u6ce8\u518c", } for ln in lines: if len(ln) < 2 or len(ln) > 40: continue if ln in skip or ln.startswith("http") or ln.isdigit(): continue if "\u7c89\u4e1d" in ln or "\u5173\u6ce8" in ln or "\u7b14\u8bb0" in ln or "\u6536\u85cf" in ln: continue if re.match(r"^[\d\s+]+$", ln): continue return ln return None async def _fetch_xiaohongshu_profile(url: str) -> dict | None: profile_pattern = re.compile(r"/user/profile/([a-zA-Z0-9_-]+)") m = profile_pattern.search(url) target_user_id = m.group(1) if m else None resolved_url = url parsed = None try: response = requests.get(url, allow_redirects=True, headers=_XHS_REQUEST_HEADERS, timeout=15) resolved_url = response.url or url state = _extract_state_from_html(response.text) parsed = _parse_profile_from_state(state, target_user_id) title_match = re.search(r"