feat: implement browser extension for media publishing and add backend support for media management
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import type { StoredPlatformState } from "../platforms";
|
||||
|
||||
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
|
||||
import { zhihuAdapter } from "./zhihu";
|
||||
|
||||
const adapters = new Map<string, PlatformAdapter>([[zhihuAdapter.platformId, zhihuAdapter]]);
|
||||
|
||||
export function getPlatformAdapter(platformId: string): PlatformAdapter | undefined {
|
||||
return adapters.get(platformId);
|
||||
}
|
||||
|
||||
export async function detectPlatformState(platform: StoredPlatformState): Promise<StoredPlatformState> {
|
||||
const adapter = getPlatformAdapter(platform.platform_id);
|
||||
if (!adapter) {
|
||||
return platform;
|
||||
}
|
||||
return adapter.detect(platform);
|
||||
}
|
||||
|
||||
export async function publishViaAdapter(platformId: string, context: AdapterContext): Promise<PlatformPublishResult | null> {
|
||||
const adapter = getPlatformAdapter(platformId);
|
||||
if (!adapter) {
|
||||
return null;
|
||||
}
|
||||
return adapter.publish(context);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PublisherPublishArticleRequest, PublishBatchTask } from "@geo/shared-types";
|
||||
|
||||
import type { StoredPlatformState } from "../platforms";
|
||||
|
||||
export interface AdapterContext {
|
||||
article: PublisherPublishArticleRequest;
|
||||
task: PublishBatchTask;
|
||||
}
|
||||
|
||||
export interface PlatformPublishResult {
|
||||
success: boolean;
|
||||
status: "success" | "failed" | "pending_review";
|
||||
externalArticleId?: string | null;
|
||||
externalArticleUrl?: string | null;
|
||||
externalManageUrl?: string | null;
|
||||
message?: string | null;
|
||||
responsePayload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PlatformAdapter {
|
||||
platformId: string;
|
||||
detect(platform: StoredPlatformState): Promise<StoredPlatformState>;
|
||||
publish(context: AdapterContext): Promise<PlatformPublishResult>;
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { browser } from "wxt/browser";
|
||||
import md5Lib from "js-md5";
|
||||
import { marked } from "marked";
|
||||
|
||||
import type { StoredPlatformState } from "../platforms";
|
||||
|
||||
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 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 = next.replace(/<img([^>]+src="[^"]+"[^>]*)>/gi, "<figure><img$1></figure>");
|
||||
|
||||
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: me.avatar_url ?? null,
|
||||
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: "ai_creation",
|
||||
disclaimer_status: "open",
|
||||
},
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user