's'
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user