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
84 changes: 84 additions & 0 deletions src/clis/douyin/_shared/public-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { IPage } from '../../../types.js';
import { browserFetch } from './browser-fetch.js';

export interface DouyinComment {
text?: string;
digg_count?: number;
user?: {
nickname?: string;
};
}

export interface DouyinVideo {
aweme_id: string;
desc?: string;
video?: {
duration?: number;
play_addr?: {
url_list?: string[];
};
};
statistics?: {
digg_count?: number;
};
}

export interface DouyinVideoListResponse {
aweme_list?: DouyinVideo[];
}

export interface DouyinCommentListResponse {
comments?: DouyinComment[];
}

export async function fetchDouyinUserVideos(
page: IPage,
secUid: string,
count: number,
): Promise<DouyinVideo[]> {
const params = new URLSearchParams({
sec_user_id: secUid,
max_cursor: '0',
count: String(count),
aid: '6383',
});

const data = await browserFetch(
page,
'GET',
`https://www.douyin.com/aweme/v1/web/aweme/post/?${params.toString()}`,
{
headers: { referer: 'https://www.douyin.com/' },
},
) as DouyinVideoListResponse;

return data.aweme_list || [];
}

export async function fetchDouyinComments(
page: IPage,
awemeId: string,
count: number,
): Promise<Array<{ text: string; digg_count: number; nickname: string }>> {
const params = new URLSearchParams({
aweme_id: awemeId,
count: String(count),
cursor: '0',
aid: '6383',
});

const data = await browserFetch(
page,
'GET',
`https://www.douyin.com/aweme/v1/web/comment/list/?${params.toString()}`,
{
headers: { referer: 'https://www.douyin.com/' },
},
) as DouyinCommentListResponse;

return (data.comments || []).slice(0, count).map((comment) => ({
text: comment.text || '',
digg_count: comment.digg_count ?? 0,
nickname: comment.user?.nickname || '',
}));
}
165 changes: 112 additions & 53 deletions src/clis/douyin/user-videos.test.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,122 @@
import { describe, expect, it, vi } from 'vitest';
import { ArgumentError, CommandExecutionError } from '../../errors.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { fetchDouyinUserVideosMock, fetchDouyinCommentsMock } = vi.hoisted(() => ({
fetchDouyinUserVideosMock: vi.fn(),
fetchDouyinCommentsMock: vi.fn(),
}));

vi.mock('./_shared/public-api.js', () => ({
fetchDouyinUserVideos: fetchDouyinUserVideosMock,
fetchDouyinComments: fetchDouyinCommentsMock,
}));

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();
import { DEFAULT_COMMENT_LIMIT, MAX_USER_VIDEOS_LIMIT, normalizeCommentLimit, normalizeUserVideosLimit } from './user-videos.js';

describe('douyin user-videos', () => {
beforeEach(() => {
fetchDouyinUserVideosMock.mockReset();
fetchDouyinCommentsMock.mockReset();
});

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' });
it('registers the command', () => {
const registry = getRegistry();
const values = [...registry.values()];
const command = values.find((cmd) => cmd.site === 'douyin' && cmd.name === 'user-videos');
expect(command).toBeDefined();
});

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('clamps limit to a safe maximum', () => {
expect(normalizeUserVideosLimit(100)).toBe(MAX_USER_VIDEOS_LIMIT);
expect(normalizeUserVideosLimit(0)).toBe(1);
expect(normalizeCommentLimit(99)).toBe(DEFAULT_COMMENT_LIMIT);
});

it('passes normalized limit to the API and preserves mapped rows', async () => {
const cmd = getRegistry().get('douyin/user-videos');
const page = makePage(
it('uses shared public-api helpers and applies clamped limits', async () => {
const registry = getRegistry();
const command = [...registry.values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'user-videos');
expect(command?.func).toBeDefined();
if (!command?.func) throw new Error('douyin user-videos command not registered');

fetchDouyinUserVideosMock.mockResolvedValueOnce([
{
aweme_id: '1',
desc: 'test video',
video: { duration: 1234, play_addr: { url_list: ['https://example.com/video.mp4'] } },
statistics: { digg_count: 9 },
},
]);
fetchDouyinCommentsMock.mockResolvedValueOnce([
{ text: 'nice', digg_count: 3, nickname: 'alice' },
]);

const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
};

const rows = await command.func(page as any, {
sec_uid: 'MS4w-test',
limit: 100,
comment_limit: 99,
with_comments: true,
});

expect(fetchDouyinUserVideosMock).toHaveBeenCalledWith(page, 'MS4w-test', MAX_USER_VIDEOS_LIMIT);
expect(fetchDouyinCommentsMock).toHaveBeenCalledWith(page, '1', DEFAULT_COMMENT_LIMIT);
expect(rows).toEqual([
{
index: 1,
aweme_id: '1',
title: 'test video',
duration: 1,
digg_count: 9,
play_url: 'https://example.com/video.mp4',
top_comments: [
{ text: 'nice', digg_count: 3, nickname: 'alice' },
],
},
]);
});

it('skips comment enrichment when with_comments is false', async () => {
const registry = getRegistry();
const command = [...registry.values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'user-videos');
expect(command?.func).toBeDefined();
if (!command?.func) throw new Error('douyin user-videos command not registered');

fetchDouyinUserVideosMock.mockResolvedValueOnce([
{
aweme_id: '2',
desc: 'plain video',
video: { duration: 2000, play_addr: { url_list: ['https://example.com/plain.mp4'] } },
statistics: { digg_count: 1 },
},
]);

const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
};

const rows = await command.func(page as any, {
sec_uid: 'MS4w-test',
limit: 3,
with_comments: false,
comment_limit: 5,
});

expect(fetchDouyinCommentsMock).not.toHaveBeenCalled();
expect(rows).toEqual([
{
aweme_list: [{
aweme_id: '1',
desc: 'Video 1',
video: { duration: 2300, play_addr: { url_list: ['https://video.example/1.mp4'] } },
statistics: { digg_count: 12 },
}],
index: 1,
aweme_id: '2',
title: 'plain video',
duration: 2,
digg_count: 1,
play_url: 'https://example.com/plain.mp4',
top_comments: [],
},
[{ 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: [],
}]);
]);
});
});
Loading