Files
geo/apps/desktop-client/src/main/adapters/bilibili.ts
T
root 0ce3f51f62 feat(publish): add bilibili and qiehao desktop adapters
Wire bilibili and qiehao (om.qq.com) into the publish adapter registry
and auth-failure classifier. Bilibili bind uses WBI-signed space-info
fallback to retrieve the avatar when nav lacks a face URL, and the
account list image renders with a no-referrer policy so bilibili CDN
serves the avatar cross-origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:57:04 +08:00

668 lines
19 KiB
TypeScript

import { createHash } from "node:crypto";
import type { JsonValue } from "@geo/shared-types";
import { renderTablesAsParagraphRows } from "../../../../../packages/publisher-platforms/src/baijiahao";
import { resolveDesktopApiURL } from "../transport/api-client";
import {
fetchImageBlob,
normalizeArticleHtml,
sessionCookieHeader,
sessionCookieValue,
sessionFetchJson,
uploadHtmlImages,
} from "./common";
import type { PublishAdapter, PublishAdapterContext } from "./base";
const BILIBILI_API_ORIGIN = "https://api.bilibili.com";
const BILIBILI_MEMBER_ORIGIN = "https://member.bilibili.com";
const BILIBILI_EDITOR_URL = `${BILIBILI_MEMBER_ORIGIN}/read/editor/#/web?newEditor=-1`;
const BILIBILI_MANAGE_URL = `${BILIBILI_MEMBER_ORIGIN}/platform/upload/text/manage`;
const BILIBILI_CREATION_LIST_URL =
`${BILIBILI_API_ORIGIN}/x/polymer/web-dynamic/v1/opus/creationlist?ps=10&pn=1&classification_type=0`;
type BilibiliNavResponse = {
code?: number;
message?: string;
data?: {
isLogin?: boolean;
mid?: string | number;
uname?: string;
face?: string;
wbi_img?: {
img_url?: string;
sub_url?: string;
};
};
};
type BilibiliUploadResponse = {
code?: number;
message?: string;
msg?: string;
data?: {
image_url?: string;
};
};
type BilibiliCreationListResponse = {
code?: number;
message?: string;
data?: {
items?: Array<{
dyn_id?: string | number;
opus_id?: string | number;
title?: string;
pub_time?: string;
}>;
};
};
type BilibiliAccount = {
uid: string;
name: string;
};
type BilibiliEditorResult = {
success: boolean;
message?: string;
url?: string;
};
const mixinKeyEncTab = [
46, 47, 18, 2, 53, 8, 23, 32,
15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19,
29, 28, 14, 39, 12, 38, 41, 13,
37, 48, 7, 16, 24, 55, 40, 61,
26, 17, 0, 1, 60, 51, 30, 4,
22, 25, 54, 21, 56, 59, 6, 63,
57, 62, 11, 36, 20, 34, 44, 52,
];
function stringId(value: unknown): string {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : "";
}
if (typeof value !== "string") {
return "";
}
return value.trim();
}
async function bilibiliCookieHeader(context: PublishAdapterContext): Promise<string> {
return await sessionCookieHeader(context.session, "bilibili.com").catch(() => "");
}
async function bilibiliHeaders(
context: PublishAdapterContext,
extra: Record<string, string> = {},
): Promise<Record<string, string>> {
const cookie = await bilibiliCookieHeader(context);
return {
accept: "application/json, text/plain, */*",
origin: BILIBILI_MEMBER_ORIGIN,
referer: BILIBILI_MEMBER_ORIGIN,
...(cookie ? { cookie } : {}),
...extra,
};
}
async function fetchNav(context: PublishAdapterContext): Promise<BilibiliNavResponse | null> {
return await sessionFetchJson<BilibiliNavResponse>(
context.session,
`${BILIBILI_API_ORIGIN}/x/web-interface/nav?build=0&mobi_app=web`,
{
credentials: "include",
headers: await bilibiliHeaders(context),
},
).catch(() => null);
}
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
const text = await response.text();
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`);
}
if (!text) {
return {} as T;
}
return JSON.parse(text) as T;
}
function accountFromNav(response: BilibiliNavResponse | null): BilibiliAccount | null {
const user = response?.data;
const uid = stringId(user?.mid);
if (response?.code !== 0 || !user?.isLogin || !uid || !user.uname) {
return null;
}
return {
uid,
name: user.uname,
};
}
function buildMixinKey(imgKey: string, subKey: string): string {
const raw = imgKey + subKey;
return mixinKeyEncTab
.map((index) => raw.charAt(index))
.filter(Boolean)
.join("")
.slice(0, 32);
}
function keyFromWbiURL(value: string | undefined): string {
const source = value?.trim() || "";
if (!source) {
return "";
}
const slashIndex = source.lastIndexOf("/");
const dotIndex = source.lastIndexOf(".");
if (slashIndex < 0 || dotIndex <= slashIndex) {
return "";
}
return source.slice(slashIndex + 1, dotIndex);
}
async function buildWbiQuery(context: PublishAdapterContext, params: Record<string, string | number> = {}): Promise<string> {
const nav = await fetchNav(context);
const imgKey = keyFromWbiURL(nav?.data?.wbi_img?.img_url);
const subKey = keyFromWbiURL(nav?.data?.wbi_img?.sub_url);
if (!imgKey || !subKey) {
throw new Error("bilibili_wbi_key_missing");
}
const mixinKey = buildMixinKey(imgKey, subKey);
const next: Record<string, string | number> = {
...params,
wts: Math.round(Date.now() / 1000),
};
const illegalChars = /[!'()*]/g;
const query = Object.keys(next)
.sort()
.map((key) => {
const value = String(next[key]).replace(illegalChars, "");
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join("&");
const wRid = createHash("md5").update(`${query}${mixinKey}`).digest("hex");
return `${query}&w_rid=${wRid}`;
}
function buildAssetURLCandidates(sourceUrl: string): string[] {
const trimmed = sourceUrl.trim();
if (!trimmed) {
return [];
}
if (/^(data|blob):/i.test(trimmed)) {
return [trimmed];
}
const candidates: string[] = [];
const pushCandidate = (value: string) => {
if (!candidates.includes(value)) {
candidates.push(value);
}
};
try {
const parsed = new URL(trimmed, resolveDesktopApiURL("/"));
if (parsed.pathname.startsWith("/api/")) {
const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`));
if (apiAssetUrl.pathname.startsWith("/api/public/assets/")) {
apiAssetUrl.searchParams.set("format", "png");
}
pushCandidate(apiAssetUrl.toString());
}
pushCandidate(parsed.toString());
} catch {
pushCandidate(trimmed);
}
return candidates;
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate);
if (blob) {
return blob;
}
}
return null;
}
async function uploadImage(context: PublishAdapterContext, sourceUrl: string): Promise<string | null> {
const blob = await fetchAssetBlob(sourceUrl);
if (!blob) {
throw new Error("bilibili_image_fetch_failed");
}
const csrf = await sessionCookieValue(context.session, "bilibili.com", "bili_jct").catch(() => "");
if (!csrf) {
throw new Error("bilibili_csrf_missing");
}
const form = new FormData();
form.append("file_up", blob, "blob");
form.append("biz", "article");
form.append("category", "daily");
form.append("csrf", csrf);
const query = await buildWbiQuery(context);
const response = await mainFetchJson<BilibiliUploadResponse>(
`${BILIBILI_API_ORIGIN}/x/dynamic/feed/draw/upload_bfs?${query}`,
{
method: "POST",
headers: await bilibiliHeaders(context),
body: form,
},
).catch((error): BilibiliUploadResponse => ({
code: -1,
message: error instanceof Error ? error.message : "bilibili_image_upload_failed",
}));
const imageUrl = response.data?.image_url?.trim() || "";
if (response.code !== 0 || !imageUrl) {
throw new Error(response.message || response.msg || "bilibili_image_upload_failed");
}
return imageUrl;
}
async function processContentImages(context: PublishAdapterContext, html: string): Promise<string> {
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, sourceUrl),
);
return processed.html;
}
async function fillAndSubmitEditor(
context: PublishAdapterContext,
title: string,
html: string,
): Promise<BilibiliEditorResult> {
const page = context.playwright?.page;
if (!page) {
return {
success: false,
message: "bilibili_playwright_page_missing",
};
}
await page.goto(BILIBILI_EDITOR_URL, {
waitUntil: "domcontentloaded",
timeout: 45_000,
});
await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined);
return await page.evaluate(
async ({ title: nextTitle, html: nextHtml }): Promise<BilibiliEditorResult> => {
const sleep = (ms: number) => new Promise((resolve) => {
setTimeout(resolve, ms);
});
const normalizeText = (value: string | null | undefined) => (value || "").trim().replace(/\s+/g, "");
const waitForElement = async <T extends Element>(
selector: string,
timeoutMs: number,
predicate?: (element: T) => boolean,
): Promise<T | null> => {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const elements = Array.from(document.querySelectorAll(selector)) as T[];
const found = predicate ? elements.find(predicate) : elements[0];
if (found) {
return found;
}
await sleep(150);
}
return null;
};
const inputTextareaValue = (selector: string, value: string): boolean => {
const element = document.querySelector(selector) as HTMLTextAreaElement | HTMLInputElement | null;
if (!element) {
return false;
}
const prototype = element instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
if (setter) {
setter.call(element, value);
} else {
element.value = value;
}
element.focus();
element.dispatchEvent(new InputEvent("input", {
bubbles: true,
cancelable: true,
inputType: "insertText",
data: value,
}));
element.dispatchEvent(new Event("change", { bubbles: true }));
element.blur();
return true;
};
const prepareEditorHtml = (source: string): string => {
const parsed = new DOMParser().parseFromString(source, "text/html");
parsed.querySelectorAll("img").forEach((image) => {
image.setAttribute("alt", "read-normal-img");
image.setAttribute("data-aigc", "false");
image.setAttribute("data-status", "loaded");
const parent = image.parentElement;
if (parent?.tagName === "P") {
parent.className = "normal-img";
parent.setAttribute("contenteditable", "false");
return;
}
if (parent) {
const wrapper = parsed.createElement("p");
wrapper.className = "normal-img";
wrapper.setAttribute("contenteditable", "false");
parent.replaceChild(wrapper, image);
wrapper.appendChild(image);
}
});
return parsed.body.innerHTML;
};
const pasteHtmlIntoEditor = (source: string): boolean => {
const editor = document.querySelector(".rql-editor") as (HTMLElement & { __quill?: {
setText?: (text: string) => void;
clipboard?: { dangerouslyPasteHTML?: (index: number, html: string) => void };
} }) | null;
if (editor?.__quill?.clipboard?.dangerouslyPasteHTML) {
editor.__quill.setText?.("");
editor.__quill.clipboard.dangerouslyPasteHTML(0, prepareEditorHtml(source));
return true;
}
const fallback = document.querySelector(".ql-editor, [contenteditable='true']") as HTMLElement | null;
if (!fallback) {
return false;
}
fallback.innerHTML = prepareEditorHtml(source);
fallback.dispatchEvent(new InputEvent("input", {
bubbles: true,
cancelable: true,
inputType: "insertFromPaste",
data: source,
}));
return true;
};
const clickButtonByText = async (texts: string[], timeoutMs: number): Promise<boolean> => {
const button = await waitForElement<HTMLButtonElement>("button", timeoutMs, (candidate) => {
const text = normalizeText(candidate.innerText || candidate.textContent);
return texts.some((target) => text.includes(normalizeText(target)));
});
if (!button) {
return false;
}
button.click();
return true;
};
const titleReady = await waitForElement<HTMLTextAreaElement>(
".b-read-editor__title textarea, textarea",
15_000,
);
if (!titleReady) {
return {
success: false,
message: "bilibili_editor_not_ready",
url: location.href,
};
}
const titleFilled = inputTextareaValue(".b-read-editor__title textarea", nextTitle)
|| inputTextareaValue("textarea", nextTitle);
if (!titleFilled) {
return {
success: false,
message: "bilibili_title_input_missing",
url: location.href,
};
}
const editorReady = await waitForElement<HTMLElement>(".rql-editor, .ql-editor, [contenteditable='true']", 15_000);
if (!editorReady || !pasteHtmlIntoEditor(nextHtml)) {
return {
success: false,
message: "bilibili_editor_input_missing",
url: location.href,
};
}
await sleep(500);
const submitted = await clickButtonByText(["提交文章", "发布"], 8_000);
if (!submitted) {
return {
success: false,
message: "bilibili_submit_button_missing",
url: location.href,
};
}
await sleep(3_000);
const confirmed = await clickButtonByText(["确认提交", "确定", "确认"], 1_500);
if (confirmed) {
await sleep(2_000);
}
return {
success: true,
url: location.href,
};
},
{
title,
html,
},
);
}
function titleEquals(left: string | undefined, right: string): boolean {
return (left || "").trim() === right.trim();
}
function parseBilibiliPubTime(value: string | undefined): number | null {
const source = value?.trim();
if (!source) {
return null;
}
const parsed = Date.parse(source.replace(/-/g, "/"));
return Number.isFinite(parsed) ? parsed : null;
}
async function findPublishedArticle(
context: PublishAdapterContext,
title: string,
startedAt: number,
): Promise<{ id: string; url: string } | null> {
const response = await sessionFetchJson<BilibiliCreationListResponse>(
context.session,
BILIBILI_CREATION_LIST_URL,
{
credentials: "include",
headers: await bilibiliHeaders(context),
},
).catch(() => null);
const items = response?.data?.items ?? [];
const matched = items.find((item) => {
if (!titleEquals(item.title, title)) {
return false;
}
const publishedAt = parseBilibiliPubTime(item.pub_time);
return publishedAt === null || publishedAt + 60_000 >= startedAt;
});
const id = stringId(matched?.opus_id ?? matched?.dyn_id);
if (!id) {
return null;
}
return {
id,
url: `https://www.bilibili.com/opus/${encodeURIComponent(id)}`,
};
}
function buildResultPayload(
articleId: string,
mediaName: string,
externalManageUrl: string,
externalArticleUrl: string | null,
): Record<string, JsonValue> {
return {
platform: "bilibili",
media_name: mediaName,
external_article_id: articleId,
external_manage_url: externalManageUrl,
external_article_url: externalArticleUrl,
publish_type: "publish",
};
}
function buildFailureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error);
if (message === "bilibili_not_logged_in") {
return {
status: "failed",
summary: "bilibili 登录态失效,无法执行发布。",
error: {
code: "bilibili_not_logged_in",
message,
},
};
}
if (message === "bilibili_csrf_missing") {
return {
status: "failed",
summary: "bilibili CSRF Cookie 缺失,请重新绑定账号后再试。",
error: {
code: "bilibili_csrf_missing",
message,
},
};
}
if (message === "bilibili_wbi_key_missing") {
return {
status: "failed",
summary: "bilibili WBI 签名参数获取失败,请重新打开 B 站后再试。",
error: {
code: "bilibili_wbi_key_missing",
message,
},
};
}
if (message === "article_content_empty") {
return {
status: "failed",
summary: "文章内容为空,无法推送到 bilibili。",
error: {
code: "article_content_empty",
message,
},
};
}
if (message === "bilibili_image_fetch_failed" || message.startsWith("bilibili_image_fetch_failed:")) {
return {
status: "failed",
summary: "bilibili 正文图片读取失败,请检查文章图片后重试。",
error: {
code: "bilibili_image_fetch_failed",
message,
},
};
}
if (message.includes("editor_not_ready") || message.includes("input_missing") || message.includes("button_missing")) {
return {
status: "failed",
summary: "bilibili 专栏编辑器未就绪,请打开创作中心确认账号状态后重试。",
error: {
code: "bilibili_editor_unavailable",
message,
},
};
}
if (message === "bilibili_publish_result_missing") {
return {
status: "failed",
summary: "bilibili 已执行提交,但未在作品列表中查到新文章,请手动检查创作中心。",
error: {
code: "bilibili_publish_result_missing",
message,
},
};
}
return {
status: "failed",
summary: "bilibili 发布失败。",
error: {
code: "bilibili_publish_failed",
message,
},
};
}
export const bilibiliAdapter: PublishAdapter = {
platform: "bilibili",
executionMode: "playwright",
async publish(context) {
try {
context.reportProgress("bilibili.detect_login");
const account = accountFromNav(await fetchNav(context));
if (!account) {
throw new Error("bilibili_not_logged_in");
}
const html = renderTablesAsParagraphRows(normalizeArticleHtml(context.article));
if (!html) {
throw new Error("article_content_empty");
}
context.reportProgress("bilibili.upload_content_images");
const processedHtml = await processContentImages(context, html);
const title = context.article.title?.trim() || "未命名文章";
const startedAt = Date.now();
context.reportProgress("bilibili.submit_editor");
const editorResult = await fillAndSubmitEditor(context, title, processedHtml);
if (!editorResult.success) {
throw new Error(editorResult.message || "bilibili_publish_failed");
}
context.reportProgress("bilibili.resolve_article_url");
const article = await findPublishedArticle(context, title, startedAt);
if (!article) {
throw new Error("bilibili_publish_result_missing");
}
return {
status: "succeeded",
payload: buildResultPayload(article.id, account.name, BILIBILI_MANAGE_URL, article.url),
summary: "bilibili 发布成功。",
};
} catch (error) {
return buildFailureResult(error);
}
},
};