297 lines
7.8 KiB
TypeScript
297 lines
7.8 KiB
TypeScript
|
|
import type { JsonValue } from "@geo/shared-types";
|
||
|
|
|
||
|
|
import {
|
||
|
|
fetchImageBlob,
|
||
|
|
normalizeArticleHtml,
|
||
|
|
sessionFetchJson,
|
||
|
|
uploadHtmlImages,
|
||
|
|
} 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,
|
||
|
|
): 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,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export const toutiaoAdapter: PublishAdapter = {
|
||
|
|
platform: "toutiaohao",
|
||
|
|
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",
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
||
|
|
referer: "https://mp.toutiao.com/profile_v4/graphic/publish",
|
||
|
|
},
|
||
|
|
body,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : "";
|
||
|
|
if (response.code !== 0 || !articleId) {
|
||
|
|
return {
|
||
|
|
status: "failed",
|
||
|
|
summary: response.message || "头条号发布失败。",
|
||
|
|
error: {
|
||
|
|
code: "toutiaohao_publish_failed",
|
||
|
|
message: response.message || "toutiaohao_publish_failed",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const payload = buildResultPayload(articleId, context.article.publish_type, user.screen_name);
|
||
|
|
const draft = context.article.publish_type === "draft";
|
||
|
|
return {
|
||
|
|
status: "succeeded",
|
||
|
|
summary: draft ? "头条号草稿已创建,等待人工审核。" : "头条号发布成功。",
|
||
|
|
payload,
|
||
|
|
reviewUrl: typeof payload.external_manage_url === "string" ? payload.external_manage_url : null,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|