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,
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
Reference in New Issue
Block a user