From 8f38ef33250da907acf32a5c607cc2a603786ac1 Mon Sep 17 00:00:00 2001 From: Howard Wang Date: Sat, 28 Mar 2026 18:53:45 +0800 Subject: [PATCH 1/3] feat(douyin): add user-videos command with top-10 comments Adds a new adapter for fetching a public user's video list by sec_uid, alongside the top-10 hottest comments for each video. - Navigates to the user's profile page to establish a cookie session - Fetches video list via /aweme/v1/web/aweme/post/ - Concurrently fetches top-10 comments per video via /aweme/v1/web/comment/list/ (sorted by hotness, API default) Output columns: index, aweme_id, title, duration, digg_count, play_url, top_comments --- src/clis/douyin/user-videos.ts | 82 ++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/clis/douyin/user-videos.ts diff --git a/src/clis/douyin/user-videos.ts b/src/clis/douyin/user-videos.ts new file mode 100644 index 00000000..a4c019b5 --- /dev/null +++ b/src/clis/douyin/user-videos.ts @@ -0,0 +1,82 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.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) => { + await page.goto(`https://www.douyin.com/user/${kwargs.sec_uid as string}`); + await page.wait(3); + + const result = await page.evaluate(` + (async () => { + const secUid = ${JSON.stringify(kwargs.sec_uid)}; + const limit = ${JSON.stringify(kwargs.limit)}; + + const params = new URLSearchParams({ + sec_user_id: secUid, + max_cursor: '0', + count: String(limit), + aid: '6383', + }); + const res = await fetch('/aweme/v1/web/aweme/post/?' + params.toString(), { + credentials: 'include', + headers: { referer: 'https://www.douyin.com/' }, + }); + const data = await res.json(); + const awemeList = (data.aweme_list || []).slice(0, limit); + + const withComments = await Promise.all(awemeList.map(async (v) => { + try { + const cp = new URLSearchParams({ + aweme_id: 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 Object.assign({}, v, { top_comments: comments }); + } catch { + return Object.assign({}, 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[], + }; + }); + }, +}); From 5bde9d1bdd90f4dcb014615df292ad2eb3e228ec Mon Sep 17 00:00:00 2001 From: Howard Wang Date: Sat, 28 Mar 2026 19:03:32 +0800 Subject: [PATCH 2/3] refactor(douyin): replace Object.assign with spread in user-videos Co-Authored-By: Claude Sonnet 4.6 --- src/clis/douyin/user-videos.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/clis/douyin/user-videos.ts b/src/clis/douyin/user-videos.ts index a4c019b5..c765f240 100644 --- a/src/clis/douyin/user-videos.ts +++ b/src/clis/douyin/user-videos.ts @@ -52,9 +52,9 @@ cli({ digg_count: c.digg_count, nickname: c.user && c.user.nickname, })); - return Object.assign({}, v, { top_comments: comments }); + return { ...v, top_comments: comments }; } catch { - return Object.assign({}, v, { top_comments: [] }); + return { ...v, top_comments: [] }; } })); From 97f1e7c4daa12bb41405c74b869a57a384c481fa Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 29 Mar 2026 17:41:29 +0800 Subject: [PATCH 3/3] fix(douyin): validate user-videos inputs --- src/clis/douyin/user-videos.test.ts | 63 +++++++++++++++++++++++++++++ src/clis/douyin/user-videos.ts | 38 +++++++++-------- 2 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 src/clis/douyin/user-videos.test.ts 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 index c765f240..14047e2c 100644 --- a/src/clis/douyin/user-videos.ts +++ b/src/clis/douyin/user-videos.ts @@ -1,5 +1,7 @@ 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', @@ -13,31 +15,35 @@ cli({ ], 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 secUid = ${JSON.stringify(kwargs.sec_uid)}; - const limit = ${JSON.stringify(kwargs.limit)}; - - const params = new URLSearchParams({ - sec_user_id: secUid, - max_cursor: '0', - count: String(limit), - aid: '6383', - }); - const res = await fetch('/aweme/v1/web/aweme/post/?' + params.toString(), { - credentials: 'include', - headers: { referer: 'https://www.douyin.com/' }, - }); - const data = await res.json(); - const awemeList = (data.aweme_list || []).slice(0, limit); + const awemeList = ${JSON.stringify(awemeList)}; const withComments = await Promise.all(awemeList.map(async (v) => { try { const cp = new URLSearchParams({ - aweme_id: v.aweme_id, + aweme_id: String(v.aweme_id), count: '10', cursor: '0', aid: '6383',