diff --git a/src/clis/douyin/collections.test.ts b/src/clis/douyin/collections.test.ts index 3c4d460a..addd4786 100644 --- a/src/clis/douyin/collections.test.ts +++ b/src/clis/douyin/collections.test.ts @@ -1,8 +1,21 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { browserFetchMock } = vi.hoisted(() => ({ + browserFetchMock: vi.fn(), +})); + +vi.mock('./_shared/browser-fetch.js', () => ({ + browserFetch: browserFetchMock, +})); + import { getRegistry } from '../../registry.js'; import './collections.js'; -describe('douyin collections registration', () => { +describe('douyin collections', () => { + beforeEach(() => { + browserFetchMock.mockReset(); + }); + it('registers the collections command', () => { const registry = getRegistry(); const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections'); @@ -23,4 +36,24 @@ describe('douyin collections registration', () => { const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections'); expect(cmd?.strategy).toBe('cookie'); }); + + it('uses the current mix list request shape', async () => { + const registry = getRegistry(); + const command = [...registry.values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'collections'); + expect(command?.func).toBeDefined(); + if (!command?.func) throw new Error('douyin collections command not registered'); + + browserFetchMock.mockResolvedValueOnce({ + mix_list: [], + }); + + const rows = await command.func({} as any, { limit: 12 }); + + expect(browserFetchMock).toHaveBeenCalledWith( + {}, + 'GET', + 'https://creator.douyin.com/web/api/mix/list/?status=0,1,2,3,6&count=12&cursor=0&should_query_new_mix=1&device_platform=web&aid=1128', + ); + expect(rows).toEqual([]); + }); }); diff --git a/src/clis/douyin/collections.ts b/src/clis/douyin/collections.ts index 663a93db..f0fc6936 100644 --- a/src/clis/douyin/collections.ts +++ b/src/clis/douyin/collections.ts @@ -12,7 +12,7 @@ cli({ ], columns: ['mix_id', 'name', 'item_count'], func: async (page, kwargs) => { - const url = `https://creator.douyin.com/web/api/mix/list/?aid=1128&count=${kwargs.limit}`; + const url = `https://creator.douyin.com/web/api/mix/list/?status=0,1,2,3,6&count=${kwargs.limit}&cursor=0&should_query_new_mix=1&device_platform=web&aid=1128`; const res = await browserFetch(page, 'GET', url) as { mix_list: Array<{ mix_id: string; mix_name: string; item_count: number }> }; diff --git a/src/clis/douyin/hashtag.test.ts b/src/clis/douyin/hashtag.test.ts index f81c9ffd..b1d2b00b 100644 --- a/src/clis/douyin/hashtag.test.ts +++ b/src/clis/douyin/hashtag.test.ts @@ -1,8 +1,21 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { browserFetchMock } = vi.hoisted(() => ({ + browserFetchMock: vi.fn(), +})); + +vi.mock('./_shared/browser-fetch.js', () => ({ + browserFetch: browserFetchMock, +})); + import { getRegistry } from '../../registry.js'; import './hashtag.js'; -describe('douyin hashtag registration', () => { +describe('douyin hashtag', () => { + beforeEach(() => { + browserFetchMock.mockReset(); + }); + it('registers the hashtag command', () => { const registry = getRegistry(); const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag'); @@ -25,4 +38,31 @@ describe('douyin hashtag registration', () => { const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag'); expect(cmd?.strategy).toBe('cookie'); }); + + it('parses the current hotspot recommendation shape', async () => { + const registry = getRegistry(); + const command = [...registry.values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'hashtag'); + expect(command?.func).toBeDefined(); + if (!command?.func) throw new Error('douyin hashtag command not registered'); + + browserFetchMock.mockResolvedValueOnce({ + all_sentences: [ + { + word: '在公园花海里大晒一场', + hot_value: 12141172, + sentence_id: '2448416', + }, + ], + }); + + const rows = await command.func({} as any, { action: 'hot', keyword: '', limit: 5 }); + + expect(rows).toEqual([ + { + name: '在公园花海里大晒一场', + id: '2448416', + view_count: 12141172, + }, + ]); + }); }); diff --git a/src/clis/douyin/hashtag.ts b/src/clis/douyin/hashtag.ts index d2f725c1..a04737ee 100644 --- a/src/clis/douyin/hashtag.ts +++ b/src/clis/douyin/hashtag.ts @@ -42,11 +42,19 @@ cli({ const kw = kwargs.keyword as string; const url = `https://creator.douyin.com/aweme/v1/hotspot/recommend/?${kw ? `keyword=${encodeURIComponent(kw)}&` : ''}aid=1128`; const res = await browserFetch(page, 'GET', url) as { - hotspot_list: Array<{ sentence: string; hot_value: number }> + hotspot_list?: Array<{ sentence: string; hot_value: number }>; + all_sentences?: Array<{ sentence_id?: string; word?: string; hot_value: number }>; }; - return (res.hotspot_list ?? []).slice(0, kwargs.limit as number).map(h => ({ + const items = res.hotspot_list + ?? res.all_sentences?.map(h => ({ + sentence: h.word ?? '', + hot_value: h.hot_value, + sentence_id: h.sentence_id ?? '', + })) + ?? []; + return items.slice(0, kwargs.limit as number).map(h => ({ name: h.sentence, - id: '', + id: 'sentence_id' in h ? h.sentence_id : '', view_count: h.hot_value, })); } diff --git a/src/clis/douyin/videos.test.ts b/src/clis/douyin/videos.test.ts index 0ac74cf8..d7c7d3ac 100644 --- a/src/clis/douyin/videos.test.ts +++ b/src/clis/douyin/videos.test.ts @@ -1,12 +1,62 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { browserFetchMock } = vi.hoisted(() => ({ + browserFetchMock: vi.fn(), +})); + +vi.mock('./_shared/browser-fetch.js', () => ({ + browserFetch: browserFetchMock, +})); + import { getRegistry } from '../../registry.js'; import './videos.js'; -describe('douyin videos registration', () => { +describe('douyin videos', () => { + beforeEach(() => { + browserFetchMock.mockReset(); + }); + it('registers the videos command', () => { const registry = getRegistry(); const values = [...registry.values()]; const cmd = values.find(c => c.site === 'douyin' && c.name === 'videos'); expect(cmd).toBeDefined(); }); + + it('parses the current creator work_list api shape', async () => { + const registry = getRegistry(); + const command = [...registry.values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'videos'); + expect(command?.func).toBeDefined(); + if (!command?.func) throw new Error('douyin videos command not registered'); + + browserFetchMock.mockResolvedValueOnce({ + aweme_list: [ + { + aweme_id: '7000000000000000001', + desc: '测试视频标题', + create_time: 1581571130, + statistics: { + play_count: 0, + digg_count: 12, + }, + status: { + is_private: true, + }, + }, + ], + }); + + const rows = await command.func({} as any, { limit: 5, page: 1, status: 'all' }); + + expect(rows).toEqual([ + { + aweme_id: '7000000000000000001', + title: '测试视频标题', + status: 'private', + play_count: 0, + digg_count: 12, + create_time: new Date(1581571130 * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo' }), + }, + ]); + }); }); diff --git a/src/clis/douyin/videos.ts b/src/clis/douyin/videos.ts index 40430e62..bbe2a7a8 100644 --- a/src/clis/douyin/videos.ts +++ b/src/clis/douyin/videos.ts @@ -2,6 +2,48 @@ import { cli, Strategy } from '../../registry.js'; import { browserFetch } from './_shared/browser-fetch.js'; import type { IPage } from '../../types.js'; +interface LegacyWorkItem { + aweme_id: string; + desc: string; + status: number; + public_time: number; + create_time: number; + statistics: { play_count: number; digg_count: number }; +} + +interface CurrentWorkItem { + aweme_id: string; + desc?: string; + status?: { + in_reviewing?: boolean; + is_private?: boolean; + is_delete?: boolean; + is_prohibited?: boolean; + }; + public_time?: number; + create_time?: number; + statistics?: { play_count?: number; digg_count?: number }; +} + +function normalizeVideoStatus( + status: number | { + in_reviewing?: boolean; + is_private?: boolean; + is_delete?: boolean; + is_prohibited?: boolean; + } | undefined, + publicTime: number | undefined, +): number | string { + if (typeof status === 'number') return status; + if (!status) return publicTime && publicTime > Date.now() / 1000 ? 'scheduled' : 'published'; + if (status.is_delete) return 'deleted'; + if (status.is_prohibited) return 'prohibited'; + if (status.in_reviewing) return 'reviewing'; + if (status.is_private) return 'private'; + if (publicTime && publicTime > Date.now() / 1000) return 'scheduled'; + return 'published'; +} + cli({ site: 'douyin', name: 'videos', @@ -19,31 +61,23 @@ cli({ const statusNum = statusMap[kwargs.status as string] ?? 0; const url = `https://creator.douyin.com/janus/douyin/creator/pc/work_list?page_size=${kwargs.limit}&page_num=${kwargs.page}&status=${statusNum}`; const res = (await browserFetch(page, 'GET', url)) as { - data: { - work_list: Array<{ - aweme_id: string; - desc: string; - status: number; - public_time: number; - create_time: number; - statistics: { play_count: number; digg_count: number }; - }>; - }; + data?: { work_list?: LegacyWorkItem[] }; + aweme_list?: CurrentWorkItem[]; }; - let items = res.data?.work_list ?? []; + let items: Array = res.data?.work_list ?? res.aweme_list ?? []; // The API has a bug with status=16 for scheduled, so filter client-side if (kwargs.status === 'scheduled') { - items = items.filter((v) => v.public_time > Date.now() / 1000); + items = items.filter((v) => (v.public_time ?? 0) > Date.now() / 1000); } return items.map((v) => ({ aweme_id: v.aweme_id, - title: v.desc, - status: v.status, + title: v.desc ?? '', + status: normalizeVideoStatus(v.status, v.public_time), play_count: v.statistics?.play_count ?? 0, digg_count: v.statistics?.digg_count ?? 0, - create_time: new Date(v.create_time * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo' }), + create_time: new Date((v.create_time ?? v.public_time ?? 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo' }), })); }, });