Files
geo/apps/browser-extension/src/adapters/zhihu.ts
T
root 1b01caac0f feat(prompts): add prompts loader and configuration management
- Implemented a new prompts loader in `loader.go` to manage prompt templates and configurations.
- Introduced caching mechanism for prompt configurations to optimize loading.
- Added functions to apply platform-specific prompt template overrides.
- Created unit tests in `loader_test.go` to validate prompt configuration loading and reloading behavior.
- Ensured that the last valid configuration is retained in case of errors during reload.
2026-04-13 16:08:12 +08:00

411 lines
11 KiB
TypeScript

import { browser } from "wxt/browser";
import md5Lib from "js-md5";
import { marked } from "marked";
import type { StoredPlatformState } from "../platforms";
import { normalizeRemoteUrl } from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
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 getCookie(name?: string): Promise<string> {
const cookies = await browser.cookies.getAll({ domain: "zhihu.com" });
if (name) {
return cookies.find((item) => item.name === name)?.value ?? "";
}
return cookies.map((item) => `${item.name}=${item.value}`).join("; ");
}
async function readJson<T>(response: Response): Promise<T> {
const text = await response.text();
if (!response.ok) {
throw new Error(text || `zhihu_request_failed_${response.status}`);
}
if (!text) {
return {} as T;
}
return JSON.parse(text) as T;
}
async function zhihuFetch<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, {
...init,
credentials: "include",
headers: buildHeaders(init?.headers),
});
return readJson<T>(response);
}
function randomTraceId(): string {
const uuid =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `geo-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${Date.now()},${uuid}`;
}
function markdownToHtml(markdown: string): string {
return marked.parse(markdown, { async: false, gfm: true, breaks: false }) as string;
}
function baseContent(article: AdapterContext["article"]): string {
const html = article.html_content?.trim();
if (html) {
return html;
}
return markdownToHtml(article.markdown_content ?? "");
}
function wrapStandaloneImages(html: string): string {
if (typeof DOMParser === "undefined") {
return html.replace(/<img([^>]+src="[^"]+"[^>]*)>/gi, "<figure><img$1></figure>");
}
const doc = new DOMParser().parseFromString(`<div data-root="zhihu-content">${html}</div>`, "text/html");
const root = doc.body.querySelector('[data-root="zhihu-content"]');
if (!(root instanceof HTMLElement)) {
return html;
}
for (const image of [...root.querySelectorAll("img")]) {
if (image.closest("figure")) {
continue;
}
const parent = image.parentElement;
if (parent?.tagName === "P") {
continue;
}
const figure = doc.createElement("figure");
image.parentNode?.insertBefore(figure, image);
figure.appendChild(image);
}
return root.innerHTML.trim();
}
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);
return next;
}
function extractImageSources(html: string): string[] {
const sources = new Set<string>();
for (const match of html.matchAll(/<img\b[^>]*src="([^"]+)"[^>]*>/gi)) {
const src = match[1]?.trim();
if (src) {
sources.add(src);
}
}
return [...sources];
}
async function md5Hex(buffer: ArrayBuffer): Promise<string> {
const md5 = md5Lib as unknown as (value: string | ArrayBuffer | Uint8Array) => string;
return md5(buffer);
}
async function hmacSha1Base64(key: string, message: string): Promise<string> {
const encoder = new TextEncoder();
const cryptoKey = await crypto.subtle.importKey(
"raw",
encoder.encode(key),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(message));
return btoa(String.fromCharCode(...new Uint8Array(signature)));
}
function buildPictureUrl(imageHash: string, mimeType: string): string {
const extension = mimeType.split("/")[1] || "png";
return `https://picx.zhimg.com/v2-${imageHash}.${extension}`;
}
async function uploadBinaryImage(sourceUrl: string): Promise<string | null> {
const blob = await fetch(sourceUrl).then((response) => response.blob()).catch(() => null);
if (!blob || !blob.type.startsWith("image/")) {
return null;
}
const buffer = await blob.arrayBuffer();
const imageHash = await md5Hex(buffer);
const token = await zhihuFetch<ZhihuImageTokenResponse>("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 = await 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(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(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(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const me = await zhihuFetch<ZhihuMeResponse>("https://www.zhihu.com/api/v4/me", {
method: "GET",
});
const uid = me.uid || me.id ? String(me.uid || me.id) : undefined;
if (!uid || !me.name) {
return {
...platform,
connected: false,
platform_uid: null,
nickname: null,
avatar_url: null,
message: "未检测到知乎登录态",
};
}
return {
...platform,
connected: true,
platform_uid: uid,
nickname: me.name,
avatar_url: normalizeRemoteUrl(me.avatar_url),
message: null,
};
} catch (error) {
return {
...platform,
connected: false,
platform_uid: null,
nickname: null,
avatar_url: null,
message: error instanceof Error ? error.message : "知乎登录检测失败",
};
}
}
async function createDraft(context: AdapterContext, titleImage: string | null): Promise<string> {
const title = context.article.title?.trim() || "未命名文章";
const body = {
delta_time: 0,
can_reward: false,
title,
content: await processContentImages(transformContent(baseContent(context.article))),
...(titleImage ? { titleImage } : {}),
};
const created = await zhihuFetch<ZhihuDraftCreateResponse>("https://zhuanlan.zhihu.com/api/articles/drafts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!created.id) {
throw new Error("zhihu_create_draft_failed");
}
return created.id;
}
async function publishDraft(draftId: string): Promise<boolean> {
const xsrfToken = await getCookie("_xsrf");
const result = await zhihuFetch<ZhihuPublishResponse>("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;
}
async function publishZhihu(context: AdapterContext): Promise<PlatformPublishResult> {
const freshState = await detectZhihu({
platform_id: "zhihu",
platform_name: "知乎",
short_name: "知",
accent_color: "#1677ff",
login_url: "https://www.zhihu.com/signin",
logo_path: "logos/logo_zhihu.png",
connected: false,
platform_uid: null,
nickname: null,
avatar_url: null,
message: null,
});
if (!freshState.connected) {
throw new Error("zhihu_not_logged_in");
}
const coverUrl = context.article.cover_asset_url?.trim() || null;
const titleImage = coverUrl ? await uploadBinaryImage(coverUrl) : null;
const draftId = await createDraft(context, titleImage);
if (context.article.publish_type === "draft") {
return {
success: true,
status: "pending_review",
externalArticleId: draftId,
externalArticleUrl: `https://zhuanlan.zhihu.com/p/${draftId}/edit`,
externalManageUrl: `https://zhuanlan.zhihu.com/p/${draftId}/edit`,
message: "知乎草稿创建成功。",
responsePayload: {
mode: "zhihu-draft",
},
};
}
const published = await publishDraft(draftId);
if (!published) {
throw new Error("zhihu_publish_failed");
}
const publishedUrl = `https://zhuanlan.zhihu.com/p/${draftId}`;
return {
success: true,
status: "success",
externalArticleId: draftId,
externalArticleUrl: publishedUrl,
externalManageUrl: `${publishedUrl}/edit`,
message: "知乎正式发布成功。",
responsePayload: {
mode: "zhihu-direct-publish",
draft_id: draftId,
},
};
}
export const zhihuAdapter: PlatformAdapter = {
platformId: "zhihu",
detect: detectZhihu,
publish: publishZhihu,
};