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
@@ -0,0 +1,9 @@
{
"name": "@geo/publisher-platforms",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
}
}
@@ -0,0 +1 @@
export * from "./toutiao";
+320
View File
@@ -0,0 +1,320 @@
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;
};
};
export interface ToutiaoMediaSnapshot {
platformUid: string | null;
screenName: string | null;
avatarUrl: string | null;
hasAdPermission: boolean;
}
export interface ToutiaoPublishArticleInput {
title: string;
html: string;
coverAssetUrl?: string | null;
publishType: "publish" | "draft";
}
export interface ToutiaoPublishTransport {
fetchJson<T>(input: string, init?: RequestInit): Promise<T>;
fetchImageBlob(sourceUrl: string): Promise<Blob | null>;
uploadHtmlImages(
html: string,
uploader: (sourceUrl: string) => Promise<string | null>,
): Promise<{ html: string }>;
reportProgress?(stage: "media_info" | "upload_content_images" | "upload_cover" | "submit"): void;
}
export type ToutiaoPublishResult =
| {
success: true;
status: "success" | "pending_review";
articleId: string;
mediaName: string;
externalManageUrl: string;
externalArticleUrl: string | null;
message: string;
}
| {
success: false;
status: "failed";
code: "toutiaohao_not_logged_in" | "article_content_empty" | "toutiaohao_publish_failed";
message: string;
};
export async function fetchToutiaoMediaSnapshot(
fetchJson: <T>(input: string, init?: RequestInit) => Promise<T>,
): Promise<ToutiaoMediaSnapshot | null> {
const response = await fetchJson<ToutiaoMediaInfoResponse>(
"https://mp.toutiao.com/mp/agw/media/get_media_info",
{
headers: {
accept: "application/json, text/plain, */*",
},
},
).catch(() => null);
const user = response?.data?.user;
if (!user?.id_str || !user.screen_name) {
return null;
}
return {
platformUid: user.id_str,
screenName: user.screen_name,
avatarUrl: user.https_avatar_url ?? null,
hasAdPermission: Boolean(
response?.data?.media?.has_third_party_ad_permission
|| response?.data?.media?.has_toutiao_ad_permission,
),
};
}
async function uploadCover(
transport: ToutiaoPublishTransport,
sourceUrl: string,
publishType: "publish" | "draft",
) {
const blob = await transport.fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
if (publishType === "publish") {
const form = new FormData();
form.append("upfile", blob, "cover.png");
const uploaded = await transport.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 firstForm = new FormData();
firstForm.append("image", blob, "cover.png");
const first = await transport.fetchJson<ToutiaoSpiceImageResponse>(
"https://mp.toutiao.com/spice/image?device_platform=web",
{
method: "POST",
body: firstForm,
},
).catch(() => null);
const imageUrl = first?.data?.image_url;
if (!imageUrl) {
return null;
}
const secondForm = new FormData();
secondForm.append("imageUrl", imageUrl);
const final = await transport.fetchJson<ToutiaoSpiceImageResponse>(
"https://mp.toutiao.com/spice/image?device_platform=web&need_cover_url=1",
{
method: "POST",
body: secondForm,
},
).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(
transport: ToutiaoPublishTransport,
sourceUrl: string,
): Promise<string | null> {
const blob = await transport.fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
const form = new FormData();
form.append("image", blob, "image.png");
const uploaded = await transport.fetchJson<ToutiaoSpiceImageResponse>(
"https://mp.toutiao.com/spice/image?device_platform=web",
{
method: "POST",
body: form,
},
).catch(() => null);
return uploaded?.data?.image_url ?? null;
}
export async function publishToutiaoArticle(
input: ToutiaoPublishArticleInput,
transport: ToutiaoPublishTransport,
): Promise<ToutiaoPublishResult> {
transport.reportProgress?.("media_info");
const media = await fetchToutiaoMediaSnapshot(transport.fetchJson);
if (!media?.platformUid || !media.screenName) {
return {
success: false,
status: "failed",
code: "toutiaohao_not_logged_in",
message: "未检测到头条号登录态",
};
}
const normalizedHtml = input.html.trim();
if (!normalizedHtml) {
return {
success: false,
status: "failed",
code: "article_content_empty",
message: "html_content is empty",
};
}
transport.reportProgress?.("upload_content_images");
const processed = await transport.uploadHtmlImages(
normalizedHtml,
async (sourceUrl) => uploadContentImage(transport, sourceUrl),
);
transport.reportProgress?.("upload_cover");
const cover = input.coverAssetUrl?.trim()
? await uploadCover(transport, input.coverAssetUrl.trim(), input.publishType)
: null;
const body = new URLSearchParams({
content: processed.html,
title: input.title,
mp_editor_stat: JSON.stringify({ code_block: 1 }),
is_refute_rumor: "0",
save: input.publishType === "publish" ? "1" : "0",
timer_status: "0",
draft_form_data: JSON.stringify({ coverType: 1 }),
article_ad_type: media.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: {},
}),
});
transport.reportProgress?.("submit");
const response: ToutiaoPublishResponse = await transport.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,
},
).catch((error) => ({
code: -1,
message: error instanceof Error ? error.message : "toutiaohao_publish_failed",
}));
const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : "";
if (response.code !== 0 || !articleId) {
return {
success: false,
status: "failed",
code: "toutiaohao_publish_failed",
message: response.message || "toutiaohao_publish_failed",
};
}
if (input.publishType === "draft") {
return {
success: true,
status: "pending_review",
articleId,
mediaName: media.screenName,
externalManageUrl: "https://mp.toutiao.com/profile_v4/graphic/publish",
externalArticleUrl: null,
message: "头条号草稿保存成功。",
};
}
return {
success: true,
status: "success",
articleId,
mediaName: media.screenName,
externalManageUrl: "https://mp.toutiao.com/profile_v4/index",
externalArticleUrl: `https://www.toutiao.com/article/${articleId}/`,
message: "头条号发布成功。",
};
}
+23 -10
View File
@@ -80,6 +80,7 @@ export interface DesktopClientInfo {
}
export interface DesktopClientRegisterRequest {
client_id?: string;
device_name: string;
os: string;
cpu_arch?: string;
@@ -100,6 +101,7 @@ export interface DesktopClientHeartbeatRequest {
cpu_arch?: string;
client_version?: string;
channel?: string;
account_ids?: string[];
}
export interface DesktopClientHeartbeatResponse {
@@ -118,6 +120,7 @@ export interface DesktopAccountInfo {
platform: string;
platform_uid: string;
display_name: string;
avatar_url: string | null;
health: "live" | "expired" | "captcha" | "risk";
client_id: string | null;
account_fingerprint: string | null;
@@ -126,6 +129,9 @@ export interface DesktopAccountInfo {
sync_version: number;
deleted_at: string | null;
delete_requested_at: string | null;
client_online: boolean | null;
client_last_seen_at: string | null;
client_device_name: string | null;
}
export interface UpsertDesktopAccountRequest {
@@ -133,6 +139,7 @@ export interface UpsertDesktopAccountRequest {
platform_uid: string;
account_fingerprint?: string | null;
display_name: string;
avatar_url?: string | null;
health: "live" | "expired" | "captcha" | "risk";
verified_at?: string | null;
tags?: string[];
@@ -157,18 +164,32 @@ export interface DesktopTaskInfo {
platform: string;
kind: "publish" | "monitor";
payload: Record<string, JsonValue> | null;
status: "queued" | "in_progress" | "waiting_user" | "succeeded" | "failed" | "unknown" | "aborted";
status: "queued" | "in_progress" | "succeeded" | "failed" | "unknown" | "aborted";
dedup_key: string | null;
active_attempt_id: string | null;
lease_expires_at: string | null;
attempts: number;
parked_reason: string | null;
result: Record<string, JsonValue> | null;
error: Record<string, JsonValue> | null;
created_at: string;
updated_at: string;
}
export interface ListDesktopPublishTasksParams {
page?: number;
page_size?: number;
title?: string;
}
export interface DesktopPublishTaskListResponse {
items: DesktopTaskInfo[];
page: number;
page_size: number;
total: number;
pending_count: number;
history_total: number;
}
export interface LeaseDesktopTaskRequest {
task_id?: string;
kind?: "publish" | "monitor";
@@ -185,11 +206,6 @@ export interface ExtendDesktopTaskRequest {
lease_token: string;
}
export interface ParkDesktopTaskRequest {
lease_token: string;
reason: "waiting_user" | "captcha";
}
export interface CompleteDesktopTaskRequest {
lease_token: string;
status: "succeeded" | "failed" | "unknown";
@@ -213,7 +229,6 @@ export interface DesktopTaskEventMessage {
| "task_available"
| "task_leased"
| "task_extended"
| "task_parked"
| "task_completed"
| "task_canceled"
| "task_reconciled";
@@ -224,7 +239,6 @@ export interface DesktopTaskEventMessage {
status:
| "queued"
| "in_progress"
| "waiting_user"
| "succeeded"
| "failed"
| "unknown"
@@ -250,7 +264,6 @@ export interface DesktopRuntimeSessionSyncRequest {
export interface CreatePublishJobAccountRequest {
account_id: string;
mode: "auto" | "manual";
}
export interface CreatePublishJobRequest {