Files
gitlab-instance-0a899031_sa…/app/routers/salon_meetup.py
2026-03-18 00:53:37 -05:00

1541 lines
61 KiB
Python
Raw 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.
"""
salon 加入流程check-user、ensure-user、complete-order
克隆 meetup 逻辑,使用 site_id=salon
"""
import asyncio
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_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",
}
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_order(order_id: str) -> dict | None:
"""查询 ZPAY 订单状态,返回 {paid, money}。使用 get_payment_provider_by_name 确保查询 ZPAY不依赖默认 provider"""
try:
from ..payment import get_payment_provider_by_name
from ..payment.zpay import ZPayProvider
provider = get_payment_provider_by_name("zpay")
if not isinstance(provider, ZPayProvider):
return None
result = provider.query_order_status(order_id, timeout=10)
return result if result.get("paid") else None
except Exception as e:
logging.warning(f"salon complete-order zpay query failed: {e}")
return None
def _query_xorpay_paid(order_id: str) -> tuple[bool, str]:
"""查询 XorPay 是否已支付,返回 (paid, pay_price)。微信内订单走 XorPay需显式查 xorpay 不依赖默认 provider"""
try:
from ..payment import get_payment_provider_by_name
from ..payment.xorpay import XorPayProvider
provider = get_payment_provider_by_name("xorpay")
if not isinstance(provider, XorPayProvider):
return False, ""
result = provider.query_order_status(order_id, timeout=8)
if result.get("paid"):
return True, str(result.get("pay_price", "") or "")
return False, ""
except Exception as e:
logging.warning(f"salon complete-order xorpay query failed: {e}")
return False, ""
def _update_solan_red_on_payment(token: str, user_id: str, order_id: str, amount_cents: int) -> None:
"""支付成功后兜底更新 solanRedorder_id、amount、vip"""
base = (PB_URL or "").rstrip("/")
try:
r = requests.get(
f"{base}/api/collections/solanRed/records",
params={"filter": f'user_id = "{user_id}"', "sort": "-created", "perPage": 1},
headers={"Authorization": f"Bearer {token}"},
timeout=(8, 20),
)
if r.status_code != 200:
return
data = r.json()
items = data.get("items") or []
if not items:
return
rec_id = items[0].get("id")
if not rec_id:
return
patch = {"order_id": order_id, "amount": amount_cents, "vip": True}
requests.patch(
f"{base}/api/collections/solanRed/records/{rec_id}",
json=patch,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
timeout=(8, 20),
)
logging.info(f"salon complete-order 兜底更新 solanRed - user_id: {user_id}, amount: {amount_cents}")
except requests.exceptions.RequestException as e:
logging.warning(f"salon complete-order 兜底更新 solanRed 请求超时/失败: {e}")
except Exception as e:
logging.warning(f"salon complete-order 兜底更新 solanRed 失败: {e}")
@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="订单格式无效")
def _query_solan_red_paid() -> dict | None:
"""查 solanRed 是否已有 vip 和 amount有则说明已支付成功可快速返回"""
try:
esc = user_id.replace("\\", "\\\\").replace('"', '\\"')
r = requests.get(
f"{base}/api/collections/solanRed/records",
params={"filter": f'user_id = "{esc}"', "sort": "-created", "perPage": 1},
headers={"Authorization": f"Bearer {token}"},
timeout=(6, 12),
)
if r.status_code != 200:
return None
items = r.json().get("items") or []
if not items:
return None
rec = items[0]
if rec.get("vip") and (rec.get("amount") or 0) > 0:
return {"wechatId": rec.get("wechatId"), "nickname": rec.get("nickname"), "media": rec.get("media")}
except requests.exceptions.RequestException as e:
logging.warning(f"salon complete-order PocketBase solanRed 请求超时/失败: {e}")
return None
paid_record = await asyncio.to_thread(_query_solan_red_paid)
if paid_record is not None:
if user_id.startswith("pending_"):
email = _decode_pending_email(user_id)
if not email:
raise HTTPException(status_code=400, detail="订单格式无效")
def _login_pending() -> dict | None:
try:
login_r = requests.post(
f"{base}/api/collections/users/auth-with-password",
json={"identity": email, "password": DEFAULT_PASSWORD},
timeout=(6, 15),
)
if login_r.status_code != 200:
return None
return login_r.json()
except requests.exceptions.RequestException as e:
logging.warning(f"salon complete-order PocketBase login 请求超时/失败: {e}")
return None
auth_data = await asyncio.to_thread(_login_pending)
if not auth_data:
raise HTTPException(status_code=500, detail="登录失败,请稍后重试")
auth_rec = auth_data.get("record") or {}
if paid_record.get("wechatId") and not auth_rec.get("wechatId"):
auth_rec = {**auth_rec, "wechatId": paid_record["wechatId"]}
return {"ok": True, "token": auth_data.get("token"), "record": auth_rec, "is_new": True}
return {"ok": True, "token": None, "record": paid_record, "is_new": False}
def _query_site_vip_count() -> int:
try:
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=(8, 20),
)
return (r.json().get("totalItems") or 0) if r.status_code == 200 else 0
except requests.exceptions.RequestException as e:
logging.warning(f"salon complete-order PocketBase site_vip 请求超时/失败: {e}")
return 0
cached_amount_cents: int | None = None # 从 poll 路径获取时缓存,避免兜底重复查 ZPAY
max_retries = 4
for attempt in range(max_retries):
total = await asyncio.to_thread(_query_site_vip_count)
if total > 0:
break
if attempt < max_retries - 1:
paid_via = None
pay_price = ""
zpay_result, (xorpay_paid, xorpay_price) = await asyncio.gather(
asyncio.to_thread(_query_zpay_order, order_id),
asyncio.to_thread(_query_xorpay_paid, order_id),
)
if zpay_result:
paid_via = "zpay"
pay_price = str(zpay_result.get("money", "") or zpay_result.get("pay_price", ""))
if not paid_via and xorpay_paid:
paid_via = "xorpay"
pay_price = xorpay_price
if paid_via:
money_y = float(pay_price or 0) if pay_price else 0
cached_amount_cents = int(money_y * 100) if money_y > 0 else 100
from ..services.payment import handle_payment_success, try_add_processed_order, remove_processed_order
if try_add_processed_order(order_id):
price = pay_price if pay_price and float(pay_price or 0) > 0 else "1"
try:
await asyncio.to_thread(
handle_payment_success,
order_id,
price,
f"salon-{paid_via}-poll",
use_memos=False,
site_id=SITE_ID,
)
await asyncio.sleep(0.3) # 短暂等待 PB 写入完成
continue
except Exception as e:
remove_processed_order(order_id)
logging.warning(f"salon complete-order 手动触发支付处理失败: {e}")
await asyncio.sleep(1)
continue
logging.warning(f"salon complete-order 未找到订单: order_id={order_id}, attempt={attempt + 1}")
raise HTTPException(status_code=400, detail="订单不存在或未支付成功")
# 兜底更新 solanRed回调可能未到确保 order_id、amount、vip 正确(按 user_id 查找pending_ 用户记录存的是 pending_xxx
if cached_amount_cents is not None:
amount_cents = cached_amount_cents
else:
zpay_result = await asyncio.to_thread(_query_zpay_order, order_id)
money_yuan = float(zpay_result.get("money", 0) or 0) if zpay_result else 0
amount_cents = int(money_yuan * 100) if money_yuan > 0 else 100
lookup_user_id = user_id
await asyncio.to_thread(_update_solan_red_on_payment, token, lookup_user_id, order_id, amount_cents)
if user_id.startswith("pending_"):
email = _decode_pending_email(user_id)
if not email:
raise HTTPException(status_code=400, detail="订单格式无效")
def _login_pending() -> dict | None:
try:
login_r = requests.post(
f"{base}/api/collections/users/auth-with-password",
json={"identity": email, "password": DEFAULT_PASSWORD},
timeout=(6, 15),
)
if login_r.status_code != 200:
return None
return login_r.json()
except requests.exceptions.RequestException as e:
logging.warning(f"salon complete-order PocketBase login 请求超时/失败: {e}")
return None
auth_data = await asyncio.to_thread(_login_pending)
if not auth_data:
raise HTTPException(status_code=500, detail="登录失败,请稍后重试")
return {
"ok": True,
"token": auth_data.get("token"),
"record": auth_data.get("record"),
"is_new": True,
}
# 非 pending 用户:返回 solanRed 记录供前端显示微信号
def _fetch_solan_record() -> dict | None:
try:
esc = lookup_user_id.replace("\\", "\\\\").replace('"', '\\"')
r = requests.get(
f"{base}/api/collections/solanRed/records",
params={"filter": f'user_id = "{esc}"', "sort": "-created", "perPage": 1},
headers={"Authorization": f"Bearer {token}"},
timeout=(6, 12),
)
if r.status_code == 200:
items = r.json().get("items") or []
if items:
rec = items[0]
return {"wechatId": rec.get("wechatId"), "nickname": rec.get("nickname"), "media": rec.get("media")}
except requests.exceptions.RequestException as e:
logging.warning(f"salon complete-order PocketBase fetch_solan_record 请求超时/失败: {e}")
return None
record = await asyncio.to_thread(_fetch_solan_record)
return {"ok": True, "token": None, "record": record, "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}
def _query_by_wechat_sync(wechat_id: str) -> dict:
"""同步按 wechatId 查询 solanRed供 asyncio.to_thread 调用"""
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 = {
"id": _get(rec, "id"),
"user_id": _get(rec, "user_id"),
"wechatId": _get(rec, "wechatId"),
"nickname": _get(rec, "nickname"),
"media": _get(rec, "media"),
"amount": amount,
"order_id": _get(rec, "order_id"),
"vip": vip,
"is_volunteer": bool(_get(rec, "is_volunteer")),
}
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}
@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}
return await asyncio.to_thread(_query_by_wechat_sync, wechat_id)
def _fetch_volunteers_sync(token: str) -> dict:
"""同步获取志愿者,供 asyncio.to_thread 调用避免阻塞事件循环"""
base = (PB_URL or "").rstrip("/")
if not base:
return {"volunteer": None}
try:
r = requests.get(
f"{base}/api/collections/solanRed/records",
params={
"filter": 'is_volunteer = true && is_approved = true',
"sort": "-created",
"perPage": 1,
},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if r.status_code != 200:
return {"volunteer": None}
data = r.json()
items = data.get("items") or []
volunteer = None
if items:
rec = items[0]
nickname = str(rec.get("xiaohongshu_nickname") or rec.get("nickname") or "").strip() or "志愿者"
media = str(rec.get("media") or "").strip() or None
xiaohongshu_url = str(rec.get("xiaohongshu_url") or "").strip() or None
volunteer = {
"nickname": nickname,
"avatar": media if media else None,
"xiaohongshu_url": xiaohongshu_url if xiaohongshu_url else None,
}
return {"volunteer": volunteer}
except Exception as e:
logging.warning(f"salon join volunteers 查询失败: {e}")
return {"volunteer": None}
@router.get("/join/volunteers")
async def join_volunteers():
"""获取志愿者列表 - solanRed 中 is_volunteer=true && is_approved=true"""
token, _ = _get_admin_token()
if not token:
return {"volunteer": None}
return await asyncio.to_thread(_fetch_volunteers_sync, token)
class XiaohongshuProfileRequest(BaseModel):
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:
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 URLm/ 为主页短链)"""
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 _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")
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*</script>", 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
# 优先匹配「数字万?+? 关注/粉丝/获赞与收藏」格式,如 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
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+粉丝
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)
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"<title>(.*?)</title>", response.text, re.I | re.S)
if title_match:
title_text = re.sub(r"\s+", " ", title_match.group(1)).strip()
fallback_nick = title_text.split(" - ")[0].lstrip("@").strip()
if fallback_nick:
parsed = _merge_profile(parsed, {"nickname": fallback_nick})
if parsed and parsed.get("nickname") and parsed.get("postCount", 0) > 0:
parsed["resolvedUrl"] = resolved_url
return parsed
except Exception as exc:
logging.debug(f"[xiaohongshu] html fetch fallback failed: {exc}")
try:
from playwright.async_api import async_playwright
except ImportError:
logging.warning("playwright is not installed; run `pip install playwright && playwright install chromium`")
if parsed:
parsed["resolvedUrl"] = resolved_url
return parsed
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()
async def handle_route(route):
if route.request.resource_type in {"image", "media", "font"}:
await route.abort()
else:
await route.continue_()
await page.route("**/*", handle_route)
await page.goto(resolved_url, wait_until="domcontentloaded", timeout=20000)
await page.wait_for_timeout(2500)
state = await page.evaluate("""() => {
const unwrap = (value) => {
if (!value) return value ?? null;
if (typeof value !== "object") return value;
if (value.__v_isRef) return unwrap(value._rawValue ?? value._value);
if (Array.isArray(value)) return value.map(unwrap);
const out = {};
for (const [key, nested] of Object.entries(value)) {
if (key === "dep") continue;
out[key] = unwrap(nested);
}
return out;
};
const w = window;
return unwrap(w.__INITIAL_STATE__ || w.__INITIAL_SSR_STATE__ || null);
}""")
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
nick_from_body = _extract_nickname_from_body(body_text or "")
fallback_nick = nick_from_title or nick_from_body or None
parsed = _merge_profile(parsed, _parse_profile_from_state(state, target_user_id))
parsed = _merge_profile(parsed, dom_parsed)
if fallback_nick:
parsed = _merge_profile(parsed, {"nickname": fallback_nick})
if not parsed or not parsed.get("nickname"):
logging.debug(f"[xiaohongshu] no state, title={title!r}, body_preview={body_text[:200] if body_text else ''!r}")
return None
parsed["resolvedUrl"] = page.url or resolved_url
return parsed
except Exception as e:
logging.warning(f"[xiaohongshu] fetch error: {e}", exc_info=True)
try:
await browser.close()
except Exception:
pass
if parsed:
parsed["resolvedUrl"] = resolved_url
return parsed
@router.post("/xiaohongshu/profile")
async def xiaohongshu_profile(item: XiaohongshuProfileRequest):
"""获取小红书用户资料支持主页链接、xhslink.com/m/ 短链、笔记链接 xhslink.com/o/"""
logging.info("[xiaohongshu] profile request received")
url_raw = (item.get_url_or_id() or "").strip()
if not url_raw:
logging.warning("[xiaohongshu] 400: 请输入小红书链接")
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 _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="链接解析失败,请确认链接有效")
parsed = await _fetch_xiaohongshu_profile(url)
if not parsed:
logging.warning(f"[xiaohongshu] 400: 无法解析用户资料 url={url[:80]}")
raise HTTPException(status_code=400, detail="无法解析用户资料,请确认链接有效")
logging.info(f"[xiaohongshu] 200 ok: nickname={parsed.get('nickname', '')}")
return {"ok": True, **parsed}
@router.post("/join")
async def submit_join(item: dict):
"""salon 加入社区报名表单,写入 solanRed 集合"""
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
pb_client = get_pb_client()
solan = pb_client.collection("solanRed")
raw_type = item.get("type", "")
# PocketBase type 仅接受 session-1/session-2 等digital-nomad/volunteer 映射为空
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": reason,
"wechatId": item.get("wechatId", ""),
"gender": item.get("gender", ""),
"education": item.get("education", ""),
"gradYear": item.get("gradYear", ""),
"single": item.get("single", ""),
"media": item.get("media", ""),
"calculated_age": item.get("calculated_age", 0),
"type": pb_type,
"order_id": item.get("order_id", ""),
"amount": item.get("amount", 0),
"xiaohongshu_url": item.get("xiaohongshu_url", ""),
"is_volunteer": bool(item.get("is_volunteer", False)),
"is_wechat_friend": bool(item.get("is_wechat_friend", False)),
"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():
pb_data["phone"] = str(phone_val).strip()
record = solan.create(pb_data)
logging.info(f"salon 申请表单提交成功 - user_id: {user_id}, record_id: {record.id}")
return {"ok": True, "user_id": user_id, "record_id": record.id}
except Exception as e:
logging.error(f"salon join 提交失败: {e}")
raise HTTPException(status_code=500, detail="submit failed")