feat(desktop): drop parked review flow, add publish management

SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除
manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询,
desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次
发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based
desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与
媒体库;plan A / spec 文档同步口径。
This commit is contained in:
2026-04-20 09:52:48 +08:00
parent b16e9f0bd1
commit a617d39a4a
93 changed files with 5519 additions and 3594 deletions
@@ -3,22 +3,20 @@ import type { Session, WebContentsView } from "electron";
export type AdapterTaskPhase = "initial" | "resume";
export type AdapterCompletionStatus = "succeeded" | "failed" | "unknown";
export type AdapterExecutionMode = "session" | "view" | "playwright";
export interface AdapterContext {
taskId: string;
accountId: string;
session: Session;
view: WebContentsView;
view: WebContentsView | null;
signal: AbortSignal;
mode: "auto" | "manual";
phase: AdapterTaskPhase;
reportProgress(stage: string): void;
}
export interface PublishAdapterContext extends AdapterContext {
article: DesktopArticleContent & {
publish_type: "publish" | "draft";
};
article: DesktopArticleContent;
}
export interface AdapterExecutionResult {
@@ -26,15 +24,16 @@ export interface AdapterExecutionResult {
summary: string;
payload?: Record<string, JsonValue>;
error?: Record<string, JsonValue>;
reviewUrl?: string | null;
}
export interface PublishAdapter {
platform: string;
executionMode?: AdapterExecutionMode;
publish(context: PublishAdapterContext, payload: Record<string, unknown>): Promise<AdapterExecutionResult>;
}
export interface MonitorAdapter {
provider: string;
executionMode?: AdapterExecutionMode;
query(context: AdapterContext, payload: Record<string, unknown>): Promise<AdapterExecutionResult>;
}
@@ -1,5 +1,6 @@
import type { DesktopArticleContent } from "@geo/shared-types";
import type { Session, WebContents, WebContentsView } from "electron/main";
import { marked } from "marked";
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from "../user-agent";
@@ -32,7 +33,8 @@ export function normalizeRemoteUrl(value?: string | null): string | null {
}
export function normalizeArticleHtml(article: DesktopArticleContent): string {
let next = article.html_content?.trim() ?? "";
const source = article.html_content?.trim() || markdownToHtml(article.markdown_content ?? "");
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, "");
@@ -41,6 +43,18 @@ export function normalizeArticleHtml(article: DesktopArticleContent): string {
return next.trim();
}
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 extractImageSources(html: string): string[] {
const sources = new Set<string>();
for (const match of html.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
@@ -597,11 +597,16 @@ function parseRuntimeState(value: unknown): DoubaoRuntimeState | null {
}
async function loadDoubaoRuntimeState(context: Parameters<MonitorAdapter["query"]>[0]): Promise<DoubaoRuntimeState> {
if (!context.view) {
throw new Error("doubao_view_required");
}
const view = context.view;
context.reportProgress("doubao.bootstrap_view");
await ensureViewLoaded(context.view, DOUBAO_REFERER, context.signal);
await ensureViewLoaded(view, DOUBAO_REFERER, context.signal);
context.reportProgress("doubao.read_runtime_state");
const state = await context.view.webContents.executeJavaScript(
const state = await view.webContents.executeJavaScript(
`(() => {
try {
const safeParse = (value) => {
@@ -711,7 +716,19 @@ function buildAdapterError(
export const doubaoAdapter: MonitorAdapter = {
provider: "doubao",
executionMode: "view",
async query(context, payload) {
if (!context.view) {
return {
status: "failed",
summary: "豆包监测缺少页面执行上下文。",
error: buildAdapterError(
"doubao_view_required",
"doubao_view_required",
),
};
}
const questionText = extractQuestionText(payload);
const runtimeState = await loadDoubaoRuntimeState(context);
const requestBody = buildDoubaoRequestBody(questionText, runtimeState);
+40 -251
View File
@@ -1,3 +1,6 @@
import {
publishToutiaoArticle,
} from "../../../../../packages/publisher-platforms/src/toutiao";
import type { JsonValue } from "@geo/shared-types";
import {
@@ -8,289 +11,75 @@ import {
} from "./common";
import type { PublishAdapter } from "./base";
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(context: Parameters<PublishAdapter["publish"]>[0]) {
const response = await sessionFetchJson<ToutiaoMediaInfoResponse>(
context.session,
"https://mp.toutiao.com/mp/agw/media/get_media_info",
{
headers: {
accept: "application/json, text/plain, */*",
},
},
).catch(() => null);
return response?.data ?? null;
}
async function uploadCover(
context: Parameters<PublishAdapter["publish"]>[0],
sourceUrl: string,
) {
const blob = await fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
if (context.article.publish_type === "publish") {
const form = new FormData();
form.append("upfile", blob, "cover.png");
const uploaded = await sessionFetchJson<ToutiaoUploadPictureResponse>(
context.session,
"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 sessionFetchJson<ToutiaoSpiceImageResponse>(
context.session,
"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 sessionFetchJson<ToutiaoSpiceImageResponse>(
context.session,
"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(
context: Parameters<PublishAdapter["publish"]>[0],
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 sessionFetchJson<ToutiaoSpiceImageResponse>(
context.session,
"https://mp.toutiao.com/spice/image?device_platform=web",
{
method: "POST",
body: form,
},
).catch(() => null);
return uploaded?.data?.image_url ?? null;
}
function buildResultPayload(
articleId: string,
publishType: "publish" | "draft",
mediaName: string,
externalManageUrl: string,
externalArticleUrl: string | null,
): Record<string, JsonValue> {
const manageURL =
publishType === "draft"
? "https://mp.toutiao.com/profile_v4/graphic/publish"
: "https://mp.toutiao.com/profile_v4/index";
return {
platform: "toutiaohao",
media_name: mediaName,
external_article_id: articleId,
external_manage_url: manageURL,
external_article_url:
publishType === "publish" ? `https://www.toutiao.com/article/${articleId}/` : null,
publish_type: publishType,
external_manage_url: externalManageUrl,
external_article_url: externalArticleUrl,
publish_type: "publish",
};
}
export const toutiaoAdapter: PublishAdapter = {
platform: "toutiaohao",
executionMode: "session",
async publish(context) {
context.reportProgress("toutiao.media_info");
const mediaInfo = await fetchMediaInfo(context);
const user = mediaInfo?.user;
if (!user?.id_str || !user.screen_name) {
return {
status: "failed",
summary: "头条号登录态失效,无法执行发布。",
error: {
code: "toutiaohao_not_logged_in",
message: "未检测到头条号登录态",
},
};
}
context.reportProgress("toutiao.normalize_html");
const html = normalizeArticleHtml(context.article);
if (!html) {
return {
status: "failed",
summary: "文章内容为空,无法推送到头条号。",
error: {
code: "article_content_empty",
message: "html_content is empty",
},
};
}
context.reportProgress("toutiao.upload_content_images");
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadContentImage(context, sourceUrl),
);
context.reportProgress("toutiao.upload_cover");
const cover = context.article.cover_asset_url?.trim()
? await uploadCover(context, context.article.cover_asset_url.trim())
: 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: 5,
source_author_uid: "",
time_format: "",
position: {},
}),
});
context.reportProgress("toutiao.submit");
const response = await sessionFetchJson<ToutiaoPublishResponse>(
context.session,
"https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231",
const result = await publishToutiaoArticle(
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
referer: "https://mp.toutiao.com/profile_v4/graphic/publish",
title: context.article.title,
html,
coverAssetUrl: context.article.cover_asset_url,
publishType: "publish",
},
{
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
fetchImageBlob,
uploadHtmlImages,
reportProgress(stage) {
context.reportProgress(`toutiao.${stage}`);
},
body,
},
);
const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : "";
if (response.code !== 0 || !articleId) {
if (!result.success) {
const summary =
result.code === "toutiaohao_not_logged_in"
? "头条号登录态失效,无法执行发布。"
: result.code === "article_content_empty"
? "文章内容为空,无法推送到头条号。"
: result.message || "头条号发布失败。";
return {
status: "failed",
summary: response.message || "头条号发布失败。",
summary,
error: {
code: "toutiaohao_publish_failed",
message: response.message || "toutiaohao_publish_failed",
code: result.code,
message: result.message,
},
};
}
const payload = buildResultPayload(articleId, context.article.publish_type, user.screen_name);
const draft = context.article.publish_type === "draft";
const payload = buildResultPayload(
result.articleId,
result.mediaName,
result.externalManageUrl,
result.externalArticleUrl,
);
return {
status: "succeeded",
summary: draft ? "头条号草稿已创建,等待人工审核。" : "头条号发布成功。",
payload,
reviewUrl: typeof payload.external_manage_url === "string" ? payload.external_manage_url : null,
summary: "头条号发布成功。",
};
},
};