feat: implement browser extension for media publishing and add backend support for media management
@@ -0,0 +1,31 @@
|
||||
import { browser } from "wxt/browser";
|
||||
import { defineBackground } from "wxt/utils/define-background";
|
||||
|
||||
import { handlePublisherAction } from "../src/runtime";
|
||||
import { getExtensionState } from "../src/storage";
|
||||
|
||||
export default defineBackground(() => {
|
||||
void getExtensionState();
|
||||
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
void getExtensionState();
|
||||
browser.alarms.create("geo.publisher.background-heartbeat", {
|
||||
periodInMinutes: 60,
|
||||
});
|
||||
});
|
||||
|
||||
browser.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name !== "geo.publisher.background-heartbeat") {
|
||||
return;
|
||||
}
|
||||
await getExtensionState();
|
||||
});
|
||||
|
||||
browser.runtime.onMessage.addListener((message) => {
|
||||
if (!message || message.type !== "geo.publisher.request") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return handlePublisherAction(message.action, message.payload);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { browser } from "wxt/browser";
|
||||
import { defineContentScript } from "wxt/utils/define-content-script";
|
||||
|
||||
type PublisherRequestMessage = {
|
||||
source: "geo-admin";
|
||||
type: "geo.publisher.request";
|
||||
requestId: string;
|
||||
action: "ping" | "detectPlatforms" | "registerInstallation" | "bindAccount" | "publishArticle";
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["http://*/*", "https://*/*"],
|
||||
main() {
|
||||
window.addEventListener("message", async (event: MessageEvent<PublisherRequestMessage>) => {
|
||||
if (event.source !== window || !event.data || event.data.type !== "geo.publisher.request") {
|
||||
return;
|
||||
}
|
||||
if (event.data.source !== "geo-admin") {
|
||||
return;
|
||||
}
|
||||
|
||||
const { requestId, action, payload } = event.data;
|
||||
|
||||
try {
|
||||
const data = await browser.runtime.sendMessage({
|
||||
type: "geo.publisher.request",
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
source: "geo-extension",
|
||||
type: "geo.publisher.response",
|
||||
requestId,
|
||||
success: true,
|
||||
data,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
} catch (error) {
|
||||
window.postMessage(
|
||||
{
|
||||
source: "geo-extension",
|
||||
type: "geo.publisher.response",
|
||||
requestId,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "publisher_plugin_unknown_error",
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,767 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { browser } from "wxt/browser";
|
||||
|
||||
import { getExtensionState, resetPlatformStates, updatePlatformState } from "../../src/storage";
|
||||
import type { StoredPlatformState } from "../../src/platforms";
|
||||
|
||||
const loading = ref(true);
|
||||
const refreshing = ref(false);
|
||||
const resetting = ref(false);
|
||||
const savingId = ref<string | null>(null);
|
||||
const expandedId = ref<string | null>(null);
|
||||
|
||||
const installationKey = ref("");
|
||||
const installationId = ref<number | null>(null);
|
||||
const apiBaseUrl = ref<string | null>(null);
|
||||
const platforms = ref<StoredPlatformState[]>([]);
|
||||
|
||||
const connectedCount = computed(() => platforms.value.filter((item) => item.connected).length);
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (loading.value || refreshing.value) {
|
||||
return "正在检测平台状态...";
|
||||
}
|
||||
return `已连接 ${connectedCount.value}/${platforms.value.length} 个平台`;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void initializePopup();
|
||||
});
|
||||
|
||||
function clonePlatforms(items: StoredPlatformState[]): StoredPlatformState[] {
|
||||
return items.map((platform) => ({ ...platform }));
|
||||
}
|
||||
|
||||
async function loadState(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const state = await getExtensionState();
|
||||
installationKey.value = state.installation_key;
|
||||
installationId.value = state.plugin_installation_id;
|
||||
apiBaseUrl.value = state.api_base_url;
|
||||
platforms.value = clonePlatforms(state.platforms);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializePopup(): Promise<void> {
|
||||
try {
|
||||
await loadState();
|
||||
await refreshDetectedPlatforms();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function titleFor(platform: StoredPlatformState): string {
|
||||
return platform.nickname?.trim() || platform.platform_name;
|
||||
}
|
||||
|
||||
function uidFor(platform: StoredPlatformState): string {
|
||||
return platform.platform_uid != null ? String(platform.platform_uid).trim() : "未检测到平台账号";
|
||||
}
|
||||
|
||||
function avatarFor(platform: StoredPlatformState): string | null {
|
||||
if (platform.avatar_url?.trim()) {
|
||||
return platform.avatar_url.trim();
|
||||
}
|
||||
if (platform.connected) {
|
||||
return `https://api.dicebear.com/9.x/initials/svg?seed=${encodeURIComponent(titleFor(platform))}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function statusLabel(platform: StoredPlatformState): string {
|
||||
if (refreshing.value || loading.value) {
|
||||
return "检测中...";
|
||||
}
|
||||
return platform.connected ? "已登录" : "未登录";
|
||||
}
|
||||
|
||||
function statusTone(platform: StoredPlatformState): "success" | "danger" | "pending" {
|
||||
if (refreshing.value || loading.value) {
|
||||
return "pending";
|
||||
}
|
||||
return platform.connected ? "success" : "danger";
|
||||
}
|
||||
|
||||
function fillDemo(platform: StoredPlatformState): void {
|
||||
platform.connected = true;
|
||||
platform.nickname = platform.nickname?.trim() || `${platform.platform_name} Demo`;
|
||||
platform.platform_uid = (platform.platform_uid != null ? String(platform.platform_uid).trim() : "") || `${platform.platform_id}-demo`;
|
||||
platform.avatar_url =
|
||||
platform.avatar_url?.trim() ||
|
||||
`https://api.dicebear.com/9.x/initials/svg?seed=${encodeURIComponent(platform.nickname || platform.platform_name)}`;
|
||||
platform.message = null;
|
||||
}
|
||||
|
||||
function toggleExpanded(platformId: string): void {
|
||||
expandedId.value = expandedId.value === platformId ? null : platformId;
|
||||
}
|
||||
|
||||
async function savePlatform(platform: StoredPlatformState): Promise<void> {
|
||||
savingId.value = platform.platform_id;
|
||||
try {
|
||||
const state = await updatePlatformState(platform.platform_id, {
|
||||
connected: platform.connected,
|
||||
nickname: platform.nickname?.trim() || null,
|
||||
platform_uid: platform.platform_uid != null ? String(platform.platform_uid).trim() || null : null,
|
||||
avatar_url: platform.avatar_url?.trim() || null,
|
||||
message: platform.connected ? null : "Sign in required",
|
||||
});
|
||||
installationId.value = state.plugin_installation_id;
|
||||
apiBaseUrl.value = state.api_base_url;
|
||||
platforms.value = clonePlatforms(state.platforms);
|
||||
} finally {
|
||||
savingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function simulateDetect(): Promise<void> {
|
||||
await refreshDetectedPlatforms();
|
||||
}
|
||||
|
||||
async function refreshDetectedPlatforms(): Promise<void> {
|
||||
refreshing.value = true;
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
type: "geo.publisher.request",
|
||||
action: "detectPlatforms",
|
||||
});
|
||||
const state = await getExtensionState();
|
||||
installationId.value = state.plugin_installation_id;
|
||||
apiBaseUrl.value = state.api_base_url;
|
||||
platforms.value = clonePlatforms(state.platforms);
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(): Promise<void> {
|
||||
resetting.value = true;
|
||||
try {
|
||||
const state = await resetPlatformStates();
|
||||
platforms.value = clonePlatforms(state.platforms);
|
||||
expandedId.value = null;
|
||||
} finally {
|
||||
resetting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="popup-shell">
|
||||
<header class="popup-header">
|
||||
<p class="popup-brand">My GEO</p>
|
||||
<p class="popup-subtitle">{{ loading || refreshing ? "正在检测平台状态..." : "管理您的媒体平台账号" }}</p>
|
||||
</header>
|
||||
|
||||
<section v-if="loading" class="popup-loading">
|
||||
<div class="popup-spinner" />
|
||||
<p>正在读取插件状态...</p>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<section class="popup-list">
|
||||
<article
|
||||
v-for="platform in platforms"
|
||||
:key="platform.platform_id"
|
||||
class="platform-card"
|
||||
:class="[`is-${statusTone(platform)}`, { 'is-expanded': expandedId === platform.platform_id }]"
|
||||
:style="{ '--platform-accent': platform.accent_color }"
|
||||
@click="toggleExpanded(platform.platform_id)"
|
||||
>
|
||||
<div class="platform-card__accent" />
|
||||
|
||||
<div class="platform-card__body">
|
||||
<div class="platform-card__identity">
|
||||
<div class="platform-card__logo">
|
||||
<img v-if="platform.logo_path" :src="platform.logo_path" :alt="platform.platform_name" />
|
||||
<span v-else>{{ platform.short_name }}</span>
|
||||
<i />
|
||||
</div>
|
||||
|
||||
<div class="platform-card__meta">
|
||||
<h2>{{ platform.platform_name }}</h2>
|
||||
<p
|
||||
v-if="!platform.connected && !loading && !refreshing && platform.message"
|
||||
class="platform-card__hint"
|
||||
:title="platform.message"
|
||||
>
|
||||
{{ platform.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="platform.connected && !refreshing" class="platform-card__account">
|
||||
<img v-if="avatarFor(platform)" :src="avatarFor(platform)!" :alt="titleFor(platform)" class="platform-card__avatar" />
|
||||
<div class="platform-card__account-copy">
|
||||
<strong>{{ titleFor(platform) }}</strong>
|
||||
<span>{{ uidFor(platform) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="platform-card__status-chip" :class="`is-${statusTone(platform)}`">
|
||||
{{ statusLabel(platform) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="expandedId === platform.platform_id" class="platform-editor" @click.stop>
|
||||
<div class="platform-editor__grid">
|
||||
<label>
|
||||
<span>昵称</span>
|
||||
<input v-model="platform.nickname" type="text" placeholder="比如 CharmingGeeker" />
|
||||
</label>
|
||||
<label>
|
||||
<span>UID</span>
|
||||
<input v-model="platform.platform_uid" type="text" placeholder="平台账号 UID" />
|
||||
</label>
|
||||
<label class="platform-editor__full">
|
||||
<span>头像 URL</span>
|
||||
<input v-model="platform.avatar_url" type="text" placeholder="可选" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="platform-editor__actions">
|
||||
<label class="platform-editor__toggle">
|
||||
<input v-model="platform.connected" type="checkbox" />
|
||||
<span>{{ platform.connected ? "已登录" : "未登录" }}</span>
|
||||
</label>
|
||||
|
||||
<div class="platform-editor__buttons">
|
||||
<button type="button" class="ghost" @click="fillDemo(platform)">填充示例</button>
|
||||
<button type="button" class="ghost" @click="platform.connected = false">设为未登录</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
:disabled="savingId === platform.platform_id"
|
||||
@click="savePlatform(platform)"
|
||||
>
|
||||
{{ savingId === platform.platform_id ? "保存中..." : "保存本地状态" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<p class="popup-status">{{ statusText }}</p>
|
||||
|
||||
<details class="popup-debug">
|
||||
<summary>调试信息</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>安装实例 ID</dt>
|
||||
<dd>{{ installationId ?? "--" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>SaaS 地址</dt>
|
||||
<dd>{{ apiBaseUrl || "--" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Installation Key</dt>
|
||||
<dd>{{ installationKey }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<button type="button" class="popup-reset" :disabled="resetting" @click="handleReset">
|
||||
{{ resetting ? "重置中..." : "重置全部平台" }}
|
||||
</button>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<footer class="popup-footer">
|
||||
<button type="button" class="popup-detect" :disabled="refreshing || resetting" @click="simulateDetect">
|
||||
<span class="popup-detect__icon" :class="{ 'is-spinning': refreshing }">↻</span>
|
||||
<span>{{ refreshing ? "检测中..." : "重新检测" }}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(html) {
|
||||
width: 360px;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: #516173;
|
||||
font-family: "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(140, 179, 255, 0.32), transparent 42%),
|
||||
linear-gradient(180deg, #edf4ff 0%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
:global(#app) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.popup-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
flex-shrink: 0;
|
||||
padding: 24px 24px 14px;
|
||||
text-align: center;
|
||||
background: rgba(248, 251, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(216, 225, 238, 0.5);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.popup-brand {
|
||||
margin: 0;
|
||||
color: #18263a;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.popup-subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: #6c7b90;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
color: #7a8698;
|
||||
padding: 16px 14px;
|
||||
}
|
||||
|
||||
.popup-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid rgba(71, 122, 255, 0.18);
|
||||
border-top-color: #4f7dff;
|
||||
border-radius: 999px;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.popup-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 14px;
|
||||
}
|
||||
|
||||
.popup-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.popup-list::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(122, 144, 179, 0.28);
|
||||
}
|
||||
|
||||
.platform-card {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
border-color 180ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.platform-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.platform-card.is-expanded {
|
||||
border-color: rgba(128, 156, 213, 0.6);
|
||||
}
|
||||
|
||||
.platform-card__accent {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: #ff7f86;
|
||||
}
|
||||
|
||||
.platform-card.is-success .platform-card__accent {
|
||||
background: linear-gradient(180deg, #55d7b7, #47b69f);
|
||||
}
|
||||
|
||||
.platform-card.is-danger .platform-card__accent {
|
||||
background: linear-gradient(180deg, #ff8b91, #ff6c74);
|
||||
}
|
||||
|
||||
.platform-card.is-pending .platform-card__accent {
|
||||
background: linear-gradient(180deg, #ff8b91, #ff6c74);
|
||||
}
|
||||
|
||||
.platform-card__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.platform-card__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.platform-card__logo {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
color: var(--platform-accent);
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.platform-card__logo img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.platform-card__logo i {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
background: #f7bf4b;
|
||||
}
|
||||
|
||||
.platform-card__meta h2 {
|
||||
margin: 0;
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.platform-card__hint {
|
||||
margin: 4px 0 0;
|
||||
overflow: hidden;
|
||||
color: #9aa5b5;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-card__account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.platform-card__avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
background: #f3f6fb;
|
||||
}
|
||||
|
||||
.platform-card__account-copy {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.platform-card__account-copy strong,
|
||||
.platform-card__account-copy span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-card__account-copy strong {
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.platform-card__account-copy span {
|
||||
margin-top: 2px;
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.platform-card__status-chip {
|
||||
min-width: 72px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.platform-card__status-chip.is-danger,
|
||||
.platform-card__status-chip.is-pending {
|
||||
color: #ff6b6b;
|
||||
border-color: rgba(255, 107, 107, 0.3);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.platform-card__status-chip.is-success {
|
||||
color: #4ab79f;
|
||||
border-color: rgba(74, 183, 159, 0.3);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.platform-editor {
|
||||
padding: 0 18px 18px 20px;
|
||||
}
|
||||
|
||||
.platform-editor__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.platform-editor__full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.platform-editor label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.platform-editor span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #8793a4;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.platform-editor input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(216, 224, 238, 1);
|
||||
border-radius: 14px;
|
||||
outline: none;
|
||||
background: rgba(248, 251, 255, 0.92);
|
||||
color: #516173;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.platform-editor input:focus {
|
||||
border-color: rgba(90, 124, 255, 0.6);
|
||||
box-shadow: 0 0 0 4px rgba(91, 126, 255, 0.08);
|
||||
}
|
||||
|
||||
.platform-editor__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.platform-editor__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #6a788b;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.platform-editor__toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.platform-editor__buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.platform-editor__buttons button,
|
||||
.popup-detect,
|
||||
.popup-reset {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
opacity 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.platform-editor__buttons button:hover,
|
||||
.popup-detect:hover,
|
||||
.popup-reset:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.platform-editor__buttons button:disabled,
|
||||
.popup-detect:disabled,
|
||||
.popup-reset:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
padding: 10px 14px;
|
||||
background: rgba(242, 246, 252, 0.98);
|
||||
color: #65758a;
|
||||
}
|
||||
|
||||
.primary {
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(135deg, #4f7dff, #6a83ff);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 10px 20px rgba(79, 125, 255, 0.2);
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
background: rgba(248, 251, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(216, 225, 238, 0.5);
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.04);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.popup-detect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #a5aab7, #9197a5);
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.popup-detect__icon {
|
||||
display: inline-block;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.popup-detect__icon.is-spinning {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.popup-status {
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
color: #7f8b9d;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popup-debug {
|
||||
margin-top: 12px;
|
||||
border: 1px solid rgba(216, 225, 238, 0.95);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.popup-debug summary {
|
||||
padding: 12px 14px;
|
||||
color: #76859a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popup-debug dl {
|
||||
margin: 0;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.popup-debug dl div + div {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.popup-debug dt {
|
||||
color: #8b97a8;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.popup-debug dd {
|
||||
margin: 6px 0 0;
|
||||
color: #526275;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.popup-reset {
|
||||
width: calc(100% - 28px);
|
||||
margin: 0 14px 14px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(244, 114, 118, 0.12);
|
||||
color: #df5e6d;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GEO Publisher</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "browser-extension",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"build": "wxt build",
|
||||
"zip": "wxt zip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@geo/shared-types": "workspace:*",
|
||||
"js-md5": "^0.8.3",
|
||||
"marked": "^17.0.5",
|
||||
"vue": "^3.5.31"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.38",
|
||||
"@wxt-dev/module-vue": "^1.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vue-tsc": "^3.2.6",
|
||||
"wxt": "^0.20.20"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 559 B |
|
After Width: | Height: | Size: 916 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path
|
||||
d="M0 0m170.666667 0l682.666666 0q170.666667 0 170.666667 170.666667l0 682.666666q0 170.666667-170.666667 170.666667l-682.666666 0q-170.666667 0-170.666667-170.666667l0-682.666666q0-170.666667 170.666667-170.666667Z"
|
||||
fill="#FECC32"></path>
|
||||
<path
|
||||
d="M512 199.369697c-202.364121 0-366.390303 164.026182-366.390303 366.390303A365.164606 365.164606 0 0 0 250.414545 822.30303l99.669334-92.004848a230.089697 230.089697 0 0 1-68.949334-164.553697c0-127.503515 103.330909-230.865455 230.84994-230.865455s230.865455 103.361939 230.865454 230.865455c0 65.318788-27.10497 124.276364-70.68703 166.275879l99.126303 92.609939a365.288727 365.288727 0 0 0 107.116606-258.885818c0-202.364121-164.057212-366.405818-366.421333-366.405818z"
|
||||
fill="#000000"></path>
|
||||
<path
|
||||
d="M279.489939 522.627879c37.701818-13.187879 137.75903-32.845576 248.754425-30.409697 110.995394 2.435879 150.900364 12.955152 210.788848 26.298182 30.937212 6.888727 77.746424 42.713212 80.32194 83.673212 2.435879 38.291394-66.746182 57.607758-95.232 57.607757 0 0-105.580606-27.415273-211.999031-27.492848-106.433939-0.077576-213.767758 27.182545-213.767757 27.182545l-69.725091-50.641454 50.858666-86.217697z"
|
||||
fill="#000000"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="500" height="500" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#FFFFFF" rx="8" />
|
||||
<text x="30" y="320" font-family="Source Han Sans SC" font-size="220" fill="#D95354" font-weight="bold">简书</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="128" height="128">
|
||||
<path
|
||||
d="M0 0m225.890154 0l572.219692 0q225.890154 0 225.890154 225.890154l0 572.219692q0 225.890154-225.890154 225.890154l-572.219692 0q-225.890154 0-225.890154-225.890154l0-572.219692q0-225.890154 225.890154-225.890154Z"
|
||||
fill="#EE3731"></path>
|
||||
<path
|
||||
d="M280.725047 864.454516l-56.635994-328.614599a610.844676 610.844676 0 0 1-51.282037 67.491947l-8.104955-3.367813q23.438987-75.251485 39.278808-176.668281 15.469731-106.338983 15.790476-204.474321l137.401809 15.691785c-1.060923 9.782693-9.264568 16.098883-24.425892 18.627826q-16.54299 83.714193-43.806233 154.783669l32.074403 5.464985c-0.629152 7.056369-6.834315 12.644717-18.504463 16.962425l67.491946 391.147349c-0.209717 4.095655-7.574494 10.942306-22.007975 20.317901-16.629345 10.214464-33.308034 16.839062-49.801679 19.738094z m133.096438-25.696532l-81.18525-470.210753 111.026781-8.746443c0.222054 9.585312-6.106473 16.641681-19.158287 21.267797l66.44336 384.89284 225.421374-38.859373 34.220921-66.01159a177.901912 177.901912 0 0 1-35.05979 9.474286L698.827231 673.562471l-4.416398-25.474478-94.545472 16.320936 2.948378 16.740372c0.629152 3.996964-5.057887 9.572976-17.172143 16.740371-14.630862 8.52439-32.32113 14.519836-52.848747 18.109702l-16.740371 2.837351L447.721662 323.211295l78.545279 14.803571-9.165877-57.067765L356.75372 308.407725l-8.536726-17.788958 165.627284-28.644909-15.691785-91.17766 129.296855-11.26305c-0.320744 12.003229-7.895238 20.971725-23.056562 26.745118 0.629152 11.361741 1.468021 31.272543 2.738661 59.584372l28.102112-4.835833 37.79845-73.499729 36.540148 19.984821q41.178599 22.637127 65.283747 37.79845c-0.641488 7.475803-6.427217 12.212946-17.591577 14.100401l-149.614755 25.733541c0.629152 22.42741 1.233631 44.965846 1.998482 67.393256l26.745118-4.638452 36.219403-58.745504 92.028865 49.123183c-4.107991 8.104955-12.53369 14.322455-25.06738 18.84988l51.393063 297.749154c0.209717 1.36933-2.109509 4.107991-6.957678 8.104955A107.399906 107.399906 0 0 1 762.852675 656.291638q60.953703 33.073644 96.445264 54.440132c-0.530461 7.475803-6.427217 12.212946-17.591577 14.112737l-347.452142 60.003807 2.208199 12.743407c0.740179 4.428735-6.106473 11.16436-20.737336 20.108184s-30.643392 15.691785-45.903405 18.319419zM561.955883 444.35385l94.545472-16.283928-14.100401-81.814401-94.668835 16.283927z m16.851398 97.913284l94.545472-16.320937-13.56994-79.174431-94.656498 16.320937z m17.788958 103.291915l94.656499-16.320937-14.630863-84.553061-94.557808 16.320936z"
|
||||
fill="#FFFFFF"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="128" height="128">
|
||||
<path
|
||||
d="M849.92 51.2H174.08c-67.8656 0-122.88 55.0144-122.88 122.88v675.84c0 67.8656 55.0144 122.88 122.88 122.88h675.84c67.8656 0 122.88-55.0144 122.88-122.88V174.08c0-67.8656-55.0144-122.88-122.88-122.88zM448.18432 230.94272c176.98304-53.95968 267.17696 110.98624 267.17696 110.98624-32.59392-17.78176-130.39104-37.53472-235.09504 16.7936s-126.4384 172.87168-126.4384 172.87168c-42.56256-45.4144-44.4928-118.6304-44.4928-118.6304 5.03296-137.41568 138.84928-182.02112 138.84928-182.02112zM393.50784 796.42112c-256.12288-49.6384-197.85216-273.38752-133.81632-371.95264 0 0-2.88256 138.13248 130.22208 214.4 0 0 15.82592 7.1936 10.79296 30.21312l-5.03808 29.49632s-6.656 20.1472 6.02624 22.30272c0 0 4.04992 0 13.39904-6.4768l48.92672-32.37376s10.07104-7.1936 23.01952-5.03808c12.94848 2.16064 95.68768 23.74656 177.70496-44.60032-0.00512 0-15.10912 213.67296-271.23712 164.02944z m256.8448-19.42016c16.54784-7.9104 97.1264-102.8864 58.98752-231.66464s-167.6288-157.55776-167.6288-157.55776c66.19136-28.0576 143.89248-7.19872 143.89248-7.19872 117.9904 34.5344 131.6608 146.77504 131.6608 146.77504 23.01952 200.71936-166.912 249.64608-166.912 249.64608z"
|
||||
fill="#01CC7A"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,48 @@
|
||||
[
|
||||
{
|
||||
"id": 4,
|
||||
"priority": 1,
|
||||
"action": {
|
||||
"type": "modifyHeaders",
|
||||
"requestHeaders": [
|
||||
{
|
||||
"header": "Referer",
|
||||
"operation": "set",
|
||||
"value": "https://zhuanlan.zhihu.com"
|
||||
},
|
||||
{
|
||||
"header": "origin",
|
||||
"operation": "set",
|
||||
"value": "https://zhuanlan.zhihu.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
"condition": {
|
||||
"urlFilter": "https://zhuanlan.zhihu.com/*",
|
||||
"resourceTypes": ["xmlhttprequest"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"priority": 1,
|
||||
"action": {
|
||||
"type": "modifyHeaders",
|
||||
"requestHeaders": [
|
||||
{
|
||||
"header": "Referer",
|
||||
"operation": "set",
|
||||
"value": "https://zhuanlan.zhihu.com"
|
||||
},
|
||||
{
|
||||
"header": "origin",
|
||||
"operation": "set",
|
||||
"value": "https://zhuanlan.zhihu.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
"condition": {
|
||||
"urlFilter": "*://*.zhimg.com/*",
|
||||
"resourceTypes": ["xmlhttprequest"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_305_516)">
|
||||
<g clip-path="url(#clip1_305_516)">
|
||||
<path d="M49.0229 69.1875C54.1272 69.1875 58.265 65.0497 58.265 59.9454V50.7033H59.9454C65.0497 50.7033 69.1875 46.5655 69.1875 41.4612C69.1875 36.357 65.0497 32.2191 59.9454 32.2191H58.265V22.9771C58.265 17.8728 54.1272 13.735 49.0229 13.735H39.7809V12.0546C39.7809 6.95032 35.643 2.8125 30.5388 2.8125C25.4345 2.8125 21.2967 6.95032 21.2967 12.0546V13.735H12.0546C6.95032 13.735 2.8125 17.8728 2.8125 22.9771V32.2191H4.49288C9.59714 32.2191 13.735 36.357 13.735 41.4612C13.735 46.5655 9.59714 50.7033 4.49288 50.7033H2.8125V69.1875H21.2967V67.5071C21.2967 62.4029 25.4345 58.265 30.5388 58.265C35.643 58.265 39.7809 62.4029 39.7809 67.5071V69.1875H49.0229Z" stroke="#67D55E" stroke-width="5.625"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_305_516">
|
||||
<rect width="72" height="72" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_305_516">
|
||||
<rect width="72" height="72" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -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() });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../packages/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"types": ["@types/chrome"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.vue", ".wxt/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "wxt";
|
||||
|
||||
export default defineConfig({
|
||||
modules: ["@wxt-dev/module-vue"],
|
||||
manifest: {
|
||||
name: "GEO Publisher",
|
||||
description: "Bind media accounts, execute local publish flows, and keep background detection tasks running.",
|
||||
permissions: ["storage", "tabs", "alarms", "cookies", "declarativeNetRequest"],
|
||||
host_permissions: ["http://*/*", "https://*/*"],
|
||||
declarative_net_request: {
|
||||
rule_resources: [
|
||||
{
|
||||
id: "publisher-rules",
|
||||
enabled: true,
|
||||
path: "rules.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
action: {
|
||||
default_title: "GEO Publisher",
|
||||
default_popup: "popup.html",
|
||||
},
|
||||
},
|
||||
});
|
||||