Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/clis/douyin/user-videos.test.ts
Original file line number Diff line number Diff line change
@@ -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: [],
}]);
});
});
88 changes: 88 additions & 0 deletions src/clis/douyin/user-videos.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> };
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<Record<string, unknown>>;

return (result || []).map((v, i) => {
const video = v.video as Record<string, unknown> | undefined;
const playAddr = video?.play_addr as Record<string, unknown> | undefined;
const urlList = playAddr?.url_list as string[] | undefined;
const playUrl = urlList?.[0] ?? '';
const statistics = v.statistics as Record<string, unknown> | 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[],
};
});
},
});