返回 AiToEarn
workDetail.ts
根目录 / project / aitoearn-web / src / store / plugin / plats / douyin / workDetail.ts
1 /**
2 * 抖音作品详情功能模块
3 *
4 * 抖音不需要额外请求详情API,直接从list的item数据获取详情
5 */
6
7 import type { GetWorkDetailParams, GetWorkDetailResult, TopicInfo, WorkDetail } from '../types'
8
9 /**
10 * 构建抖音作者主页链接
11 * @param secUid 作者的 sec_uid
12 */
13 function buildAuthorUrl(secUid: string): string {
14 const params = new URLSearchParams({
15 from_tab_name: 'main',
16 })
17 return `https://www.douyin.com/user/${secUid}?${params.toString()}`
18 }
19
20 /**
21 * 构建抖音话题搜索链接
22 * @param keyword 话题关键词
23 */
24 function buildTopicUrl(keyword: string): string {
25 return `https://www.douyin.com/jingxuan/search/${encodeURIComponent(keyword)}?type=general`
26 }
27
28 /**
29 * 从描述中提取话题标签
30 * @param desc 作品描述
31 * @returns 话题名称数组
32 */
33 function extractTopicsFromDesc(desc: string): string[] {
34 if (!desc)
35 return []
36 // 匹配 #话题 格式的标签(中文、英文、数字)
37 const regex = /#([^\s#]+)/g
38 const topics: string[] = []
39 let match
40 while ((match = regex.exec(desc)) !== null) { // eslint-disable-line no-cond-assign
41 topics.push(match[1])
42 }
43 return topics
44 }
45
46 /**
47 * 格式化数字(转换大数字为"万")
48 */
49 function formatCount(count: number | undefined): string {
50 if (!count)
51 return '0'
52 if (count >= 10000) {
53 return `${(count / 10000).toFixed(1)}万`
54 }
55 return String(count)
56 }
57
58 /**
59 * 将抖音原始数据转换为作品详情
60 * @param item 抖音原始作品数据(来自list的origin字段)
61 */
62 export function transformToWorkDetail(item: any): WorkDetail {
63 // 提取作者信息
64 const author = item.author || {}
65 const authorAvatarList = author.avatar_thumb?.url_list || author.avatar_medium?.url_list || []
66 const authorSecUid = author.sec_uid || ''
67
68 // 提取视频信息
69 const videoData = item.video || {}
70 const coverList = videoData.cover?.url_list || videoData.origin_cover?.url_list || []
71
72 // 提取统计信息
73 const statistics = item.statistics || {}
74
75 // 解析话题列表
76 const topicNames: string[] = []
77
78 if (item.cha_list && Array.isArray(item.cha_list)) {
79 item.cha_list.forEach((cha: any) => {
80 if (cha.cha_name) {
81 topicNames.push(cha.cha_name)
82 }
83 })
84 }
85
86 if (topicNames.length === 0 && item.text_extra && Array.isArray(item.text_extra)) {
87 item.text_extra.forEach((extra: any) => {
88 if (extra.hashtag_name) {
89 topicNames.push(extra.hashtag_name)
90 }
91 })
92 }
93
94 if (topicNames.length === 0) {
95 // 从描述中解析话题
96 topicNames.push(...extractTopicsFromDesc(item.desc || ''))
97 }
98
99 // 去重并构建话题对象
100 const uniqueTopics = [...new Set(topicNames)]
101 const topics: TopicInfo[] = uniqueTopics.map(name => ({
102 name,
103 url: buildTopicUrl(name),
104 }))
105
106 // 构建作者主页链接
107 const authorUrl = buildAuthorUrl(authorSecUid)
108
109 // 构建图片列表(抖音主要是视频,这里用封面)
110 const imageList
111 = coverList.length > 0
112 ? [
113 {
114 url: coverList[0],
115 width: videoData.width,
116 height: videoData.height,
117 },
118 ]
119 : []
120
121 // 构建视频信息
122 const video: WorkDetail['video'] = {
123 url: videoData.play_addr?.url_list?.[0] || '',
124 duration: videoData.duration ? Math.floor(videoData.duration / 1000) : undefined,
125 width: videoData.width,
126 height: videoData.height,
127 }
128
129 return {
130 workId: item.aweme_id || '',
131 type: 'video', // 抖音主要是视频
132 title: item.desc || item.preview_title || '',
133 description: item.desc || '',
134 coverUrl: coverList[0] || '',
135 imageList,
136 video,
137 author: {
138 id: author.uid || authorSecUid || '',
139 name: author.nickname || '',
140 avatar: authorAvatarList[0] || '',
141 url: authorUrl,
142 },
143 interactInfo: {
144 likeCount: formatCount(statistics.digg_count),
145 collectCount: formatCount(statistics.collect_count),
146 commentCount: formatCount(statistics.comment_count),
147 shareCount: formatCount(statistics.share_count),
148 isLiked: item.user_digged === 1,
149 isCollected: item.collect_stat === 1,
150 isFollowed: author.follow_status === 1,
151 },
152 topics,
153 publishTime: item.create_time ? item.create_time * 1000 : undefined,
154 origin: item,
155 }
156 }
157
158 /**
159 * 获取抖音作品详情
160 * 抖音直接从list的item数据获取详情,不需要额外请求API
161 * @param params 详情请求参数(需要包含 origin 字段,来自 HomeFeedItem.origin)
162 */
163 export async function getWorkDetail(params: GetWorkDetailParams): Promise<GetWorkDetailResult> {
164 const { workId, origin } = params
165
166 if (!workId) {
167 return {
168 success: false,
169 message: '作品ID不能为空',
170 }
171 }
172
173 // 如果传入了 origin 数据,直接使用
174 if (origin) {
175 return getWorkDetailFromListItem(origin)
176 }
177
178 // 没有传入 origin 数据,返回错误提示
179 return {
180 success: false,
181 message: '抖音详情需要从列表数据获取,请在参数中传入 origin 字段(来自 HomeFeedItem.origin)',
182 }
183 }
184
185 /**
186 * 从列表项获取作品详情
187 * @param listItemOrigin HomeFeedItem.origin 原始数据
188 */
189 export function getWorkDetailFromListItem(listItemOrigin: any): GetWorkDetailResult {
190 if (!listItemOrigin) {
191 return {
192 success: false,
193 message: '列表项数据不能为空',
194 }
195 }
196
197 if (!listItemOrigin.aweme_id) {
198 return {
199 success: false,
200 message: '无效的列表项数据',
201 }
202 }
203
204 try {
205 const detail = transformToWorkDetail(listItemOrigin)
206 return {
207 success: true,
208 detail,
209 }
210 }
211 catch (error) {
212 return {
213 success: false,
214 message: error instanceof Error ? error.message : '转换详情数据失败',
215 }
216 }
217 }
218
218 lines TYPESCRIPT