返回 AiToEarn
publish.schema.ts
1 import type { Locale } from '@yikart/common'
2 import { AccountType } from '@yikart/common'
3 import { z } from 'zod'
4 import { BilibiliOptionSchema } from './bilibili/bilibili.schema'
5 import { DouyinOptionSchema } from './douyin/douyin.schema'
6 import { FacebookOptionSchema } from './facebook/facebook.schema'
7 import { InstagramOptionSchema } from './instagram/instagram.schema'
8 import { KwaiOptionSchema } from './kwai/kwai.schema'
9 import { LinkedInOptionSchema } from './linkedin/linkedin.schema'
10 import { PinterestOptionSchema } from './pinterest/pinterest.schema'
11 import { PublishContentMode } from './platforms.interface'
12 import { RedNoteOptionSchema } from './rednote/rednote.schema'
13 import { ThreadsOptionSchema } from './threads/threads.schema'
14 import { TiktokOptionSchema } from './tiktok/tiktok.schema'
15 import { TwitterOptionSchema } from './twitter/twitter.schema'
16 import { WeChatChannelsOptionSchema } from './wechat/wechat-channels/wechat-channels.schema'
17 import { WeChatOfficialOptionSchema } from './wechat/wechat-official/wechat-official.schema'
18 import { YoutubeOptionSchema } from './youtube/youtube.schema'
19
20 export enum PublishValidationIssueCode {
21 Required = 'required',
22 TooBig = 'too_big',
23 TooSmall = 'too_small',
24 InvalidCombination = 'invalid_combination',
25 UnsupportedFormat = 'unsupported_format',
26 UnsupportedContentMode = 'unsupported_content_mode',
27 InvalidDuration = 'invalid_duration',
28 InvalidUrl = 'invalid_url',
29 InvalidOption = 'invalid_option',
30 }
31
32 export enum PublishValidationField {
33 Post = 'post',
34 Title = 'title',
35 Body = 'body',
36 Text = 'text',
37 Media = 'media',
38 Image = 'image',
39 Video = 'video',
40 Cover = 'cover',
41 Topic = 'topic',
42 Url = 'url',
43 Option = 'option',
44 }
45
46 export enum PublishValidationCombination {
47 ImageVideo = 'image_video',
48 ReelImage = 'reel_image',
49 }
50
51 export interface PublishValidationIssue {
52 code: PublishValidationIssueCode
53 path: Array<string | number>
54 message?: string
55 params?: Record<string, unknown>
56 }
57
58 export function formatPublishValidationIssue(
59 issue: PublishValidationIssue,
60 locale: Locale,
61 ): PublishValidationIssue {
62 return {
63 ...issue,
64 message: getPublishValidationIssueMessage(issue, locale),
65 }
66 }
67
68 function getPublishValidationIssueMessage(issue: PublishValidationIssue, locale: Locale): string {
69 const label = getFieldLabel(getIssueField(issue), locale)
70
71 switch (issue.code) {
72 case PublishValidationIssueCode.Required:
73 return locale === 'zh-CN'
74 ? `${label}为必填项`
75 : `${label} is required`
76
77 case PublishValidationIssueCode.TooBig:
78 return getLimitMessage(issue, label, 'maximum', locale)
79
80 case PublishValidationIssueCode.TooSmall:
81 return getLimitMessage(issue, label, 'minimum', locale)
82
83 case PublishValidationIssueCode.InvalidCombination:
84 return getCombinationMessage(issue, locale)
85
86 case PublishValidationIssueCode.UnsupportedFormat:
87 return locale === 'zh-CN'
88 ? `${label}格式不支持,支持格式:${getFormatsParam(issue)}`
89 : `${label} format is not supported. Supported formats: ${getFormatsParam(issue)}`
90
91 case PublishValidationIssueCode.UnsupportedContentMode:
92 return locale === 'zh-CN'
93 ? `平台不支持${getContentModeLabel(issue, locale)}发布`
94 : `Platform does not support ${getContentModeLabel(issue, locale)} publishing`
95
96 case PublishValidationIssueCode.InvalidDuration:
97 return getDurationMessage(issue, label, locale)
98
99 case PublishValidationIssueCode.InvalidUrl:
100 return locale === 'zh-CN'
101 ? `${label}必须使用 HTTPS`
102 : `${label} must use HTTPS`
103
104 case PublishValidationIssueCode.InvalidOption:
105 return locale === 'zh-CN'
106 ? `${label}无效`
107 : `${label} is invalid`
108 }
109 }
110
111 function getFieldLabel(field: PublishValidationField, locale: Locale): string {
112 switch (field) {
113 case PublishValidationField.Title:
114 return locale === 'zh-CN' ? '标题' : 'Title'
115
116 case PublishValidationField.Body:
117 return locale === 'zh-CN' ? '正文' : 'Body'
118
119 case PublishValidationField.Text:
120 return locale === 'zh-CN' ? '文本' : 'Text'
121
122 case PublishValidationField.Media:
123 return locale === 'zh-CN' ? '媒体' : 'Media'
124
125 case PublishValidationField.Image:
126 return locale === 'zh-CN' ? '图片' : 'Image'
127
128 case PublishValidationField.Video:
129 return locale === 'zh-CN' ? '视频' : 'Video'
130
131 case PublishValidationField.Cover:
132 return locale === 'zh-CN' ? '封面' : 'Cover'
133
134 case PublishValidationField.Topic:
135 return locale === 'zh-CN' ? '话题' : 'Topic'
136
137 case PublishValidationField.Url:
138 return locale === 'zh-CN' ? '链接' : 'URL'
139
140 case PublishValidationField.Option:
141 return locale === 'zh-CN' ? '发布选项' : 'Publish option'
142
143 case PublishValidationField.Post:
144 return locale === 'zh-CN' ? '发布内容' : 'Post content'
145 }
146 }
147
148 function getIssueField(issue: PublishValidationIssue): PublishValidationField {
149 const field = issue.params?.['field']
150 if (isPublishValidationField(field)) {
151 return field
152 }
153
154 const lastPath = issue.path[issue.path.length - 1]
155 if (isPublishValidationField(lastPath)) {
156 return lastPath
157 }
158
159 return PublishValidationField.Post
160 }
161
162 function isPublishValidationField(value: unknown): value is PublishValidationField {
163 return typeof value === 'string'
164 && Object.values(PublishValidationField).includes(value as PublishValidationField)
165 }
166
167 function getCombinationMessage(issue: PublishValidationIssue, locale: Locale): string {
168 if (issue.params?.['combination'] === PublishValidationCombination.ReelImage) {
169 return locale === 'zh-CN'
170 ? 'Reels 不支持图片媒体'
171 : 'Reels do not support image media'
172 }
173
174 return locale === 'zh-CN'
175 ? '不能在同一篇发布中混合图片和视频'
176 : 'Images and videos cannot be mixed in one post'
177 }
178
179 function getNumberParam(issue: PublishValidationIssue, key: string): number {
180 const value = issue.params?.[key]
181 return Number(value ?? 0)
182 }
183
184 function getOptionalNumberParam(issue: PublishValidationIssue, key: string): number | undefined {
185 const value = issue.params?.[key]
186 return value === undefined ? undefined : Number(value)
187 }
188
189 function getDurationMessage(issue: PublishValidationIssue, label: string, locale: Locale): string {
190 const minimum = getOptionalNumberParam(issue, 'minimum')
191 const maximum = getOptionalNumberParam(issue, 'maximum')
192 if (minimum !== undefined && maximum !== undefined) {
193 return locale === 'zh-CN'
194 ? `${label}时长必须在 ${minimum} 到 ${maximum} 秒之间`
195 : `${label} duration must be between ${minimum} and ${maximum} seconds`
196 }
197 if (minimum !== undefined) {
198 return locale === 'zh-CN'
199 ? `${label}时长不能少于 ${minimum} 秒`
200 : `${label} duration must be at least ${minimum} seconds`
201 }
202 if (maximum !== undefined) {
203 return locale === 'zh-CN'
204 ? `${label}时长不能超过 ${maximum} 秒`
205 : `${label} duration must be at most ${maximum} seconds`
206 }
207 return locale === 'zh-CN'
208 ? `${label}时长无效`
209 : `${label} duration is invalid`
210 }
211
212 function getLimitMessage(issue: PublishValidationIssue, label: string, boundKey: 'minimum' | 'maximum', locale: Locale): string {
213 const bound = getNumberParam(issue, boundKey)
214 if (issue.params?.['dimension'] === 'aspectRatio') {
215 const current = getOptionalNumberParam(issue, 'current')
216 const currentText = current === undefined
217 ? ''
218 : locale === 'zh-CN'
219 ? `,当前为 ${current}`
220 : `, current is ${current}`
221
222 if (boundKey === 'minimum') {
223 return locale === 'zh-CN'
224 ? `${label}宽高比不能少于 ${bound}${currentText}`
225 : `${label} aspect ratio must be at least ${bound}${currentText}`
226 }
227
228 return locale === 'zh-CN'
229 ? `${label}宽高比不能超过 ${bound}${currentText}`
230 : `${label} aspect ratio must be at most ${bound}${currentText}`
231 }
232
233 const unitLabel = getUnitLabel(issue, locale)
234 if (boundKey === 'minimum') {
235 return locale === 'zh-CN'
236 ? `${label}不能少于 ${bound} ${unitLabel}`
237 : `${label} must be at least ${bound} ${unitLabel}`
238 }
239 return locale === 'zh-CN'
240 ? `${label}不能超过 ${bound} ${unitLabel}`
241 : `${label} must be at most ${bound} ${unitLabel}`
242 }
243
244 function getFormatsParam(issue: PublishValidationIssue): string {
245 const formats = issue.params?.['formats'] ?? issue.params?.['allowed']
246 if (Array.isArray(formats)) {
247 return formats.filter((format): format is string => typeof format === 'string').join(', ')
248 }
249 if (typeof formats === 'string') {
250 return formats
251 }
252 return ''
253 }
254
255 function getContentModeLabel(issue: PublishValidationIssue, locale: Locale): string {
256 const mode = issue.params?.['mode']
257 if (mode === 'text' || mode === PublishContentMode.Text) {
258 return locale === 'zh-CN' ? '纯文本' : 'text'
259 }
260 if (mode === 'image_text' || mode === PublishContentMode.ImageText) {
261 return locale === 'zh-CN' ? '图文' : 'image-text'
262 }
263 if (mode === 'video' || mode === PublishContentMode.Video) {
264 return locale === 'zh-CN' ? '视频' : 'video'
265 }
266 return locale === 'zh-CN' ? '该内容模式' : 'this content mode'
267 }
268
269 function getUnitLabel(issue: PublishValidationIssue, locale: Locale): string {
270 const unit = issue.params?.['unit']
271 if (unit === 'characters') {
272 return locale === 'zh-CN' ? '个字符' : 'characters'
273 }
274 if (unit === 'items') {
275 return locale === 'zh-CN' ? '项' : 'items'
276 }
277 if (unit === 'bytes') {
278 return locale === 'zh-CN' ? '字节' : 'bytes'
279 }
280 if (unit === 'pixels') {
281 return locale === 'zh-CN' ? '像素' : 'pixels'
282 }
283 if (unit === 'seconds') {
284 return locale === 'zh-CN' ? '秒' : 'seconds'
285 }
286 return ''
287 }
288
289 export function createPlatformPublishOptionItemSchema<TShape extends z.ZodRawShape>(extraShape: TShape) {
290 return z.discriminatedUnion('platform', [
291 z.object({
292 platform: z.literal(AccountType.Bilibili).describe('平台'),
293 option: BilibiliOptionSchema.describe('哔哩哔哩发布选项'),
294 ...extraShape,
295 }),
296 z.object({
297 platform: z.literal(AccountType.YouTube).describe('平台'),
298 option: YoutubeOptionSchema.optional().describe('YouTube 发布选项'),
299 ...extraShape,
300 }),
301 z.object({
302 platform: z.literal(AccountType.WeChatOfficial).describe('平台'),
303 option: WeChatOfficialOptionSchema.optional().describe('微信公众号发布选项'),
304 ...extraShape,
305 }),
306 z.object({
307 platform: z.literal(AccountType.Facebook).describe('平台'),
308 option: FacebookOptionSchema.optional().describe('Facebook 发布选项'),
309 ...extraShape,
310 }),
311 z.object({
312 platform: z.literal(AccountType.Instagram).describe('平台'),
313 option: InstagramOptionSchema.optional().describe('Instagram 发布选项'),
314 ...extraShape,
315 }),
316 z.object({
317 platform: z.literal(AccountType.Threads).describe('平台'),
318 option: ThreadsOptionSchema.optional().describe('Threads 发布选项'),
319 ...extraShape,
320 }),
321 z.object({
322 platform: z.literal(AccountType.Pinterest).describe('平台'),
323 option: PinterestOptionSchema.describe('Pinterest 发布选项'),
324 ...extraShape,
325 }),
326 z.object({
327 platform: z.literal(AccountType.TikTok).describe('平台'),
328 option: TiktokOptionSchema.optional().describe('TikTok 发布选项'),
329 ...extraShape,
330 }),
331 z.object({
332 platform: z.literal(AccountType.Douyin).describe('平台'),
333 option: DouyinOptionSchema.optional().describe('抖音发布选项'),
334 ...extraShape,
335 }),
336 z.object({
337 platform: z.literal(AccountType.Twitter).describe('平台'),
338 option: TwitterOptionSchema.optional().describe('Twitter / X 发布选项'),
339 ...extraShape,
340 }),
341 z.object({
342 platform: z.literal(AccountType.Kwai).describe('平台'),
343 option: KwaiOptionSchema.optional().describe('快手发布选项'),
344 ...extraShape,
345 }),
346 z.object({
347 platform: z.literal(AccountType.LinkedIn).describe('平台'),
348 option: LinkedInOptionSchema.optional().describe('LinkedIn 发布选项'),
349 ...extraShape,
350 }),
351 z.object({
352 platform: z.literal(AccountType.RedNote).describe('平台'),
353 option: RedNoteOptionSchema.describe('小红书发布选项'),
354 ...extraShape,
355 }),
356 z.object({
357 platform: z.literal(AccountType.WeChatChannels).describe('平台'),
358 option: WeChatChannelsOptionSchema.optional().describe('微信视频号发布选项'),
359 ...extraShape,
360 }),
361 ])
362 }
363
364 export const PlatformPublishOptionItemSchema = createPlatformPublishOptionItemSchema({})
365
366 const topicPattern = /#([\w\p{Script=Han}]+)/gu
367
368 export function parseTopicsFromBody(body?: string): string[] {
369 if (!body)
370 return []
371 const topics: string[] = []
372 for (const match of body.matchAll(topicPattern)) {
373 const topic = match[1]
374 if (topic && !topics.includes(topic)) {
375 topics.push(topic)
376 }
377 }
378 return topics
379 }
380
381 export function stripTopicsFromBody(body?: string): string | undefined {
382 if (body === undefined)
383 return undefined
384 return body
385 .replace(topicPattern, '')
386 .replace(/[ \t]+/g, ' ')
387 .replace(/[ \t]+\n/g, '\n')
388 .replace(/\n[ \t]+/g, '\n')
389 .replace(/\n{3,}/g, '\n\n')
390 .trim()
391 }
392
393 export function parseTopicInsertionsFromBody(body?: string): Array<{ name: string, start: number }> {
394 if (!body)
395 return []
396
397 const insertions: Array<{ name: string, start: number }> = []
398 const seen = new Set<string>()
399 for (const match of body.matchAll(topicPattern)) {
400 const name = match[1]
401 if (!name || seen.has(name)) {
402 continue
403 }
404 seen.add(name)
405 insertions.push({
406 name,
407 start: stripTopicsFromBody(body.slice(0, match.index))?.length ?? 0,
408 })
409 }
410 return insertions
411 }
412
413 export type PlatformPublishOptionItem = z.infer<typeof PlatformPublishOptionItemSchema>
414
414 lines TYPESCRIPT