Files
geo/apps/desktop-client/src/main/adapters/zol.ts
T
root e1a1916bc5 fix(publish): harden zol login detection and content image upload
- Detect zol login redirects via passport/service hostnames and verify
  console access by re-detecting the bound account so a different logged
  in user is treated as expired instead of active.
- Skip networkPic upload for local/private URLs and fall back to file
  upload (with siteType retry) so desktop-hosted assets no longer reach
  ZOL as unreachable links and produce blank images.
- Surface zol_content_image_upload_failed and challenge errors instead
  of silently substituting empty image URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:21:12 +08:00

604 lines
17 KiB
TypeScript

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<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),
},
).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,
},
).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,
},
).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);
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,
},
).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);
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,
},
).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),
},
).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: "中关村在线封面文件读取失败,请重新上传封面图后重试。",
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 = `<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", "[]");
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,
},
).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);
}
},
};