1b01caac0f
- 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.
411 lines
11 KiB
TypeScript
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,
|
|
};
|