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 { return await sessionCookieHeader(context.session, "bilibili.com").catch(() => ""); } async function bilibiliHeaders( context: PublishAdapterContext, extra: Record = {}, ): Promise> { 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 { return await sessionFetchJson( 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(input: string, init?: RequestInit): Promise { 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 = {}): Promise { 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 = { ...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 { 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 { 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( `${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 { const processed = await uploadHtmlImages( html, async (sourceUrl) => uploadImage(context, sourceUrl), ); return processed.html; } async function fillAndSubmitEditor( context: PublishAdapterContext, title: string, html: string, ): Promise { 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 => { const sleep = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); const normalizeText = (value: string | null | undefined) => (value || "").trim().replace(/\s+/g, ""); const waitForElement = async ( selector: string, timeoutMs: number, predicate?: (element: T) => boolean, ): Promise => { 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 => { const button = await waitForElement("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( ".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(".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( 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 { 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 extends Promise ? 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); } }, };