Files
geo/apps/desktop-client/src/main/adapters/zol.ts
T
root 8ea4a2ffff
Desktop Client Build / Resolve Build Metadata (push) Successful in 20s
Frontend CI / Frontend (push) Successful in 2m57s
Desktop Client Build / Build Desktop Client (push) Successful in 25m11s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 34s
Backend CI / Backend (push) Successful in 50m56s
fix(publish): require submit marker before platform write
2026-05-30 22:29:28 +08:00

638 lines
17 KiB
TypeScript

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<string, string> = {},
): Promise<Record<string, string>> {
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<ZolAccount | null> {
const response = await sessionFetchJson<ZolUserInfoResponse>(
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<string> {
const response = await sessionFetchJson<ZolDraftResponse>(
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<ZolCoverImage | null> {
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<ZolImageUploadResponse>(
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<ZolCoverImage[] | null> {
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<string | null> {
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<string | null> {
if (!networkPic) {
return null
}
const form = new FormData()
form.append('networkPic', networkPic)
form.append('siteType', '1')
const response = await sessionFetchJson<ZolImageUploadResponse>(
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<string | null> {
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<ZolImageUploadResponse>(
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<string> {
const sources = extractImageSources(html)
if (!sources.length) {
return html
}
const uploaded = new Map<string, string>()
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<void> {
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<ZolSubjectListResponse>(
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<string, JsonValue> {
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<PublishAdapter['publish']> extends Promise<infer T> ? 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 = `<img src="${coverAssetUrl}" />${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<ZolPublishResponse>(
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)
}
},
}