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:
@@ -1,9 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<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 { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import { message } from "ant-design-vue";
|
import { message, notification } from "ant-design-vue";
|
||||||
import type { MediaPlatform, PlatformAccount, PublisherLocalPlatformState } from "@geo/shared-types";
|
import type {
|
||||||
import { computed, ref, watch } from "vue";
|
MediaPlatform,
|
||||||
|
PlatformAccount,
|
||||||
|
PublisherLocalPlatformState,
|
||||||
|
PublisherPublishResponse,
|
||||||
|
PublisherPublishTaskResult,
|
||||||
|
} from "@geo/shared-types";
|
||||||
|
import { computed, h, ref, watch } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
import { articlesApi, getApiBaseURL, mediaApi } from "@/lib/api";
|
import { articlesApi, getApiBaseURL, mediaApi } from "@/lib/api";
|
||||||
@@ -32,6 +38,24 @@ const pluginInstalled = ref(false);
|
|||||||
const pluginVersion = ref<string | undefined>();
|
const pluginVersion = ref<string | undefined>();
|
||||||
const pluginInstallationId = ref<number | null>(null);
|
const pluginInstallationId = ref<number | null>(null);
|
||||||
const localPlatforms = ref<PublisherLocalPlatformState[]>([]);
|
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({
|
const detailQuery = useQuery({
|
||||||
queryKey: computed(() => ["articles", "detail", props.articleId, "publish-modal"]),
|
queryKey: computed(() => ["articles", "detail", props.articleId, "publish-modal"]),
|
||||||
@@ -57,6 +81,7 @@ watch(
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
selectedAccountIds.value = [];
|
selectedAccountIds.value = [];
|
||||||
coverAssetUrl.value = "";
|
coverAssetUrl.value = "";
|
||||||
|
fileList.value = [];
|
||||||
pluginInstallationId.value = null;
|
pluginInstallationId.value = null;
|
||||||
localPlatforms.value = [];
|
localPlatforms.value = [];
|
||||||
return;
|
return;
|
||||||
@@ -70,6 +95,10 @@ const localPlatformMap = computed(() => {
|
|||||||
return new Map(localPlatforms.value.map((item) => [item.platform_id, item]));
|
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 accountCards = computed(() => {
|
||||||
const accountMap = new Map((accountsQuery.data.value ?? []).map((account) => [account.platform_id, account]));
|
const accountMap = new Map((accountsQuery.data.value ?? []).map((account) => [account.platform_id, account]));
|
||||||
|
|
||||||
@@ -87,6 +116,8 @@ const accountCards = computed(() => {
|
|||||||
accountId: account?.id ?? null,
|
accountId: account?.id ?? null,
|
||||||
platformId: platform.platform_id,
|
platformId: platform.platform_id,
|
||||||
platformName: platform.name,
|
platformName: platform.name,
|
||||||
|
platformShortName: platform.short_name,
|
||||||
|
platformAccentColor: platform.accent_color,
|
||||||
platformCategory: platform.category,
|
platformCategory: platform.category,
|
||||||
platformUid: account?.platform_uid ?? local?.platform_uid ?? "--",
|
platformUid: account?.platform_uid ?? local?.platform_uid ?? "--",
|
||||||
nickname: account?.nickname ?? local?.nickname ?? t("media.card.unbound"),
|
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 successCount = result.results.filter((item) => item.success).length;
|
||||||
const failedCount = result.results.length - successCount;
|
const failedCount = result.results.length - successCount;
|
||||||
if (failedCount === 0) {
|
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 {
|
} else {
|
||||||
message.warning(t("media.publish.messages.partial", { success: successCount, failed: failedCount }));
|
showPublishFailures(result);
|
||||||
}
|
}
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
||||||
@@ -206,33 +242,72 @@ function resolveAccountStatusText(
|
|||||||
}
|
}
|
||||||
return t("media.account.ready");
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-modal
|
<a-modal
|
||||||
:open="open"
|
:open="open"
|
||||||
:title="t('media.publish.title')"
|
:title="t('media.publish.title')"
|
||||||
width="920"
|
:width="650"
|
||||||
:confirm-loading="publishMutation.isPending.value"
|
:confirm-loading="publishMutation.isPending.value"
|
||||||
@ok="publishMutation.mutate()"
|
@ok="publishMutation.mutate()"
|
||||||
@cancel="emit('update:open', false)"
|
@cancel="emit('update:open', false)"
|
||||||
>
|
>
|
||||||
<div class="publish-modal">
|
<div class="publish-modal">
|
||||||
<section class="publish-modal__hero">
|
<section class="publish-modal__hero">
|
||||||
<div>
|
<div class="publish-modal__hero-top">
|
||||||
<p class="eyebrow">{{ t("media.publish.subtitle") }}</p>
|
<span class="eyebrow">标题</span>
|
||||||
<h3>{{ modalTitle }}</h3>
|
|
||||||
<p>{{ t("media.publish.description") }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="publish-modal__hero-actions">
|
<div class="publish-modal__hero-actions">
|
||||||
<a-tag :color="pluginInstalled ? 'success' : 'error'">
|
<a-tag :color="pluginInstalled ? 'success' : 'error'" class="hero-tag">
|
||||||
{{ pluginInstalled ? t("media.plugin.ready") : t("media.plugin.notInstalled") }}
|
{{ pluginInstalled ? t("media.plugin.ready") : t("media.plugin.notInstalled") }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-button :loading="runtimeLoading" @click="refreshRuntime">
|
<a-button :loading="runtimeLoading" @click="refreshRuntime" size="small" class="hero-btn">
|
||||||
<template #icon><ReloadOutlined /></template>
|
<template #icon><ReloadOutlined /></template>
|
||||||
{{ t("media.actions.redetect") }}
|
{{ t("media.actions.redetect") }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3>{{ modalTitle }}</h3>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<a-alert
|
<a-alert
|
||||||
@@ -244,12 +319,10 @@ function resolveAccountStatusText(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="publish-modal__section">
|
<section class="publish-modal__section">
|
||||||
<div class="section-heading">
|
<div class="publish-modal__section-header">
|
||||||
<div>
|
<h3><span class="required-star">*</span> {{ t("media.publish.platformsTitle") }}:</h3>
|
||||||
<h3>{{ t("media.publish.platformsTitle") }}</h3>
|
|
||||||
<p class="muted">{{ t("media.publish.platformsHint") }}</p>
|
<p class="muted">{{ t("media.publish.platformsHint") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="accountsQuery.isPending.value || platformsQuery.isPending.value" class="publish-modal__loading">
|
<div v-if="accountsQuery.isPending.value || platformsQuery.isPending.value" class="publish-modal__loading">
|
||||||
<a-skeleton active :paragraph="{ rows: 5 }" />
|
<a-skeleton active :paragraph="{ rows: 5 }" />
|
||||||
@@ -267,20 +340,31 @@ function resolveAccountStatusText(
|
|||||||
}"
|
}"
|
||||||
@click="account.accountId ? toggleAccount(account.accountId, account.selectable) : undefined"
|
@click="account.accountId ? toggleAccount(account.accountId, account.selectable) : undefined"
|
||||||
>
|
>
|
||||||
|
<div class="publish-modal__card-left">
|
||||||
<span class="publish-modal__check">
|
<span class="publish-modal__check">
|
||||||
<span
|
<span
|
||||||
v-if="account.accountId && isSelected(account.accountId)"
|
v-if="account.accountId && isSelected(account.accountId)"
|
||||||
class="publish-modal__check-dot"
|
class="publish-modal__check-inner"
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="publish-modal__card-copy">
|
<span class="publish-modal__card-badge" :style="{ color: account.platformAccentColor }">
|
||||||
<strong>{{ account.platformName }}</strong>
|
{{ account.platformShortName }}
|
||||||
<small>{{ account.nickname }} · ID: {{ account.platformUid }}</small>
|
|
||||||
<small>{{ account.statusText }}</small>
|
|
||||||
</span>
|
</span>
|
||||||
<a-tag :color="account.selectable ? 'success' : account.accountId ? 'warning' : 'default'">
|
<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") }}
|
{{ account.selectable ? t("media.account.selectable") : t("media.account.unavailable") }}
|
||||||
</a-tag>
|
</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -288,20 +372,29 @@ function resolveAccountStatusText(
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="publish-modal__section">
|
<section class="publish-modal__section">
|
||||||
<div class="publish-modal__cover">
|
<div class="publish-modal__section-header inline-header">
|
||||||
<div class="publish-modal__cover-head">
|
<h3>{{ t("media.publish.coverTitle") }}:</h3>
|
||||||
<div>
|
|
||||||
<h3>{{ t("media.publish.coverTitle") }}</h3>
|
|
||||||
<p class="muted">{{ t("media.publish.coverHint") }}</p>
|
|
||||||
</div>
|
|
||||||
<a-switch v-model:checked="coverEnabled" />
|
<a-switch v-model:checked="coverEnabled" />
|
||||||
</div>
|
</div>
|
||||||
|
<p class="muted">请保证封面清晰、美观和完整</p>
|
||||||
|
|
||||||
<a-input
|
<div class="publish-modal__cover-body" v-if="coverEnabled">
|
||||||
v-if="coverEnabled"
|
<a-upload
|
||||||
v-model:value="coverAssetUrl"
|
v-model:file-list="fileList"
|
||||||
:placeholder="t('media.publish.coverPlaceholder')"
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,133 +405,245 @@ function resolveAccountStatusText(
|
|||||||
.publish-modal {
|
.publish-modal {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__hero {
|
.publish-modal__hero {
|
||||||
display: flex;
|
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;
|
justify-content: space-between;
|
||||||
gap: 20px;
|
margin-bottom: 12px;
|
||||||
padding: 20px 22px;
|
|
||||||
border: 1px solid #e6edf5;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: linear-gradient(135deg, #fbfdff 0%, #f4f9ff 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__hero h3,
|
.publish-modal__hero h3 {
|
||||||
.publish-modal__section h3 {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
color: #111827;
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__hero p:last-child {
|
.eyebrow {
|
||||||
margin: 8px 0 0;
|
font-size: 16px;
|
||||||
color: #6b7280;
|
text-transform: uppercase;
|
||||||
|
color: #000000;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__hero-actions {
|
.publish-modal__hero-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.publish-modal__section {
|
.publish-modal__section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.publish-modal__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__card {
|
.publish-modal__card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 18px;
|
padding: 12px 16px;
|
||||||
border: 1px solid #dbe5f0;
|
border: 1px solid #d9d9d9;
|
||||||
border-radius: 18px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
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 {
|
.publish-modal__card:hover:not(.publish-modal__card--disabled) {
|
||||||
border-color: #adc6ff;
|
border-color: #1677ff;
|
||||||
box-shadow: 0 16px 28px rgba(22, 119, 255, 0.08);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__card--active {
|
.publish-modal__card--active {
|
||||||
border-color: #1677ff;
|
border-color: #1677ff;
|
||||||
background: linear-gradient(135deg, #f7fbff 0%, #eef6ff 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__card--disabled {
|
.publish-modal__card--disabled {
|
||||||
opacity: 0.72;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
background-color: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-modal__card-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__check {
|
.publish-modal__check {
|
||||||
width: 22px;
|
width: 16px;
|
||||||
height: 22px;
|
height: 16px;
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid #d9d9d9;
|
||||||
border-radius: 999px;
|
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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.publish-modal__card-text {
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.publish-modal__card-copy {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__card-copy strong {
|
.publish-modal__card-name {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
color: #0f172a;
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__card-copy small {
|
.publish-modal__card-sub {
|
||||||
color: #64748b;
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__cover {
|
.publish-modal__card-status {
|
||||||
padding: 18px;
|
font-size: 13px;
|
||||||
border: 1px solid #e6edf5;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__cover-head {
|
.status-selectable {
|
||||||
display: flex;
|
color: #52c41a;
|
||||||
align-items: center;
|
}
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
.status-disabled {
|
||||||
margin-bottom: 14px;
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__loading {
|
.publish-modal__loading {
|
||||||
padding: 12px 0;
|
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) {
|
@media (max-width: 860px) {
|
||||||
.publish-modal__hero {
|
.publish-modal__hero {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -476,8 +476,14 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
bindSuccess: "{platform} bound successfully.",
|
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.",
|
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.",
|
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.",
|
unbindConfirm: "Unbind this platform account? Publishing will no longer be available until it is bound again.",
|
||||||
},
|
},
|
||||||
publish: {
|
publish: {
|
||||||
@@ -491,8 +497,13 @@ const enUS = {
|
|||||||
coverPlaceholder: "Enter an optional cover asset URL",
|
coverPlaceholder: "Enter an optional cover asset URL",
|
||||||
messages: {
|
messages: {
|
||||||
success: "{count} publish tasks submitted.",
|
success: "{count} publish tasks submitted.",
|
||||||
|
successTitle: "Publish successful",
|
||||||
partial: "Publish finished with {success} succeeded and {failed} failed.",
|
partial: "Publish finished with {success} succeeded and {failed} failed.",
|
||||||
|
partialTitle: "Partial publish failure",
|
||||||
selectPlatform: "Choose at least one available platform first.",
|
selectPlatform: "Choose at least one available platform first.",
|
||||||
|
failureTitle: "Publish failed",
|
||||||
|
failureItem: "Publishing to [{platform}] failed: [{reason}]",
|
||||||
|
unknownFailure: "Unknown error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
records: {
|
records: {
|
||||||
|
|||||||
@@ -483,8 +483,14 @@ const zhCN = {
|
|||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
bindSuccess: "{platform} 绑定成功",
|
bindSuccess: "{platform} 绑定成功",
|
||||||
|
bindSuccessTitle: "绑定成功",
|
||||||
|
bindSuccessDetail: "已绑定【{platform}】的账号【{account}】",
|
||||||
bindPending: "插件尚未检测到该平台登录态,请先在插件中登录后重试。",
|
bindPending: "插件尚未检测到该平台登录态,请先在插件中登录后重试。",
|
||||||
|
bindPendingTitle: "绑定未完成",
|
||||||
|
bindFailureTitle: "绑定失败",
|
||||||
unbindSuccess: "账号解绑成功",
|
unbindSuccess: "账号解绑成功",
|
||||||
|
unbindSuccessTitle: "解绑成功",
|
||||||
|
unbindFailureTitle: "解绑失败",
|
||||||
unbindConfirm: "确认解绑这个平台账号吗?解绑后将无法直接发布。",
|
unbindConfirm: "确认解绑这个平台账号吗?解绑后将无法直接发布。",
|
||||||
},
|
},
|
||||||
publish: {
|
publish: {
|
||||||
@@ -498,8 +504,13 @@ const zhCN = {
|
|||||||
coverPlaceholder: "请输入封面素材 URL(可选)",
|
coverPlaceholder: "请输入封面素材 URL(可选)",
|
||||||
messages: {
|
messages: {
|
||||||
success: "已提交 {count} 个平台发布任务",
|
success: "已提交 {count} 个平台发布任务",
|
||||||
|
successTitle: "发布成功",
|
||||||
partial: "发布执行完成,成功 {success} 个,失败 {failed} 个",
|
partial: "发布执行完成,成功 {success} 个,失败 {failed} 个",
|
||||||
|
partialTitle: "部分发布失败",
|
||||||
selectPlatform: "请先选择至少一个可发布的平台",
|
selectPlatform: "请先选择至少一个可发布的平台",
|
||||||
|
failureTitle: "发布失败",
|
||||||
|
failureItem: "文章发布到平台【{platform}】失败【{reason}】",
|
||||||
|
unknownFailure: "未知错误",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
records: {
|
records: {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ const errorMessageMap: Record<string, string> = {
|
|||||||
article_title_required: "请输入文章标题",
|
article_title_required: "请输入文章标题",
|
||||||
invalid_payload: "请求参数不合法",
|
invalid_payload: "请求参数不合法",
|
||||||
update_failed: "保存文章失败",
|
update_failed: "保存文章失败",
|
||||||
|
publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试",
|
||||||
|
publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试",
|
||||||
|
publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试",
|
||||||
|
publisher_plugin_unknown_error: "浏览器插件调用失败,请稍后重试",
|
||||||
network_error: "网络连接失败,请稍后重试",
|
network_error: "网络连接失败,请稍后重试",
|
||||||
unknown_error: "发生未知错误",
|
unknown_error: "发生未知错误",
|
||||||
};
|
};
|
||||||
@@ -39,7 +43,7 @@ export function formatError(error: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return error.message;
|
return errorMessageMap[error.message] ?? error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "发生未知错误";
|
return "发生未知错误";
|
||||||
|
|||||||
@@ -35,6 +35,47 @@ type PluginPayloadMap = {
|
|||||||
publishArticle: PublisherPublishResponse;
|
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 {
|
function nextRequestId(): string {
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -42,6 +83,12 @@ function nextRequestId(): string {
|
|||||||
return `geo-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
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>(
|
async function requestPlugin<T extends PluginAction>(
|
||||||
action: T,
|
action: T,
|
||||||
payload?: T extends "bindAccount"
|
payload?: T extends "bindAccount"
|
||||||
@@ -75,6 +122,10 @@ async function requestPlugin<T extends PluginAction>(
|
|||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
if (event.data.success) {
|
if (event.data.success) {
|
||||||
|
if (!isPayloadValid(action, event.data.data)) {
|
||||||
|
reject(new Error("publisher_plugin_invalid_response"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolve(event.data.data);
|
resolve(event.data.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -103,6 +154,10 @@ async function requestPlugin<T extends PluginAction>(
|
|||||||
export async function pingPublisherPlugin(): Promise<PublisherPluginPingResponse> {
|
export async function pingPublisherPlugin(): Promise<PublisherPluginPingResponse> {
|
||||||
try {
|
try {
|
||||||
return await requestPlugin("ping", undefined, 1500);
|
return await requestPlugin("ping", undefined, 1500);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await sleep(300);
|
||||||
|
return await requestPlugin("ping", undefined, 2000);
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
installed: false,
|
installed: false,
|
||||||
@@ -110,6 +165,7 @@ export async function pingPublisherPlugin(): Promise<PublisherPluginPingResponse
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function detectPublisherPlatforms(): Promise<PublisherLocalPlatformState[]> {
|
export async function detectPublisherPlatforms(): Promise<PublisherLocalPlatformState[]> {
|
||||||
return requestPlugin("detectPlatforms", undefined, 2500);
|
return requestPlugin("detectPlatforms", undefined, 2500);
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ export interface PublisherRuntimeState {
|
|||||||
pluginInstallationId: number | null;
|
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 {
|
function detectBrowserName(): string {
|
||||||
const ua = navigator.userAgent;
|
const ua = navigator.userAgent;
|
||||||
if (/Edg\//.test(ua)) {
|
if (/Edg\//.test(ua)) {
|
||||||
@@ -31,7 +48,7 @@ function detectBrowserName(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPublisherRuntimeState(): Promise<PublisherRuntimeState> {
|
export async function loadPublisherRuntimeState(): Promise<PublisherRuntimeState> {
|
||||||
const ping = await pingPublisherPlugin();
|
const ping = normalizePingResponse(await pingPublisherPlugin());
|
||||||
if (!ping.installed || !ping.installation_key) {
|
if (!ping.installed || !ping.installation_key) {
|
||||||
return {
|
return {
|
||||||
ping,
|
ping,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
TimePicker,
|
TimePicker,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Upload,
|
||||||
} from "ant-design-vue";
|
} from "ant-design-vue";
|
||||||
|
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
@@ -100,6 +101,7 @@ app.component("ATextarea", Input.TextArea);
|
|||||||
Tag,
|
Tag,
|
||||||
TimePicker,
|
TimePicker,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Upload,
|
||||||
].forEach((component) => {
|
].forEach((component) => {
|
||||||
app.use(component);
|
app.use(component);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -275,6 +275,14 @@ function toneClass(tone: CardTone): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="media-runtime-card panel">
|
||||||
<div class="media-runtime-card__copy">
|
<div class="media-runtime-card__copy">
|
||||||
<h3 class="panel-title">{{ t("media.plugin.title") }}</h3>
|
<h3 class="panel-title">{{ t("media.plugin.title") }}</h3>
|
||||||
@@ -298,13 +306,7 @@ function toneClass(tone: CardTone): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="media-list-wrapper panel">
|
||||||
<div class="custom-tabs">
|
<div class="custom-tabs">
|
||||||
@@ -331,7 +333,7 @@ function toneClass(tone: CardTone): string {
|
|||||||
class="media-search"
|
class="media-search"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<SearchOutlined style="color: #bfbfbf;" />
|
<SearchOutlined style="color: #737373;" />
|
||||||
</template>
|
</template>
|
||||||
</a-input>
|
</a-input>
|
||||||
|
|
||||||
@@ -375,7 +377,7 @@ function toneClass(tone: CardTone): string {
|
|||||||
</a>
|
</a>
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item v-if="card.accountCount > 1" disabled>
|
<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-item>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</template>
|
</template>
|
||||||
@@ -596,7 +598,7 @@ function toneClass(tone: CardTone): string {
|
|||||||
top: 4px;
|
top: 4px;
|
||||||
right: -8px;
|
right: -8px;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
color: #bfbfbf;
|
color: f3f#737373;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -716,12 +718,12 @@ function toneClass(tone: CardTone): string {
|
|||||||
|
|
||||||
.media-card__identity-text p {
|
.media-card__identity-text p {
|
||||||
margin: 2px 0 0;
|
margin: 2px 0 0;
|
||||||
color: #bfbfbf;
|
color: #737373;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-icon {
|
.more-icon {
|
||||||
color: #bfbfbf;
|
color: #737373;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
@@ -750,7 +752,7 @@ function toneClass(tone: CardTone): string {
|
|||||||
|
|
||||||
.media-card__account-unbound {
|
.media-card__account-unbound {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #bfbfbf;
|
color: #737373;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-card__footer {
|
.media-card__footer {
|
||||||
|
|||||||
@@ -501,7 +501,7 @@ function refreshDashboard(): void {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.muted { color: #bfbfbf; font-size: 11px; }
|
.muted { color: #737373; font-size: 11px; }
|
||||||
.muted-dot { color: #d9d9d9; font-size: 10px; }
|
.muted-dot { color: #d9d9d9; font-size: 10px; }
|
||||||
|
|
||||||
/* Status Badges */
|
/* Status Badges */
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { defineBackground } from "wxt/utils/define-background";
|
|||||||
import { handlePublisherAction } from "../src/runtime";
|
import { handlePublisherAction } from "../src/runtime";
|
||||||
import { getExtensionState } from "../src/storage";
|
import { getExtensionState } from "../src/storage";
|
||||||
|
|
||||||
|
type BackgroundSuccessResponse = {
|
||||||
|
ok: true;
|
||||||
|
data: Awaited<ReturnType<typeof handlePublisherAction>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackgroundErrorResponse = {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
void getExtensionState();
|
void getExtensionState();
|
||||||
|
|
||||||
@@ -21,11 +31,25 @@ export default defineBackground(() => {
|
|||||||
await getExtensionState();
|
await getExtensionState();
|
||||||
});
|
});
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener((message) => {
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
if (!message || message.type !== "geo.publisher.request") {
|
if (!message || message.type !== "geo.publisher.request") {
|
||||||
return undefined;
|
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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ type PublisherRequestMessage = {
|
|||||||
payload?: unknown;
|
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({
|
export default defineContentScript({
|
||||||
matches: ["http://*/*", "https://*/*"],
|
matches: ["http://*/*", "https://*/*"],
|
||||||
main() {
|
main() {
|
||||||
@@ -23,12 +37,25 @@ export default defineContentScript({
|
|||||||
const { requestId, action, payload } = event.data;
|
const { requestId, action, payload } = event.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await browser.runtime.sendMessage({
|
const response = await browser.runtime.sendMessage({
|
||||||
type: "geo.publisher.request",
|
type: "geo.publisher.request",
|
||||||
action,
|
action,
|
||||||
payload,
|
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(
|
window.postMessage(
|
||||||
{
|
{
|
||||||
source: "geo-extension",
|
source: "geo-extension",
|
||||||
|
|||||||
@@ -2,14 +2,22 @@
|
|||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { browser } from "wxt/browser";
|
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";
|
import type { StoredPlatformState } from "../../src/platforms";
|
||||||
|
|
||||||
|
type BackgroundSuccessResponse = {
|
||||||
|
ok: true;
|
||||||
|
data: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackgroundErrorResponse = {
|
||||||
|
ok: false;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const refreshing = ref(false);
|
const refreshing = ref(false);
|
||||||
const resetting = ref(false);
|
|
||||||
const savingId = ref<string | null>(null);
|
|
||||||
const expandedId = ref<string | null>(null);
|
|
||||||
|
|
||||||
const installationKey = ref("");
|
const installationKey = ref("");
|
||||||
const installationId = ref<number | null>(null);
|
const installationId = ref<number | null>(null);
|
||||||
@@ -33,6 +41,10 @@ function clonePlatforms(items: StoredPlatformState[]): StoredPlatformState[] {
|
|||||||
return items.map((platform) => ({ ...platform }));
|
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> {
|
async function loadState(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -64,8 +76,9 @@ function uidFor(platform: StoredPlatformState): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function avatarFor(platform: StoredPlatformState): string | null {
|
function avatarFor(platform: StoredPlatformState): string | null {
|
||||||
if (platform.avatar_url?.trim()) {
|
const normalizedAvatar = normalizeRemoteUrl(platform.avatar_url);
|
||||||
return platform.avatar_url.trim();
|
if (normalizedAvatar) {
|
||||||
|
return normalizedAvatar;
|
||||||
}
|
}
|
||||||
if (platform.connected) {
|
if (platform.connected) {
|
||||||
return `https://api.dicebear.com/9.x/initials/svg?seed=${encodeURIComponent(titleFor(platform))}`;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(platform: StoredPlatformState): string {
|
|
||||||
if (refreshing.value || loading.value) {
|
|
||||||
return "检测中...";
|
|
||||||
}
|
|
||||||
return platform.connected ? "已登录" : "未登录";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusTone(platform: StoredPlatformState): "success" | "danger" | "pending" {
|
function statusTone(platform: StoredPlatformState): "success" | "danger" | "pending" {
|
||||||
if (refreshing.value || loading.value) {
|
if (refreshing.value || loading.value) {
|
||||||
return "pending";
|
return "pending";
|
||||||
@@ -87,36 +93,18 @@ function statusTone(platform: StoredPlatformState): "success" | "danger" | "pend
|
|||||||
return platform.connected ? "success" : "danger";
|
return platform.connected ? "success" : "danger";
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillDemo(platform: StoredPlatformState): void {
|
function loginButtonLabel(platform: StoredPlatformState): string {
|
||||||
platform.connected = true;
|
if (refreshing.value || loading.value) {
|
||||||
platform.nickname = platform.nickname?.trim() || `${platform.platform_name} Demo`;
|
return "检测中...";
|
||||||
platform.platform_uid = (platform.platform_uid != null ? String(platform.platform_uid).trim() : "") || `${platform.platform_id}-demo`;
|
}
|
||||||
platform.avatar_url =
|
return "未登录";
|
||||||
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 {
|
async function openLoginPage(platform: StoredPlatformState): Promise<void> {
|
||||||
expandedId.value = expandedId.value === platformId ? null : platformId;
|
if (!platform.login_url || refreshing.value || loading.value) {
|
||||||
}
|
return;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
await browser.tabs.create({ url: platform.login_url });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function simulateDetect(): Promise<void> {
|
async function simulateDetect(): Promise<void> {
|
||||||
@@ -126,10 +114,13 @@ async function simulateDetect(): Promise<void> {
|
|||||||
async function refreshDetectedPlatforms(): Promise<void> {
|
async function refreshDetectedPlatforms(): Promise<void> {
|
||||||
refreshing.value = true;
|
refreshing.value = true;
|
||||||
try {
|
try {
|
||||||
await browser.runtime.sendMessage({
|
const response = await browser.runtime.sendMessage({
|
||||||
type: "geo.publisher.request",
|
type: "geo.publisher.request",
|
||||||
action: "detectPlatforms",
|
action: "detectPlatforms",
|
||||||
});
|
});
|
||||||
|
if (isBackgroundResponse(response) && !response.ok) {
|
||||||
|
throw new Error(response.error || "publisher_plugin_unknown_error");
|
||||||
|
}
|
||||||
const state = await getExtensionState();
|
const state = await getExtensionState();
|
||||||
installationId.value = state.plugin_installation_id;
|
installationId.value = state.plugin_installation_id;
|
||||||
apiBaseUrl.value = state.api_base_url;
|
apiBaseUrl.value = state.api_base_url;
|
||||||
@@ -138,17 +129,6 @@ async function refreshDetectedPlatforms(): Promise<void> {
|
|||||||
refreshing.value = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -169,9 +149,8 @@ async function handleReset(): Promise<void> {
|
|||||||
v-for="platform in platforms"
|
v-for="platform in platforms"
|
||||||
:key="platform.platform_id"
|
:key="platform.platform_id"
|
||||||
class="platform-card"
|
class="platform-card"
|
||||||
:class="[`is-${statusTone(platform)}`, { 'is-expanded': expandedId === platform.platform_id }]"
|
:class="[`is-${statusTone(platform)}`]"
|
||||||
:style="{ '--platform-accent': platform.accent_color }"
|
:style="{ '--platform-accent': platform.accent_color }"
|
||||||
@click="toggleExpanded(platform.platform_id)"
|
|
||||||
>
|
>
|
||||||
<div class="platform-card__accent" />
|
<div class="platform-card__accent" />
|
||||||
|
|
||||||
@@ -203,47 +182,17 @@ async function handleReset(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="platform-card__status-chip" :class="`is-${statusTone(platform)}`">
|
|
||||||
{{ statusLabel(platform) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section v-if="expandedId === platform.platform_id" class="platform-editor" @click.stop>
|
|
||||||
<div class="platform-editor__grid">
|
|
||||||
<label>
|
|
||||||
<span>昵称</span>
|
|
||||||
<input v-model="platform.nickname" type="text" placeholder="比如 CharmingGeeker" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>UID</span>
|
|
||||||
<input v-model="platform.platform_uid" type="text" placeholder="平台账号 UID" />
|
|
||||||
</label>
|
|
||||||
<label class="platform-editor__full">
|
|
||||||
<span>头像 URL</span>
|
|
||||||
<input v-model="platform.avatar_url" type="text" placeholder="可选" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="platform-editor__actions">
|
|
||||||
<label class="platform-editor__toggle">
|
|
||||||
<input v-model="platform.connected" type="checkbox" />
|
|
||||||
<span>{{ platform.connected ? "已登录" : "未登录" }}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="platform-editor__buttons">
|
|
||||||
<button type="button" class="ghost" @click="fillDemo(platform)">填充示例</button>
|
|
||||||
<button type="button" class="ghost" @click="platform.connected = false">设为未登录</button>
|
|
||||||
<button
|
<button
|
||||||
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="primary"
|
class="platform-card__status-chip"
|
||||||
:disabled="savingId === platform.platform_id"
|
:class="`is-${statusTone(platform)}`"
|
||||||
@click="savePlatform(platform)"
|
:disabled="!platform.login_url || refreshing || loading"
|
||||||
|
@click.stop="openLoginPage(platform)"
|
||||||
>
|
>
|
||||||
{{ savingId === platform.platform_id ? "保存中..." : "保存本地状态" }}
|
{{ loginButtonLabel(platform) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<p class="popup-status">{{ statusText }}</p>
|
<p class="popup-status">{{ statusText }}</p>
|
||||||
@@ -264,15 +213,11 @@ async function handleReset(): Promise<void> {
|
|||||||
<dd>{{ installationKey }}</dd>
|
<dd>{{ installationKey }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<button type="button" class="popup-reset" :disabled="resetting" @click="handleReset">
|
|
||||||
{{ resetting ? "重置中..." : "重置全部平台" }}
|
|
||||||
</button>
|
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="popup-footer">
|
<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 class="popup-detect__icon" :class="{ 'is-spinning': refreshing }">↻</span>
|
||||||
<span>{{ refreshing ? "检测中..." : "重新检测" }}</span>
|
<span>{{ refreshing ? "检测中..." : "重新检测" }}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -390,7 +335,6 @@ async function handleReset(): Promise<void> {
|
|||||||
transform 180ms ease,
|
transform 180ms ease,
|
||||||
box-shadow 180ms ease,
|
box-shadow 180ms ease,
|
||||||
border-color 180ms ease;
|
border-color 180ms ease;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-card:hover {
|
.platform-card:hover {
|
||||||
@@ -398,10 +342,6 @@ async function handleReset(): Promise<void> {
|
|||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
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 {
|
.platform-card__accent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
@@ -527,6 +467,7 @@ async function handleReset(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.platform-card__status-chip {
|
.platform-card__status-chip {
|
||||||
|
appearance: none;
|
||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
@@ -534,6 +475,21 @@ async function handleReset(): Promise<void> {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
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,
|
.platform-card__status-chip.is-danger,
|
||||||
@@ -548,80 +504,7 @@ async function handleReset(): Promise<void> {
|
|||||||
border-color: rgba(74, 183, 159, 0.3);
|
border-color: rgba(74, 183, 159, 0.3);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
.popup-detect {
|
||||||
.platform-editor {
|
|
||||||
padding: 0 18px 18px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor__full {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor label {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
color: #8793a4;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid rgba(216, 224, 238, 1);
|
|
||||||
border-radius: 14px;
|
|
||||||
outline: none;
|
|
||||||
background: rgba(248, 251, 255, 0.92);
|
|
||||||
color: #516173;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor input:focus {
|
|
||||||
border-color: rgba(90, 124, 255, 0.6);
|
|
||||||
box-shadow: 0 0 0 4px rgba(91, 126, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor__actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor__toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: #6a788b;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor__toggle input {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor__buttons {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-editor__buttons button,
|
|
||||||
.popup-detect,
|
|
||||||
.popup-reset {
|
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -633,33 +516,16 @@ async function handleReset(): Promise<void> {
|
|||||||
box-shadow 150ms ease;
|
box-shadow 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-editor__buttons button:hover,
|
.popup-detect:hover {
|
||||||
.popup-detect:hover,
|
|
||||||
.popup-reset:hover {
|
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-editor__buttons button:disabled,
|
.popup-detect:disabled {
|
||||||
.popup-detect:disabled,
|
|
||||||
.popup-reset:disabled {
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.68;
|
opacity: 0.68;
|
||||||
transform: none;
|
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 {
|
.popup-footer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -746,16 +612,6 @@ async function handleReset(): Promise<void> {
|
|||||||
word-break: break-all;
|
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 {
|
@keyframes spin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
|
|||||||
@@ -44,5 +44,304 @@
|
|||||||
"urlFilter": "*://*.zhimg.com/*",
|
"urlFilter": "*://*.zhimg.com/*",
|
||||||
"resourceTypes": ["xmlhttprequest"]
|
"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(),
|
||||||
|
};
|
||||||
@@ -1,9 +1,37 @@
|
|||||||
import type { StoredPlatformState } from "../platforms";
|
import type { StoredPlatformState } from "../platforms";
|
||||||
|
|
||||||
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
|
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 { 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 {
|
export function getPlatformAdapter(platformId: string): PlatformAdapter | undefined {
|
||||||
return adapters.get(platformId);
|
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(),
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { marked } from "marked";
|
|||||||
|
|
||||||
import type { StoredPlatformState } from "../platforms";
|
import type { StoredPlatformState } from "../platforms";
|
||||||
|
|
||||||
|
import { normalizeRemoteUrl } from "./common";
|
||||||
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
|
import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types";
|
||||||
|
|
||||||
type ZhihuMeResponse = {
|
type ZhihuMeResponse = {
|
||||||
@@ -232,7 +233,7 @@ async function detectZhihu(platform: StoredPlatformState): Promise<StoredPlatfor
|
|||||||
connected: true,
|
connected: true,
|
||||||
platform_uid: uid,
|
platform_uid: uid,
|
||||||
nickname: me.name,
|
nickname: me.name,
|
||||||
avatar_url: me.avatar_url ?? null,
|
avatar_url: normalizeRemoteUrl(me.avatar_url),
|
||||||
message: null,
|
message: null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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(),
|
||||||
|
};
|
||||||
@@ -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) {
|
switch (platformId) {
|
||||||
case "toutiaohao":
|
case "toutiaohao":
|
||||||
return `https://www.toutiao.com/article/${externalArticleId}/`;
|
return `https://www.toutiao.com/article/${externalArticleId}/`;
|
||||||
case "zhihu":
|
case "zhihu":
|
||||||
return `https://zhuanlan.zhihu.com/p/${externalArticleId}`;
|
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":
|
case "jianshu":
|
||||||
return `https://www.jianshu.com/p/${externalArticleId}`;
|
return `https://www.jianshu.com/p/${externalArticleId}`;
|
||||||
default:
|
default:
|
||||||
return `https://example.com/${platformId}/${externalArticleId}`;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,30 @@ function normalizeText(value?: string | null): string | null {
|
|||||||
return trimmed ? trimmed : 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>(
|
async function postCallback<T>(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -51,25 +75,78 @@ async function postCallback<T>(
|
|||||||
return body.data;
|
return body.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextExternalArticleId(taskId: number): string {
|
|
||||||
return `${Date.now()}${String(taskId).slice(-4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asFailedAdapterResult(error: unknown) {
|
function asFailedAdapterResult(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "publisher_adapter_failed";
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
status: "failed" as const,
|
status: "failed" as const,
|
||||||
externalArticleId: null,
|
externalArticleId: null,
|
||||||
externalArticleUrl: null,
|
externalArticleUrl: null,
|
||||||
externalManageUrl: null,
|
externalManageUrl: null,
|
||||||
message: error instanceof Error ? error.message : "publisher_adapter_failed",
|
message,
|
||||||
responsePayload: {
|
responsePayload: {
|
||||||
mode: "platform-adapter",
|
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(
|
export async function handlePublisherAction(
|
||||||
action: PublisherAction,
|
action: PublisherAction,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
@@ -193,110 +270,55 @@ async function handlePublishArticle(payload: PublisherPublishArticleRequest): Pr
|
|||||||
}).catch(asFailedAdapterResult);
|
}).catch(asFailedAdapterResult);
|
||||||
|
|
||||||
if (adapterResult) {
|
if (adapterResult) {
|
||||||
await postCallback(payload.callback_base_url, "/api/callback/plugin/publish", {
|
if (!adapterResult.success) {
|
||||||
plugin_session_id: task.plugin_session_id,
|
logPublishRuntime("error", "publish-task-failed", {
|
||||||
session_token: task.session_token,
|
|
||||||
article_id: payload.article_id,
|
article_id: payload.article_id,
|
||||||
publish_record_id: task.publish_record_id,
|
publish_record_id: task.publish_record_id,
|
||||||
platform_account_id: task.platform_account_id,
|
platform_account_id: task.platform_account_id,
|
||||||
platform_id: task.platform_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,
|
message: adapterResult.message ?? null,
|
||||||
|
response_payload: adapterResult.responsePayload ?? null,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
results.push(await postPublishCallback(payload.callback_base_url, payload, task, adapterResult));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!platform?.connected) {
|
const fallbackFailure = !platform?.connected
|
||||||
await postCallback(payload.callback_base_url, "/api/callback/plugin/publish", {
|
? {
|
||||||
plugin_session_id: task.plugin_session_id,
|
|
||||||
session_token: task.session_token,
|
|
||||||
article_id: payload.article_id,
|
|
||||||
publish_record_id: task.publish_record_id,
|
|
||||||
platform_account_id: task.platform_account_id,
|
|
||||||
platform_id: task.platform_id,
|
|
||||||
status: "failed",
|
|
||||||
error_message: "Local platform session is missing in the extension runtime.",
|
|
||||||
client_version: browser.runtime.getManifest().version,
|
|
||||||
request_payload: {
|
|
||||||
title: payload.title,
|
|
||||||
publish_type: payload.publish_type,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
publish_record_id: task.publish_record_id,
|
|
||||||
platform_account_id: task.platform_account_id,
|
|
||||||
platform_id: task.platform_id,
|
|
||||||
success: false,
|
success: false,
|
||||||
status: "failed",
|
status: "failed" as const,
|
||||||
message: "Local platform session is missing.",
|
externalArticleId: null,
|
||||||
});
|
externalArticleUrl: null,
|
||||||
continue;
|
externalManageUrl: normalizeText(platform?.login_url),
|
||||||
}
|
message: "Local platform session is missing in the extension runtime.",
|
||||||
|
responsePayload: {
|
||||||
const externalArticleId = nextExternalArticleId(task.publish_record_id);
|
mode: "missing-session",
|
||||||
const externalArticleUrl = buildPublishedUrl(task.platform_id, externalArticleId);
|
|
||||||
|
|
||||||
await postCallback(payload.callback_base_url, "/api/callback/plugin/publish", {
|
|
||||||
plugin_session_id: task.plugin_session_id,
|
|
||||||
session_token: task.session_token,
|
|
||||||
article_id: payload.article_id,
|
|
||||||
publish_record_id: task.publish_record_id,
|
|
||||||
platform_account_id: task.platform_account_id,
|
|
||||||
platform_id: task.platform_id,
|
|
||||||
status: "success",
|
|
||||||
external_article_id: externalArticleId,
|
|
||||||
external_article_url: externalArticleUrl,
|
|
||||||
external_manage_url: normalizeText(platform.login_url),
|
|
||||||
published_at: new Date().toISOString(),
|
|
||||||
client_version: browser.runtime.getManifest().version,
|
|
||||||
request_payload: {
|
|
||||||
title: payload.title,
|
|
||||||
cover_asset_url: normalizeText(payload.cover_asset_url),
|
|
||||||
publish_type: payload.publish_type,
|
|
||||||
},
|
|
||||||
response_payload: {
|
|
||||||
mode: "mock-adapter",
|
|
||||||
installation_key: state.installation_key,
|
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({
|
logPublishRuntime("warn", "publish-task-rejected", {
|
||||||
|
article_id: payload.article_id,
|
||||||
publish_record_id: task.publish_record_id,
|
publish_record_id: task.publish_record_id,
|
||||||
platform_account_id: task.platform_account_id,
|
platform_account_id: task.platform_account_id,
|
||||||
platform_id: task.platform_id,
|
platform_id: task.platform_id,
|
||||||
success: true,
|
reason: fallbackFailure.message,
|
||||||
status: "success",
|
response_payload: fallbackFailure.responsePayload,
|
||||||
external_article_id: externalArticleId,
|
|
||||||
external_article_url: externalArticleUrl,
|
|
||||||
external_manage_url: normalizeText(platform.login_url),
|
|
||||||
message: "Published through the mock adapter.",
|
|
||||||
});
|
});
|
||||||
|
results.push(await postPublishCallback(payload.callback_base_url, payload, task, fallbackFailure));
|
||||||
}
|
}
|
||||||
|
|
||||||
const successCount = results.filter((item) => item.success).length;
|
const successCount = results.filter((item) => item.success).length;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
@@ -20,10 +21,11 @@ const minBrowserExtensionVersion = "0.1.0"
|
|||||||
|
|
||||||
type MediaService struct {
|
type MediaService struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMediaService(pool *pgxpool.Pool) *MediaService {
|
func NewMediaService(pool *pgxpool.Pool, logger *zap.Logger) *MediaService {
|
||||||
return &MediaService{pool: pool}
|
return &MediaService{pool: pool, logger: logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaPlatformResponse struct {
|
type MediaPlatformResponse struct {
|
||||||
@@ -712,6 +714,22 @@ func (s *MediaService) HandlePublishCallback(ctx context.Context, req PluginPubl
|
|||||||
publishedAt = &now
|
publishedAt = &now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if reqStatus != "success" && s.logger != nil {
|
||||||
|
s.logger.Warn("publisher callback reported non-success status",
|
||||||
|
zap.Int64("plugin_session_id", req.PluginSessionID),
|
||||||
|
zap.Int64("publish_record_id", req.PublishRecordID),
|
||||||
|
zap.Int64("platform_account_id", req.PlatformAccountID),
|
||||||
|
zap.Int64("article_id", req.ArticleID),
|
||||||
|
zap.String("platform_id", req.PlatformID),
|
||||||
|
zap.String("status", reqStatus),
|
||||||
|
zap.Stringp("error_message", req.ErrorMessage),
|
||||||
|
zap.Stringp("external_article_id", req.ExternalArticleID),
|
||||||
|
zap.Stringp("external_article_url", req.ExternalArticleURL),
|
||||||
|
zap.Any("request_payload", req.RequestPayload),
|
||||||
|
zap.Any("response_payload", req.ResponsePayload),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
UPDATE publish_records
|
UPDATE publish_records
|
||||||
SET status = $1,
|
SET status = $1,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
@@ -12,10 +13,14 @@ import (
|
|||||||
|
|
||||||
type MediaHandler struct {
|
type MediaHandler struct {
|
||||||
svc *app.MediaService
|
svc *app.MediaService
|
||||||
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMediaHandler(a *bootstrap.App) *MediaHandler {
|
func NewMediaHandler(a *bootstrap.App) *MediaHandler {
|
||||||
return &MediaHandler{svc: app.NewMediaService(a.DB)}
|
return &MediaHandler{
|
||||||
|
svc: app.NewMediaService(a.DB, a.Logger),
|
||||||
|
logger: a.Logger,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MediaHandler) ListPlatforms(c *gin.Context) {
|
func (h *MediaHandler) ListPlatforms(c *gin.Context) {
|
||||||
@@ -118,6 +123,18 @@ func (h *MediaHandler) PluginBindCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
data, err := h.svc.HandleBindCallback(c.Request.Context(), req)
|
data, err := h.svc.HandleBindCallback(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if h.logger != nil {
|
||||||
|
appErr := response.Normalize(err)
|
||||||
|
h.logger.Warn("publisher bind callback failed",
|
||||||
|
zap.String("request_id", c.GetString("request_id")),
|
||||||
|
zap.Int64("plugin_session_id", req.PluginSessionID),
|
||||||
|
zap.String("platform_id", req.PlatformID),
|
||||||
|
zap.String("platform_uid", req.PlatformUID),
|
||||||
|
zap.Int("code", appErr.Code),
|
||||||
|
zap.String("message", appErr.Message),
|
||||||
|
zap.String("detail", appErr.Detail),
|
||||||
|
)
|
||||||
|
}
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -132,6 +149,24 @@ func (h *MediaHandler) PluginPublishCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
data, err := h.svc.HandlePublishCallback(c.Request.Context(), req)
|
data, err := h.svc.HandlePublishCallback(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if h.logger != nil {
|
||||||
|
appErr := response.Normalize(err)
|
||||||
|
h.logger.Warn("publisher publish callback failed",
|
||||||
|
zap.String("request_id", c.GetString("request_id")),
|
||||||
|
zap.Int64("plugin_session_id", req.PluginSessionID),
|
||||||
|
zap.Int64("publish_record_id", req.PublishRecordID),
|
||||||
|
zap.Int64("platform_account_id", req.PlatformAccountID),
|
||||||
|
zap.Int64("article_id", req.ArticleID),
|
||||||
|
zap.String("platform_id", req.PlatformID),
|
||||||
|
zap.String("status", req.Status),
|
||||||
|
zap.Stringp("error_message", req.ErrorMessage),
|
||||||
|
zap.Int("code", appErr.Code),
|
||||||
|
zap.String("message", appErr.Message),
|
||||||
|
zap.String("detail", appErr.Detail),
|
||||||
|
zap.Any("request_payload", req.RequestPayload),
|
||||||
|
zap.Any("response_payload", req.ResponsePayload),
|
||||||
|
)
|
||||||
|
}
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
目前已接入“真实直发”的平台:
|
||||||
|
|
||||||
|
- 头条号:toutiaohao.ts
|
||||||
|
- 百家号:baijiahao.ts
|
||||||
|
- 搜狐号:sohuhao.ts
|
||||||
|
- 企鹅号:qiehao.ts
|
||||||
|
- 简书:jianshu.ts
|
||||||
|
|
||||||
|
目前已接入“真实登录检测,但暂未直发”的平台:
|
||||||
|
|
||||||
|
- bilibili:bilibili.ts
|
||||||
|
- 掘金:juejin.ts
|
||||||
|
- 网易号:wangyihao.ts
|
||||||
|
- 什么值得买:smzdm.ts
|
||||||
|
- 公众号:weixin_gzh.ts
|
||||||
|
- 中关村在线:zol.ts
|
||||||
|
- 懂车帝:dongchedi.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
没有问题的:
|
||||||
|
|
||||||
|
头条号:toutiaohao.ts
|
||||||
|
|
||||||
|
简书:jianshu.ts
|
||||||
|
|
||||||
|
知乎
|
||||||
Reference in New Issue
Block a user