(() => { "use strict"; const PENDING = new Set(["VIDEO_ABSENT", "METADATA_NOT_READY", "OWNER_PENDING", "DIMENSIONS_PENDING"]); const ACCESS_MARKERS = [ ["HTTP_412", /412|请求被拦截/i], ["CAPTCHA", /验证码|安全验证|captcha/i], ["LOGIN_REQUIRED", /登录后|请先登录/i], ["PAYWALL", /付费后|购买后|充电专属|会员专享/i], ]; const UID = /^[1-9][0-9]{0,19}$/; const ITEM_ID = /^[A-Za-z0-9_-]{1,128}$/; const CONTROL = /[\u0000-\u001f\u007f]/; const IMAGE_HOSTS = new Set(["i0.hdslb.com", "i1.hdslb.com", "i2.hdslb.com"]); function fail(code) { const error = new Error(code); error.code = code; throw error; } function exactKeys(value, keys, code) { if (!value || typeof value !== "object" || Array.isArray(value)) fail(code); const actual = Object.keys(value).sort(); const expected = [...keys].sort(); if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) fail(code); return value; } function canonicalSpaceUrl(raw, uid, dynamic) { const url = new URL(raw); const path = dynamic ? `/${uid}/dynamic` : `/${uid}`; if (url.protocol !== "https:" || url.hostname !== "space.bilibili.com" || url.search || url.hash || url.pathname.replace(/\/$/, "") !== path) { fail("E_PAGE_IDENTITY"); } return `https://space.bilibili.com${path}`; } function validatePublicConfig(raw) { const value = exactKeys(raw, ["creator_uid", "creator_name", "dynamic_url", "profile_url", "include_types", "deadline_ms", "observation_interval_ms", "stable_observations"], "E_CONFIG_SCHEMA"); if (typeof value.creator_uid !== "string" || !UID.test(value.creator_uid)) fail("E_CONFIG_IDENTITY"); if (typeof value.creator_name !== "string" || !value.creator_name.trim() || value.creator_name.length > 80 || CONTROL.test(value.creator_name)) fail("E_CONFIG_IDENTITY"); if (!Array.isArray(value.include_types) || !value.include_types.length || new Set(value.include_types).size !== value.include_types.length || value.include_types.some((item) => !["article", "text", "image"].includes(item))) fail("E_CONFIG_SCHEMA"); if (!Number.isInteger(value.deadline_ms) || value.deadline_ms < 5000 || value.deadline_ms > 600000) fail("E_CONFIG_SCHEMA"); if (!Number.isInteger(value.observation_interval_ms) || value.observation_interval_ms < 100 || value.observation_interval_ms > 10000) fail("E_CONFIG_SCHEMA"); if (!Number.isInteger(value.stable_observations) || value.stable_observations < 2 || value.stable_observations > 5) fail("E_CONFIG_SCHEMA"); return Object.freeze({ ...value, creator_name: value.creator_name.trim(), dynamic_url: canonicalSpaceUrl(value.dynamic_url, value.creator_uid, true), profile_url: canonicalSpaceUrl(value.profile_url, value.creator_uid, false), include_types: Object.freeze([...value.include_types]), }); } function visibleText(document) { const body = document && document.body; return body && typeof body.innerText === "string" ? body.innerText.slice(0, 20000) : ""; } function accessState(document) { const text = visibleText(document); for (const [state, pattern] of ACCESS_MARKERS) { if (pattern.test(text)) return state; } return null; } function canonicalOpusUrl(raw, stableId) { const url = new URL(raw, "https://www.bilibili.com/"); if (url.protocol !== "https:" || url.hostname !== "www.bilibili.com" || url.search || url.hash || url.pathname.replace(/\/$/, "") !== `/opus/${stableId}`) fail("E_ITEM_IDENTITY"); return `https://www.bilibili.com/opus/${stableId}`; } function imageCandidate(raw) { const url = new URL(raw, "https://www.bilibili.com/"); if (url.protocol !== "https:" || !IMAGE_HOSTS.has(url.hostname) || !url.pathname.startsWith("/bfs/") || url.search || url.hash) fail("E_IMAGE_IDENTITY"); return `${url.protocol}//${url.hostname}${url.pathname}`; } function textFrom(root, selectors) { for (const selector of selectors) { const node = root.querySelector(selector); if (node && typeof node.innerText === "string" && node.innerText.trim()) return node.innerText.replace(/\r\n?/g, "\n").trimEnd(); } return ""; } function canonicalJson(value) { if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); if (typeof value === "number" && Number.isSafeInteger(value)) return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`; if (value && typeof value === "object") { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`; } fail("E_READINESS_SCHEMA"); } function canonicalAcceptedItem(raw) { const item = exactKeys( raw, ["stable_id", "item_type", "title", "source_url", "published_at", "body_text", "body_complete", "original_image_candidates"], "E_READINESS_SCHEMA", ); if (typeof item.stable_id !== "string" || !ITEM_ID.test(item.stable_id)) fail("E_ITEM_IDENTITY"); if (!["article", "text", "image"].includes(item.item_type)) fail("E_READINESS_SCHEMA"); if (typeof item.title !== "string" || !item.title.trim() || CONTROL.test(item.title)) fail("E_READINESS_SCHEMA"); if (typeof item.body_text !== "string" || !item.body_text || item.body_complete !== true) fail("E_READINESS_SCHEMA"); if (!Array.isArray(item.original_image_candidates)) fail("E_READINESS_SCHEMA"); const images = item.original_image_candidates.map((candidate) => imageCandidate(candidate)); if (new Set(images).size !== images.length) fail("E_IMAGE_IDENTITY"); const publishedAtEpochMs = Date.parse(item.published_at); if (!Number.isSafeInteger(publishedAtEpochMs)) fail("E_READINESS_SCHEMA"); return { body_complete: true, body_text: item.body_text.replace(/\r\n?/gu, "\n").replace(/\n+$/gu, ""), image_count: images.length, item_type: item.item_type, published_at_epoch_ms: publishedAtEpochMs, source_url: canonicalOpusUrl(item.source_url, item.stable_id), stable_id: item.stable_id, title: item.title.trim(), }; } async function acceptedSnapshotFingerprint(items) { if (!Array.isArray(items)) fail("E_READINESS_SCHEMA"); const canonicalItems = items.map((item) => canonicalAcceptedItem(item)).sort((left, right) => left.stable_id < right.stable_id ? -1 : (left.stable_id > right.stable_id ? 1 : 0)); if (new Set(canonicalItems.map((item) => item.stable_id)).size !== canonicalItems.length) fail("E_ITEM_IDENTITY"); const bytes = new TextEncoder().encode(canonicalJson({items: canonicalItems, schema_version: 1})); const digest = await crypto.subtle.digest("SHA-256", bytes); return [...new Uint8Array(digest)].map((part) => part.toString(16).padStart(2, "0")).join("").toUpperCase(); } function currentOpusSnapshot(document, locationLike, rawConfig) { const config = validatePublicConfig(rawConfig); const access = accessState(document); if (access) return { state: access, reason: access, item: null }; const current = new URL(locationLike.href); const match = current.protocol === "https:" && current.hostname === "www.bilibili.com" ? current.pathname.match(/^\/opus\/([A-Za-z0-9_-]{1,128})\/?$/) : null; if (!match) return { state: "METADATA_NOT_READY", reason: "METADATA_NOT_READY", item: null }; const stableId = match[1]; if (!ITEM_ID.test(stableId)) fail("E_ITEM_IDENTITY"); const ownerProof = document.querySelector(`[data-mid="${CSS.escape(config.creator_uid)}"], [data-user-id="${CSS.escape(config.creator_uid)}"]`); if (!ownerProof) return { state: "OWNER_PENDING", reason: "OWNER_PENDING", item: null }; const bodyText = textFrom(document, [".opus-module-content", ".article-content", ".bili-rich-text__content", "[data-content=\"opus\"]"]); if (!bodyText) return { state: "METADATA_NOT_READY", reason: "METADATA_NOT_READY", item: null }; const title = textFrom(document, ["h1", ".opus-module-title", ".article-title"]) || bodyText.split("\n", 1)[0].slice(0, 80); const published = document.querySelector("time[datetime], [data-published-at]"); const publishedAt = published ? (published.getAttribute("datetime") || published.getAttribute("data-published-at")) : ""; if (!publishedAt || Number.isNaN(Date.parse(publishedAt))) return { state: "METADATA_NOT_READY", reason: "METADATA_NOT_READY", item: null }; const imageNodes = [...document.querySelectorAll(".opus-module-content img[src], .article-content img[src], .bili-rich-text__content img[src]")]; if (imageNodes.some((node) => !node.naturalWidth || !node.naturalHeight)) return { state: "DIMENSIONS_PENDING", reason: "DIMENSIONS_PENDING", item: null }; const images = [...new Set(imageNodes.map((node) => imageCandidate(node.currentSrc || node.src)))]; const itemType = document.querySelector(".article-content, .opus-module-title") ? "article" : (images.length ? "image" : "text"); if (!config.include_types.includes(itemType)) return { state: "READY", reason: "READY", item: null }; return { state: "READY", reason: "READY", item: { stable_id: stableId, item_type: itemType, title, source_url: canonicalOpusUrl(current.href, stableId), published_at: new Date(publishedAt).toISOString(), body_text: bodyText, body_complete: true, original_image_candidates: images, }, }; } async function observeUntilStable(rawConfig, sample, sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))) { const config = validatePublicConfig(rawConfig); if (typeof sample !== "function") fail("E_CONFIG_SCHEMA"); const started = performance.now(); let prior = null; let streak = 0; const observations = []; while (true) { const elapsed = Math.floor(performance.now() - started); if (elapsed > config.deadline_ms) fail("E_READINESS_TIMEOUT"); const snapshot = await sample(); if (!snapshot || typeof snapshot !== "object" || typeof snapshot.state !== "string" || typeof snapshot.reason !== "string") fail("E_READINESS_SCHEMA"); if (PENDING.has(snapshot.state)) { if (snapshot.reason !== snapshot.state) fail("E_READINESS_SCHEMA"); streak = 0; prior = null; observations.push({ elapsed_ms: elapsed, state: snapshot.state, reason: snapshot.reason, snapshot_sha256: null }); } else if (snapshot.state === "READY" && snapshot.reason === "READY") { const acceptedItems = Array.isArray(snapshot.items) ? snapshot.items : (snapshot.item === null ? [] : [snapshot.item]); const digest = await acceptedSnapshotFingerprint(acceptedItems); streak = digest === prior ? streak + 1 : 1; prior = digest; observations.push({ elapsed_ms: elapsed, state: "READY", reason: "READY", snapshot_sha256: digest }); if (streak >= config.stable_observations) return { observations, item: snapshot.item, items: acceptedItems }; } else { fail(snapshot.state.startsWith("E_") ? snapshot.state : "E_ACCESS_CONTROL"); } await sleep(config.observation_interval_ms); } } const api = Object.freeze({ validatePublicConfig, currentOpusSnapshot, acceptedSnapshotFingerprint, observeUntilStable }); if (typeof module === "object" && module.exports) module.exports = api; globalThis.BiliArticleImageCapture = api; })();