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 type { PublishAdapter, PublishAdapterContext } from './base' import { fetchImageBlob, normalizeArticleHtml, sessionCookieHeader, sessionCookieValue, sessionFetchJson, uploadHtmlImages, } from './common' 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) } }, }