diff --git a/apps/desktop-client/src/main/adapters/index.ts b/apps/desktop-client/src/main/adapters/index.ts index f61b13c..ad7d604 100644 --- a/apps/desktop-client/src/main/adapters/index.ts +++ b/apps/desktop-client/src/main/adapters/index.ts @@ -1,3 +1,4 @@ export * from "./base"; export * from "./doubao"; export * from "./toutiao"; +export * from "./zhihu"; diff --git a/apps/desktop-client/src/main/adapters/zhihu.ts b/apps/desktop-client/src/main/adapters/zhihu.ts new file mode 100644 index 0000000..c8c8700 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/zhihu.ts @@ -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( + context: Parameters>[0], + input: string, + init?: RequestInit, +): Promise { + return await sessionFetchJson(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>[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(/]+src="[^"]+"[^>]*)>/gi, "
"); +} + +function convertTablesToZhihuFormat(html: string): string { + let next = html; + next = next.replace(/<\/thead>\s*/gi, ""); + next = next.replace(//gi, ""); + next = next.replace(/<\/thead>/gi, ""); + next = next.replace( + //gi, ""); + 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(/]*>\s*()\s*<\/figure>/gi, "$1"); + next = next.replace(/
/gi, '
');
+  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>[0],
+  sourceUrl: string,
+): Promise {
+  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(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>[0],
+  html: string,
+): Promise {
+  const sources = extractImageSources(html);
+  if (!sources.length) {
+    return html;
+  }
+
+  let next = html;
+  const uploaded = new Map();
+
+  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>[0],
+): Promise {
+  const me = await zhihuFetch(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>[0],
+  titleImage: string | null,
+): Promise {
+  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(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>[0],
+  draftId: string,
+): Promise {
+  const xsrfToken = await sessionCookieValue(context.session, "zhihu.com", "_xsrf");
+
+  context.reportProgress("zhihu.publish_draft");
+  const result = await zhihuFetch(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 {
+  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 extends Promise ? 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);
+    }
+  },
+};
diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts
index 3ca0eb7..7eab01f 100644
--- a/apps/desktop-client/src/main/runtime-controller.ts
+++ b/apps/desktop-client/src/main/runtime-controller.ts
@@ -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;
 }