MB-X Bilibili Pipeline
6 days ago da3fb90457c961937a98000874dfc22c3f789ec6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
(() => {
  "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;
})();