front/xhs-agent-desktop/engine/scripts/xhs/extract_feeds_state.py
zzh 5a26ae0048 feat: 小红书模块迁移、登录重构与 xhs-agent-desktop 桌面 Agent
- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手)

- 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度

- 登录页重构,整合微信登录与 OMS JWT 认证

- 订单、渠道、用户中心等页面与 API 增强

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:13:13 +08:00

347 lines
11 KiB
Python
Raw Permalink 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.

"""从页面 __INITIAL_STATE__ 或 DOM 提取笔记列表(首页 / 搜索共用)。"""
from __future__ import annotations
import json
import logging
import re
from .cdp import Page
logger = logging.getLogger(__name__)
_EXTRACT_STATE_FEEDS_JS = """
(() => {
function unwrap(v) {
if (v == null) return null;
if (Array.isArray(v)) return v;
if (typeof v !== "object") return null;
if (Array.isArray(v.value)) return v.value;
if (Array.isArray(v._value)) return v._value;
if (v.value !== undefined) return unwrap(v.value);
if (v._value !== undefined) return unwrap(v._value);
return null;
}
function str(v) {
if (v == null) return "";
return String(v).trim();
}
function compact(item) {
if (!item || typeof item !== "object") return null;
let nc = item.noteCard || item.note_card || item.note;
if (!nc || typeof nc !== "object") nc = {};
if (!nc.displayTitle && !nc.title && (item.displayTitle || item.title)) {
nc = Object.assign({}, item, nc);
}
const user = Object.assign({}, nc.user || {}, item.user || {});
const interact = Object.assign(
{},
nc.interactInfo || nc.interact_info || {},
item.interactInfo || item.interact_info || item.interact || {},
);
const id = str(item.id || nc.id || nc.noteId || item.note_id);
if (!id) return null;
return {
id,
xsecToken: str(item.xsecToken || item.xsec_token || nc.xsecToken || nc.xsec_token),
modelType: str(item.modelType || item.model_type || nc.type),
noteCard: {
displayTitle: str(nc.displayTitle || nc.display_title || nc.title || item.title),
type: str(nc.type || item.type),
user: {
nickname: str(user.nickname || user.nickName || user.nick_name || user.name),
userId: str(user.userId || user.user_id || user.id),
},
interactInfo: {
likedCount: str(
interact.likedCount || interact.liked_count || interact.liked || item.liked_count || "",
),
commentCount: str(
interact.commentCount || interact.comment_count || interact.comments || "",
),
collectedCount: str(interact.collectedCount || interact.collected_count || ""),
},
},
};
}
const st = window.__INITIAL_STATE__;
if (!st) return "";
const candidates = [
() => st.search && st.search.feeds,
() => st.search && st.search.notes,
() => st.search && st.search.items,
() => st.feed && st.feed.feeds,
() => st.feeds,
() => st.homefeed && st.homefeed.feeds,
() => st.homeFeed && st.homeFeed.feeds,
];
for (const pick of candidates) {
try {
const data = unwrap(pick());
if (data && data.length) {
const out = data.map(compact).filter(Boolean);
if (out.length) return JSON.stringify(out);
}
} catch (e) {}
}
return "";
})()
"""
_EXTRACT_DOM_FEEDS_JS = """
(() => {
function clean(t) {
return (t || "").replace(/\\s+/g, " ").trim();
}
function parseCount(text) {
const t = clean(text);
if (!t) return "";
const m = t.match(/^([\\d.]+\\s*[万亿]?)\\s*$/);
return m ? m[1] : t;
}
function pickAuthor(card) {
const sels = [
'[class*="author"] [class*="name"]',
'[class*="user-name"]',
'[class*="nickname"]',
'[class*="name"][class*="user"]',
'a[href*="/user/"] span',
'.author-wrapper .name',
'.user .name',
];
for (const sel of sels) {
const el = card.querySelector(sel);
const t = clean(el && el.textContent);
if (t && t.length < 40) return t;
}
const userLink = card.querySelector('a[href*="/user/"]');
if (userLink) {
const t = clean(userLink.textContent);
if (t && t.length < 40) return t;
}
return "";
}
function pickLike(card) {
const sels = [
'[class*="like-wrapper"] span',
'[class*="liked"] span',
'[class*="like"] span.count',
'[class*="engage"] [class*="like"]',
'span[class*="count"]',
];
for (const sel of sels) {
const nodes = card.querySelectorAll(sel);
for (const el of nodes) {
const t = clean(el.textContent);
if (/^[\\d.]+[万亿]?$/.test(t)) return parseCount(t);
}
}
const text = clean(card.innerText || "");
const m = text.match(/([\\d.]+[万亿]?)\\s*赞/);
if (m) return m[1];
return "";
}
const seen = new Set();
const out = [];
const anchors = document.querySelectorAll('a[href*="/explore/"]');
for (const a of anchors) {
const href = (a.getAttribute("href") || a.href || "");
const m = href.match(/\\/explore\\/([a-f0-9]{16,24})/i);
if (!m) continue;
const id = m[1];
if (seen.has(id)) continue;
seen.add(id);
let token = "";
const tok = href.match(/xsec_token=([^&]+)/i);
if (tok) {
try { token = decodeURIComponent(tok[1]); } catch (e) { token = tok[1]; }
}
const card =
a.closest("section, article, li, [class*='note'], [class*='feed'], [class*='card']") ||
a.parentElement ||
a;
const titleEl = card.querySelector(
"[class*='title'], .title, h3, h4, [class*='desc']",
);
const title = clean((titleEl && titleEl.textContent) || a.textContent)
.split("\\n")
.map(s => s.trim())
.filter(Boolean)[0] || "(无标题)";
const author = pickAuthor(card);
const liked = pickLike(card);
out.push({
id,
xsecToken: token,
modelType: "normal",
noteCard: {
displayTitle: title.slice(0, 200),
user: { nickname: author },
interactInfo: {
likedCount: liked,
commentCount: "",
collectedCount: "",
},
},
});
}
return JSON.stringify(out);
})()
"""
_FEED_ID_RE = re.compile(r"^[a-f0-9]{16,24}$", re.I)
def _note_block(item: dict) -> dict:
for key in ("noteCard", "note_card", "note"):
block = item.get(key)
if isinstance(block, dict):
return block
return {}
def _feed_richness(item: dict) -> int:
note = _note_block(item)
user = note.get("user") if isinstance(note.get("user"), dict) else {}
interact = note.get("interactInfo") if isinstance(note.get("interactInfo"), dict) else {}
if not interact and isinstance(note.get("interact_info"), dict):
interact = note["interact_info"]
score = 0
nick = str(user.get("nickname") or user.get("nickName") or user.get("nick_name") or "").strip()
if nick and nick != "—":
score += 3
liked = str(interact.get("likedCount") or interact.get("liked_count") or "").strip()
if liked and liked != "0":
score += 2
title = str(note.get("displayTitle") or note.get("display_title") or note.get("title") or "").strip()
if title and title != "(无标题)":
score += 1
token = str(item.get("xsecToken") or item.get("xsec_token") or "").strip()
if token:
score += 1
return score
def _merge_feed_records(base: dict, incoming: dict) -> dict:
"""合并两条同源笔记记录,保留更完整的作者/互动数据。"""
if _feed_richness(incoming) > _feed_richness(base):
rich, poor = incoming, base
else:
rich, poor = base, incoming
out = json.loads(json.dumps(rich, ensure_ascii=False))
poor_note = _note_block(poor)
out_note = _note_block(out)
if not out_note:
out["noteCard"] = json.loads(json.dumps(poor_note, ensure_ascii=False))
out_note = out["noteCard"]
poor_user = poor_note.get("user") if isinstance(poor_note.get("user"), dict) else {}
out_user = out_note.get("user") if isinstance(out_note.get("user"), dict) else {}
if not out_user:
out_note["user"] = {}
out_user = out_note["user"]
for key in ("nickname", "nickName", "nick_name"):
if not str(out_user.get(key) or "").strip() and poor_user.get(key):
out_user["nickname"] = poor_user[key]
break
poor_inter = poor_note.get("interactInfo") or poor_note.get("interact_info") or {}
if not isinstance(poor_inter, dict):
poor_inter = {}
out_inter = out_note.get("interactInfo") or out_note.get("interact_info") or {}
if not isinstance(out_inter, dict):
out_inter = {}
out_note["interactInfo"] = out_inter
for key in ("likedCount", "liked_count", "commentCount", "comment_count", "collectedCount"):
if not str(out_inter.get(key) or "").strip() and poor_inter.get(key):
canon = "likedCount" if "like" in key else (
"commentCount" if "comment" in key else "collectedCount"
)
out_inter[canon] = poor_inter[key]
if not str(out.get("xsecToken") or out.get("xsec_token") or "").strip():
tok = poor.get("xsecToken") or poor.get("xsec_token")
if tok:
out["xsecToken"] = tok
if not str(out.get("id") or "").strip() and poor.get("id"):
out["id"] = poor["id"]
title = str(out_note.get("displayTitle") or out_note.get("title") or "").strip()
if not title or title == "(无标题)":
pt = poor_note.get("displayTitle") or poor_note.get("title")
if pt:
out_note["displayTitle"] = pt
return out
def _feed_key(item: dict) -> str | None:
fid = str(item.get("id") or item.get("feed_id") or "").strip()
if fid and _FEED_ID_RE.match(fid):
return fid
note = _note_block(item)
nid = str(note.get("id") or note.get("noteId") or "").strip()
if nid and _FEED_ID_RE.match(nid):
return nid
return None
def extract_feeds_list(page: Page) -> list[dict]:
"""先 __INITIAL_STATE__(含作者/点赞),再用 DOM 补全缺失条目。"""
merged: dict[str, dict] = {}
state_count = 0
dom_count = 0
raw = page.evaluate(_EXTRACT_STATE_FEEDS_JS)
if isinstance(raw, str) and raw.strip():
try:
data = json.loads(raw)
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
key = _feed_key(item)
if key:
merged[key] = item
state_count += 1
except json.JSONDecodeError as exc:
logger.warning("解析 __INITIAL_STATE__ feeds 失败: %s", exc)
dom_raw = page.evaluate(_EXTRACT_DOM_FEEDS_JS)
if isinstance(dom_raw, str) and dom_raw.strip():
try:
data = json.loads(dom_raw)
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
key = _feed_key(item)
if not key:
continue
if key in merged:
merged[key] = _merge_feed_records(merged[key], item)
else:
merged[key] = item
dom_count += 1
except json.JSONDecodeError as exc:
logger.warning("解析 DOM feeds 失败: %s", exc)
if merged:
logger.info(
"提取笔记 %d 条(state=%d, dom新增/合并=%d)",
len(merged),
state_count,
dom_count,
)
return list(merged.values())
def merge_feed_dicts(target: dict[str, dict], items: list[dict]) -> int:
added = 0
for item in items:
key = _feed_key(item)
if not key:
continue
if key not in target:
added += 1
target[key] = item
else:
target[key] = _merge_feed_records(target[key], item)
return added