返回 AiToEarn
homeFeed.ts
根目录 / project / aitoearn-web / src / store / plugin / plats / douyin / homeFeed.ts
1 /**
2 * 抖音首页列表功能模块
3 *
4 * 通过插件调用抖音 API 获取首页推荐作品列表
5 */
6
7 import type { HomeFeedItem, HomeFeedListParams, HomeFeedListResult } from '../types'
8
9 /**
10 * 深度过滤对象中的 null 值字段
11 * @param obj 要过滤的对象
12 * @returns 过滤后的对象
13 */
14 function filterNullValues<T>(obj: T): T {
15 if (obj === null || obj === undefined) {
16 return obj
17 }
18
19 if (Array.isArray(obj)) {
20 return obj.map(item => filterNullValues(item)) as T
21 }
22
23 if (typeof obj === 'object') {
24 const result: Record<string, unknown> = {}
25 for (const [key, value] of Object.entries(obj)) {
26 if (value !== null) {
27 result[key] = filterNullValues(value)
28 }
29 }
30 return result as T
31 }
32
33 return obj
34 }
35
36 /**
37 * 抖音首页列表 API 基础 URL
38 */
39 const DOUYIN_FEED_API = 'https://www.douyin.com/aweme/v2/web/module/feed/'
40
41 /**
42 * 首页列表游标管理器
43 * 抖音分页使用 refresh_index 自增方式
44 */
45 class HomeFeedCursorManager {
46 /**
47 * 当前刷新索引
48 */
49 private refreshIndex = 1
50
51 /**
52 * 当前已请求的最大页码
53 */
54 private maxRequestedPage = 0
55
56 /**
57 * 重置游标缓存
58 */
59 reset(): void {
60 this.refreshIndex = 1
61 this.maxRequestedPage = 0
62 }
63
64 /**
65 * 获取刷新索引
66 */
67 getRefreshIndex(page: number): number {
68 // 第一页返回 1,后续页面返回累积的刷新索引
69 if (page === 1) {
70 return 1
71 }
72 return this.refreshIndex
73 }
74
75 /**
76 * 更新刷新索引
77 */
78 updateRefreshIndex(currentPage: number): void {
79 this.refreshIndex = currentPage + 1
80 this.maxRequestedPage = currentPage
81 }
82
83 /**
84 * 获取最大已请求页码
85 */
86 getMaxRequestedPage(): number {
87 return this.maxRequestedPage
88 }
89 }
90
91 /**
92 * 游标管理器实例
93 */
94 export const homeFeedCursor = new HomeFeedCursorManager()
95
96 /**
97 * 构建抖音首页列表请求 URL
98 * @param params 分页参数
99 * @param refreshIndex 刷新索引
100 */
101 function buildFeedRequestUrl(params: HomeFeedListParams, refreshIndex: number): string {
102 const { size } = params
103
104 // 基础查询参数
105 const queryParams = new URLSearchParams({
106 'device_platform': 'webapp',
107 'aid': '6383',
108 'channel': 'channel_pc_web',
109 'module_id': '3003101',
110 'count': String(size),
111 'filterGids': '',
112 'presented_ids': '',
113 'refresh_index': String(refreshIndex),
114 'refer_id': '',
115 'refer_type': '10',
116 'pull_type': '0',
117 'awemePcRecRawData': JSON.stringify({
118 is_xigua_user: 0,
119 danmaku_switch_status: 0,
120 is_client: false,
121 }),
122 'Seo-Flag': '0',
123 'tag_id': '300204',
124 'active_id': '',
125 'is_active_tab': 'false',
126 'use_lite_type': '1',
127 'xigua_user': '0',
128 'pc_client_type': '1',
129 'pc_libra_divert': 'Windows',
130 'update_version_code': '170400',
131 'support_h265': '1',
132 'support_dash': '1',
133 'version_code': '170400',
134 'version_name': '17.4.0',
135 'cookie_enabled': 'true',
136 'screen_width': '1920',
137 'screen_height': '1080',
138 'browser_language': 'zh-CN',
139 'browser_platform': 'Win32',
140 'browser_name': 'Chrome',
141 'browser_version': '120.0.0.0',
142 'browser_online': 'true',
143 'engine_name': 'Blink',
144 'engine_version': '120.0.0.0',
145 'os_name': 'Windows',
146 'os_version': '10',
147 'cpu_core_num': '8',
148 'device_memory': '8',
149 'platform': 'PC',
150 'downlink': '10',
151 'effective_type': '4g',
152 'round_trip_time': '50',
153 })
154
155 return `${DOUYIN_FEED_API}?${queryParams.toString()}`
156 }
157
158 /**
159 * 构建抖音作者主页链接
160 * @param secUid 作者的 sec_uid
161 */
162 function buildAuthorUrl(secUid: string): string {
163 const params = new URLSearchParams({
164 from_tab_name: 'main',
165 })
166 return `https://www.douyin.com/user/${secUid}?${params.toString()}`
167 }
168
169 /**
170 * 将抖音原始数据转换为统一格式
171 * 注:list不包含收藏数和话题,这些在详情中获取
172 * @param item 抖音原始作品数据(any 类型,字段太多不写类型)
173 */
174 export function transformToHomeFeedItem(item: any): HomeFeedItem {
175 // 提取作者信息
176 const author = item.author || {}
177 const authorAvatarList = author.avatar_thumb?.url_list || author.avatar_medium?.url_list || []
178 const authorSecUid = author.sec_uid || ''
179
180 // 提取视频信息
181 const video = item.video || {}
182 const coverList = video.cover?.url_list || video.origin_cover?.url_list || []
183
184 // 提取统计信息
185 const statistics = item.statistics || {}
186
187 // 格式化点赞数(转换大数字为"万")
188 const formatLikeCount = (count: number): string => {
189 if (count >= 10000) {
190 return `${(count / 10000).toFixed(1)}万`
191 }
192 return String(count || 0)
193 }
194
195 // 提取封面尺寸
196 const cover = video.cover || {}
197
198 // 构建作者主页链接
199 const authorUrl = buildAuthorUrl(authorSecUid)
200
201 return {
202 workId: item.aweme_id || '',
203 thumbnail: coverList[0] || '',
204 thumbnailWidth: cover.width,
205 thumbnailHeight: cover.height,
206 title: item.desc || item.preview_title || '',
207 authorAvatar: authorAvatarList[0] || '',
208 authorName: author.nickname || '',
209 authorId: author.uid || authorSecUid || '',
210 authorUrl,
211 likeCount: formatLikeCount(statistics.digg_count || 0),
212 isFollowed: author.follow_status === 1,
213 isLiked: item.user_digged === 1,
214 isVideo: true, // 抖音主要是视频
215 videoDuration: video.duration ? Math.floor(video.duration / 1000) : undefined,
216 origin: filterNullValues(item),
217 }
218 }
219
220 /**
221 * 获取首页作品列表
222 * 通过插件调用抖音 API
223 * @param params 分页参数
224 */
225 export async function getHomeFeedList(params: HomeFeedListParams): Promise<HomeFeedListResult> {
226 // 检查插件是否可用
227 if (!window.AIToEarnPlugin) {
228 return {
229 success: false,
230 message: '插件未安装或未就绪',
231 items: [],
232 hasMore: false,
233 }
234 }
235
236 const { page, size } = params
237
238 // 页码从1开始
239 if (page < 1) {
240 return {
241 success: false,
242 message: '页码必须大于0',
243 items: [],
244 hasMore: false,
245 }
246 }
247
248 // 检查跳页情况
249 const maxPage = homeFeedCursor.getMaxRequestedPage()
250 if (page > maxPage + 1) {
251 return {
252 success: false,
253 message: `不支持跳页,请先请求第 ${maxPage + 1} 页`,
254 items: [],
255 hasMore: false,
256 }
257 }
258
259 // 如果是第1页,重置游标
260 if (page === 1) {
261 homeFeedCursor.reset()
262 }
263
264 // 获取当前页的刷新索引
265 const refreshIndex = homeFeedCursor.getRefreshIndex(page)
266
267 // 构建请求 URL
268 const requestUrl = buildFeedRequestUrl(params, refreshIndex)
269
270 try {
271 // 调用插件的抖音请求接口
272 const response = await window.AIToEarnPlugin.douyinRequest<any>({
273 path: requestUrl,
274 method: 'POST',
275 })
276
277 // 检查响应状态
278 if (response.status_code !== 0) {
279 return {
280 success: false,
281 message: response.status_msg || '获取首页列表失败',
282 items: [],
283 hasMore: false,
284 rawData: response,
285 }
286 }
287
288 // 获取作品列表
289 const awemeList = response.aweme_list || []
290
291 // 转换数据格式
292 const items: HomeFeedItem[] = awemeList
293 .filter((item: any) => item.aweme_id) // 过滤无效数据
294 .map((item: any) => transformToHomeFeedItem(item))
295
296 // 更新游标
297 homeFeedCursor.updateRefreshIndex(page)
298
299 // 判断是否有更多数据
300 const hasMore = response.has_more === 1 && items.length >= size
301
302 return {
303 success: true,
304 items,
305 hasMore,
306 rawData: response,
307 }
308 }
309 catch (error) {
310 return {
311 success: false,
312 message: error instanceof Error ? error.message : '请求失败',
313 items: [],
314 hasMore: false,
315 }
316 }
317 }
318
318 lines TYPESCRIPT