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