feat: Add platform adapters for various content publishing platforms

- Implemented Dongchedi adapter for user detection and publishing.
- Implemented Jianshu adapter for user detection and publishing.
- Implemented Juejin adapter for user detection and publishing.
- Implemented Qiehao adapter for user detection and publishing.
- Implemented Smzdm adapter for user detection and publishing.
- Implemented Sohuhao adapter for user detection and publishing.
- Implemented Toutiaohao adapter for user detection and publishing.
- Implemented Wangyihao adapter for user detection and publishing.
- Implemented Weixin Gzh adapter for user detection and publishing.
- Implemented Zol adapter for user detection and publishing.
- Added documentation for manual testing of media publishing.
This commit is contained in:
2026-04-03 17:48:30 +08:00
parent 32d6a462cd
commit 134dd063c3
33 changed files with 2722 additions and 457 deletions
@@ -1,9 +1,15 @@
<script setup lang="ts">
import { ReloadOutlined, SendOutlined } from "@ant-design/icons-vue";
import { ReloadOutlined, SendOutlined, PlusOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import type { MediaPlatform, PlatformAccount, PublisherLocalPlatformState } from "@geo/shared-types";
import { computed, ref, watch } from "vue";
import { message, notification } from "ant-design-vue";
import type {
MediaPlatform,
PlatformAccount,
PublisherLocalPlatformState,
PublisherPublishResponse,
PublisherPublishTaskResult,
} from "@geo/shared-types";
import { computed, h, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { articlesApi, getApiBaseURL, mediaApi } from "@/lib/api";
@@ -32,6 +38,24 @@ const pluginInstalled = ref(false);
const pluginVersion = ref<string | undefined>();
const pluginInstallationId = ref<number | null>(null);
const localPlatforms = ref<PublisherLocalPlatformState[]>([]);
const fileList = ref<any[]>([]);
function handleBeforeUpload(file: File) {
const url = URL.createObjectURL(file);
coverAssetUrl.value = url;
fileList.value = [{
uid: file.name,
name: file.name,
status: 'done',
url: url,
}];
return false;
}
function handleRemove() {
coverAssetUrl.value = "";
fileList.value = [];
}
const detailQuery = useQuery({
queryKey: computed(() => ["articles", "detail", props.articleId, "publish-modal"]),
@@ -57,6 +81,7 @@ watch(
if (!open) {
selectedAccountIds.value = [];
coverAssetUrl.value = "";
fileList.value = [];
pluginInstallationId.value = null;
localPlatforms.value = [];
return;
@@ -70,6 +95,10 @@ const localPlatformMap = computed(() => {
return new Map(localPlatforms.value.map((item) => [item.platform_id, item]));
});
const platformNameMap = computed(() => {
return new Map((platformsQuery.data.value ?? []).map((platform) => [platform.platform_id, platform.name]));
});
const accountCards = computed(() => {
const accountMap = new Map((accountsQuery.data.value ?? []).map((account) => [account.platform_id, account]));
@@ -87,6 +116,8 @@ const accountCards = computed(() => {
accountId: account?.id ?? null,
platformId: platform.platform_id,
platformName: platform.name,
platformShortName: platform.short_name,
platformAccentColor: platform.accent_color,
platformCategory: platform.category,
platformUid: account?.platform_uid ?? local?.platform_uid ?? "--",
nickname: account?.nickname ?? local?.nickname ?? t("media.card.unbound"),
@@ -131,9 +162,14 @@ const publishMutation = useMutation({
const successCount = result.results.filter((item) => item.success).length;
const failedCount = result.results.length - successCount;
if (failedCount === 0) {
message.success(t("media.publish.messages.success", { count: successCount }));
notification.success({
message: t("media.publish.messages.successTitle"),
description: t("media.publish.messages.success", { count: successCount }),
placement: "topRight",
duration: 4.5,
});
} else {
message.warning(t("media.publish.messages.partial", { success: successCount, failed: failedCount }));
showPublishFailures(result);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles"] }),
@@ -206,33 +242,72 @@ function resolveAccountStatusText(
}
return t("media.account.ready");
}
function platformNameForResult(result: PublisherPublishTaskResult): string {
return platformNameMap.value.get(result.platform_id) ?? result.platform_id;
}
function publishFailureText(result: PublisherPublishTaskResult): string {
return t("media.publish.messages.failureItem", {
platform: platformNameForResult(result),
reason: result.message?.trim() || t("media.publish.messages.unknownFailure"),
});
}
function showPublishFailures(result: PublisherPublishResponse): void {
const failedResults = result.results.filter((item) => !item.success);
if (!failedResults.length) {
return;
}
const successCount = result.results.filter((item) => item.success).length;
const failedCount = failedResults.length;
const summary = t("media.publish.messages.partial", {
success: successCount,
failed: failedCount,
});
notification.error({
message: t("media.publish.messages.failureTitle"),
description: h(
"div",
[
h("div", { style: "line-height:1.75;font-weight:500;margin-bottom:8px;" }, summary),
...failedResults.map((item) =>
h("div", { style: "line-height:1.75;" }, publishFailureText(item)),
),
],
),
placement: "topRight",
duration: 8,
});
}
</script>
<template>
<a-modal
:open="open"
:title="t('media.publish.title')"
width="920"
:width="650"
:confirm-loading="publishMutation.isPending.value"
@ok="publishMutation.mutate()"
@cancel="emit('update:open', false)"
>
<div class="publish-modal">
<section class="publish-modal__hero">
<div>
<p class="eyebrow">{{ t("media.publish.subtitle") }}</p>
<h3>{{ modalTitle }}</h3>
<p>{{ t("media.publish.description") }}</p>
</div>
<div class="publish-modal__hero-actions">
<a-tag :color="pluginInstalled ? 'success' : 'error'">
{{ pluginInstalled ? t("media.plugin.ready") : t("media.plugin.notInstalled") }}
</a-tag>
<a-button :loading="runtimeLoading" @click="refreshRuntime">
<template #icon><ReloadOutlined /></template>
{{ t("media.actions.redetect") }}
</a-button>
<div class="publish-modal__hero-top">
<span class="eyebrow">标题</span>
<div class="publish-modal__hero-actions">
<a-tag :color="pluginInstalled ? 'success' : 'error'" class="hero-tag">
{{ pluginInstalled ? t("media.plugin.ready") : t("media.plugin.notInstalled") }}
</a-tag>
<a-button :loading="runtimeLoading" @click="refreshRuntime" size="small" class="hero-btn">
<template #icon><ReloadOutlined /></template>
{{ t("media.actions.redetect") }}
</a-button>
</div>
</div>
<h3>{{ modalTitle }}</h3>
</section>
<a-alert
@@ -244,11 +319,9 @@ function resolveAccountStatusText(
/>
<section class="publish-modal__section">
<div class="section-heading">
<div>
<h3>{{ t("media.publish.platformsTitle") }}</h3>
<p class="muted">{{ t("media.publish.platformsHint") }}</p>
</div>
<div class="publish-modal__section-header">
<h3><span class="required-star">*</span> {{ t("media.publish.platformsTitle") }}</h3>
<p class="muted">{{ t("media.publish.platformsHint") }}</p>
</div>
<div v-if="accountsQuery.isPending.value || platformsQuery.isPending.value" class="publish-modal__loading">
@@ -267,20 +340,31 @@ function resolveAccountStatusText(
}"
@click="account.accountId ? toggleAccount(account.accountId, account.selectable) : undefined"
>
<span class="publish-modal__check">
<span
v-if="account.accountId && isSelected(account.accountId)"
class="publish-modal__check-dot"
></span>
</span>
<span class="publish-modal__card-copy">
<strong>{{ account.platformName }}</strong>
<small>{{ account.nickname }} · ID: {{ account.platformUid }}</small>
<small>{{ account.statusText }}</small>
</span>
<a-tag :color="account.selectable ? 'success' : account.accountId ? 'warning' : 'default'">
{{ account.selectable ? t("media.account.selectable") : t("media.account.unavailable") }}
</a-tag>
<div class="publish-modal__card-left">
<span class="publish-modal__check">
<span
v-if="account.accountId && isSelected(account.accountId)"
class="publish-modal__check-inner"
></span>
</span>
<span class="publish-modal__card-badge" :style="{ color: account.platformAccentColor }">
{{ account.platformShortName }}
</span>
<div class="publish-modal__card-text">
<span class="publish-modal__card-name">{{ account.platformName }}</span>
<span class="publish-modal__card-sub" v-if="account.accountId">
{{ account.nickname }}
</span>
<span class="publish-modal__card-sub" v-else>
{{ t("media.card.unbound") }}
</span>
</div>
</div>
<div class="publish-modal__card-right">
<span class="publish-modal__card-status" :class="account.selectable ? 'status-selectable' : 'status-disabled'">
{{ account.selectable ? t("media.account.selectable") : t("media.account.unavailable") }}
</span>
</div>
</button>
</div>
@@ -288,20 +372,29 @@ function resolveAccountStatusText(
</section>
<section class="publish-modal__section">
<div class="publish-modal__cover">
<div class="publish-modal__cover-head">
<div>
<h3>{{ t("media.publish.coverTitle") }}</h3>
<p class="muted">{{ t("media.publish.coverHint") }}</p>
</div>
<a-switch v-model:checked="coverEnabled" />
</div>
<div class="publish-modal__section-header inline-header">
<h3>{{ t("media.publish.coverTitle") }}</h3>
<a-switch v-model:checked="coverEnabled" />
</div>
<p class="muted">请保证封面清晰美观和完整</p>
<a-input
v-if="coverEnabled"
v-model:value="coverAssetUrl"
:placeholder="t('media.publish.coverPlaceholder')"
/>
<div class="publish-modal__cover-body" v-if="coverEnabled">
<a-upload
v-model:file-list="fileList"
list-type="picture-card"
class="cover-uploader"
:show-upload-list="true"
:before-upload="handleBeforeUpload"
@remove="handleRemove"
accept="image/*"
>
<div v-if="fileList.length < 1">
<div class="upload-icon-circle">
<PlusOutlined />
</div>
<div class="upload-text">上传封面图</div>
</div>
</a-upload>
</div>
</section>
</div>
@@ -312,133 +405,245 @@ function resolveAccountStatusText(
.publish-modal {
display: flex;
flex-direction: column;
gap: 20px;
gap: 24px;
}
.publish-modal__hero {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px;
border: 1px solid #e5e5e5;
border-radius: 12px;
background: #f7f7f7;
}
.publish-modal__hero-top {
display: flex;
align-items: center;
gap: 16px;
justify-content: space-between;
gap: 20px;
padding: 20px 22px;
border: 1px solid #e6edf5;
border-radius: 18px;
background: linear-gradient(135deg, #fbfdff 0%, #f4f9ff 100%);
margin-bottom: 12px;
}
.publish-modal__hero h3,
.publish-modal__section h3 {
.publish-modal__hero h3 {
margin: 0;
font-size: 18px;
color: #111827;
font-size: 16px;
font-weight: 600;
color: #262626;
line-height: 1.5;
}
.publish-modal__hero p:last-child {
margin: 8px 0 0;
color: #6b7280;
.eyebrow {
font-size: 16px;
text-transform: uppercase;
color: #000000;
margin: 0;
}
.publish-modal__hero-actions {
display: flex;
align-items: flex-start;
align-items: center;
gap: 10px;
}
.hero-tag {
margin: 0;
}
.publish-modal__section {
display: flex;
flex-direction: column;
gap: 14px;
}
.publish-modal__section-header {
margin-bottom: 12px;
}
.publish-modal__section-header.inline-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.publish-modal__section-header h3 {
margin: 0 0 6px 0;
font-size: 15px;
font-weight: 600;
color: #262626;
}
.publish-modal__section-header.inline-header h3 {
margin: 0;
}
.required-star {
color: #ff4d4f;
margin-right: 4px;
font-family: SimSun, sans-serif;
}
.muted {
font-size: 13px;
color: #8c8c8c;
margin: 0;
}
.publish-modal__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
gap: 12px;
margin-top: 4px;
}
.publish-modal__card {
display: flex;
align-items: center;
gap: 16px;
justify-content: space-between;
width: 100%;
padding: 18px;
border: 1px solid #dbe5f0;
border-radius: 18px;
padding: 12px 16px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
transition: all 0.2s ease;
}
.publish-modal__card:hover {
border-color: #adc6ff;
box-shadow: 0 16px 28px rgba(22, 119, 255, 0.08);
transform: translateY(-1px);
.publish-modal__card:hover:not(.publish-modal__card--disabled) {
border-color: #1677ff;
}
.publish-modal__card--active {
border-color: #1677ff;
background: linear-gradient(135deg, #f7fbff 0%, #eef6ff 100%);
}
.publish-modal__card--disabled {
opacity: 0.72;
opacity: 0.6;
cursor: not-allowed;
background-color: #f7f7f7;
}
.publish-modal__card-left {
display: flex;
align-items: center;
gap: 12px;
}
.publish-modal__check {
width: 22px;
height: 22px;
border: 1px solid #cbd5e1;
border-radius: 999px;
width: 16px;
height: 16px;
border: 1px solid #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
transition: all 0.2s;
}
.publish-modal__card--active .publish-modal__check {
background-color: #1677ff;
border-color: #1677ff;
}
.publish-modal__check-inner {
width: 8px;
height: 8px;
background-color: transparent;
border: 2px solid #fff;
border-top: 0;
border-left: 0;
transform: rotate(45deg) scale(1) translate(-1px, -1px);
display: block;
}
.publish-modal__card-badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 6px;
background: #f7f7f7;
border: 1px solid #e5e5e5;
font-size: 12px;
font-weight: 700;
}
.publish-modal__check-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: #1677ff;
}
.publish-modal__card-copy {
flex: 1;
.publish-modal__card-text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.publish-modal__card-copy strong {
font-size: 16px;
color: #0f172a;
.publish-modal__card-name {
font-size: 14px;
font-weight: 500;
color: #262626;
line-height: 1.3;
}
.publish-modal__card-copy small {
color: #64748b;
.publish-modal__card-sub {
font-size: 12px;
font-weight: 400;
color: #8c8c8c;
margin-top: 2px;
}
.publish-modal__cover {
padding: 18px;
border: 1px solid #e6edf5;
border-radius: 18px;
background: #fff;
.publish-modal__card-status {
font-size: 13px;
}
.publish-modal__cover-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
.status-selectable {
color: #52c41a;
}
.status-disabled {
color: #1677ff;
}
.publish-modal__loading {
padding: 12px 0;
}
.publish-modal__cover-body {
margin-top: 12px;
}
.cover-uploader :deep(.ant-upload.ant-upload-select-picture-card) {
width: 160px;
height: 120px;
background-color: #fff;
border: 1px dashed #d9d9d9;
border-radius: 8px;
}
.cover-uploader :deep(.ant-upload-list-picture-card-container) {
width: 160px;
height: 120px;
}
.cover-uploader :deep(.ant-upload-list-item) {
border-radius: 8px;
}
.upload-icon-circle {
width: 28px;
height: 28px;
background-color: #2b303b;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 8px;
font-size: 14px;
}
.upload-text {
font-size: 13px;
color: #595959;
}
@media (max-width: 860px) {
.publish-modal__hero {
flex-direction: column;
+11
View File
@@ -476,8 +476,14 @@ const enUS = {
},
messages: {
bindSuccess: "{platform} bound successfully.",
bindSuccessTitle: "Binding successful",
bindSuccessDetail: "Bound account [{account}] on [{platform}]",
bindPending: "The extension did not detect a local session for this platform yet. Sign in via the extension and retry.",
bindPendingTitle: "Binding incomplete",
bindFailureTitle: "Binding failed",
unbindSuccess: "Account unbound.",
unbindSuccessTitle: "Unbind successful",
unbindFailureTitle: "Unbind failed",
unbindConfirm: "Unbind this platform account? Publishing will no longer be available until it is bound again.",
},
publish: {
@@ -491,8 +497,13 @@ const enUS = {
coverPlaceholder: "Enter an optional cover asset URL",
messages: {
success: "{count} publish tasks submitted.",
successTitle: "Publish successful",
partial: "Publish finished with {success} succeeded and {failed} failed.",
partialTitle: "Partial publish failure",
selectPlatform: "Choose at least one available platform first.",
failureTitle: "Publish failed",
failureItem: "Publishing to [{platform}] failed: [{reason}]",
unknownFailure: "Unknown error",
},
},
records: {
+11
View File
@@ -483,8 +483,14 @@ const zhCN = {
},
messages: {
bindSuccess: "{platform} 绑定成功",
bindSuccessTitle: "绑定成功",
bindSuccessDetail: "已绑定【{platform}】的账号【{account}】",
bindPending: "插件尚未检测到该平台登录态,请先在插件中登录后重试。",
bindPendingTitle: "绑定未完成",
bindFailureTitle: "绑定失败",
unbindSuccess: "账号解绑成功",
unbindSuccessTitle: "解绑成功",
unbindFailureTitle: "解绑失败",
unbindConfirm: "确认解绑这个平台账号吗?解绑后将无法直接发布。",
},
publish: {
@@ -498,8 +504,13 @@ const zhCN = {
coverPlaceholder: "请输入封面素材 URL(可选)",
messages: {
success: "已提交 {count} 个平台发布任务",
successTitle: "发布成功",
partial: "发布执行完成,成功 {success} 个,失败 {failed} 个",
partialTitle: "部分发布失败",
selectPlatform: "请先选择至少一个可发布的平台",
failureTitle: "发布失败",
failureItem: "文章发布到平台【{platform}】失败【{reason}】",
unknownFailure: "未知错误",
},
},
records: {
+5 -1
View File
@@ -28,6 +28,10 @@ const errorMessageMap: Record<string, string> = {
article_title_required: "请输入文章标题",
invalid_payload: "请求参数不合法",
update_failed: "保存文章失败",
publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试",
publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试",
publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试",
publisher_plugin_unknown_error: "浏览器插件调用失败,请稍后重试",
network_error: "网络连接失败,请稍后重试",
unknown_error: "发生未知错误",
};
@@ -39,7 +43,7 @@ export function formatError(error: unknown): string {
}
if (error instanceof Error) {
return error.message;
return errorMessageMap[error.message] ?? error.message;
}
return "发生未知错误";
+60 -4
View File
@@ -35,6 +35,47 @@ type PluginPayloadMap = {
publishArticle: PublisherPublishResponse;
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isPingPayload(value: unknown): value is PublisherPluginPingResponse {
return isPlainObject(value) && typeof value.installed === "boolean";
}
function isDetectPlatformsPayload(value: unknown): value is PublisherLocalPlatformState[] {
return Array.isArray(value);
}
function isRegisterInstallationPayload(value: unknown): value is PublisherRegisterInstallationResult {
return isPlainObject(value) && typeof value.success === "boolean" && typeof value.plugin_installation_id === "number";
}
function isBindPayload(value: unknown): value is PublisherBindResponse {
return isPlainObject(value) && typeof value.success === "boolean" && typeof value.platform_id === "string";
}
function isPublishPayload(value: unknown): value is PublisherPublishResponse {
return isPlainObject(value) && Array.isArray(value.results);
}
function isPayloadValid<T extends PluginAction>(action: T, value: unknown): value is PluginPayloadMap[T] {
switch (action) {
case "ping":
return isPingPayload(value);
case "detectPlatforms":
return isDetectPlatformsPayload(value);
case "registerInstallation":
return isRegisterInstallationPayload(value);
case "bindAccount":
return isBindPayload(value);
case "publishArticle":
return isPublishPayload(value);
default:
return false;
}
}
function nextRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
@@ -42,6 +83,12 @@ function nextRequestId(): string {
return `geo-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
async function requestPlugin<T extends PluginAction>(
action: T,
payload?: T extends "bindAccount"
@@ -75,6 +122,10 @@ async function requestPlugin<T extends PluginAction>(
cleanup();
if (event.data.success) {
if (!isPayloadValid(action, event.data.data)) {
reject(new Error("publisher_plugin_invalid_response"));
return;
}
resolve(event.data.data);
return;
}
@@ -104,10 +155,15 @@ export async function pingPublisherPlugin(): Promise<PublisherPluginPingResponse
try {
return await requestPlugin("ping", undefined, 1500);
} catch {
return {
installed: false,
capabilities: [],
};
try {
await sleep(300);
return await requestPlugin("ping", undefined, 2000);
} catch {
return {
installed: false,
capabilities: [],
};
}
}
}
+18 -1
View File
@@ -13,6 +13,23 @@ export interface PublisherRuntimeState {
pluginInstallationId: number | null;
}
function normalizePingResponse(ping: PublisherPluginPingResponse | null | undefined): PublisherPluginPingResponse {
if (!ping || typeof ping.installed !== "boolean") {
return {
installed: false,
capabilities: [],
};
}
return {
installed: ping.installed,
version: ping.version,
capabilities: Array.isArray(ping.capabilities) ? ping.capabilities : [],
installation_key: ping.installation_key,
plugin_installation_id: ping.plugin_installation_id ?? null,
};
}
function detectBrowserName(): string {
const ua = navigator.userAgent;
if (/Edg\//.test(ua)) {
@@ -31,7 +48,7 @@ function detectBrowserName(): string {
}
export async function loadPublisherRuntimeState(): Promise<PublisherRuntimeState> {
const ping = await pingPublisherPlugin();
const ping = normalizePingResponse(await pingPublisherPlugin());
if (!ping.installed || !ping.installation_key) {
return {
ping,
+2
View File
@@ -39,6 +39,7 @@ import {
Tag,
TimePicker,
Tooltip,
Upload,
} from "ant-design-vue";
import App from "./App.vue";
@@ -100,6 +101,7 @@ app.component("ATextarea", Input.TextArea);
Tag,
TimePicker,
Tooltip,
Upload,
].forEach((component) => {
app.use(component);
});
+15 -13
View File
@@ -275,6 +275,14 @@ function toneClass(tone: CardTone): string {
</div>
</section>
<a-alert
v-if="!pluginInstalled"
type="warning"
show-icon
:message="t('media.plugin.installTitle')"
:description="t('media.plugin.installDesc')"
/>
<section class="media-runtime-card panel">
<div class="media-runtime-card__copy">
<h3 class="panel-title">{{ t("media.plugin.title") }}</h3>
@@ -298,13 +306,7 @@ function toneClass(tone: CardTone): string {
</div>
</section>
<a-alert
v-if="!pluginInstalled"
type="warning"
show-icon
:message="t('media.plugin.installTitle')"
:description="t('media.plugin.installDesc')"
/>
<section class="media-list-wrapper panel">
<div class="custom-tabs">
@@ -331,7 +333,7 @@ function toneClass(tone: CardTone): string {
class="media-search"
>
<template #prefix>
<SearchOutlined style="color: #bfbfbf;" />
<SearchOutlined style="color: #737373;" />
</template>
</a-input>
@@ -375,7 +377,7 @@ function toneClass(tone: CardTone): string {
</a>
</a-menu-item>
<a-menu-item v-if="card.accountCount > 1" disabled>
<span style="color: #bfbfbf;">{{ t("media.card.multiAccount", { count: card.accountCount }) }}</span>
<span style="color: #737373;">{{ t("media.card.multiAccount", { count: card.accountCount }) }}</span>
</a-menu-item>
</a-menu>
</template>
@@ -596,7 +598,7 @@ function toneClass(tone: CardTone): string {
top: 4px;
right: -8px;
background: #f5f5f5;
color: #bfbfbf;
color: f3f#737373;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
@@ -716,12 +718,12 @@ function toneClass(tone: CardTone): string {
.media-card__identity-text p {
margin: 2px 0 0;
color: #bfbfbf;
color: #737373;
font-size: 11px;
}
.more-icon {
color: #bfbfbf;
color: #737373;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
@@ -750,7 +752,7 @@ function toneClass(tone: CardTone): string {
.media-card__account-unbound {
font-size: 13px;
color: #bfbfbf;
color: #737373;
}
.media-card__footer {
+1 -1
View File
@@ -501,7 +501,7 @@ function refreshDashboard(): void {
align-items: center;
gap: 6px;
}
.muted { color: #bfbfbf; font-size: 11px; }
.muted { color: #737373; font-size: 11px; }
.muted-dot { color: #d9d9d9; font-size: 10px; }
/* Status Badges */
@@ -4,6 +4,16 @@ import { defineBackground } from "wxt/utils/define-background";
import { handlePublisherAction } from "../src/runtime";
import { getExtensionState } from "../src/storage";
type BackgroundSuccessResponse = {
ok: true;
data: Awaited<ReturnType<typeof handlePublisherAction>>;
};
type BackgroundErrorResponse = {
ok: false;
error: string;
};
export default defineBackground(() => {
void getExtensionState();
@@ -21,11 +31,25 @@ export default defineBackground(() => {
await getExtensionState();
});
browser.runtime.onMessage.addListener((message) => {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (!message || message.type !== "geo.publisher.request") {
return undefined;
}
return handlePublisherAction(message.action, message.payload);
void handlePublisherAction(message.action, message.payload)
.then((data) => {
sendResponse({
ok: true,
data,
} satisfies BackgroundSuccessResponse);
})
.catch((error: unknown) => {
sendResponse({
ok: false,
error: error instanceof Error ? error.message : "publisher_plugin_unknown_error",
} satisfies BackgroundErrorResponse);
});
return true;
});
});
+28 -1
View File
@@ -9,6 +9,20 @@ type PublisherRequestMessage = {
payload?: unknown;
};
type BackgroundSuccessResponse = {
ok: true;
data: unknown;
};
type BackgroundErrorResponse = {
ok: false;
error?: string;
};
function isBackgroundResponse(value: unknown): value is BackgroundSuccessResponse | BackgroundErrorResponse {
return typeof value === "object" && value !== null && "ok" in value;
}
export default defineContentScript({
matches: ["http://*/*", "https://*/*"],
main() {
@@ -23,12 +37,25 @@ export default defineContentScript({
const { requestId, action, payload } = event.data;
try {
const data = await browser.runtime.sendMessage({
const response = await browser.runtime.sendMessage({
type: "geo.publisher.request",
action,
payload,
});
const data = isBackgroundResponse(response)
? (() => {
if (!response.ok) {
throw new Error(response.error || "publisher_plugin_unknown_error");
}
return response.data;
})()
: response;
if (typeof data === "undefined") {
throw new Error("publisher_plugin_empty_response");
}
window.postMessage(
{
source: "geo-extension",
+65 -209
View File
@@ -2,14 +2,22 @@
import { computed, onMounted, ref } from "vue";
import { browser } from "wxt/browser";
import { getExtensionState, resetPlatformStates, updatePlatformState } from "../../src/storage";
import { normalizeRemoteUrl } from "../../src/adapters/common";
import { getExtensionState } from "../../src/storage";
import type { StoredPlatformState } from "../../src/platforms";
type BackgroundSuccessResponse = {
ok: true;
data: unknown;
};
type BackgroundErrorResponse = {
ok: false;
error?: string;
};
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);
@@ -33,6 +41,10 @@ function clonePlatforms(items: StoredPlatformState[]): StoredPlatformState[] {
return items.map((platform) => ({ ...platform }));
}
function isBackgroundResponse(value: unknown): value is BackgroundSuccessResponse | BackgroundErrorResponse {
return typeof value === "object" && value !== null && "ok" in value;
}
async function loadState(): Promise<void> {
loading.value = true;
try {
@@ -64,8 +76,9 @@ function uidFor(platform: StoredPlatformState): string {
}
function avatarFor(platform: StoredPlatformState): string | null {
if (platform.avatar_url?.trim()) {
return platform.avatar_url.trim();
const normalizedAvatar = normalizeRemoteUrl(platform.avatar_url);
if (normalizedAvatar) {
return normalizedAvatar;
}
if (platform.connected) {
return `https://api.dicebear.com/9.x/initials/svg?seed=${encodeURIComponent(titleFor(platform))}`;
@@ -73,13 +86,6 @@ function avatarFor(platform: StoredPlatformState): string | null {
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";
@@ -87,36 +93,18 @@ function statusTone(platform: StoredPlatformState): "success" | "danger" | "pend
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;
function loginButtonLabel(platform: StoredPlatformState): string {
if (refreshing.value || loading.value) {
return "检测中...";
}
return "未登录";
}
async function openLoginPage(platform: StoredPlatformState): Promise<void> {
if (!platform.login_url || refreshing.value || loading.value) {
return;
}
await browser.tabs.create({ url: platform.login_url });
}
async function simulateDetect(): Promise<void> {
@@ -126,10 +114,13 @@ async function simulateDetect(): Promise<void> {
async function refreshDetectedPlatforms(): Promise<void> {
refreshing.value = true;
try {
await browser.runtime.sendMessage({
const response = await browser.runtime.sendMessage({
type: "geo.publisher.request",
action: "detectPlatforms",
});
if (isBackgroundResponse(response) && !response.ok) {
throw new Error(response.error || "publisher_plugin_unknown_error");
}
const state = await getExtensionState();
installationId.value = state.plugin_installation_id;
apiBaseUrl.value = state.api_base_url;
@@ -138,17 +129,6 @@ async function refreshDetectedPlatforms(): Promise<void> {
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>
@@ -169,9 +149,8 @@ async function handleReset(): Promise<void> {
v-for="platform in platforms"
:key="platform.platform_id"
class="platform-card"
:class="[`is-${statusTone(platform)}`, { 'is-expanded': expandedId === platform.platform_id }]"
:class="[`is-${statusTone(platform)}`]"
:style="{ '--platform-accent': platform.accent_color }"
@click="toggleExpanded(platform.platform_id)"
>
<div class="platform-card__accent" />
@@ -203,47 +182,17 @@ async function handleReset(): Promise<void> {
</div>
</div>
<div v-else class="platform-card__status-chip" :class="`is-${statusTone(platform)}`">
{{ statusLabel(platform) }}
</div>
<button
v-else
type="button"
class="platform-card__status-chip"
:class="`is-${statusTone(platform)}`"
:disabled="!platform.login_url || refreshing || loading"
@click.stop="openLoginPage(platform)"
>
{{ loginButtonLabel(platform) }}
</button>
</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>
@@ -264,15 +213,11 @@ async function handleReset(): Promise<void> {
<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">
<button type="button" class="popup-detect" :disabled="refreshing" @click="simulateDetect">
<span class="popup-detect__icon" :class="{ 'is-spinning': refreshing }"></span>
<span>{{ refreshing ? "检测中..." : "重新检测" }}</span>
</button>
@@ -390,7 +335,6 @@ async function handleReset(): Promise<void> {
transform 180ms ease,
box-shadow 180ms ease,
border-color 180ms ease;
cursor: pointer;
}
.platform-card:hover {
@@ -398,10 +342,6 @@ async function handleReset(): Promise<void> {
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;
@@ -527,6 +467,7 @@ async function handleReset(): Promise<void> {
}
.platform-card__status-chip {
appearance: none;
min-width: 72px;
padding: 6px 12px;
border: 1px solid transparent;
@@ -534,6 +475,21 @@ async function handleReset(): Promise<void> {
text-align: center;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
transform 150ms ease,
opacity 150ms ease,
box-shadow 150ms ease;
}
.platform-card__status-chip:hover:not(:disabled) {
transform: translateY(-1px);
}
.platform-card__status-chip:disabled {
cursor: not-allowed;
opacity: 0.72;
transform: none;
}
.platform-card__status-chip.is-danger,
@@ -548,80 +504,7 @@ async function handleReset(): Promise<void> {
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 {
.popup-detect {
appearance: none;
border: none;
border-radius: 14px;
@@ -633,33 +516,16 @@ async function handleReset(): Promise<void> {
box-shadow 150ms ease;
}
.platform-editor__buttons button:hover,
.popup-detect:hover,
.popup-reset:hover {
.popup-detect:hover {
transform: translateY(-1px);
}
.platform-editor__buttons button:disabled,
.popup-detect:disabled,
.popup-reset:disabled {
.popup-detect: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;
@@ -746,16 +612,6 @@ async function handleReset(): Promise<void> {
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);
+299
View File
@@ -44,5 +44,304 @@
"urlFilter": "*://*.zhimg.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 6,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://baijiahao.baidu.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://baijiahao.baidu.com"
}
]
},
"condition": {
"urlFilter": "*://baijiahao.baidu.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 7,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://mp.toutiao.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://mp.toutiao.com"
}
]
},
"condition": {
"urlFilter": "*://mp.toutiao.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 8,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://mp.sohu.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://mp.sohu.com"
}
]
},
"condition": {
"urlFilter": "*://mp.sohu.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 9,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://www.jianshu.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://www.jianshu.com"
}
]
},
"condition": {
"urlFilter": "*://*.jianshu.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 10,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://om.qq.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://om.qq.com"
}
]
},
"condition": {
"urlFilter": "*://om.qq.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 11,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://image.om.qq.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://image.om.qq.com"
}
]
},
"condition": {
"urlFilter": "*://image.om.qq.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 12,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://api.juejin.cn/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://juejin.cn"
}
]
},
"condition": {
"urlFilter": "*://api.juejin.cn/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 13,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://www.bilibili.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://www.bilibili.com"
}
]
},
"condition": {
"urlFilter": "*://api.bilibili.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 14,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://mp.weixin.qq.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://mp.weixin.qq.com"
}
]
},
"condition": {
"urlFilter": "*://mp.weixin.qq.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 15,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://mp.163.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://mp.163.com"
}
]
},
"condition": {
"urlFilter": "*://mp.163.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 16,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://zhiyou.smzdm.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://zhiyou.smzdm.com"
}
]
},
"condition": {
"urlFilter": "*://zhiyou.smzdm.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 17,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://post.zol.com.cn/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://post.zol.com.cn"
}
]
},
"condition": {
"urlFilter": "*://open-api.zol.com.cn/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 18,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Referer",
"operation": "set",
"value": "https://mp.dcdapp.com/"
},
{
"header": "Origin",
"operation": "set",
"value": "https://mp.dcdapp.com"
}
]
},
"condition": {
"urlFilter": "*://mp.dcdapp.com/*",
"resourceTypes": ["xmlhttprequest"]
}
}
]
@@ -0,0 +1,210 @@
import type { StoredPlatformState } from "../platforms";
import {
connectedPlatform,
disconnectedPlatform,
fetchImageBlob,
fetchJson,
fetchText,
manageUrlResult,
normalizeArticleHtml,
uploadHtmlImages,
} from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type BaijiahaoAppInfoResponse = {
data?: {
user?: {
userid?: string | number;
name?: string;
uname?: string;
username?: string;
avatar?: string;
app_id?: string;
};
};
};
type BaijiahaoPublishResponse = {
errno?: number;
errmsg?: string;
ret?: {
id?: string | number;
};
};
type BaijiahaoUploadResponse = {
ret?: {
https_url?: string;
};
};
type BaijiahaoCutResponse = {
data?: {
new_url?: string;
};
};
async function fetchAppInfo(): Promise<NonNullable<BaijiahaoAppInfoResponse["data"]>["user"] | null> {
const response = await fetchJson<BaijiahaoAppInfoResponse>("https://baijiahao.baidu.com/builder/app/appinfo", {
headers: {
accept: "application/json, text/plain, */*",
},
}).catch(() => null);
return response?.data?.user ?? null;
}
async function detectBaijiahao(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const user = await fetchAppInfo();
const uid = user?.userid != null ? String(user.userid) : "";
const nickname = user?.name || user?.uname || user?.username || "";
if (!uid || !nickname) {
return disconnectedPlatform(platform, "未检测到百家号登录态");
}
return connectedPlatform(platform, uid, nickname, user?.avatar ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "百家号登录检测失败");
}
}
async function getPublishToken(): Promise<string> {
const html = await fetchText("https://baijiahao.baidu.com/builder/rc/edit?type=news", {
headers: {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
},
});
return /window\.__BJH__INIT__AUTH__\s*=\s*(["'])(.*?)\1/.exec(html)?.[2] ?? "";
}
async function uploadImage(sourceUrl: string, appId: string, cropCover: boolean): Promise<string | null> {
const blob = await fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
const form = new FormData();
form.append("media", blob, "image.png");
form.append("org_file_name", "image.png");
form.append("type", "image");
form.append("app_id", appId);
form.append("is_waterlog", "0");
form.append("save_material", "1");
form.append("no_compress", "0");
form.append("article_type", "news");
const uploaded = await fetchJson<BaijiahaoUploadResponse>("https://baijiahao.baidu.com/materialui/picture/uploadProxy", {
method: "POST",
body: form,
}).catch(() => null);
const uploadedUrl = uploaded?.ret?.https_url ?? null;
if (!uploadedUrl) {
return null;
}
if (!cropCover) {
return uploadedUrl;
}
const cut = await fetchJson<BaijiahaoCutResponse>("https://baijiahao.baidu.com/materialui/picture/auto_cutting", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
org_url: uploadedUrl,
type: "news",
cutting_type: "cover_image",
}),
}).catch(() => null);
return cut?.data?.new_url ?? uploadedUrl;
}
async function publishBaijiahao(context: AdapterContext): Promise<PlatformPublishResult> {
const user = await fetchAppInfo();
const appId = user?.app_id ?? "";
if (!user?.userid || !appId) {
throw new Error("baijiahao_not_logged_in");
}
const token = await getPublishToken();
if (!token) {
throw new Error("baijiahao_token_missing");
}
const content = normalizeArticleHtml(context.article);
const processed = await uploadHtmlImages(
content,
async (sourceUrl) => uploadImage(sourceUrl, appId, false),
["baijiahao.baidu.com", "bdstatic.com", "bcebos.com"],
);
const coverUrl = context.article.cover_asset_url?.trim()
? await uploadImage(context.article.cover_asset_url.trim(), appId, true)
: null;
const endpoint =
context.article.publish_type === "publish"
? "https://baijiahao.baidu.com/pcui/article/publish"
: "https://baijiahao.baidu.com/pcui/article/save";
const response = await fetchJson<BaijiahaoPublishResponse>(endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
Token: token,
},
body: new URLSearchParams({
type: "news",
title: context.article.title,
content: processed.html,
abstract_from: "1",
cover_layout: coverUrl ? "one" : "",
cover_images: coverUrl
? JSON.stringify([
{
src: coverUrl,
cropData: {},
machine_chooseimg: 0,
isLegal: 0,
cover_source_tag: "local",
},
])
: "",
activity_list: JSON.stringify([
{ id: "ttv", is_checked: 0 },
{ id: "ai_tts", is_checked: 0 },
{ id: "aigc_bjh_status", is_checked: 1 },
{ id: "reward", is_checked: 0 },
]),
}),
});
const articleId = response?.ret?.id != null ? String(response.ret.id) : "";
if (response.errno !== 0 || !articleId) {
throw new Error(response.errmsg || "baijiahao_publish_failed");
}
const manageUrl = `https://baijiahao.baidu.com/builder/rc/edit?type=news&article_id=${articleId}`;
if (context.article.publish_type === "draft") {
return manageUrlResult(true, "pending_review", articleId, manageUrl, "百家号草稿保存成功。");
}
return manageUrlResult(
true,
"success",
articleId,
manageUrl,
"百家号发布成功,公开链接待补查。",
null,
);
}
export const baijiahaoAdapter: PlatformAdapter = {
platformId: "baijiahao",
detect: detectBaijiahao,
publish: publishBaijiahao,
};
@@ -0,0 +1,48 @@
import type { StoredPlatformState } from "../platforms";
import { connectedPlatform, disconnectedPlatform, fetchJson, getCookieString, unsupportedPublishResult } from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type BilibiliNavResponse = {
code?: number;
data?: {
isLogin?: boolean;
mid?: string | number;
uname?: string;
face?: string;
};
};
async function detectBilibili(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const cookie = await getCookieString("bilibili.com");
const response = await fetchJson<BilibiliNavResponse>(
"https://api.bilibili.com/x/web-interface/nav?build=0&mobi_app=web",
{
headers: {
accept: "application/json, text/plain, */*",
...(cookie ? { cookie } : {}),
},
},
);
const user = response.data;
const uid = user?.mid != null ? String(user.mid) : "";
if (response.code !== 0 || !user?.isLogin || !uid || !user.uname) {
return disconnectedPlatform(platform, "未检测到 bilibili 登录态");
}
return connectedPlatform(platform, uid, user.uname, user.face ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "bilibili 登录检测失败");
}
}
async function publishBilibili(): Promise<PlatformPublishResult> {
return unsupportedPublishResult("bilibili");
}
export const bilibiliAdapter: PlatformAdapter = {
platformId: "bilibili",
detect: detectBilibili,
publish: (_context: AdapterContext) => publishBilibili(),
};
@@ -0,0 +1,279 @@
import type { PublisherPublishArticleRequest } from "@geo/shared-types";
import { marked } from "marked";
import { browser } from "wxt/browser";
import type { StoredPlatformState } from "../platforms";
import type { PlatformPublishResult } from "./types";
export function normalizeRemoteUrl(value?: string | null): string | null {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
if (/^(data|blob|chrome-extension):/i.test(trimmed)) {
return trimmed;
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed;
}
if (/^\/\//.test(trimmed)) {
return `https:${trimmed}`;
}
if (/^[a-z][a-z\d+\-.]*:/i.test(trimmed)) {
return trimmed;
}
return `https://${trimmed.replace(/^\/+/, "")}`;
}
export function connectedPlatform(
platform: StoredPlatformState,
platformUid: string,
nickname: string,
avatarUrl?: string | null,
): StoredPlatformState {
return {
...platform,
connected: true,
platform_uid: platformUid,
nickname,
avatar_url: normalizeRemoteUrl(avatarUrl),
message: null,
};
}
export function disconnectedPlatform(platform: StoredPlatformState, message: string): StoredPlatformState {
return {
...platform,
connected: false,
platform_uid: null,
nickname: null,
avatar_url: null,
message,
};
}
export function unsupportedPublishResult(platformName: string): PlatformPublishResult {
return {
success: false,
status: "failed",
externalArticleId: null,
externalArticleUrl: null,
externalManageUrl: null,
message: `${platformName} 暂未接入真实发布链路。`,
responsePayload: {
mode: "unsupported-adapter",
platform: platformName,
},
};
}
export async function getCookieValue(domain: string, name: string): Promise<string> {
const cookies = await browser.cookies.getAll({ domain });
return cookies.find((item) => item.name === name)?.value ?? "";
}
export async function getCookieString(domain: string): Promise<string> {
const cookies = await browser.cookies.getAll({ domain });
return cookies.map((item) => `${item.name}=${item.value}`).join("; ");
}
export async function fetchText(input: string, init?: RequestInit): Promise<string> {
const response = await fetch(input, {
...init,
credentials: init?.credentials ?? "include",
});
const text = await response.text();
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`);
}
return text;
}
export async function fetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const text = await fetchText(input, init);
if (!text) {
return {} as T;
}
return JSON.parse(text) as T;
}
export function markdownToHtml(markdown: string): string {
const source = markdown.trim();
if (!source) {
return "";
}
return marked.parse(source, {
async: false,
gfm: true,
breaks: false,
}) as string;
}
export function normalizeArticleHtml(article: PublisherPublishArticleRequest): string {
const markdown = article.markdown_content?.trim();
const source = markdown ? markdownToHtml(markdown) : article.html_content?.trim() || "";
let next = source.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-[a-z-]+="[^"]*"/gi, "");
next = next.replace(/<figure[^>]*>\s*(<img[\s\S]*?>)\s*<\/figure>/gi, "$1");
return next;
}
export function extractImageSources(html: string): string[] {
const sources = new Set<string>();
for (const match of html.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
const src = match[2]?.trim();
if (src) {
sources.add(src);
}
}
return [...sources];
}
export function extractMarkdownImageSources(markdown: string): string[] {
const sources = new Set<string>();
for (const match of markdown.matchAll(/!\[[^\]]*]\((?:<([^>\s]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\)/g)) {
const src = match[1] || match[2];
const normalized = src?.trim();
if (normalized) {
sources.add(normalized);
}
}
for (const source of extractImageSources(markdown)) {
sources.add(source);
}
return [...sources];
}
function shouldSkipSource(source: string, skipPatterns?: Array<string | RegExp>): boolean {
if (!skipPatterns?.length) {
return false;
}
return skipPatterns.some((pattern) =>
typeof pattern === "string" ? source.includes(pattern) : pattern.test(source),
);
}
export async function uploadHtmlImages(
html: string,
uploader: (sourceUrl: string) => Promise<string | null>,
skipPatterns?: Array<string | RegExp>,
): Promise<{ html: string; uploaded: Map<string, string> }> {
const sources = extractImageSources(html);
if (!sources.length) {
return {
html,
uploaded: new Map(),
};
}
const uploaded = new Map<string, string>();
for (const source of sources) {
if (shouldSkipSource(source, skipPatterns)) {
continue;
}
const target = await uploader(source);
if (target) {
uploaded.set(source, target);
}
}
let next = html;
for (const [from, to] of uploaded.entries()) {
next = next.split(from).join(to);
}
return { html: next, uploaded };
}
export async function uploadMarkdownImages(
markdown: string,
uploader: (sourceUrl: string) => Promise<string | null>,
skipPatterns?: Array<string | RegExp>,
): Promise<{ markdown: string; uploaded: Map<string, string> }> {
const sources = extractMarkdownImageSources(markdown);
if (!sources.length) {
return {
markdown,
uploaded: new Map(),
};
}
const uploaded = new Map<string, string>();
for (const source of sources) {
if (shouldSkipSource(source, skipPatterns)) {
continue;
}
const target = await uploader(source);
if (target) {
uploaded.set(source, target);
}
}
let next = markdown;
for (const [from, to] of uploaded.entries()) {
next = next.split(from).join(to);
}
return { markdown: next, uploaded };
}
export async function fetchImageBlob(sourceUrl: string): Promise<Blob | null> {
const normalizedUrl = normalizeRemoteUrl(sourceUrl);
if (!normalizedUrl) {
return null;
}
const response = await fetch(normalizedUrl).catch(() => null);
if (!response?.ok) {
return null;
}
const blob = await response.blob().catch(() => null);
if (!blob || !blob.type.startsWith("image/")) {
return null;
}
return blob;
}
export function randomHex(length: number): string {
const chars = "0123456789abcdef";
let result = "";
for (let index = 0; index < length; index += 1) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export function articleMarkdown(article: PublisherPublishArticleRequest): string {
return article.markdown_content || "";
}
export function manageUrlResult(
success: boolean,
status: PlatformPublishResult["status"],
externalArticleId: string | null,
externalManageUrl: string | null,
message: string,
externalArticleUrl?: string | null,
): PlatformPublishResult {
return {
success,
status,
externalArticleId,
externalArticleUrl: externalArticleUrl ?? null,
externalManageUrl,
message,
};
}
@@ -0,0 +1,38 @@
import type { StoredPlatformState } from "../platforms";
import { connectedPlatform, disconnectedPlatform, fetchJson, unsupportedPublishResult } from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type DongchediAccountInfoResponse = {
data?: {
user_id_str?: string;
name?: string;
avatar_url?: string;
};
};
async function detectDongchedi(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const response = await fetchJson<DongchediAccountInfoResponse>(
"https://mp.dcdapp.com/passport/account/info/v2/?aid=2302&account_sdk_source=web",
).catch(() => null);
const user = response?.data;
if (!user?.user_id_str || !user.name) {
return disconnectedPlatform(platform, "未检测到懂车帝登录态");
}
return connectedPlatform(platform, user.user_id_str, user.name, user.avatar_url ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "懂车帝登录检测失败");
}
}
async function publishDongchedi(): Promise<PlatformPublishResult> {
return unsupportedPublishResult("懂车帝");
}
export const dongchediAdapter: PlatformAdapter = {
platformId: "dongchedi",
detect: detectDongchedi,
publish: (_context: AdapterContext) => publishDongchedi(),
};
+29 -1
View File
@@ -1,9 +1,37 @@
import type { StoredPlatformState } from "../platforms";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
import { baijiahaoAdapter } from "./baijiahao";
import { bilibiliAdapter } from "./bilibili";
import { dongchediAdapter } from "./dongchedi";
import { jianshuAdapter } from "./jianshu";
import { juejinAdapter } from "./juejin";
import { qiehaoAdapter } from "./qiehao";
import { smzdmAdapter } from "./smzdm";
import { sohuhaoAdapter } from "./sohuhao";
import { toutiaohaoAdapter } from "./toutiaohao";
import { wangyihaoAdapter } from "./wangyihao";
import { weixinGzhAdapter } from "./weixin_gzh";
import { zhihuAdapter } from "./zhihu";
import { zolAdapter } from "./zol";
const adapters = new Map<string, PlatformAdapter>([[zhihuAdapter.platformId, zhihuAdapter]]);
const adapters = new Map<string, PlatformAdapter>(
[
zhihuAdapter,
toutiaohaoAdapter,
baijiahaoAdapter,
sohuhaoAdapter,
qiehaoAdapter,
jianshuAdapter,
bilibiliAdapter,
juejinAdapter,
wangyihaoAdapter,
smzdmAdapter,
weixinGzhAdapter,
zolAdapter,
dongchediAdapter,
].map((adapter) => [adapter.platformId, adapter]),
);
export function getPlatformAdapter(platformId: string): PlatformAdapter | undefined {
return adapters.get(platformId);
@@ -0,0 +1,222 @@
import type { StoredPlatformState } from "../platforms";
import {
articleMarkdown,
connectedPlatform,
disconnectedPlatform,
fetchImageBlob,
fetchJson,
manageUrlResult,
normalizeArticleHtml,
randomHex,
uploadHtmlImages,
uploadMarkdownImages,
} from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type JianshuUser = {
id?: string | number;
nickname?: string;
avatar?: string;
preferred_note_type?: string;
};
type JianshuNotebook = {
id?: string | number;
};
type JianshuNote = {
id?: string | number;
};
type JianshuUploadToken = {
token?: string;
key?: string;
};
type JianshuErrorResponse = {
error?: Array<{
message?: string;
}>;
};
const JIANSHU_JSON_HEADERS = {
Accept: "application/json",
"Content-Type": "application/json; charset=UTF-8",
};
async function fetchCurrentUser(): Promise<JianshuUser | null> {
return fetchJson<JianshuUser>("https://www.jianshu.com/author/current_user", {
headers: {
accept: "application/json",
},
}).catch(() => null);
}
async function detectJianshu(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const user = await fetchCurrentUser();
const uid = user?.id != null ? String(user.id) : "";
if (!uid || !user?.nickname) {
return disconnectedPlatform(platform, "未检测到简书登录态");
}
return connectedPlatform(platform, uid, user.nickname, user.avatar ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "简书登录检测失败");
}
}
async function uploadImage(noteId: string, sourceUrl: string): Promise<string | null> {
const blob = await fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
const uploadToken = await fetchJson<JianshuUploadToken>(
`https://www.jianshu.com/upload_images/token.json?filename=${noteId}.png`,
{
headers: {
accept: "application/json",
},
},
).catch(() => null);
if (!uploadToken?.token || !uploadToken.key) {
return null;
}
const form = new FormData();
form.append("token", uploadToken.token);
form.append("key", uploadToken.key);
form.append("file", blob, `${noteId}.png`);
form.append("x:protocol", "https");
const uploaded = await fetchJson<{ url?: string }>("https://upload.qiniup.com", {
method: "POST",
headers: {
accept: "application/json",
},
body: form,
}).catch(() => null);
return uploaded?.url ?? null;
}
async function publishJianshu(context: AdapterContext): Promise<PlatformPublishResult> {
const user = await fetchCurrentUser();
if (!user?.id || !user.nickname) {
throw new Error("jianshu_not_logged_in");
}
const notebooks = await fetchJson<JianshuNotebook[]>("https://www.jianshu.com/author/notebooks", {
headers: {
Accept: "application/json",
},
});
const notebookId = notebooks[0]?.id != null ? String(notebooks[0].id) : "";
if (!notebookId) {
throw new Error("jianshu_notebook_missing");
}
let noteId = "";
try {
const created = await fetchJson<JianshuNote>("https://www.jianshu.com/author/notes", {
method: "POST",
headers: JIANSHU_JSON_HEADERS,
body: JSON.stringify({
notebook_id: notebookId,
title: context.article.title,
at_bottom: true,
}),
});
noteId = created.id != null ? String(created.id) : "";
if (!noteId) {
throw new Error("jianshu_note_create_failed");
}
const preferredType = user.preferred_note_type || "plain";
const rawMarkdown = articleMarkdown(context.article).trim();
const useMarkdownMode = preferredType !== "plain" && Boolean(rawMarkdown);
const markdownProcessed = useMarkdownMode
? await uploadMarkdownImages(rawMarkdown, async (sourceUrl) => uploadImage(noteId, sourceUrl))
: null;
const htmlProcessed = useMarkdownMode
? null
: await uploadHtmlImages(normalizeArticleHtml(context.article), async (sourceUrl) => uploadImage(noteId, sourceUrl));
await fetchJson(`https://www.jianshu.com/author/notes/${noteId}`, {
method: "PUT",
headers: JIANSHU_JSON_HEADERS,
body: JSON.stringify({
id: noteId,
autosave_control: 1,
title: context.article.title,
content: useMarkdownMode ? (markdownProcessed?.markdown ?? rawMarkdown) : (htmlProcessed?.html ?? ""),
}),
});
if (context.article.cover_asset_url?.trim()) {
const coverUrl = await uploadImage(noteId || randomHex(12), context.article.cover_asset_url.trim());
if (coverUrl) {
await fetchJson(`https://www.jianshu.com/author/notes/${noteId}/update_cover_images`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=UTF-8",
Accept: "application/json",
},
body: JSON.stringify({
flow_type: 14,
abbr: "",
cover_image_urls: [coverUrl],
}),
}).catch(() => null);
}
}
const manageUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${noteId}`;
if (context.article.publish_type === "draft") {
return manageUrlResult(true, "pending_review", noteId, manageUrl, "简书草稿保存成功。");
}
const publishResponse = await fetch(`https://www.jianshu.com/author/notes/${noteId}/publicize`, {
method: "POST",
credentials: "include",
headers: JIANSHU_JSON_HEADERS,
body: JSON.stringify({}),
});
const publishBody = (await publishResponse.json().catch(() => ({}))) as JianshuErrorResponse;
if (!publishResponse.ok || publishBody.error?.[0]?.message) {
throw new Error(publishBody.error?.[0]?.message || "jianshu_publish_failed");
}
noteId = "";
return manageUrlResult(
true,
"success",
created.id != null ? String(created.id) : "",
manageUrl,
"简书发布成功。",
`https://www.jianshu.com/p/${created.id}`,
);
} finally {
if (noteId) {
await fetch(`https://www.jianshu.com/author/notes/${noteId}/soft_destroy`, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
}).catch(() => null);
}
}
}
export const jianshuAdapter: PlatformAdapter = {
platformId: "jianshu",
detect: detectJianshu,
publish: publishJianshu,
};
@@ -0,0 +1,46 @@
import type { StoredPlatformState } from "../platforms";
import { connectedPlatform, disconnectedPlatform, fetchJson, unsupportedPublishResult } from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type JuejinProfileResponse = {
data?: {
bui_user?: {
user_id?: string;
screen_name?: string;
avatar_large?: string;
avatar_url?: string;
};
};
};
async function detectJuejin(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const response = await fetchJson<JuejinProfileResponse>("https://api.juejin.cn/user_api/v1/user/get_profile", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: "{}",
});
const user = response.data?.bui_user;
if (!user?.user_id || !user.screen_name) {
return disconnectedPlatform(platform, "未检测到掘金登录态");
}
return connectedPlatform(platform, user.user_id, user.screen_name, user.avatar_url ?? user.avatar_large ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "掘金登录检测失败");
}
}
async function publishJuejin(): Promise<PlatformPublishResult> {
return unsupportedPublishResult("掘金");
}
export const juejinAdapter: PlatformAdapter = {
platformId: "juejin",
detect: detectJuejin,
publish: (_context: AdapterContext) => publishJuejin(),
};
@@ -0,0 +1,242 @@
import type { StoredPlatformState } from "../platforms";
import {
connectedPlatform,
disconnectedPlatform,
fetchImageBlob,
fetchJson,
getCookieString,
manageUrlResult,
normalizeArticleHtml,
sleep,
uploadHtmlImages,
} from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
const QIEHAO_APP_ID = "LA6zXi1lWzAioIzdiAD6iM10aHarlHF6";
type QiehaoBasicInfoResponse = {
data?: {
cpInfo?: {
mediaId?: string | number;
mediaName?: string;
header?: string;
};
};
};
type QiehaoPublishResponse = {
response?: {
code?: number;
msg?: string;
};
data?: {
articleId?: string | number;
};
msg?: string;
};
type QiehaoArticleListResponse = {
data?: {
articles?: Array<{
article_id?: string | number;
url?: string;
}>;
};
};
type QiehaoUploadStepOneResponse = {
data?: {
url?: {
size?: Record<string, { imageUrl?: string }>;
};
};
};
type QiehaoUploadStepTwoResponse = {
data?: {
url?: string;
};
};
async function fetchBasicInfo() {
const cookie = await getCookieString("om.qq.com");
return fetchJson<QiehaoBasicInfoResponse>("https://om.qq.com/maccountsetting/basicinfo?relogin=1", {
headers: {
accept: "application/json, text/plain, */*",
...(cookie ? { cookie } : {}),
},
}).catch(() => null);
}
async function detectQiehao(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const cpInfo = (await fetchBasicInfo())?.data?.cpInfo;
const uid = cpInfo?.mediaId != null ? String(cpInfo.mediaId) : "";
if (!uid || !cpInfo?.mediaName) {
return disconnectedPlatform(platform, "未检测到企鹅号登录态");
}
return connectedPlatform(platform, uid, cpInfo.mediaName, cpInfo.header ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "企鹅号登录检测失败");
}
}
async function uploadImage(sourceUrl: string, includeCoverMeta: boolean): Promise<string | null> {
const blob = await fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
const form = new FormData();
form.append("file", blob, "image.png");
form.append("appid", QIEHAO_APP_ID);
form.append("isUpOrg", "1");
form.append("endpoint", "1");
form.append("isRetImgAttr", "1");
form.append("opCode", "151");
const first = await fetchJson<QiehaoUploadStepOneResponse>("https://image.om.qq.com/cpom_pimage/ArchacaleUploadViaFile", {
method: "POST",
body: form,
}).catch(() => null);
const imageUrl = first?.data?.url?.size?.["641"]?.imageUrl ?? null;
if (!imageUrl) {
return null;
}
if (!includeCoverMeta) {
return imageUrl;
}
const second = await fetchJson<QiehaoUploadStepTwoResponse>("https://image.om.qq.com/cpom_pimage/ListImageUploadViaUrl", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
auth: {
appid: QIEHAO_APP_ID,
endpoint: 1,
},
reqData: {
appid: QIEHAO_APP_ID,
isUpOrg: 1,
endpoint: 1,
isRetImgAttr: 1,
opCode: 151,
imageUrl,
},
relogin: 1,
}),
}).catch(() => null);
return second?.data?.url ?? imageUrl;
}
async function publishQiehao(context: AdapterContext): Promise<PlatformPublishResult> {
const cpInfo = (await fetchBasicInfo())?.data?.cpInfo;
const mediaId = cpInfo?.mediaId != null ? String(cpInfo.mediaId) : "";
if (!mediaId) {
throw new Error("qiehao_not_logged_in");
}
const cookie = await getCookieString("om.qq.com");
if (!cookie) {
throw new Error("qiehao_cookie_missing");
}
const content = normalizeArticleHtml(context.article);
const processed = await uploadHtmlImages(content, async (sourceUrl) => uploadImage(sourceUrl, false));
const coverImages = context.article.cover_asset_url?.trim()
? [await uploadImage(context.article.cover_asset_url.trim(), true)].filter(Boolean)
: [];
const uploadedImageList = Array.from(processed.uploaded.values());
const resourceMarkInfo = uploadedImageList.reduce<Record<string, Record<string, unknown>>>((result, imageUrl) => {
result[imageUrl] = {
image_url: imageUrl,
id: imageUrl,
resource_type: "image",
can_modify: 1,
aigc_user_statement: 2,
};
return result;
}, {});
const endpoint =
context.article.publish_type === "publish"
? "https://om.qq.com/marticlepublish/omPublish"
: "https://om.qq.com/marticlepublish/omSave";
const response = await fetchJson<QiehaoPublishResponse>(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
cookie,
},
body: JSON.stringify({
title: context.article.title,
content: processed.html,
imgurl_ext: JSON.stringify(coverImages),
cover_type: "1",
imgurlsrc: "custom",
orignal: 0,
user_original: 0,
mediaId,
needpub: 1,
type: 0,
relogin: 1,
self_declare: '{"id":7,"desc":"作者声明:该文章由AI辅助创作"}',
resource_aigc_mark_info: JSON.stringify(resourceMarkInfo),
}),
});
const articleId = response.data?.articleId != null ? String(response.data.articleId) : "";
if (response.response?.code !== 0 || !articleId) {
throw new Error(response.msg || response.response?.msg || "qiehao_publish_failed");
}
await sleep(1500);
const list = await fetchJson<QiehaoArticleListResponse>(
"https://om.qq.com/marticle/article/list?category=&search=&source=&startDate=&endDate=&num=20&ftype=&readChannel=qb&dstChannel=&isPartDst=0&refreshField=&relogin=1",
{
headers: {
accept: "application/json, text/plain, */*",
cookie,
},
},
).catch(() => null);
const article = list?.data?.articles?.find((item) => String(item.article_id) === articleId);
const articleUrl = article?.url?.trim() || null;
const externalArticleId = articleUrl ? articleUrl.split("/").filter(Boolean).pop() || articleId : articleId;
if (context.article.publish_type === "draft") {
return manageUrlResult(
true,
"pending_review",
externalArticleId,
"https://om.qq.com/article/manage",
"企鹅号草稿保存成功。",
articleUrl,
);
}
return manageUrlResult(
true,
"success",
externalArticleId,
"https://om.qq.com/article/manage",
"企鹅号发布成功。",
articleUrl,
);
}
export const qiehaoAdapter: PlatformAdapter = {
platformId: "qiehao",
detect: detectQiehao,
publish: publishQiehao,
};
@@ -0,0 +1,35 @@
import type { StoredPlatformState } from "../platforms";
import { connectedPlatform, disconnectedPlatform, fetchJson, unsupportedPublishResult } from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type SmzdmCurrentUser = {
smzdm_id?: string | number;
nickname?: string;
audit_nickname?: string;
avatar?: string;
};
async function detectSmzdm(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const user = await fetchJson<SmzdmCurrentUser>("https://zhiyou.smzdm.com/user/info/jsonp_get_current").catch(() => null);
const uid = user?.smzdm_id != null ? String(user.smzdm_id) : "";
const nickname = user?.nickname || user?.audit_nickname || "";
if (!uid || !nickname) {
return disconnectedPlatform(platform, "未检测到什么值得买登录态");
}
return connectedPlatform(platform, uid, nickname, user?.avatar ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "什么值得买登录检测失败");
}
}
async function publishSmzdm(): Promise<PlatformPublishResult> {
return unsupportedPublishResult("什么值得买");
}
export const smzdmAdapter: PlatformAdapter = {
platformId: "smzdm",
detect: detectSmzdm,
publish: (_context: AdapterContext) => publishSmzdm(),
};
@@ -0,0 +1,142 @@
import type { StoredPlatformState } from "../platforms";
import {
connectedPlatform,
disconnectedPlatform,
fetchImageBlob,
fetchJson,
getCookieValue,
manageUrlResult,
normalizeArticleHtml,
randomHex,
uploadHtmlImages,
} from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type SohuRegisterInfoResponse = {
data?: {
account?: {
id?: string | number;
nickName?: string;
avatar?: string;
};
};
};
type SohuPublishResponse = {
code?: number;
msg?: string;
data?: string | number;
};
type SohuUploadResponse = {
url?: string;
msg?: string;
};
function generateDeviceId(): string {
return randomHex(32);
}
async function fetchAccountInfo() {
const response = await fetchJson<SohuRegisterInfoResponse>("https://mp.sohu.com/mpbp/bp/account/register-info").catch(
() => null,
);
return response?.data?.account ?? null;
}
async function detectSohuhao(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const account = await fetchAccountInfo();
const uid = account?.id != null ? String(account.id) : "";
if (!uid || !account?.nickName) {
return disconnectedPlatform(platform, "未检测到搜狐号登录态");
}
return connectedPlatform(platform, uid, account.nickName, account.avatar ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "搜狐号登录检测失败");
}
}
async function uploadImage(sourceUrl: string, accountId: string, spCm: string): Promise<string | null> {
const blob = await fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
const form = new FormData();
form.append("file", blob, "image.png");
form.append("accountId", accountId);
const response = await fetchJson<SohuUploadResponse>(
`https://mp.sohu.com/commons/front/outerUpload/image/file?accountId=${accountId}`,
{
method: "POST",
headers: {
"Sp-Cm": spCm,
},
body: form,
},
).catch(() => null);
return response?.url ?? null;
}
async function publishSohuhao(context: AdapterContext): Promise<PlatformPublishResult> {
const account = await fetchAccountInfo();
const accountId = account?.id != null ? String(account.id) : "";
if (!accountId) {
throw new Error("sohuhao_not_logged_in");
}
const spCm = (await getCookieValue("sohu.com", "mp-cv")) || `100-${Date.now()}-${generateDeviceId()}`;
const content = normalizeArticleHtml(context.article);
const processed = await uploadHtmlImages(content, async (sourceUrl) => uploadImage(sourceUrl, accountId, spCm), [
"sohu.com",
]);
const coverUrl = context.article.cover_asset_url?.trim()
? await uploadImage(context.article.cover_asset_url.trim(), accountId, spCm)
: "";
const endpoint =
context.article.publish_type === "publish"
? "https://mp.sohu.com/mpbp/bp/news/v4/news/publish/v2"
: "https://mp.sohu.com/mpbp/bp/news/v4/news/draft/v2";
const response = await fetchJson<SohuPublishResponse>(`${endpoint}?accountId=${accountId}`, {
method: "POST",
headers: {
"content-type": "application/json",
"Sp-Cm": spCm,
},
body: JSON.stringify({
title: context.article.title,
content: processed.html,
brief: "",
channelId: 30,
categoryId: -1,
cover: coverUrl,
accountId,
infoResource: 2,
}),
});
const articleId = response.data != null ? String(response.data) : "";
if (response.code !== 2000000 || !articleId) {
throw new Error(response.msg || "sohuhao_publish_failed");
}
const manageUrl = `https://mp.sohu.com/mpfe/v4/contentManagement/news/addarticle?spm=smmp.articlelist.0.0&contentStatus=2&id=${articleId}`;
if (context.article.publish_type === "draft") {
return manageUrlResult(true, "pending_review", articleId, manageUrl, "搜狐号草稿保存成功。");
}
return manageUrlResult(true, "success", articleId, manageUrl, "搜狐号发布成功,公开链接待补查。");
}
export const sohuhaoAdapter: PlatformAdapter = {
platformId: "sohuhao",
detect: detectSohuhao,
publish: publishSohuhao,
};
@@ -0,0 +1,249 @@
import type { StoredPlatformState } from "../platforms";
import {
connectedPlatform,
disconnectedPlatform,
fetchImageBlob,
fetchJson,
manageUrlResult,
normalizeArticleHtml,
uploadHtmlImages,
} from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type ToutiaoMediaInfoResponse = {
data?: {
user?: {
id_str?: string;
screen_name?: string;
https_avatar_url?: string;
};
media?: {
has_third_party_ad_permission?: boolean;
has_toutiao_ad_permission?: boolean;
};
};
code?: number;
message?: string;
};
type ToutiaoUploadPictureResponse = {
url?: string;
web_uri?: string;
origin_web_uri?: string;
rigin_web_uri?: string;
width?: number;
height?: number;
};
type ToutiaoSpiceImageResponse = {
data?: {
image_url?: string;
image_uri?: string;
image_width?: number;
image_height?: number;
};
};
type ToutiaoPublishResponse = {
code?: number;
message?: string;
data?: {
pgc_id?: string | number;
};
};
async function fetchMediaInfo(): Promise<ToutiaoMediaInfoResponse["data"] | null> {
const response = await fetchJson<ToutiaoMediaInfoResponse>("https://mp.toutiao.com/mp/agw/media/get_media_info", {
headers: {
accept: "application/json, text/plain, */*",
},
}).catch(() => null);
return response?.data ?? null;
}
async function detectToutiaohao(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const data = await fetchMediaInfo();
const user = data?.user;
if (!user?.id_str || !user.screen_name) {
return disconnectedPlatform(platform, "未检测到头条号登录态");
}
return connectedPlatform(platform, user.id_str, user.screen_name, user.https_avatar_url ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "头条号登录检测失败");
}
}
async function uploadCover(sourceUrl: string, publishType: "publish" | "draft") {
const blob = await fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
if (publishType === "publish") {
const form = new FormData();
form.append("upfile", blob, "cover.png");
const uploaded = await fetchJson<ToutiaoUploadPictureResponse>(
"https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture",
{
method: "POST",
body: form,
},
).catch(() => null);
if (!uploaded?.url || !uploaded.web_uri) {
return null;
}
return {
id: 0,
url: uploaded.url,
uri: uploaded.web_uri,
origin_uri: uploaded.origin_web_uri ?? uploaded.rigin_web_uri ?? "",
thumb_width: uploaded.width ?? 0,
thumb_height: uploaded.height ?? 0,
};
}
const form = new FormData();
form.append("image", blob, "cover.png");
const first = await fetchJson<ToutiaoSpiceImageResponse>("https://mp.toutiao.com/spice/image?device_platform=web", {
method: "POST",
body: form,
}).catch(() => null);
const imageUrl = first?.data?.image_url;
if (!imageUrl) {
return null;
}
const second = new FormData();
second.append("imageUrl", imageUrl);
const final = await fetchJson<ToutiaoSpiceImageResponse>(
"https://mp.toutiao.com/spice/image?device_platform=web&need_cover_url=1",
{
method: "POST",
body: second,
},
).catch(() => null);
if (!final?.data?.image_url || !first.data?.image_uri) {
return null;
}
return {
id: "",
url: final.data.image_url,
uri: first.data.image_uri,
ic_uri: "",
thumb_width: first.data.image_width ?? 0,
thumb_height: first.data.image_height ?? 0,
extra: {
from_content_uri: "",
from_content: "0",
},
};
}
async function uploadContentImage(sourceUrl: string): Promise<string | null> {
const blob = await fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
const form = new FormData();
form.append("image", blob, "image.png");
const uploaded = await fetchJson<ToutiaoSpiceImageResponse>("https://mp.toutiao.com/spice/image?device_platform=web", {
method: "POST",
body: form,
}).catch(() => null);
return uploaded?.data?.image_url ?? null;
}
async function publishToutiaohao(context: AdapterContext): Promise<PlatformPublishResult> {
const mediaInfo = await fetchMediaInfo();
const user = mediaInfo?.user;
if (!user?.id_str || !user.screen_name) {
throw new Error("toutiaohao_not_logged_in");
}
const content = normalizeArticleHtml(context.article);
const processed = await uploadHtmlImages(content, uploadContentImage);
const cover = context.article.cover_asset_url?.trim()
? await uploadCover(context.article.cover_asset_url.trim(), context.article.publish_type)
: null;
const hasAdPermission = Boolean(
mediaInfo?.media?.has_third_party_ad_permission || mediaInfo?.media?.has_toutiao_ad_permission,
);
const body = new URLSearchParams({
content: processed.html,
title: context.article.title,
mp_editor_stat: JSON.stringify({ code_block: 1 }),
is_refute_rumor: "0",
save: context.article.publish_type === "publish" ? "1" : "0",
timer_status: "0",
draft_form_data: JSON.stringify({ coverType: 1 }),
article_ad_type: hasAdPermission ? "3" : "2",
pgc_feed_covers: JSON.stringify(cover ? [cover] : []),
is_fans_article: "0",
govern_forward: "0",
praise: "0",
disable_praise: "0",
tree_plan_article: "0",
activity_tag: "0",
trends_writing_tag: "0",
claim_exclusive: "0",
info_source: JSON.stringify({
source_type: 3,
source_author_uid: "",
time_format: "",
position: {},
}),
});
const response = await fetchJson<ToutiaoPublishResponse>(
"https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
);
const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : "";
if (response.code !== 0 || !articleId) {
throw new Error(response.message || "toutiaohao_publish_failed");
}
if (context.article.publish_type === "draft") {
return manageUrlResult(
true,
"pending_review",
articleId,
"https://mp.toutiao.com/profile_v4/graphic/publish",
"头条号草稿保存成功。",
);
}
return manageUrlResult(
true,
"success",
articleId,
"https://mp.toutiao.com/profile_v4/index",
"头条号发布成功。",
`https://www.toutiao.com/article/${articleId}/`,
);
}
export const toutiaohaoAdapter: PlatformAdapter = {
platformId: "toutiaohao",
detect: detectToutiaohao,
publish: publishToutiaohao,
};
@@ -0,0 +1,38 @@
import type { StoredPlatformState } from "../platforms";
import { connectedPlatform, disconnectedPlatform, fetchJson, unsupportedPublishResult } from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type WangyiInfoResponse = {
data?: {
mediaInfo?: {
userId?: string | number;
tname?: string;
icon?: string;
};
};
};
async function detectWangyihao(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const response = await fetchJson<WangyiInfoResponse>("https://mp.163.com/wemedia/info.do").catch(() => null);
const media = response?.data?.mediaInfo;
const uid = media?.userId != null ? String(media.userId) : "";
if (!uid || !media?.tname) {
return disconnectedPlatform(platform, "未检测到网易号登录态");
}
return connectedPlatform(platform, uid, media.tname, media.icon ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "网易号登录检测失败");
}
}
async function publishWangyihao(): Promise<PlatformPublishResult> {
return unsupportedPublishResult("网易号");
}
export const wangyihaoAdapter: PlatformAdapter = {
platformId: "wangyihao",
detect: detectWangyihao,
publish: (_context: AdapterContext) => publishWangyihao(),
};
@@ -0,0 +1,34 @@
import type { StoredPlatformState } from "../platforms";
import { connectedPlatform, disconnectedPlatform, fetchText, unsupportedPublishResult } from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
async function detectWeixinGzh(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const html = await fetchText("https://mp.weixin.qq.com/");
const token = html.match(/data:\s*\{[\s\S]*?t:\s*["']([^"']+)["']/)?.[1] ?? "";
const userName = html.match(/user_name:\s*["']([^"']+)["']/)?.[1] ?? "";
const nickName = html.match(/nick_name:\s*["']([^"']+)["']/)?.[1] ?? "";
const avatarFromHead = html.match(/class="weui-desktop-account__thumb"[^>]*src="([^"]+)"/)?.[1] ?? "";
const avatarFromMeta = html.match(/head_img:\s*['"]([^'"]+)['"]/)?.[1] ?? "";
const avatarUrl = (avatarFromHead || avatarFromMeta || "").replace(/^http:\/\//, "https://");
if (!token || !userName || !nickName) {
return disconnectedPlatform(platform, "未检测到公众号登录态");
}
return connectedPlatform(platform, userName, nickName, avatarUrl || null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "公众号登录检测失败");
}
}
async function publishWeixinGzh(): Promise<PlatformPublishResult> {
return unsupportedPublishResult("微信公众号");
}
export const weixinGzhAdapter: PlatformAdapter = {
platformId: "weixin_gzh",
detect: detectWeixinGzh,
publish: (_context: AdapterContext) => publishWeixinGzh(),
};
+2 -1
View File
@@ -4,6 +4,7 @@ import { marked } from "marked";
import type { StoredPlatformState } from "../platforms";
import { normalizeRemoteUrl } from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type ZhihuMeResponse = {
@@ -232,7 +233,7 @@ async function detectZhihu(platform: StoredPlatformState): Promise<StoredPlatfor
connected: true,
platform_uid: uid,
nickname: me.name,
avatar_url: me.avatar_url ?? null,
avatar_url: normalizeRemoteUrl(me.avatar_url),
message: null,
};
} catch (error) {
@@ -0,0 +1,41 @@
import type { StoredPlatformState } from "../platforms";
import { connectedPlatform, disconnectedPlatform, fetchJson, unsupportedPublishResult } from "./common";
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
type ZolUserInfoResponse = {
data?: {
userId?: string | number;
nickName?: string;
photo?: string;
};
};
async function detectZol(platform: StoredPlatformState): Promise<StoredPlatformState> {
try {
const response = await fetchJson<ZolUserInfoResponse>("https://open-api.zol.com.cn/api/v1/creator.user.getinfo", {
headers: {
accept: "application/json, text/plain, */*",
},
}).catch(() => null);
const user = response?.data;
const uid = user?.userId != null ? String(user.userId) : "";
if (!uid || !user?.nickName) {
return disconnectedPlatform(platform, "未检测到中关村在线登录态");
}
return connectedPlatform(platform, uid, user.nickName, user.photo ?? null);
} catch (error) {
return disconnectedPlatform(platform, error instanceof Error ? error.message : "中关村在线登录检测失败");
}
}
async function publishZol(): Promise<PlatformPublishResult> {
return unsupportedPublishResult("中关村在线");
}
export const zolAdapter: PlatformAdapter = {
platformId: "zol",
detect: detectZol,
publish: (_context: AdapterContext) => publishZol(),
};
+2 -6
View File
@@ -128,19 +128,15 @@ export function createDefaultPlatformStates(): StoredPlatformState[] {
}));
}
export function buildPublishedUrl(platformId: string, externalArticleId: string): string {
export function buildPublishedUrl(platformId: string, externalArticleId: string): string | null {
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}`;
return null;
}
}
+123 -101
View File
@@ -28,6 +28,30 @@ function normalizeText(value?: string | null): string | null {
return trimmed ? trimmed : null;
}
function logPublishRuntime(level: "info" | "warn" | "error", event: string, payload: Record<string, unknown>): void {
const prefix = `[geo-publisher] ${event}`;
if (level === "error") {
console.error(prefix, payload);
return;
}
if (level === "warn") {
console.warn(prefix, payload);
return;
}
console.info(prefix, payload);
}
function normalizeUrl(value?: string | null, platformId?: string | null, externalArticleId?: string | null): string | null {
const explicit = normalizeText(value);
if (explicit) {
return explicit;
}
if (!platformId || !externalArticleId) {
return null;
}
return normalizeText(buildPublishedUrl(platformId, externalArticleId));
}
async function postCallback<T>(
baseUrl: string,
path: string,
@@ -51,25 +75,78 @@ async function postCallback<T>(
return body.data;
}
function nextExternalArticleId(taskId: number): string {
return `${Date.now()}${String(taskId).slice(-4)}`;
}
function asFailedAdapterResult(error: unknown) {
const message = error instanceof Error ? error.message : "publisher_adapter_failed";
return {
success: false,
status: "failed" as const,
externalArticleId: null,
externalArticleUrl: null,
externalManageUrl: null,
message: error instanceof Error ? error.message : "publisher_adapter_failed",
message,
responsePayload: {
mode: "platform-adapter",
error: error instanceof Error ? error.message : "publisher_adapter_failed",
error: message,
stack: error instanceof Error ? error.stack ?? null : null,
},
};
}
async function postPublishCallback(
callbackBaseUrl: string,
payload: PublisherPublishArticleRequest,
task: PublisherPublishArticleRequest["tasks"][number],
adapterResult: {
success: boolean;
status: PublisherPublishTaskResult["status"];
externalArticleId?: string | null;
externalArticleUrl?: string | null;
externalManageUrl?: string | null;
message?: string | null;
responsePayload?: Record<string, unknown>;
},
) {
const externalArticleId = normalizeText(adapterResult.externalArticleId);
const externalArticleUrl = normalizeUrl(adapterResult.externalArticleUrl, task.platform_id, externalArticleId);
const externalManageUrl = normalizeText(adapterResult.externalManageUrl);
await postCallback(callbackBaseUrl, "/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: externalArticleId,
external_article_url: externalArticleUrl,
external_manage_url: 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",
},
});
return {
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: externalArticleId,
external_article_url: externalArticleUrl,
external_manage_url: externalManageUrl,
message: adapterResult.message ?? null,
} satisfies PublisherPublishTaskResult;
}
export async function handlePublisherAction(
action: PublisherAction,
payload?: unknown,
@@ -193,110 +270,55 @@ async function handlePublishArticle(payload: PublisherPublishArticleRequest): Pr
}).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,
});
if (!adapterResult.success) {
logPublishRuntime("error", "publish-task-failed", {
article_id: payload.article_id,
publish_record_id: task.publish_record_id,
platform_account_id: task.platform_account_id,
platform_id: task.platform_id,
message: adapterResult.message ?? null,
response_payload: adapterResult.responsePayload ?? null,
});
}
results.push(await postPublishCallback(payload.callback_base_url, payload, task, adapterResult));
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,
},
});
const fallbackFailure = !platform?.connected
? {
success: false,
status: "failed" as const,
externalArticleId: null,
externalArticleUrl: null,
externalManageUrl: normalizeText(platform?.login_url),
message: "Local platform session is missing in the extension runtime.",
responsePayload: {
mode: "missing-session",
installation_key: state.installation_key,
},
}
: {
success: false,
status: "failed" as const,
externalArticleId: null,
externalArticleUrl: null,
externalManageUrl: normalizeText(platform.login_url),
message: "No direct-publish adapter is available for this platform yet.",
responsePayload: {
mode: "missing-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: 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,
logPublishRuntime("warn", "publish-task-rejected", {
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.",
reason: fallbackFailure.message,
response_payload: fallbackFailure.responsePayload,
});
results.push(await postPublishCallback(payload.callback_base_url, payload, task, fallbackFailure));
}
const successCount = results.filter((item) => item.success).length;