import type { JsonValue } from "@geo/shared-types"; import { extractImageSources, normalizeArticleHtml, normalizeRemoteUrl, sessionCookieHeader, sessionFetchJson, } from "./common"; import { cropImageAssetBlob, fetchImageAssetBlob, type ImageAssetBlob, } from "./media-image"; import type { PublishAdapter, PublishAdapterContext } from "./base"; 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), }, ).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, }, ).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, }, ).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); 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, }, ).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); 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, }, ).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), }, ).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: "中关村在线封面文件读取失败,请重新上传封面图后重试。", 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); 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", "[]"); 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, }, ).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); } }, };