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>
This commit is contained in:
2026-04-27 12:21:12 +08:00
parent b4cfc6f992
commit e1a1916bc5
3 changed files with 293 additions and 18 deletions
@@ -0,0 +1,46 @@
import { Buffer } from "node:buffer";
import { describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
nativeImage: {
createFromBuffer: () => ({
isEmpty: () => false,
getSize: () => ({ width: 1, height: 1 }),
crop: () => ({
isEmpty: () => false,
getSize: () => ({ width: 1, height: 1 }),
toPNG: () => Buffer.from([]),
}),
toPNG: () => Buffer.from([]),
}),
},
}));
vi.mock("../transport/api-client", () => ({
fetchDesktopApiURL: vi.fn(),
resolveDesktopApiURL: (path: string) => `http://127.0.0.1:8080${path}`,
}));
import {
directNetworkPicUrl,
normalizeZolUploadedImageUrl,
} from "./zol";
describe("zol image helpers", () => {
it("does not send local asset URLs to ZOL networkPic upload", () => {
expect(directNetworkPicUrl("/api/public/assets/12")).toBeNull();
expect(directNetworkPicUrl("http://127.0.0.1:8080/api/public/assets/12")).toBeNull();
expect(directNetworkPicUrl("data:image/png;base64,abc")).toBeNull();
});
it("keeps public remote image URLs eligible for networkPic upload", () => {
expect(directNetworkPicUrl("//cdn.example.com/a.png")).toBe("https://cdn.example.com/a.png");
expect(directNetworkPicUrl("https://img.example.com/a.png")).toBe("https://img.example.com/a.png");
});
it("normalizes uploaded ZOL image URLs for editor content", () => {
expect(normalizeZolUploadedImageUrl("//icon.zol-img.com.cn/a.png")).toBe("https://icon.zol-img.com.cn/a.png");
expect(normalizeZolUploadedImageUrl("/upload/a.png")).toBe("https://post.zol.com.cn/upload/a.png");
});
});
+154 -5
View File
@@ -6,7 +6,6 @@ import {
normalizeRemoteUrl,
sessionCookieHeader,
sessionFetchJson,
uploadHtmlImages,
} from "./common";
import {
cropImageAssetBlob,
@@ -72,6 +71,7 @@ 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") {
@@ -92,6 +92,57 @@ function isZolChallengeMessage(message: string): boolean {
.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> = {},
@@ -231,7 +282,21 @@ async function uploadCover(context: PublishAdapterContext, sourceUrl: string): P
}
async function uploadContentImage(context: PublishAdapterContext, sourceUrl: string): Promise<string | null> {
const networkPic = normalizeRemoteUrl(sourceUrl);
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;
}
@@ -251,7 +316,80 @@ async function uploadContentImage(context: PublishAdapterContext, sourceUrl: str
},
).catch(() => null);
return response?.errcode === 0 ? response.data?.pic?.trim() || null : 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> {
@@ -349,6 +487,17 @@ function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> ex
};
}
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",
@@ -409,11 +558,11 @@ export const zolAdapter: PublishAdapter = {
await applyRecommendSubjects(context, form);
context.reportProgress("zol.upload_content_images");
const processed = await uploadHtmlImages(html, async (sourceUrl) => uploadContentImage(context, sourceUrl));
const processedHtml = await uploadContentImages(context, html);
form.set("draftId", draftId);
form.set("saveType", "1");
form.set("scontent", processed.html);
form.set("scontent", processedHtml);
form.append("draftUpdateId", draftId);
form.append("taskType", "1");
form.append("taskIds", "[]");