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