fa52d4ca60
Deployment Config CI / Deployment Config (push) Successful in 30s
Desktop Client Build / Resolve Build Metadata (push) Successful in 17s
Frontend CI / Frontend (push) Successful in 3m35s
Backend CI / Backend (push) Successful in 16m50s
Desktop Client Build / Build Desktop Client (push) Successful in 24m2s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 30s
Consolidate per-platform cover/image fetch failure prompts into a single PUBLISH_IMAGE_FETCH_FAILED_MESSAGE constant shared by baijiahao, bilibili, dongchedi, qiehao, smzdm, and zol adapters, and generalize the normalizer to match any *_image_fetch_failed / *_cover_fetch_failed code. Also split desktop publish list loading into initial vs manual-refresh states so the refresh button no longer blanks the table, and fix admin-web active-task detection to cover all pending statuses.
601 lines
16 KiB
TypeScript
601 lines
16 KiB
TypeScript
import { Buffer } from 'node:buffer'
|
|
|
|
import type { JsonValue } from '@geo/shared-types'
|
|
import { nativeImage } from 'electron'
|
|
import {
|
|
prepareBaijiahaoArticleHtml,
|
|
prepareBaijiahaoMarkdown,
|
|
} from '../../../../../packages/publisher-platforms/src/baijiahao'
|
|
|
|
import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-errors'
|
|
import { resolveDesktopApiURL } from '../transport/api-client'
|
|
import type { PublishAdapter, PublishAdapterContext } from './base'
|
|
import {
|
|
normalizeArticleHtml,
|
|
sessionFetchJson,
|
|
sessionFetchText,
|
|
uploadHtmlImages,
|
|
} from './common'
|
|
|
|
type BaijiahaoAppInfoResponse = {
|
|
data?: {
|
|
user?: {
|
|
userid?: string | number
|
|
name?: string
|
|
uname?: string
|
|
username?: string
|
|
avatar?: string
|
|
app_id?: string | number
|
|
}
|
|
}
|
|
}
|
|
|
|
type BaijiahaoPublishResponse = {
|
|
errno?: number
|
|
errmsg?: string
|
|
ret?: {
|
|
id?: string | number
|
|
}
|
|
}
|
|
|
|
type BaijiahaoUploadResponse = {
|
|
errno?: number
|
|
errmsg?: string
|
|
err_msg?: string
|
|
ret?: {
|
|
https_url?: string
|
|
url?: string
|
|
}
|
|
}
|
|
|
|
type BaijiahaoCutResponse = {
|
|
data?: {
|
|
new_url?: string
|
|
}
|
|
}
|
|
|
|
type BaijiahaoUser = NonNullable<NonNullable<BaijiahaoAppInfoResponse['data']>['user']>
|
|
type BaijiahaoImageBlob = {
|
|
blob: Blob
|
|
fileName: string
|
|
}
|
|
|
|
const BAIJIAHAO_SUPPORTED_IMAGE_TYPES = new Set([
|
|
'image/png',
|
|
'image/jpeg',
|
|
'image/jpg',
|
|
'image/gif',
|
|
])
|
|
const BAIJIAHAO_ORIGIN = 'https://baijiahao.baidu.com'
|
|
const BAIJIAHAO_EDIT_URL = `${BAIJIAHAO_ORIGIN}/builder/rc/edit?type=news`
|
|
const BAIJIAHAO_STEP_DELAY_MS = 1400
|
|
const BAIJIAHAO_RISK_CONTROL_PROMPT = '您触发平台风控,请手动发稿一篇并完成验证,方可继续使用'
|
|
|
|
function normalizeUserId(value: unknown): string {
|
|
if (value === null || value === undefined) {
|
|
return ''
|
|
}
|
|
return String(value).trim()
|
|
}
|
|
|
|
function displayNameFromUser(user: BaijiahaoUser): string {
|
|
return user.name?.trim() || user.uname?.trim() || user.username?.trim() || ''
|
|
}
|
|
|
|
function baijiahaoHeaders(headers?: HeadersInit): Headers {
|
|
const next = new Headers(headers)
|
|
if (!next.has('origin')) {
|
|
next.set('origin', BAIJIAHAO_ORIGIN)
|
|
}
|
|
if (!next.has('referer')) {
|
|
next.set('referer', BAIJIAHAO_EDIT_URL)
|
|
}
|
|
return next
|
|
}
|
|
|
|
function waitForHumanPace(signal?: AbortSignal): Promise<void> {
|
|
if (signal?.aborted) {
|
|
return Promise.reject(new Error('adapter_aborted'))
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const cleanup = () => {
|
|
signal?.removeEventListener('abort', onAbort)
|
|
}
|
|
const timeout = setTimeout(() => {
|
|
cleanup()
|
|
resolve()
|
|
}, BAIJIAHAO_STEP_DELAY_MS)
|
|
const onAbort = () => {
|
|
clearTimeout(timeout)
|
|
cleanup()
|
|
reject(new Error('adapter_aborted'))
|
|
}
|
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
})
|
|
}
|
|
|
|
function baijiahaoResponseMessage(
|
|
response:
|
|
| {
|
|
errno?: number
|
|
errmsg?: string
|
|
err_msg?: string
|
|
message?: string
|
|
}
|
|
| null
|
|
| undefined,
|
|
): string {
|
|
return (
|
|
response?.errmsg ||
|
|
response?.err_msg ||
|
|
response?.message ||
|
|
`errno_${response?.errno ?? 'unknown'}`
|
|
)
|
|
}
|
|
|
|
function isBaijiahaoChallengeMessage(message: string): boolean {
|
|
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required)/i.test(
|
|
message,
|
|
)
|
|
}
|
|
|
|
async function fetchAppInfo(context: PublishAdapterContext): Promise<BaijiahaoUser | null> {
|
|
const response = await sessionFetchJson<BaijiahaoAppInfoResponse>(
|
|
context.session,
|
|
'https://baijiahao.baidu.com/builder/app/appinfo',
|
|
{
|
|
credentials: 'include',
|
|
headers: baijiahaoHeaders({
|
|
accept: 'application/json, text/plain, */*',
|
|
}),
|
|
},
|
|
).catch(() => null)
|
|
|
|
return response?.data?.user ?? null
|
|
}
|
|
|
|
async function getPublishToken(context: PublishAdapterContext): Promise<string> {
|
|
const html = await sessionFetchText(context.session, BAIJIAHAO_EDIT_URL, {
|
|
credentials: 'include',
|
|
headers: baijiahaoHeaders({
|
|
accept:
|
|
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
|
}),
|
|
})
|
|
|
|
return /window\.__BJH__INIT__AUTH__\s*=\s*(["'])(.*?)\1/.exec(html)?.[2] ?? ''
|
|
}
|
|
|
|
async function uploadImage(
|
|
context: PublishAdapterContext,
|
|
sourceUrl: string,
|
|
appId: string,
|
|
cropCover: boolean,
|
|
): Promise<string | null> {
|
|
const image = await fetchBaijiahaoImageBlob(sourceUrl)
|
|
if (!image) {
|
|
throw new Error(cropCover ? 'baijiahao_cover_fetch_failed' : 'baijiahao_image_fetch_failed')
|
|
}
|
|
|
|
const form = new FormData()
|
|
form.append('media', image.blob, 'cover.png')
|
|
form.append('org_file_name', 'cover.png')
|
|
form.append('type', 'image')
|
|
form.append('app_id', appId)
|
|
form.append('is_waterlog', '0')
|
|
form.append('save_material', '1')
|
|
form.append('no_compress', '0')
|
|
form.append('article_type', 'news')
|
|
|
|
const uploaded = await sessionFetchJson<BaijiahaoUploadResponse>(
|
|
context.session,
|
|
`${BAIJIAHAO_ORIGIN}/materialui/picture/uploadProxy`,
|
|
{
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: baijiahaoHeaders(),
|
|
body: form,
|
|
},
|
|
).catch((error) => {
|
|
throw new Error(
|
|
error instanceof Error
|
|
? `baijiahao_upload_proxy_request_failed:${error.message}`
|
|
: 'baijiahao_upload_proxy_request_failed',
|
|
)
|
|
})
|
|
|
|
const uploadedUrl = uploaded?.ret?.https_url ?? uploaded?.ret?.url ?? null
|
|
if (!uploadedUrl) {
|
|
const message = baijiahaoResponseMessage(uploaded)
|
|
if (isBaijiahaoChallengeMessage(message)) {
|
|
throw new Error(`baijiahao_challenge_required:${message}`)
|
|
}
|
|
throw new Error(`baijiahao_upload_proxy_failed:${message}`)
|
|
}
|
|
|
|
if (!cropCover) {
|
|
return uploadedUrl
|
|
}
|
|
|
|
const cut = await sessionFetchJson<BaijiahaoCutResponse>(
|
|
context.session,
|
|
`${BAIJIAHAO_ORIGIN}/materialui/picture/auto_cutting`,
|
|
{
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: baijiahaoHeaders({
|
|
'content-type': 'application/x-www-form-urlencoded',
|
|
}),
|
|
body: new URLSearchParams({
|
|
org_url: uploadedUrl,
|
|
type: 'news',
|
|
cutting_type: 'cover_image',
|
|
}),
|
|
},
|
|
).catch((error) => {
|
|
throw new Error(
|
|
error instanceof Error
|
|
? `baijiahao_cover_cut_failed:${error.message}`
|
|
: 'baijiahao_cover_cut_failed',
|
|
)
|
|
})
|
|
|
|
return cut?.data?.new_url ?? uploadedUrl
|
|
}
|
|
|
|
function buildImageURLCandidates(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 fetchBaijiahaoImageBlob(sourceUrl: string): Promise<BaijiahaoImageBlob | null> {
|
|
for (const candidate of buildImageURLCandidates(sourceUrl)) {
|
|
const response = await fetch(candidate).catch(() => null)
|
|
if (!response?.ok) {
|
|
continue
|
|
}
|
|
|
|
const sourceBlob = await response.blob().catch(() => null)
|
|
if (!sourceBlob || !sourceBlob.type.startsWith('image/')) {
|
|
continue
|
|
}
|
|
|
|
return normalizeBaijiahaoImageBlob(sourceBlob)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function normalizeBaijiahaoImageBlob(sourceBlob: Blob): Promise<BaijiahaoImageBlob | null> {
|
|
const sourceType = sourceBlob.type.toLowerCase()
|
|
if (BAIJIAHAO_SUPPORTED_IMAGE_TYPES.has(sourceType)) {
|
|
return {
|
|
blob: sourceBlob,
|
|
fileName:
|
|
sourceType === 'image/gif'
|
|
? 'image.gif'
|
|
: sourceType === 'image/jpeg' || sourceType === 'image/jpg'
|
|
? 'image.jpg'
|
|
: 'image.png',
|
|
}
|
|
}
|
|
|
|
const buffer = Buffer.from(await sourceBlob.arrayBuffer())
|
|
const image = nativeImage.createFromBuffer(buffer)
|
|
if (image.isEmpty()) {
|
|
return null
|
|
}
|
|
|
|
const png = image.toPNG()
|
|
return {
|
|
blob: new Blob([new Uint8Array(png)], { type: 'image/png' }),
|
|
fileName: 'image.png',
|
|
}
|
|
}
|
|
|
|
async function processContentImages(
|
|
context: PublishAdapterContext,
|
|
html: string,
|
|
appId: string,
|
|
): Promise<string> {
|
|
const processed = await uploadHtmlImages(html, async (sourceUrl) => {
|
|
await waitForHumanPace(context.signal)
|
|
return await uploadImage(context, sourceUrl, appId, false)
|
|
})
|
|
|
|
return processed.html
|
|
}
|
|
|
|
function buildActivityList(): string {
|
|
return JSON.stringify([
|
|
{ id: 'ttv', is_checked: 0 },
|
|
{ id: 'ai_tts', is_checked: 0 },
|
|
{ id: 'aigc_bjh_status', is_checked: 0 },
|
|
{ id: 'reward', is_checked: 0 },
|
|
])
|
|
}
|
|
|
|
function buildCoverImages(coverUrl: string): string {
|
|
return JSON.stringify([
|
|
{
|
|
src: coverUrl,
|
|
cropData: {},
|
|
machine_chooseimg: 0,
|
|
isLegal: 0,
|
|
cover_source_tag: 'local',
|
|
},
|
|
])
|
|
}
|
|
|
|
function baijiahaoManageUrl(articleId: string): string {
|
|
return `${BAIJIAHAO_EDIT_URL}&article_id=${encodeURIComponent(articleId)}`
|
|
}
|
|
|
|
function baijiahaoPublicUrl(articleId: string): string {
|
|
return `${BAIJIAHAO_ORIGIN}/s?id=${encodeURIComponent(articleId)}`
|
|
}
|
|
|
|
function buildResultPayload(
|
|
articleId: string,
|
|
mediaName: string,
|
|
externalManageUrl: string,
|
|
externalArticleUrl: string,
|
|
): Record<string, JsonValue> {
|
|
return {
|
|
platform: 'baijiahao',
|
|
media_name: mediaName,
|
|
external_article_id: articleId,
|
|
external_manage_url: externalManageUrl,
|
|
external_article_url: externalArticleUrl,
|
|
publish_type: 'publish',
|
|
}
|
|
}
|
|
|
|
function failureResult(
|
|
error: unknown,
|
|
): ReturnType<PublishAdapter['publish']> extends Promise<infer T> ? T : never {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
|
|
if (message === 'baijiahao_not_logged_in') {
|
|
return {
|
|
status: 'failed',
|
|
summary: '百家号登录态失效,无法执行发布。',
|
|
error: {
|
|
code: 'baijiahao_not_logged_in',
|
|
message,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (message === 'baijiahao_token_missing') {
|
|
return {
|
|
status: 'failed',
|
|
summary: '百家号发布 Token 获取失败,请重新打开百家号后台确认登录态。',
|
|
error: {
|
|
code: 'baijiahao_token_missing',
|
|
message,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (message === 'publish_cover_required') {
|
|
return {
|
|
status: 'failed',
|
|
summary: '百家号发布必须上传封面图。',
|
|
error: {
|
|
code: 'publish_cover_required',
|
|
message,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (message === 'baijiahao_cover_upload_failed') {
|
|
return {
|
|
status: 'failed',
|
|
summary: '百家号封面上传失败,请更换封面图后重试。',
|
|
error: {
|
|
code: 'baijiahao_cover_upload_failed',
|
|
message,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (
|
|
message === 'baijiahao_cover_fetch_failed' ||
|
|
message.startsWith('baijiahao_cover_fetch_failed:')
|
|
) {
|
|
return {
|
|
status: 'failed',
|
|
summary: PUBLISH_IMAGE_FETCH_FAILED_MESSAGE,
|
|
error: {
|
|
code: 'baijiahao_cover_fetch_failed',
|
|
message,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (
|
|
message.startsWith('baijiahao_upload_proxy_failed') ||
|
|
message.startsWith('baijiahao_upload_proxy_request_failed')
|
|
) {
|
|
return {
|
|
status: 'failed',
|
|
summary: '百家号素材上传接口拒绝了封面图。',
|
|
error: {
|
|
code: 'baijiahao_upload_proxy_failed',
|
|
message,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (message.startsWith('baijiahao_cover_cut_failed')) {
|
|
return {
|
|
status: 'failed',
|
|
summary: '百家号封面裁剪接口处理失败。',
|
|
error: {
|
|
code: 'baijiahao_cover_cut_failed',
|
|
message,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (message.startsWith('baijiahao_challenge_required')) {
|
|
return {
|
|
status: 'failed',
|
|
summary: BAIJIAHAO_RISK_CONTROL_PROMPT,
|
|
error: {
|
|
code: 'baijiahao_challenge_required',
|
|
message,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (message === 'article_content_empty') {
|
|
return {
|
|
status: 'failed',
|
|
summary: '文章内容为空,无法推送到百家号。',
|
|
error: {
|
|
code: 'article_content_empty',
|
|
message,
|
|
},
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 'failed',
|
|
summary: '百家号发布失败。',
|
|
error: {
|
|
code: 'baijiahao_publish_failed',
|
|
message,
|
|
},
|
|
}
|
|
}
|
|
|
|
export const baijiahaoAdapter: PublishAdapter = {
|
|
platform: 'baijiahao',
|
|
executionMode: 'session',
|
|
async publish(context) {
|
|
try {
|
|
context.reportProgress('baijiahao.detect_login')
|
|
const user = await fetchAppInfo(context)
|
|
const platformUid = normalizeUserId(user?.userid)
|
|
const mediaName = user ? displayNameFromUser(user) : ''
|
|
const appId = normalizeUserId(user?.app_id)
|
|
if (!platformUid || !mediaName || !appId) {
|
|
throw new Error('baijiahao_not_logged_in')
|
|
}
|
|
|
|
context.reportProgress('baijiahao.get_token')
|
|
await waitForHumanPace(context.signal)
|
|
const token = await getPublishToken(context)
|
|
if (!token) {
|
|
throw new Error('baijiahao_token_missing')
|
|
}
|
|
|
|
const coverAssetUrl = context.article.cover_asset_url?.trim() || ''
|
|
if (!coverAssetUrl) {
|
|
throw new Error('publish_cover_required')
|
|
}
|
|
|
|
const html = prepareBaijiahaoArticleHtml(
|
|
normalizeArticleHtml(context.article, {
|
|
prepareMarkdown: prepareBaijiahaoMarkdown,
|
|
}),
|
|
)
|
|
if (!html) {
|
|
throw new Error('article_content_empty')
|
|
}
|
|
|
|
context.reportProgress('baijiahao.upload_content_images')
|
|
await waitForHumanPace(context.signal)
|
|
const content = await processContentImages(context, html, appId)
|
|
|
|
context.reportProgress('baijiahao.upload_cover')
|
|
await waitForHumanPace(context.signal)
|
|
const coverUrl = await uploadImage(context, coverAssetUrl, appId, true)
|
|
if (!coverUrl) {
|
|
throw new Error('baijiahao_cover_upload_failed')
|
|
}
|
|
|
|
context.reportProgress('baijiahao.submit')
|
|
await waitForHumanPace(context.signal)
|
|
const response: BaijiahaoPublishResponse = await sessionFetchJson<BaijiahaoPublishResponse>(
|
|
context.session,
|
|
`${BAIJIAHAO_ORIGIN}/pcui/article/publish`,
|
|
{
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: baijiahaoHeaders({
|
|
'content-type': 'application/x-www-form-urlencoded',
|
|
Token: token,
|
|
}),
|
|
body: new URLSearchParams({
|
|
type: 'news',
|
|
title: context.article.title?.trim() || '未命名文章',
|
|
content,
|
|
abstract_from: '1',
|
|
cover_layout: 'one',
|
|
cover_images: buildCoverImages(coverUrl),
|
|
activity_list: buildActivityList(),
|
|
}),
|
|
},
|
|
).catch(
|
|
(requestError): BaijiahaoPublishResponse => ({
|
|
errno: -1,
|
|
errmsg: requestError instanceof Error ? requestError.message : 'baijiahao_publish_failed',
|
|
}),
|
|
)
|
|
|
|
const articleId = response.ret?.id != null ? String(response.ret.id) : ''
|
|
if (response.errno !== 0 || !articleId) {
|
|
const message = baijiahaoResponseMessage(response)
|
|
if (isBaijiahaoChallengeMessage(message)) {
|
|
throw new Error(`baijiahao_challenge_required:${message}`)
|
|
}
|
|
throw new Error(message || 'baijiahao_publish_failed')
|
|
}
|
|
|
|
const manageUrl = baijiahaoManageUrl(articleId)
|
|
const articleUrl = baijiahaoPublicUrl(articleId)
|
|
return {
|
|
status: 'succeeded',
|
|
payload: buildResultPayload(articleId, mediaName, manageUrl, articleUrl),
|
|
summary: '百家号发布成功。',
|
|
}
|
|
} catch (error) {
|
|
return failureResult(error)
|
|
}
|
|
},
|
|
}
|