368 lines
9.4 KiB
TypeScript
368 lines
9.4 KiB
TypeScript
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; uploaded?: Map<string, string> }>
|
|
reportProgress?(
|
|
stage: 'media_info' | 'upload_content_images' | 'upload_cover' | 'submit',
|
|
): void | Promise<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_image_upload_failed'
|
|
| 'toutiaohao_publish_failed'
|
|
message: string
|
|
}
|
|
|
|
function extractHtmlImageSources(html: string): string[] {
|
|
const sources = new Set<string>()
|
|
for (const match of html.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
|
|
const source = match[2]?.trim()
|
|
if (source) {
|
|
sources.add(source)
|
|
}
|
|
}
|
|
return [...sources]
|
|
}
|
|
|
|
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, imageFileName(blob, 'cover'))
|
|
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, imageFileName(blob, 'cover'))
|
|
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, imageFileName(blob, 'image'))
|
|
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
|
|
}
|
|
|
|
function imageFileName(blob: Blob, baseName: string): string {
|
|
const type = blob.type.toLowerCase()
|
|
if (type === 'image/jpeg' || type === 'image/jpg') {
|
|
return `${baseName}.jpg`
|
|
}
|
|
return `${baseName}.png`
|
|
}
|
|
|
|
export async function publishToutiaoArticle(
|
|
input: ToutiaoPublishArticleInput,
|
|
transport: ToutiaoPublishTransport,
|
|
): Promise<ToutiaoPublishResult> {
|
|
await 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',
|
|
}
|
|
}
|
|
|
|
await transport.reportProgress?.('upload_content_images')
|
|
const processed = await transport.uploadHtmlImages(normalizedHtml, async (sourceUrl) =>
|
|
uploadContentImage(transport, sourceUrl),
|
|
)
|
|
const contentImageSources = extractHtmlImageSources(normalizedHtml)
|
|
const uploadedSources = processed.uploaded ?? new Map<string, string>()
|
|
const missingUpload = contentImageSources.find(
|
|
(source) => !uploadedSources.has(source) && processed.html.includes(source),
|
|
)
|
|
if (missingUpload) {
|
|
return {
|
|
success: false,
|
|
status: 'failed',
|
|
code: 'toutiaohao_image_upload_failed',
|
|
message: `toutiaohao_image_upload_failed: ${missingUpload}`,
|
|
}
|
|
}
|
|
|
|
await 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: {},
|
|
}),
|
|
})
|
|
|
|
await 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: '头条号发布成功。',
|
|
}
|
|
}
|