function targetProof(value) { const parsed = new URL(String(value)); const parts = parsed.pathname.split("/").filter(Boolean); if (parsed.protocol !== "https:" || parsed.hostname !== "space.bilibili.com" || parsed.search || parsed.hash || parts.length !== 2 || !/^[1-9][0-9]{0,19}$/u.test(parts[0]) || parts[1] !== "dynamic") throw new Error("E_PAGE_URL"); return {uid: parts[0], dynamic_url: `https://space.bilibili.com/${parts[0]}/dynamic`, profile_url: `https://space.bilibili.com/${parts[0]}`}; } function text(value) { return String(value ?? "").replace(/\s+/gu, " ").trim(); } function canonicalUrl(value, target) { const proof = targetProof(target); const parsed = new URL(String(value), proof.dynamic_url); if (parsed.protocol !== "https:" || parsed.hostname !== "space.bilibili.com") throw new Error("E_PAGE_URL"); parsed.hash = ""; return parsed.toString(); } export function canonicalizeSnapshot(raw, target) { const proof = targetProof(target); if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("E_PAGE_SHAPE"); const cards = Array.isArray(raw.cards) ? raw.cards : []; const normalized = cards.map((card, position) => { if (!card || typeof card !== "object" || Array.isArray(card)) throw new Error("E_CARD_SHAPE"); const dynamicId = text(card.dynamic_id); const publishedAt = text(card.published_at); const body = text(card.text); const url = canonicalUrl(card.url, target); if (!/^\d{1,32}$/u.test(dynamicId) || !publishedAt || !body) throw new Error("E_CARD_IDENTITY"); return {position, dynamic_id: dynamicId, published_at: publishedAt, text: body, url}; }); const seen = new Set(); for (const card of normalized) { if (seen.has(card.dynamic_id)) throw new Error("E_CARD_DUPLICATE"); seen.add(card.dynamic_id); } const marker = text(raw.terminal_marker_text); if (text(raw.creator?.uid) !== proof.uid || canonicalUrl(raw.creator?.profile_url, target).replace(/\/$/u, "") !== proof.profile_url) throw new Error("E_CREATOR_IDENTITY"); return { schema_version: 1, final_url: canonicalUrl(raw.final_url, target), page_title: text(raw.page_title), ready_state: text(raw.ready_state), visibility_state: text(raw.visibility_state), creator: {uid: proof.uid, name: text(raw.creator?.name), profile_url: proof.profile_url}, cards: normalized, unparsed_nodes: Number(raw.unparsed_nodes ?? 0), terminal_marker_text: marker, coverage_complete: marker === "已经到底了" && Number(raw.unparsed_nodes ?? 0) === 0, }; } export function collectVisiblePage(target) { const proof = targetProof(target); const normalizeText = (value) => String(value ?? "").replace(/\s+/gu, " ").trim(); const normalizeUrl = (value) => { const parsed = new URL(String(value), proof.dynamic_url); if (parsed.protocol !== "https:" || parsed.hostname !== "space.bilibili.com") throw new Error("E_PAGE_URL"); parsed.hash = ""; return parsed.toString(); }; if (normalizeUrl(location.href).replace(/\/$/u, "") !== proof.dynamic_url) throw new Error("E_PAGE_IDENTITY"); const cards = [...document.querySelectorAll("[data-dynamic-id]")].map((node) => ({ dynamic_id: normalizeText(node.getAttribute("data-dynamic-id")), published_at: normalizeText(node.getAttribute("data-published-at") || node.querySelector("time")?.getAttribute("datetime")), text: normalizeText(node.querySelector("[data-dynamic-text]")?.textContent || node.textContent), url: normalizeUrl(node.querySelector("a[href]")?.href || location.href), })); const terminal = [...document.querySelectorAll("body *")].map((node) => String(node.textContent || "").trim()).find((value) => value === "已经到底了") || ""; const seen = new Set(); for (const card of cards) { if (!/^\d{1,32}$/u.test(card.dynamic_id) || !card.published_at || !card.text || seen.has(card.dynamic_id)) throw new Error("E_CARD_IDENTITY"); seen.add(card.dynamic_id); } return { schema_version: 1, final_url: proof.dynamic_url, page_title: normalizeText(document.title), ready_state: normalizeText(document.readyState), visibility_state: normalizeText(document.visibilityState), creator: {uid: proof.uid, name: normalizeText(document.querySelector("h1")?.textContent || ""), profile_url: proof.profile_url}, cards: cards.map((card, position) => ({position, ...card})), unparsed_nodes: 0, terminal_marker_text: terminal, coverage_complete: terminal === "已经到底了", }; }