import type { JsonValue } from '@geo/shared-types' import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-errors' import type { PublishAdapter, PublishAdapterContext } from './base' import { extractImageSources, normalizeArticleHtml, normalizeRemoteUrl, sessionCookieHeader, sessionFetchJson, } from './common' import { cropImageAssetBlob, fetchImageAssetBlob, type ImageAssetBlob } from './media-image' type ZolUserInfoResponse = { data?: { userId?: string | number nickName?: string photo?: string } } type ZolDraftResponse = { errcode?: number errmsg?: string data?: { draftId?: string | number } } type ZolPublishResponse = { errcode?: number errmsg?: string data?: { contentId?: string | number } } type ZolImageUploadResponse = { errcode?: number errmsg?: string data?: { fileUrl?: string pic?: string } } type ZolSubjectListResponse = { data?: { list?: Array<{ subjectId?: string | number subjectName?: string }> } } type ZolAccount = { uid: string name: string } type ZolCoverImage = { url: string width: number height: number } const ZOL_API_ORIGIN = 'https://open-api.zol.com.cn' const ZOL_REFERER = 'https://post.zol.com.cn' const ZOL_MANAGE_URL = 'https://post.zol.com.cn/v2/manage/works/all' const ZOL_SUBJECT_LIMIT = 5 const LOCAL_IMAGE_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', 'api']) function stringId(value: unknown): string { if (typeof value === 'number') { return Number.isFinite(value) ? String(value) : '' } if (typeof value !== 'string') { return '' } return value.trim() } function zolResponseMessage( response: { errmsg?: string; errcode?: number } | null | undefined, ): string { return response?.errmsg || `zol_error_${response?.errcode ?? 'unknown'}` } function isZolChallengeMessage(message: string): boolean { return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控)/i.test( message, ) } export function normalizeZolUploadedImageUrl(value: string | null | undefined): string | null { const trimmed = value?.trim() if (!trimmed) { return null } if (/^\/\//.test(trimmed)) { return `https:${trimmed}` } if (/^\//.test(trimmed)) { return new URL(trimmed, ZOL_REFERER).toString() } return trimmed } function zolUploadedImageUrl(response: ZolImageUploadResponse | null | undefined): string | null { return ( normalizeZolUploadedImageUrl(response?.data?.pic) ?? normalizeZolUploadedImageUrl(response?.data?.fileUrl) ) } function zolImageUploadMessage(response: ZolImageUploadResponse | null | undefined): string { if (!response) { return 'empty_response' } return zolResponseMessage(response) } function isLocalImageHost(hostname: string): boolean { return LOCAL_IMAGE_HOSTS.has(hostname.toLowerCase()) } export function directNetworkPicUrl(sourceUrl: string): string | null { const trimmed = sourceUrl.trim() if (!trimmed || /^(data|blob):/i.test(trimmed) || /^\/(?!\/)/.test(trimmed)) { return null } const normalized = normalizeRemoteUrl(trimmed) if (!normalized || /^(data|blob):/i.test(normalized)) { return null } try { const parsed = new URL(normalized) if (!['http:', 'https:'].includes(parsed.protocol) || isLocalImageHost(parsed.hostname)) { return null } return parsed.toString() } catch { return null } } async function zolHeaders( context: PublishAdapterContext, extra: Record = {}, ): Promise> { const cookie = await sessionCookieHeader(context.session, 'zol.com.cn').catch(() => '') return { accept: 'application/json, text/plain, */*', origin: ZOL_REFERER, referer: ZOL_REFERER, ...(cookie ? { cookie } : {}), ...extra, } } async function fetchAccount(context: PublishAdapterContext): Promise { const response = await sessionFetchJson( context.session, `${ZOL_API_ORIGIN}/api/v1/creator.user.getinfo`, { credentials: 'include', headers: await zolHeaders(context), }, { signal: context.signal }, ).catch(() => null) const user = response?.data const uid = stringId(user?.userId) const name = user?.nickName?.trim() ?? '' return uid && name ? { uid, name } : null } function appendBaseFormFields(form: FormData, title: string, userId: string): void { form.append('businessType', '1') form.append('scontent', '') form.append('title', title) form.append('stitle', '') form.append('userId', userId) form.append('docType', '') form.append('isOriginal', '') form.append('isContribution', '0') form.append('dutyEditor', 'undefined') form.append('publishDate', '') form.append('subjectList', '') form.append('subjectIdStr', '') form.append('guideImg', '') form.append('draftId', '') form.append('contentId', '') form.append('tryId', '') form.append('goodsList', '') form.append('subjectNameStr', '') form.append('eosXuanti', '0') form.append('eosDawen', '0') form.append('eosUser', '0') form.append('eosTeyue', '0') form.append('firstEc', '0') form.append('firstEcForm', 'false') form.append('eosSyXuanti', '0') form.append('eosGEO', '0') form.append('eosZiZhu', '0') form.append('saveType', '2') } async function createDraftId(context: PublishAdapterContext, form: FormData): Promise { const response = await sessionFetchJson( context.session, `${ZOL_API_ORIGIN}/api/v1/creator.content.draft.save.orther`, { method: 'POST', credentials: 'include', headers: await zolHeaders(context), body: form, }, { signal: context.signal }, ).catch( (error): ZolDraftResponse => ({ errcode: -1, errmsg: error instanceof Error ? error.message : 'zol_draft_create_failed', }), ) const draftId = stringId(response.data?.draftId) if (!draftId) { throw new Error(`zol_draft_create_failed:${zolResponseMessage(response)}`) } return draftId } async function uploadCoverVariant( context: PublishAdapterContext, source: ImageAssetBlob, ratio: number, ): Promise { const image = await cropImageAssetBlob(source, ratio) if (!image) { return null } const form = new FormData() form.append('file', image.blob, image.fileName) form.append('siteType', '0') const response = await sessionFetchJson( context.session, `${ZOL_API_ORIGIN}/api/v1/creator.content.image.upload`, { method: 'POST', credentials: 'include', headers: await zolHeaders(context), body: form, }, { signal: context.signal }, ).catch( (error): ZolImageUploadResponse => ({ errcode: -1, errmsg: error instanceof Error ? error.message : 'zol_cover_upload_failed', }), ) const url = response.data?.fileUrl?.trim() || '' if (response.errcode !== 0 || !url) { const message = zolResponseMessage(response) if (isZolChallengeMessage(message)) { throw new Error(`zol_challenge_required:${message}`) } throw new Error(`zol_cover_upload_failed:${message}`) } return { url, width: image.width, height: image.height, } } async function uploadCover( context: PublishAdapterContext, sourceUrl: string, ): Promise { const source = await fetchImageAssetBlob(sourceUrl, { signal: context.signal }) if (!source) { throw new Error('zol_cover_fetch_failed') } const landscape = await uploadCoverVariant(context, source, 4 / 3) const portrait = await uploadCoverVariant(context, source, 3 / 4) return landscape && portrait ? [landscape, portrait] : null } async function uploadContentImage( context: PublishAdapterContext, sourceUrl: string, ): Promise { const networkPic = directNetworkPicUrl(sourceUrl) if (networkPic) { const uploaded = await uploadContentImageByNetworkPic(context, networkPic) if (uploaded) { return uploaded } } return await uploadContentImageByFile(context, sourceUrl) } async function uploadContentImageByNetworkPic( context: PublishAdapterContext, networkPic: string, ): Promise { if (!networkPic) { return null } const form = new FormData() form.append('networkPic', networkPic) form.append('siteType', '1') const response = await sessionFetchJson( context.session, `${ZOL_API_ORIGIN}/api/v1/creator.content.image.upload`, { method: 'POST', credentials: 'include', headers: await zolHeaders(context), body: form, }, { signal: context.signal }, ).catch(() => null) const uploaded = zolUploadedImageUrl(response) if (response && response.errcode !== 0 && response.errcode != null) { const message = zolResponseMessage(response) if (isZolChallengeMessage(message)) { throw new Error(`zol_challenge_required:${message}`) } return null } return uploaded } async function uploadContentImageByFile( context: PublishAdapterContext, sourceUrl: string, ): Promise { const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal }) if (!image) { throw new Error( `zol_content_image_upload_failed:${sourceUrl.slice(0, 160)}:source_fetch_failed`, ) } const errors: string[] = [] for (const siteType of ['1', '0']) { const form = new FormData() form.append('file', image.blob, image.fileName) form.append('siteType', siteType) const response = await sessionFetchJson( context.session, `${ZOL_API_ORIGIN}/api/v1/creator.content.image.upload`, { method: 'POST', credentials: 'include', headers: await zolHeaders(context), body: form, }, { signal: context.signal }, ).catch( (error): ZolImageUploadResponse => ({ errcode: -1, errmsg: error instanceof Error ? error.message : 'zol_content_image_upload_failed', }), ) const uploaded = zolUploadedImageUrl(response) if (uploaded) { return uploaded } const message = zolImageUploadMessage(response) if (isZolChallengeMessage(message)) { throw new Error(`zol_challenge_required:${message}`) } errors.push(`siteType=${siteType}:${message}`) } throw new Error(`zol_content_image_upload_failed:${sourceUrl.slice(0, 160)}:${errors.join(';')}`) } async function uploadContentImages(context: PublishAdapterContext, html: string): Promise { const sources = extractImageSources(html) if (!sources.length) { return html } const uploaded = new Map() for (const source of sources) { const target = await uploadContentImage(context, source) if (!target) { throw new Error(`zol_content_image_upload_failed:${source.slice(0, 160)}`) } uploaded.set(source, target) } let next = html for (const [source, target] of uploaded.entries()) { next = next.split(source).join(target) } return next } async function applyRecommendSubjects( context: PublishAdapterContext, form: FormData, ): Promise { const url = new URL(`${ZOL_API_ORIGIN}/api/v1/creator.comminuty.subjectlist`) url.searchParams.set('sa', 'pc') url.searchParams.set('keyword', '') url.searchParams.set('page', '1') const response = await sessionFetchJson( context.session, url.toString(), { credentials: 'include', headers: await zolHeaders(context), }, { signal: context.signal }, ).catch(() => null) const subjects = (response?.data?.list ?? []) .filter((item) => item.subjectId != null && item.subjectName?.trim()) .slice(0, ZOL_SUBJECT_LIMIT) if (!subjects.length) { return } form.set('subjectIdStr', subjects.map((item) => String(item.subjectId)).join(',')) form.set('subjectNameStr', subjects.map((item) => item.subjectName?.trim() ?? '').join(',')) } function buildResultPayload(articleId: string, mediaName: string): Record { return { platform: 'zol', media_name: mediaName, external_article_id: articleId, external_manage_url: ZOL_MANAGE_URL, external_article_url: null, publish_type: 'publish', } } function failureResult( error: unknown, ): ReturnType extends Promise ? T : never { const message = error instanceof Error ? error.message : String(error) if (message === 'zol_not_logged_in') { return { status: 'failed', summary: '中关村在线登录态失效,无法执行发布。', error: { code: 'zol_not_logged_in', message, }, } } if (message === 'article_content_empty') { return { status: 'failed', summary: '文章内容为空,无法推送到中关村在线。', error: { code: 'article_content_empty', message, }, } } if (message.startsWith('zol_draft_create_failed')) { return { status: 'failed', summary: '中关村在线草稿 ID 创建失败,请重新打开后台确认账号状态。', error: { code: 'zol_draft_create_failed', message, }, } } if (message.startsWith('zol_cover_fetch_failed')) { return { status: 'failed', summary: PUBLISH_IMAGE_FETCH_FAILED_MESSAGE, error: { code: 'zol_cover_fetch_failed', message, }, } } if (message.startsWith('zol_cover_upload_failed')) { return { status: 'failed', summary: '中关村在线封面上传失败,请稍后重试或更换封面图。', error: { code: 'zol_cover_upload_failed', message, }, } } if (message.startsWith('zol_content_image_upload_failed')) { return { status: 'failed', summary: '中关村在线正文图片上传失败,已停止发布以避免生成空白图片。', error: { code: 'zol_content_image_upload_failed', message, }, } } if (message.startsWith('zol_challenge_required') || isZolChallengeMessage(message)) { return { status: 'failed', summary: '中关村在线触发平台验证,请在中关村在线后台完成验证后重试。', error: { code: 'zol_challenge_required', message, }, } } return { status: 'failed', summary: '中关村在线发布失败。', error: { code: 'zol_publish_failed', message, }, } } export const zolAdapter: PublishAdapter = { platform: 'zol', executionMode: 'session', async publish(context) { try { context.reportProgress('zol.detect_login') const account = await fetchAccount(context) if (!account) { throw new Error('zol_not_logged_in') } let html = normalizeArticleHtml(context.article) if (!html) { throw new Error('article_content_empty') } const title = context.article.title?.trim() || '未命名文章' const form = new FormData() appendBaseFormFields(form, title, account.uid) await context.reportProgress('zol.create_draft') const draftId = await createDraftId(context, form) const coverAssetUrl = context.article.cover_asset_url?.trim() || '' if (coverAssetUrl) { context.reportProgress('zol.upload_cover') const cover = await uploadCover(context, coverAssetUrl) if (cover) { form.set('guideImg', JSON.stringify(cover)) } if (extractImageSources(html).length === 0) { html = `${html}` } } context.reportProgress('zol.apply_subjects') await applyRecommendSubjects(context, form) context.reportProgress('zol.upload_content_images') const processedHtml = await uploadContentImages(context, html) form.set('draftId', draftId) form.set('saveType', '1') form.set('scontent', processedHtml) form.append('draftUpdateId', draftId) form.append('taskType', '1') form.append('taskIds', '[]') await context.reportProgress('zol.submit') const response = await sessionFetchJson( context.session, `${ZOL_API_ORIGIN}/api/v1/creator.content.save.orther`, { method: 'POST', credentials: 'include', headers: await zolHeaders(context), body: form, }, { signal: context.signal }, ).catch( (error): ZolPublishResponse => ({ errcode: -1, errmsg: error instanceof Error ? error.message : 'zol_publish_failed', }), ) const articleId = stringId(response.data?.contentId) if (response.errcode !== 0 || !articleId) { const message = zolResponseMessage(response) if (isZolChallengeMessage(message)) { throw new Error(`zol_challenge_required:${message}`) } throw new Error(message || 'zol_publish_failed') } return { status: 'succeeded', payload: buildResultPayload(articleId, account.name), summary: '中关村在线发布成功。', } } catch (error) { return failureResult(error) } }, }