diff --git a/src/clis/douyin/user-videos.test.ts b/src/clis/douyin/user-videos.test.ts new file mode 100644 index 00000000..8acd9093 --- /dev/null +++ b/src/clis/douyin/user-videos.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError, CommandExecutionError } from '../../errors.js'; +import { getRegistry } from '../../registry.js'; +import './user-videos.js'; + +function makePage(...evaluateResults: unknown[]) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn() + .mockImplementation(() => Promise.resolve(evaluateResults.shift())), + } as any; +} + +describe('douyin user-videos command', () => { + it('throws ArgumentError when limit is not a positive integer', async () => { + const cmd = getRegistry().get('douyin/user-videos'); + const page = makePage(); + + await expect(cmd!.func!(page, { sec_uid: 'test', limit: 0 })).rejects.toThrow(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('surfaces top-level Douyin API errors through browserFetch semantics', async () => { + const cmd = getRegistry().get('douyin/user-videos'); + const page = makePage({ status_code: 8, status_msg: 'bad uid' }); + + await expect(cmd!.func!(page, { sec_uid: 'bad', limit: 3 })).rejects.toThrow(CommandExecutionError); + expect(page.goto).toHaveBeenCalledWith('https://www.douyin.com/user/bad'); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('passes normalized limit to the API and preserves mapped rows', async () => { + const cmd = getRegistry().get('douyin/user-videos'); + const page = makePage( + { + aweme_list: [{ + aweme_id: '1', + desc: 'Video 1', + video: { duration: 2300, play_addr: { url_list: ['https://video.example/1.mp4'] } }, + statistics: { digg_count: 12 }, + }], + }, + [{ aweme_id: '1', desc: 'Video 1', video: { duration: 2300, play_addr: { url_list: ['https://video.example/1.mp4'] } }, statistics: { digg_count: 12 }, top_comments: [] }], + ); + + const rows = await cmd!.func!(page, { sec_uid: 'good', limit: 1 }); + + expect(page.evaluate).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('count=1'), + ); + expect(rows).toEqual([{ + index: 1, + aweme_id: '1', + title: 'Video 1', + duration: 2, + digg_count: 12, + play_url: 'https://video.example/1.mp4', + top_comments: [], + }]); + }); +}); diff --git a/src/clis/douyin/user-videos.ts b/src/clis/douyin/user-videos.ts new file mode 100644 index 00000000..14047e2c --- /dev/null +++ b/src/clis/douyin/user-videos.ts @@ -0,0 +1,88 @@ +import { cli, Strategy } from '../../registry.js'; +import { ArgumentError } from '../../errors.js'; +import type { IPage } from '../../types.js'; +import { browserFetch } from './_shared/browser-fetch.js'; + +cli({ + site: 'douyin', + name: 'user-videos', + description: '获取指定用户的视频列表(含下载地址和热门评论)', + domain: 'www.douyin.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'sec_uid', type: 'string', required: true, positional: true, help: '用户 sec_uid(URL 末尾部分)' }, + { name: 'limit', type: 'int', default: 20, help: '获取数量' }, + ], + columns: ['index', 'aweme_id', 'title', 'duration', 'digg_count', 'play_url', 'top_comments'], + func: async (page: IPage, kwargs) => { + const limit = Number(kwargs.limit); + if (!Number.isInteger(limit) || limit <= 0) { + throw new ArgumentError('limit must be a positive integer'); + } + + await page.goto(`https://www.douyin.com/user/${kwargs.sec_uid as string}`); + await page.wait(3); + + const params = new URLSearchParams({ + sec_user_id: String(kwargs.sec_uid), + max_cursor: '0', + count: String(limit), + aid: '6383', + }); + const data = await browserFetch( + page, + 'GET', + `https://www.douyin.com/aweme/v1/web/aweme/post/?${params.toString()}`, + ) as { aweme_list?: Array> }; + const awemeList = (data.aweme_list || []).slice(0, limit); + + const result = await page.evaluate(` + (async () => { + const awemeList = ${JSON.stringify(awemeList)}; + + const withComments = await Promise.all(awemeList.map(async (v) => { + try { + const cp = new URLSearchParams({ + aweme_id: String(v.aweme_id), + count: '10', + cursor: '0', + aid: '6383', + }); + const cr = await fetch('/aweme/v1/web/comment/list/?' + cp.toString(), { + credentials: 'include', + headers: { referer: 'https://www.douyin.com/' }, + }); + const cd = await cr.json(); + const comments = (cd.comments || []).slice(0, 10).map((c) => ({ + text: c.text, + digg_count: c.digg_count, + nickname: c.user && c.user.nickname, + })); + return { ...v, top_comments: comments }; + } catch { + return { ...v, top_comments: [] }; + } + })); + + return withComments; + })() + `) as Array>; + + return (result || []).map((v, i) => { + const video = v.video as Record | undefined; + const playAddr = video?.play_addr as Record | undefined; + const urlList = playAddr?.url_list as string[] | undefined; + const playUrl = urlList?.[0] ?? ''; + const statistics = v.statistics as Record | undefined; + return { + index: i + 1, + aweme_id: v.aweme_id as string, + title: v.desc as string, + duration: Math.round(((video?.duration as number) ?? 0) / 1000), + digg_count: (statistics?.digg_count as number) ?? 0, + play_url: playUrl, + top_comments: v.top_comments as unknown[], + }; + }); + }, +});