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:
@@ -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);
|
||||
|
||||
@@ -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: "头条号发布成功。",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user