feat(desktop): add zhihu publish adapter
Ship a Zhihu publish adapter that reuses the shared session fetch and image pipeline: converts markdown to HTML via marked, uploads cover and inline images through Zhihu's image token flow, creates a draft, and publishes it, returning external article URLs. Wire it into the adapter barrel and runtime-controller's publish adapter selector so Zhihu publish tasks stop falling through to the scaffold result. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export * from "./base";
|
||||
export * from "./doubao";
|
||||
export * from "./toutiao";
|
||||
export * from "./zhihu";
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash, createHmac, randomUUID } from "node:crypto";
|
||||
|
||||
import type { JsonValue } from "@geo/shared-types";
|
||||
import { marked } from "marked";
|
||||
|
||||
import {
|
||||
extractImageSources,
|
||||
fetchImageBlob,
|
||||
normalizeRemoteUrl,
|
||||
sessionCookieValue,
|
||||
sessionFetchJson,
|
||||
} from "./common";
|
||||
import type { PublishAdapter } from "./base";
|
||||
|
||||
type ZhihuMeResponse = {
|
||||
uid?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
|
||||
type ZhihuImageTokenResponse = {
|
||||
upload_file?: {
|
||||
state?: number;
|
||||
object_key?: string;
|
||||
};
|
||||
upload_token?: {
|
||||
access_id?: string;
|
||||
access_key?: string;
|
||||
access_token?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ZhihuDraftCreateResponse = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
type ZhihuPublishResponse = {
|
||||
code?: number;
|
||||
};
|
||||
|
||||
function buildHeaders(headers?: HeadersInit): Headers {
|
||||
const next = new Headers(headers);
|
||||
if (!next.has("x-requested-with")) {
|
||||
next.set("x-requested-with", "fetch");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function zhihuFetch<T>(
|
||||
context: Parameters<NonNullable<PublishAdapter["publish"]>>[0],
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
return await sessionFetchJson<T>(context.session, input, {
|
||||
...init,
|
||||
headers: buildHeaders(init?.headers),
|
||||
});
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown: string): string {
|
||||
const source = markdown.trim();
|
||||
if (!source) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return marked.parse(source, {
|
||||
async: false,
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
}) as string;
|
||||
}
|
||||
|
||||
function baseContent(article: Parameters<NonNullable<PublishAdapter["publish"]>>[0]["article"]): string {
|
||||
const html = article.html_content?.trim();
|
||||
if (html) {
|
||||
return html;
|
||||
}
|
||||
return markdownToHtml(article.markdown_content ?? "");
|
||||
}
|
||||
|
||||
function wrapStandaloneImages(html: string): string {
|
||||
return html.replace(/<img([^>]+src="[^"]+"[^>]*)>/gi, "<figure><img$1></figure>");
|
||||
}
|
||||
|
||||
function convertTablesToZhihuFormat(html: string): string {
|
||||
let next = html;
|
||||
next = next.replace(/<\/thead>\s*<tbody>/gi, "");
|
||||
next = next.replace(/<thead>/gi, "<tbody>");
|
||||
next = next.replace(/<\/thead>/gi, "</tbody>");
|
||||
next = next.replace(
|
||||
/<table\b/gi,
|
||||
'<table data-draft-node="block" data-draft-type="table" data-draft-padding="8"',
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
function transformContent(html: string): string {
|
||||
let next = html.trim();
|
||||
|
||||
next = next.replace(/<section\b/gi, "<div").replace(/<\/section>/gi, "</div>");
|
||||
next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, "");
|
||||
next = next.replace(/\sstyle="[^"]*"/gi, "");
|
||||
next = next.replace(/\sdata-(?!draft)[a-z-]+="[^"]*"/gi, "");
|
||||
next = next.replace(/<figure[^>]*>\s*(<img[\s\S]*?>)\s*<\/figure>/gi, "$1");
|
||||
next = next.replace(/<pre><code class="language-([^"]+)">/gi, '<pre lang="$1"><code>');
|
||||
next = wrapStandaloneImages(next);
|
||||
next = convertTablesToZhihuFormat(next);
|
||||
|
||||
return next.trim();
|
||||
}
|
||||
|
||||
function md5Hex(buffer: ArrayBuffer): string {
|
||||
return createHash("md5").update(Buffer.from(buffer)).digest("hex");
|
||||
}
|
||||
|
||||
function hmacSha1Base64(key: string, message: string): string {
|
||||
return createHmac("sha1", key).update(message).digest("base64");
|
||||
}
|
||||
|
||||
function randomTraceId(): string {
|
||||
return `${Date.now()},${randomUUID()}`;
|
||||
}
|
||||
|
||||
function buildPictureUrl(imageHash: string, mimeType: string): string {
|
||||
const extension = mimeType.split("/")[1] || "png";
|
||||
return `https://picx.zhimg.com/v2-${imageHash}.${extension}`;
|
||||
}
|
||||
|
||||
async function uploadBinaryImage(
|
||||
context: Parameters<NonNullable<PublishAdapter["publish"]>>[0],
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const blob = await fetchImageBlob(sourceUrl);
|
||||
if (!blob || !blob.type.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const imageHash = md5Hex(buffer);
|
||||
|
||||
const token = await zhihuFetch<ZhihuImageTokenResponse>(context, "https://api.zhihu.com/images", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image_hash: imageHash,
|
||||
source: "article",
|
||||
}),
|
||||
});
|
||||
|
||||
if (token.upload_file?.state === 2 && token.upload_file.object_key && token.upload_token) {
|
||||
const ossDate = new Date().toUTCString();
|
||||
const ossUserAgent = "aliyun-sdk-js/6.8.0 Chrome 139.0.0.0 on OS X 10.15.7 64-bit";
|
||||
const stringToSign = `PUT\n\n${blob.type}\n${ossDate}\nx-oss-date:${ossDate}\nx-oss-security-token:${token.upload_token.access_token}\nx-oss-user-agent:${ossUserAgent}\n/zhihu-pics/v2-${imageHash}`;
|
||||
const signature = hmacSha1Base64(token.upload_token.access_key || "", stringToSign);
|
||||
|
||||
const uploadResponse = await fetch(`https://zhihu-pics-upload.zhimg.com/${token.upload_file.object_key}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": blob.type,
|
||||
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
|
||||
"x-oss-date": ossDate,
|
||||
"x-oss-user-agent": ossUserAgent,
|
||||
"x-oss-security-token": token.upload_token.access_token || "",
|
||||
authorization: `OSS ${token.upload_token.access_id}:${signature}`,
|
||||
},
|
||||
body: blob,
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`zhihu_oss_upload_failed_${uploadResponse.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
return buildPictureUrl(imageHash, blob.type);
|
||||
}
|
||||
|
||||
async function processContentImages(
|
||||
context: Parameters<NonNullable<PublishAdapter["publish"]>>[0],
|
||||
html: string,
|
||||
): Promise<string> {
|
||||
const sources = extractImageSources(html);
|
||||
if (!sources.length) {
|
||||
return html;
|
||||
}
|
||||
|
||||
let next = html;
|
||||
const uploaded = new Map<string, string>();
|
||||
|
||||
for (const source of sources) {
|
||||
if (/zhimg\.com/i.test(source)) {
|
||||
continue;
|
||||
}
|
||||
const target = await uploadBinaryImage(context, source);
|
||||
if (target) {
|
||||
uploaded.set(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [from, to] of uploaded.entries()) {
|
||||
next = next.split(from).join(to);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
async function detectZhihu(
|
||||
context: Parameters<NonNullable<PublishAdapter["publish"]>>[0],
|
||||
): Promise<ZhihuMeResponse | null> {
|
||||
const me = await zhihuFetch<ZhihuMeResponse>(context, "https://www.zhihu.com/api/v4/me", {
|
||||
method: "GET",
|
||||
}).catch(() => null);
|
||||
|
||||
const uid = me?.uid || me?.id ? String(me?.uid || me?.id) : "";
|
||||
if (!uid || !me?.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...me,
|
||||
uid,
|
||||
avatar_url: normalizeRemoteUrl(me.avatar_url) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function createDraft(
|
||||
context: Parameters<NonNullable<PublishAdapter["publish"]>>[0],
|
||||
titleImage: string | null,
|
||||
): Promise<string> {
|
||||
const title = context.article.title?.trim() || "未命名文章";
|
||||
const transformedContent = transformContent(baseContent(context.article));
|
||||
if (!transformedContent) {
|
||||
throw new Error("article_content_empty");
|
||||
}
|
||||
|
||||
context.reportProgress("zhihu.upload_content_images");
|
||||
const content = await processContentImages(context, transformedContent);
|
||||
|
||||
context.reportProgress("zhihu.create_draft");
|
||||
const created = await zhihuFetch<ZhihuDraftCreateResponse>(context, "https://zhuanlan.zhihu.com/api/articles/drafts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
delta_time: 0,
|
||||
can_reward: false,
|
||||
title,
|
||||
content,
|
||||
...(titleImage ? { titleImage } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!created.id) {
|
||||
throw new Error("zhihu_create_draft_failed");
|
||||
}
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function publishDraft(
|
||||
context: Parameters<NonNullable<PublishAdapter["publish"]>>[0],
|
||||
draftId: string,
|
||||
): Promise<boolean> {
|
||||
const xsrfToken = await sessionCookieValue(context.session, "zhihu.com", "_xsrf");
|
||||
|
||||
context.reportProgress("zhihu.publish_draft");
|
||||
const result = await zhihuFetch<ZhihuPublishResponse>(context, "https://www.zhihu.com/api/v4/content/publish", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-xsrftoken": xsrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "article",
|
||||
data: {
|
||||
publish: {
|
||||
traceId: randomTraceId(),
|
||||
},
|
||||
draft: {
|
||||
disabled: 1,
|
||||
id: draftId,
|
||||
isPublished: false,
|
||||
},
|
||||
commentsPermission: {
|
||||
comment_permission: "anyone",
|
||||
},
|
||||
creationStatement: {
|
||||
disclaimer_type: "",
|
||||
disclaimer_status: "",
|
||||
},
|
||||
contentsTables: {
|
||||
table_of_contents_enabled: false,
|
||||
},
|
||||
commercialReportInfo: {
|
||||
isReport: 0,
|
||||
},
|
||||
appreciate: {
|
||||
can_reward: false,
|
||||
tagline: "",
|
||||
},
|
||||
hybridInfo: {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return result.code === 0;
|
||||
}
|
||||
|
||||
function buildResultPayload(
|
||||
articleId: string,
|
||||
mediaName: string,
|
||||
externalManageUrl: string,
|
||||
externalArticleUrl: string | null,
|
||||
): Record<string, JsonValue> {
|
||||
return {
|
||||
platform: "zhihu",
|
||||
media_name: mediaName,
|
||||
external_article_id: articleId,
|
||||
external_manage_url: externalManageUrl,
|
||||
external_article_url: externalArticleUrl,
|
||||
publish_type: "publish",
|
||||
};
|
||||
}
|
||||
|
||||
function buildFailureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (message === "article_content_empty") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "文章内容为空,无法推送到知乎。",
|
||||
error: {
|
||||
code: "article_content_empty",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message === "zhihu_not_logged_in") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "知乎登录态失效,无法执行发布。",
|
||||
error: {
|
||||
code: "zhihu_not_logged_in",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "知乎发布失败。",
|
||||
error: {
|
||||
code: "zhihu_publish_failed",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const zhihuAdapter: PublishAdapter = {
|
||||
platform: "zhihu",
|
||||
executionMode: "session",
|
||||
async publish(context) {
|
||||
context.reportProgress("zhihu.detect_login");
|
||||
const me = await detectZhihu(context);
|
||||
if (!me?.uid || !me.name) {
|
||||
return buildFailureResult(new Error("zhihu_not_logged_in"));
|
||||
}
|
||||
|
||||
try {
|
||||
context.reportProgress("zhihu.upload_cover");
|
||||
const coverUrl = context.article.cover_asset_url?.trim() || null;
|
||||
const titleImage = coverUrl ? await uploadBinaryImage(context, coverUrl) : null;
|
||||
const draftId = await createDraft(context, titleImage);
|
||||
|
||||
const published = await publishDraft(context, draftId);
|
||||
if (!published) {
|
||||
return buildFailureResult(new Error("zhihu_publish_failed"));
|
||||
}
|
||||
|
||||
const publishedUrl = `https://zhuanlan.zhihu.com/p/${draftId}`;
|
||||
return {
|
||||
status: "succeeded",
|
||||
payload: buildResultPayload(draftId, me.name, `${publishedUrl}/edit`, publishedUrl),
|
||||
summary: "知乎发布成功。",
|
||||
};
|
||||
} catch (error) {
|
||||
return buildFailureResult(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import {
|
||||
doubaoAdapter,
|
||||
toutiaoAdapter,
|
||||
zhihuAdapter,
|
||||
type AdapterExecutionResult,
|
||||
type MonitorAdapter,
|
||||
type PublishAdapter,
|
||||
@@ -1150,6 +1151,9 @@ function selectPublishAdapter(platform: string): PublishAdapter | null {
|
||||
if (platform === toutiaoAdapter.platform) {
|
||||
return toutiaoAdapter;
|
||||
}
|
||||
if (platform === zhihuAdapter.platform) {
|
||||
return zhihuAdapter;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user