(() => {
|
"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;
|
})();
|