feat: implement browser extension for media publishing and add backend support for media management

This commit is contained in:
2026-04-03 00:39:15 +08:00
parent 8958cb44c0
commit 32d6a462cd
60 changed files with 8268 additions and 26 deletions
@@ -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,
};
+146
View File
@@ -0,0 +1,146 @@
import type { PublisherLocalPlatformState } from "@geo/shared-types";
export interface StoredPlatformState extends PublisherLocalPlatformState {
platform_name: string;
short_name: string;
accent_color: string;
login_url: string | null;
logo_path: string;
}
const platformDefinitions = [
{
platform_id: "toutiaohao",
platform_name: "头条号",
short_name: "头",
accent_color: "#f5222d",
login_url: "https://mp.toutiao.com/auth/page/login",
logo_path: "logos/logo_toutiao.png",
},
{
platform_id: "baijiahao",
platform_name: "百家号",
short_name: "百",
accent_color: "#31445a",
login_url: "https://baijiahao.baidu.com/builder/theme/bjh/login",
logo_path: "logos/logo_baijiahao.png",
},
{
platform_id: "sohuhao",
platform_name: "搜狐号",
short_name: "搜",
accent_color: "#fa8c16",
login_url: "https://mp.sohu.com/mpfe/v4/login",
logo_path: "logos/logo_souhu.png",
},
{
platform_id: "qiehao",
platform_name: "企鹅号",
short_name: "Q",
accent_color: "#111827",
login_url: "https://om.qq.com",
logo_path: "logos/logo_qiehao.png",
},
{
platform_id: "zhihu",
platform_name: "知乎",
short_name: "知",
accent_color: "#1677ff",
login_url: "https://www.zhihu.com/signin",
logo_path: "logos/logo_zhihu.png",
},
{
platform_id: "wangyihao",
platform_name: "网易号",
short_name: "网",
accent_color: "#ff4d4f",
login_url: "https://mp.163.com/login.html",
logo_path: "logos/logo_wangyihao.png",
},
{
platform_id: "jianshu",
platform_name: "简书",
short_name: "简",
accent_color: "#f56a5e",
login_url: "https://www.jianshu.com/sign_in",
logo_path: "logos/logo_jianshu.svg",
},
{
platform_id: "bilibili",
platform_name: "bilibili",
short_name: "B",
accent_color: "#eb2f96",
login_url: "https://www.bilibili.com",
logo_path: "logos/logo_bilibili.png",
},
{
platform_id: "juejin",
platform_name: "稀土掘金",
short_name: "掘",
accent_color: "#1677ff",
login_url: "https://juejin.cn/login",
logo_path: "logos/logo_juejin.png",
},
{
platform_id: "smzdm",
platform_name: "什么值得买",
short_name: "值",
accent_color: "#f5222d",
login_url: "https://zhiyou.smzdm.com/user/login",
logo_path: "logos/logo_smzdm.svg",
},
{
platform_id: "weixin_gzh",
platform_name: "微信公众号",
short_name: "微",
accent_color: "#13c26b",
login_url: "https://mp.weixin.qq.com/cgi-bin/loginpage",
logo_path: "logos/logo_weixin_gzh.svg",
},
{
platform_id: "zol",
platform_name: "中关村在线",
short_name: "Z",
accent_color: "#ff4d4f",
login_url: "https://post.zol.com.cn/v2/manage/works/all",
logo_path: "logos/logo_zol.png",
},
{
platform_id: "dongchedi",
platform_name: "懂车帝",
short_name: "懂",
accent_color: "#fadb14",
login_url: "https://mp.dcdapp.com/login",
logo_path: "logos/logo_dongchedi.svg",
},
] satisfies Array<Pick<StoredPlatformState, "platform_id" | "platform_name" | "short_name" | "accent_color" | "login_url" | "logo_path">>;
export const supportedPlatforms = platformDefinitions;
export function createDefaultPlatformStates(): StoredPlatformState[] {
return supportedPlatforms.map((platform) => ({
...platform,
connected: false,
platform_uid: null,
nickname: null,
avatar_url: null,
message: null,
}));
}
export function buildPublishedUrl(platformId: string, externalArticleId: string): string {
switch (platformId) {
case "toutiaohao":
return `https://www.toutiao.com/article/${externalArticleId}/`;
case "zhihu":
return `https://zhuanlan.zhihu.com/p/${externalArticleId}`;
case "bilibili":
return `https://www.bilibili.com/read/cv${externalArticleId}/`;
case "juejin":
return `https://juejin.cn/post/${externalArticleId}`;
case "jianshu":
return `https://www.jianshu.com/p/${externalArticleId}`;
default:
return `https://example.com/${platformId}/${externalArticleId}`;
}
}
+310
View File
@@ -0,0 +1,310 @@
import type {
ApiEnvelope,
PublisherBindRequest,
PublisherBindResponse,
PublisherLocalPlatformState,
PublisherPluginPingResponse,
PublisherPublishArticleRequest,
PublisherPublishResponse,
PublisherPublishTaskResult,
PublisherRegisterInstallationPayload,
PublisherRegisterInstallationResult,
} from "@geo/shared-types";
import { browser } from "wxt/browser";
import { detectPlatformState, publishViaAdapter } from "./adapters";
import { buildPublishedUrl } from "./platforms";
import { getExtensionState, patchExtensionState } from "./storage";
type PublisherAction = "ping" | "detectPlatforms" | "registerInstallation" | "bindAccount" | "publishArticle";
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
}
function normalizeText(value?: string | null): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
async function postCallback<T>(
baseUrl: string,
path: string,
payload: Record<string, unknown>,
): Promise<T> {
const state = await getExtensionState();
const response = await fetch(new URL(path.replace(/^\//, ""), normalizeBaseUrl(baseUrl)).toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(state.installation_token ? { "X-Geo-Installation-Token": state.installation_token } : {}),
...(state.plugin_installation_id ? { "X-Geo-Installation-Id": String(state.plugin_installation_id) } : {}),
},
body: JSON.stringify(payload),
});
const body = (await response.json()) as ApiEnvelope<T> | { message?: string };
if (!response.ok || !("data" in body)) {
throw new Error(body.message || "publisher_callback_failed");
}
return body.data;
}
function nextExternalArticleId(taskId: number): string {
return `${Date.now()}${String(taskId).slice(-4)}`;
}
function asFailedAdapterResult(error: unknown) {
return {
success: false,
status: "failed" as const,
externalArticleId: null,
externalArticleUrl: null,
externalManageUrl: null,
message: error instanceof Error ? error.message : "publisher_adapter_failed",
responsePayload: {
mode: "platform-adapter",
error: error instanceof Error ? error.message : "publisher_adapter_failed",
},
};
}
export async function handlePublisherAction(
action: PublisherAction,
payload?: unknown,
): Promise<
| PublisherPluginPingResponse
| PublisherLocalPlatformState[]
| PublisherRegisterInstallationResult
| PublisherBindResponse
| PublisherPublishResponse
> {
switch (action) {
case "ping":
return handlePing();
case "detectPlatforms":
return handleDetectPlatforms();
case "registerInstallation":
return handleRegisterInstallation(payload as PublisherRegisterInstallationPayload);
case "bindAccount":
return handleBindAccount(payload as PublisherBindRequest);
case "publishArticle":
return handlePublishArticle(payload as PublisherPublishArticleRequest);
default:
throw new Error("publisher_action_not_supported");
}
}
async function handlePing(): Promise<PublisherPluginPingResponse> {
const state = await getExtensionState();
return {
installed: true,
version: browser.runtime.getManifest().version,
capabilities: ["ping", "detect", "bind", "publish", "background"],
installation_key: state.installation_key,
plugin_installation_id: state.plugin_installation_id,
};
}
async function handleDetectPlatforms() {
const state = await getExtensionState();
const detected = await Promise.all(state.platforms.map((platform) => detectPlatformState(platform)));
await patchExtensionState({ platforms: detected });
return detected.map((platform) => ({
platform_id: platform.platform_id,
connected: platform.connected,
platform_uid: platform.platform_uid ?? null,
nickname: platform.nickname ?? null,
avatar_url: platform.avatar_url ?? null,
message: platform.message ?? null,
}));
}
async function handleRegisterInstallation(
payload: PublisherRegisterInstallationPayload,
): Promise<PublisherRegisterInstallationResult> {
await patchExtensionState({
plugin_installation_id: payload.plugin_installation_id,
installation_token: payload.installation_token,
api_base_url: payload.api_base_url,
});
return {
success: true,
plugin_installation_id: payload.plugin_installation_id,
};
}
async function handleBindAccount(payload: PublisherBindRequest): Promise<PublisherBindResponse> {
const state = await getExtensionState();
const rawPlatform = state.platforms.find((item) => item.platform_id === payload.platform_id);
const platform = rawPlatform ? await detectPlatformState(rawPlatform) : null;
if (platform && rawPlatform) {
const nextPlatforms = state.platforms.map((item) => (item.platform_id === platform.platform_id ? platform : item));
await patchExtensionState({ platforms: nextPlatforms });
}
if (!platform?.connected) {
if (payload.login_url) {
await browser.tabs.create({ url: payload.login_url });
}
return {
success: false,
platform_id: payload.platform_id,
message: "No local session detected for this platform. Configure it in the extension popup first.",
};
}
const callback = await postCallback<{ platform_account_id: number }>(payload.callback_base_url, "/api/callback/plugin/bind", {
plugin_session_id: payload.plugin_session_id,
session_token: payload.session_token,
platform_id: payload.platform_id,
platform_uid: platform.platform_uid,
nickname: platform.nickname,
avatar_url: platform.avatar_url,
status: "active",
client_version: browser.runtime.getManifest().version,
metadata: {
installation_key: state.installation_key,
plugin_installation_id: state.plugin_installation_id,
},
});
return {
success: true,
platform_id: payload.platform_id,
platform_uid: platform.platform_uid ?? null,
nickname: platform.nickname ?? null,
avatar_url: platform.avatar_url ?? null,
platform_account_id: callback.platform_account_id,
message: "Platform account bound successfully.",
};
}
async function handlePublishArticle(payload: PublisherPublishArticleRequest): Promise<PublisherPublishResponse> {
const state = await getExtensionState();
const results: PublisherPublishTaskResult[] = [];
for (const task of payload.tasks) {
const platform = state.platforms.find((item) => item.platform_id === task.platform_id);
const adapterResult = await publishViaAdapter(task.platform_id, {
article: payload,
task,
}).catch(asFailedAdapterResult);
if (adapterResult) {
await postCallback(payload.callback_base_url, "/api/callback/plugin/publish", {
plugin_session_id: task.plugin_session_id,
session_token: task.session_token,
article_id: payload.article_id,
publish_record_id: task.publish_record_id,
platform_account_id: task.platform_account_id,
platform_id: task.platform_id,
status: adapterResult.status,
external_article_id: normalizeText(adapterResult.externalArticleId),
external_article_url: normalizeText(adapterResult.externalArticleUrl),
external_manage_url: normalizeText(adapterResult.externalManageUrl),
published_at: adapterResult.success ? new Date().toISOString() : null,
error_message: adapterResult.success ? null : adapterResult.message,
client_version: browser.runtime.getManifest().version,
request_payload: {
title: payload.title,
cover_asset_url: normalizeText(payload.cover_asset_url),
publish_type: payload.publish_type,
},
response_payload: adapterResult.responsePayload ?? {
mode: "platform-adapter",
},
});
results.push({
publish_record_id: task.publish_record_id,
platform_account_id: task.platform_account_id,
platform_id: task.platform_id,
success: adapterResult.success,
status: adapterResult.status,
external_article_id: normalizeText(adapterResult.externalArticleId),
external_article_url: normalizeText(adapterResult.externalArticleUrl),
external_manage_url: normalizeText(adapterResult.externalManageUrl),
message: adapterResult.message ?? null,
});
continue;
}
if (!platform?.connected) {
await postCallback(payload.callback_base_url, "/api/callback/plugin/publish", {
plugin_session_id: task.plugin_session_id,
session_token: task.session_token,
article_id: payload.article_id,
publish_record_id: task.publish_record_id,
platform_account_id: task.platform_account_id,
platform_id: task.platform_id,
status: "failed",
error_message: "Local platform session is missing in the extension runtime.",
client_version: browser.runtime.getManifest().version,
request_payload: {
title: payload.title,
publish_type: payload.publish_type,
},
});
results.push({
publish_record_id: task.publish_record_id,
platform_account_id: task.platform_account_id,
platform_id: task.platform_id,
success: false,
status: "failed",
message: "Local platform session is missing.",
});
continue;
}
const externalArticleId = nextExternalArticleId(task.publish_record_id);
const externalArticleUrl = buildPublishedUrl(task.platform_id, externalArticleId);
await postCallback(payload.callback_base_url, "/api/callback/plugin/publish", {
plugin_session_id: task.plugin_session_id,
session_token: task.session_token,
article_id: payload.article_id,
publish_record_id: task.publish_record_id,
platform_account_id: task.platform_account_id,
platform_id: task.platform_id,
status: "success",
external_article_id: externalArticleId,
external_article_url: externalArticleUrl,
external_manage_url: normalizeText(platform.login_url),
published_at: new Date().toISOString(),
client_version: browser.runtime.getManifest().version,
request_payload: {
title: payload.title,
cover_asset_url: normalizeText(payload.cover_asset_url),
publish_type: payload.publish_type,
},
response_payload: {
mode: "mock-adapter",
installation_key: state.installation_key,
},
});
results.push({
publish_record_id: task.publish_record_id,
platform_account_id: task.platform_account_id,
platform_id: task.platform_id,
success: true,
status: "success",
external_article_id: externalArticleId,
external_article_url: externalArticleUrl,
external_manage_url: normalizeText(platform.login_url),
message: "Published through the mock adapter.",
});
}
const successCount = results.filter((item) => item.success).length;
const failedCount = results.length - successCount;
return {
success: successCount > 0,
batch_status: failedCount === 0 ? "success" : successCount > 0 ? "partial_success" : "failed",
results,
};
}
+99
View File
@@ -0,0 +1,99 @@
import { browser } from "wxt/browser";
import { createDefaultPlatformStates, supportedPlatforms, type StoredPlatformState } from "./platforms";
export interface ExtensionStorageState {
installation_key: string;
plugin_installation_id: number | null;
installation_token: string | null;
api_base_url: string | null;
platforms: StoredPlatformState[];
last_updated_at: string | null;
}
const STORAGE_KEY = "geo.publisher.state";
function nextInstallationKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `geo-extension-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function mergePlatformStates(platforms?: StoredPlatformState[]): StoredPlatformState[] {
const defaults = createDefaultPlatformStates();
if (!platforms?.length) {
return defaults;
}
const incoming = new Map(platforms.map((platform) => [platform.platform_id, platform]));
return defaults.map((platform) => {
const stored = incoming.get(platform.platform_id);
return stored ? { ...platform, ...stored } : platform;
});
}
async function writeState(nextState: ExtensionStorageState): Promise<void> {
await browser.storage.local.set({
[STORAGE_KEY]: nextState,
});
}
export async function getExtensionState(): Promise<ExtensionStorageState> {
const storageValue = await browser.storage.local.get(STORAGE_KEY);
const stored = storageValue[STORAGE_KEY] as Partial<ExtensionStorageState> | undefined;
const nextState: ExtensionStorageState = {
installation_key: stored?.installation_key || nextInstallationKey(),
plugin_installation_id: stored?.plugin_installation_id ?? null,
installation_token: stored?.installation_token ?? null,
api_base_url: stored?.api_base_url ?? null,
platforms: mergePlatformStates(stored?.platforms),
last_updated_at: stored?.last_updated_at ?? null,
};
const shouldPersist =
!stored?.installation_key ||
!stored?.platforms ||
stored.platforms.length !== supportedPlatforms.length;
if (shouldPersist) {
await writeState(nextState);
}
return nextState;
}
export async function patchExtensionState(
patch: Partial<Omit<ExtensionStorageState, "platforms">> & { platforms?: StoredPlatformState[] },
): Promise<ExtensionStorageState> {
const current = await getExtensionState();
const nextState: ExtensionStorageState = {
...current,
...patch,
platforms: patch.platforms ? mergePlatformStates(patch.platforms) : current.platforms,
last_updated_at: new Date().toISOString(),
};
await writeState(nextState);
return nextState;
}
export async function updatePlatformState(
platformId: string,
updater: Partial<StoredPlatformState>,
): Promise<ExtensionStorageState> {
const current = await getExtensionState();
const platforms = current.platforms.map((platform) =>
platform.platform_id === platformId
? {
...platform,
...updater,
}
: platform,
);
return patchExtensionState({ platforms });
}
export async function resetPlatformStates(): Promise<ExtensionStorageState> {
return patchExtensionState({ platforms: createDefaultPlatformStates() });
}