@jackwener/opencli 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/actions/setup-chrome/action.yml +5 -4
- package/.github/workflows/ci.yml +17 -3
- package/.github/workflows/e2e-headed.yml +16 -3
- package/CHANGELOG.md +23 -0
- package/PRIVACY.md +57 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/SKILL.md +101 -2
- package/dist/cli-manifest.json +720 -32
- package/dist/clis/apple-podcasts/search.js +2 -1
- package/dist/clis/arxiv/search.js +2 -2
- package/dist/clis/bbc/news.js +0 -1
- package/dist/clis/ctrip/search.js +0 -1
- package/dist/clis/douyin/_shared/browser-fetch.d.ts +10 -0
- package/dist/clis/douyin/_shared/browser-fetch.js +30 -0
- package/dist/clis/douyin/_shared/browser-fetch.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/browser-fetch.test.js +31 -0
- package/dist/clis/douyin/_shared/creation-id.d.ts +1 -0
- package/dist/clis/douyin/_shared/creation-id.js +5 -0
- package/dist/clis/douyin/_shared/creation-id.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/creation-id.test.js +22 -0
- package/dist/clis/douyin/_shared/imagex-upload.d.ts +20 -0
- package/dist/clis/douyin/_shared/imagex-upload.js +53 -0
- package/dist/clis/douyin/_shared/imagex-upload.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/imagex-upload.test.js +87 -0
- package/dist/clis/douyin/_shared/sts2.d.ts +8 -0
- package/dist/clis/douyin/_shared/sts2.js +15 -0
- package/dist/clis/douyin/_shared/text-extra.d.ts +18 -0
- package/dist/clis/douyin/_shared/text-extra.js +15 -0
- package/dist/clis/douyin/_shared/text-extra.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/text-extra.test.js +37 -0
- package/dist/clis/douyin/_shared/timing.d.ts +2 -0
- package/dist/clis/douyin/_shared/timing.js +22 -0
- package/dist/clis/douyin/_shared/timing.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/timing.test.js +28 -0
- package/dist/clis/douyin/_shared/tos-upload-short-read.test.d.ts +11 -0
- package/dist/clis/douyin/_shared/tos-upload-short-read.test.js +83 -0
- package/dist/clis/douyin/_shared/tos-upload.d.ts +53 -0
- package/dist/clis/douyin/_shared/tos-upload.js +295 -0
- package/dist/clis/douyin/_shared/tos-upload.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/tos-upload.test.js +229 -0
- package/dist/clis/douyin/_shared/transcode.d.ts +27 -0
- package/dist/clis/douyin/_shared/transcode.js +45 -0
- package/dist/clis/douyin/_shared/transcode.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/transcode.test.js +93 -0
- package/dist/clis/douyin/_shared/types.d.ts +26 -0
- package/dist/clis/douyin/_shared/types.js +1 -0
- package/dist/clis/douyin/activities.d.ts +1 -0
- package/dist/clis/douyin/activities.js +20 -0
- package/dist/clis/douyin/activities.test.d.ts +1 -0
- package/dist/clis/douyin/activities.test.js +22 -0
- package/dist/clis/douyin/collections.d.ts +1 -0
- package/dist/clis/douyin/collections.js +22 -0
- package/dist/clis/douyin/collections.test.d.ts +1 -0
- package/dist/clis/douyin/collections.test.js +23 -0
- package/dist/clis/douyin/delete.d.ts +1 -0
- package/dist/clis/douyin/delete.js +18 -0
- package/dist/clis/douyin/delete.test.d.ts +1 -0
- package/dist/clis/douyin/delete.test.js +11 -0
- package/dist/clis/douyin/draft.d.ts +14 -0
- package/dist/clis/douyin/draft.js +237 -0
- package/dist/clis/douyin/draft.test.d.ts +1 -0
- package/dist/clis/douyin/draft.test.js +11 -0
- package/dist/clis/douyin/drafts.d.ts +1 -0
- package/dist/clis/douyin/drafts.js +23 -0
- package/dist/clis/douyin/drafts.test.d.ts +1 -0
- package/dist/clis/douyin/drafts.test.js +11 -0
- package/dist/clis/douyin/hashtag.d.ts +1 -0
- package/dist/clis/douyin/hashtag.js +45 -0
- package/dist/clis/douyin/hashtag.test.d.ts +1 -0
- package/dist/clis/douyin/hashtag.test.js +25 -0
- package/dist/clis/douyin/location.d.ts +1 -0
- package/dist/clis/douyin/location.js +24 -0
- package/dist/clis/douyin/location.test.d.ts +1 -0
- package/dist/clis/douyin/location.test.js +23 -0
- package/dist/clis/douyin/profile.d.ts +1 -0
- package/dist/clis/douyin/profile.js +28 -0
- package/dist/clis/douyin/profile.test.d.ts +1 -0
- package/dist/clis/douyin/profile.test.js +11 -0
- package/dist/clis/douyin/publish.d.ts +14 -0
- package/dist/clis/douyin/publish.js +288 -0
- package/dist/clis/douyin/publish.test.d.ts +1 -0
- package/dist/clis/douyin/publish.test.js +38 -0
- package/dist/clis/douyin/stats.d.ts +1 -0
- package/dist/clis/douyin/stats.js +27 -0
- package/dist/clis/douyin/stats.test.d.ts +1 -0
- package/dist/clis/douyin/stats.test.js +22 -0
- package/dist/clis/douyin/update.d.ts +1 -0
- package/dist/clis/douyin/update.js +31 -0
- package/dist/clis/douyin/update.test.d.ts +1 -0
- package/dist/clis/douyin/update.test.js +11 -0
- package/dist/clis/douyin/videos.d.ts +1 -0
- package/dist/clis/douyin/videos.js +34 -0
- package/dist/clis/douyin/videos.test.d.ts +1 -0
- package/dist/clis/douyin/videos.test.js +11 -0
- package/dist/clis/hackernews/search.yaml +1 -1
- package/dist/clis/instagram/search.yaml +2 -1
- package/dist/clis/linux-do/search.yaml +3 -1
- package/dist/clis/medium/search.js +1 -1
- package/dist/clis/reuters/search.js +0 -1
- package/dist/clis/twitter/search.js +5 -3
- package/dist/clis/twitter/search.test.js +54 -2
- package/dist/clis/weibo/comments.d.ts +1 -0
- package/dist/clis/weibo/comments.js +53 -0
- package/dist/clis/weibo/feed.d.ts +1 -0
- package/dist/clis/weibo/feed.js +56 -0
- package/dist/clis/weibo/hot.js +0 -1
- package/dist/clis/weibo/me.d.ts +1 -0
- package/dist/clis/weibo/me.js +76 -0
- package/dist/clis/weibo/post.d.ts +1 -0
- package/dist/clis/weibo/post.js +75 -0
- package/dist/clis/weibo/user.d.ts +1 -0
- package/dist/clis/weibo/user.js +63 -0
- package/dist/clis/weibo/utils.d.ts +6 -0
- package/dist/clis/weibo/utils.js +30 -0
- package/dist/clis/weread/search.js +3 -2
- package/dist/clis/xueqiu/search.yaml +2 -1
- package/dist/clis/yahoo-finance/quote.js +0 -1
- package/dist/clis/youtube/channel.d.ts +1 -0
- package/dist/clis/youtube/channel.js +150 -0
- package/dist/clis/youtube/comments.d.ts +1 -0
- package/dist/clis/youtube/comments.js +95 -0
- package/dist/clis/youtube/search.js +0 -1
- package/dist/clis/zhihu/search.yaml +2 -1
- package/dist/external-clis.yaml +0 -17
- package/dist/weread-search-regression.test.d.ts +1 -0
- package/dist/weread-search-regression.test.js +39 -0
- package/docs/.vitepress/config.mts +13 -0
- package/docs/adapters/browser/douyin.md +75 -0
- package/docs/adapters/browser/twitter.md +6 -0
- package/docs/adapters/index.md +6 -1
- package/extension/dist/background.js +508 -518
- package/extension/manifest.json +6 -2
- package/extension/package.json +1 -1
- package/extension/popup.html +84 -0
- package/extension/popup.js +25 -0
- package/extension/src/background.ts +20 -1
- package/package.json +1 -1
- package/src/clis/apple-podcasts/search.ts +2 -1
- package/src/clis/arxiv/search.ts +2 -2
- package/src/clis/bbc/news.ts +0 -1
- package/src/clis/ctrip/search.ts +0 -1
- package/src/clis/douyin/_shared/browser-fetch.test.ts +38 -0
- package/src/clis/douyin/_shared/browser-fetch.ts +45 -0
- package/src/clis/douyin/_shared/creation-id.test.ts +26 -0
- package/src/clis/douyin/_shared/creation-id.ts +8 -0
- package/src/clis/douyin/_shared/imagex-upload.test.ts +113 -0
- package/src/clis/douyin/_shared/imagex-upload.ts +76 -0
- package/src/clis/douyin/_shared/sts2.ts +20 -0
- package/src/clis/douyin/_shared/text-extra.test.ts +42 -0
- package/src/clis/douyin/_shared/text-extra.ts +33 -0
- package/src/clis/douyin/_shared/timing.test.ts +38 -0
- package/src/clis/douyin/_shared/timing.ts +22 -0
- package/src/clis/douyin/_shared/tos-upload-short-read.test.ts +102 -0
- package/src/clis/douyin/_shared/tos-upload.test.ts +281 -0
- package/src/clis/douyin/_shared/tos-upload.ts +444 -0
- package/src/clis/douyin/_shared/transcode.test.ts +117 -0
- package/src/clis/douyin/_shared/transcode.ts +78 -0
- package/src/clis/douyin/_shared/types.ts +29 -0
- package/src/clis/douyin/activities.test.ts +25 -0
- package/src/clis/douyin/activities.ts +23 -0
- package/src/clis/douyin/collections.test.ts +26 -0
- package/src/clis/douyin/collections.ts +25 -0
- package/src/clis/douyin/delete.test.ts +12 -0
- package/src/clis/douyin/delete.ts +20 -0
- package/src/clis/douyin/draft.test.ts +12 -0
- package/src/clis/douyin/draft.ts +282 -0
- package/src/clis/douyin/drafts.test.ts +12 -0
- package/src/clis/douyin/drafts.ts +27 -0
- package/src/clis/douyin/hashtag.test.ts +28 -0
- package/src/clis/douyin/hashtag.ts +56 -0
- package/src/clis/douyin/location.test.ts +26 -0
- package/src/clis/douyin/location.ts +27 -0
- package/src/clis/douyin/profile.test.ts +12 -0
- package/src/clis/douyin/profile.ts +37 -0
- package/src/clis/douyin/publish.test.ts +45 -0
- package/src/clis/douyin/publish.ts +340 -0
- package/src/clis/douyin/stats.test.ts +25 -0
- package/src/clis/douyin/stats.ts +30 -0
- package/src/clis/douyin/update.test.ts +12 -0
- package/src/clis/douyin/update.ts +43 -0
- package/src/clis/douyin/videos.test.ts +12 -0
- package/src/clis/douyin/videos.ts +49 -0
- package/src/clis/hackernews/search.yaml +1 -1
- package/src/clis/instagram/search.yaml +2 -1
- package/src/clis/linux-do/search.yaml +3 -1
- package/src/clis/medium/search.ts +1 -1
- package/src/clis/reuters/search.ts +0 -1
- package/src/clis/twitter/search.test.ts +69 -2
- package/src/clis/twitter/search.ts +5 -3
- package/src/clis/weibo/comments.ts +54 -0
- package/src/clis/weibo/feed.ts +57 -0
- package/src/clis/weibo/hot.ts +0 -1
- package/src/clis/weibo/me.ts +77 -0
- package/src/clis/weibo/post.ts +77 -0
- package/src/clis/weibo/user.ts +64 -0
- package/src/clis/weibo/utils.ts +32 -0
- package/src/clis/weread/search.ts +3 -2
- package/src/clis/xueqiu/search.yaml +2 -1
- package/src/clis/yahoo-finance/quote.ts +0 -1
- package/src/clis/youtube/channel.ts +155 -0
- package/src/clis/youtube/comments.ts +97 -0
- package/src/clis/youtube/search.ts +0 -1
- package/src/clis/zhihu/search.yaml +2 -1
- package/src/external-clis.yaml +0 -17
- package/src/weread-search-regression.test.ts +44 -0
- package/tests/e2e/browser-public-extended.test.ts +162 -0
- package/tests/e2e/browser-public.test.ts +7 -146
- package/vitest.config.ts +24 -17
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TimeoutError } from '../../../errors.js';
|
|
3
|
+
import { pollTranscodeWithFetch } from './transcode.js';
|
|
4
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
5
|
+
function makePage() {
|
|
6
|
+
return {
|
|
7
|
+
goto: vi.fn(),
|
|
8
|
+
evaluate: vi.fn(),
|
|
9
|
+
getCookies: vi.fn(),
|
|
10
|
+
snapshot: vi.fn(),
|
|
11
|
+
click: vi.fn(),
|
|
12
|
+
typeText: vi.fn(),
|
|
13
|
+
pressKey: vi.fn(),
|
|
14
|
+
scrollTo: vi.fn(),
|
|
15
|
+
getFormState: vi.fn(),
|
|
16
|
+
wait: vi.fn(),
|
|
17
|
+
tabs: vi.fn(),
|
|
18
|
+
closeTab: vi.fn(),
|
|
19
|
+
newTab: vi.fn(),
|
|
20
|
+
selectTab: vi.fn(),
|
|
21
|
+
networkRequests: vi.fn(),
|
|
22
|
+
consoleMessages: vi.fn(),
|
|
23
|
+
scroll: vi.fn(),
|
|
24
|
+
autoScroll: vi.fn(),
|
|
25
|
+
installInterceptor: vi.fn(),
|
|
26
|
+
getInterceptedRequests: vi.fn(),
|
|
27
|
+
screenshot: vi.fn(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const COMPLETE_RESULT = {
|
|
31
|
+
encode: 2,
|
|
32
|
+
duration: 30,
|
|
33
|
+
fps: 30,
|
|
34
|
+
height: 1920,
|
|
35
|
+
width: 1080,
|
|
36
|
+
poster_uri: 'tos-cn-i-alisg.volces.com/poster/abc',
|
|
37
|
+
poster_url: 'https://p3-creator.douyinpic.com/poster/abc',
|
|
38
|
+
};
|
|
39
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
40
|
+
describe('pollTranscodeWithFetch', () => {
|
|
41
|
+
it('returns TranscodeResult immediately when first response has encode=2', async () => {
|
|
42
|
+
const fetchFn = vi.fn().mockResolvedValue(COMPLETE_RESULT);
|
|
43
|
+
const page = makePage();
|
|
44
|
+
const result = await pollTranscodeWithFetch(fetchFn, page, 'vid_123');
|
|
45
|
+
expect(result).toEqual(COMPLETE_RESULT);
|
|
46
|
+
expect(fetchFn).toHaveBeenCalledOnce();
|
|
47
|
+
const [, method, url] = fetchFn.mock.calls[0];
|
|
48
|
+
expect(method).toBe('GET');
|
|
49
|
+
expect(url).toContain('video_id=vid_123');
|
|
50
|
+
expect(url).toContain('aid=1128');
|
|
51
|
+
});
|
|
52
|
+
it('polls multiple times until encode=2 is received', async () => {
|
|
53
|
+
const pending = { encode: 1, duration: 0, fps: 0, height: 0, width: 0, poster_uri: '', poster_url: '' };
|
|
54
|
+
const fetchFn = vi
|
|
55
|
+
.fn()
|
|
56
|
+
.mockResolvedValueOnce(pending)
|
|
57
|
+
.mockResolvedValueOnce(pending)
|
|
58
|
+
.mockResolvedValueOnce(COMPLETE_RESULT);
|
|
59
|
+
const page = makePage();
|
|
60
|
+
// Use a large timeoutMs so it doesn't expire, but override POLL_INTERVAL via
|
|
61
|
+
// a short timeout knowing we'll get 3 calls quickly with mocked promises.
|
|
62
|
+
// Since pollTranscodeWithFetch uses setTimeout for 3s between polls, we need
|
|
63
|
+
// to mock timers to keep tests fast.
|
|
64
|
+
vi.useFakeTimers();
|
|
65
|
+
const resultPromise = pollTranscodeWithFetch(fetchFn, page, 'vid_456', 60_000);
|
|
66
|
+
// Advance timers for each pending poll cycle
|
|
67
|
+
await vi.runAllTimersAsync();
|
|
68
|
+
const result = await resultPromise;
|
|
69
|
+
expect(result).toEqual(COMPLETE_RESULT);
|
|
70
|
+
expect(fetchFn).toHaveBeenCalledTimes(3);
|
|
71
|
+
vi.useRealTimers();
|
|
72
|
+
});
|
|
73
|
+
it('throws TimeoutError when encode never becomes 2 within timeoutMs', async () => {
|
|
74
|
+
const pending = { encode: 1, duration: 0, fps: 0, height: 0, width: 0, poster_uri: '', poster_url: '' };
|
|
75
|
+
const fetchFn = vi.fn().mockResolvedValue(pending);
|
|
76
|
+
const page = makePage();
|
|
77
|
+
vi.useFakeTimers();
|
|
78
|
+
const resultPromise = pollTranscodeWithFetch(fetchFn, page, 'vid_789', 5_000);
|
|
79
|
+
// Suppress unhandled-rejection so vitest doesn't flag it
|
|
80
|
+
resultPromise.catch(() => undefined);
|
|
81
|
+
// Advance time past the 5s timeout
|
|
82
|
+
await vi.runAllTimersAsync();
|
|
83
|
+
await expect(resultPromise).rejects.toBeInstanceOf(TimeoutError);
|
|
84
|
+
vi.useRealTimers();
|
|
85
|
+
});
|
|
86
|
+
it('URL encodes video_id in the request URL', async () => {
|
|
87
|
+
const fetchFn = vi.fn().mockResolvedValue(COMPLETE_RESULT);
|
|
88
|
+
const page = makePage();
|
|
89
|
+
await pollTranscodeWithFetch(fetchFn, page, 'vid with spaces');
|
|
90
|
+
const [, , url] = fetchFn.mock.calls[0];
|
|
91
|
+
expect(url).toContain('video_id=vid%20with%20spaces');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface Sts2Credentials {
|
|
2
|
+
access_key_id: string;
|
|
3
|
+
secret_access_key: string;
|
|
4
|
+
session_token: string;
|
|
5
|
+
expired_time: number;
|
|
6
|
+
}
|
|
7
|
+
export interface TosUploadInfo {
|
|
8
|
+
tos_upload_url: string;
|
|
9
|
+
/** Pre-computed Authorization header value returned by ApplyVideoUpload (StoreInfos[0].Auth) */
|
|
10
|
+
auth: string;
|
|
11
|
+
video_id: string;
|
|
12
|
+
}
|
|
13
|
+
export interface TranscodeResult {
|
|
14
|
+
encode: number;
|
|
15
|
+
duration: number;
|
|
16
|
+
fps: number;
|
|
17
|
+
height: number;
|
|
18
|
+
width: number;
|
|
19
|
+
poster_uri: string;
|
|
20
|
+
poster_url: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PublishResult {
|
|
23
|
+
aweme_id: string;
|
|
24
|
+
url: string;
|
|
25
|
+
publish_time: number;
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { browserFetch } from './_shared/browser-fetch.js';
|
|
3
|
+
cli({
|
|
4
|
+
site: 'douyin',
|
|
5
|
+
name: 'activities',
|
|
6
|
+
description: '官方活动列表',
|
|
7
|
+
domain: 'creator.douyin.com',
|
|
8
|
+
strategy: Strategy.COOKIE,
|
|
9
|
+
args: [],
|
|
10
|
+
columns: ['activity_id', 'title', 'end_time'],
|
|
11
|
+
func: async (page, _kwargs) => {
|
|
12
|
+
const url = 'https://creator.douyin.com/web/api/media/activity/get/?aid=1128';
|
|
13
|
+
const res = await browserFetch(page, 'GET', url);
|
|
14
|
+
return (res.activity_list ?? []).map(a => ({
|
|
15
|
+
activity_id: a.activity_id,
|
|
16
|
+
title: a.title,
|
|
17
|
+
end_time: new Date(a.end_time * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo' }),
|
|
18
|
+
}));
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './activities.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import './activities.js';
|
|
4
|
+
describe('douyin activities registration', () => {
|
|
5
|
+
it('registers the activities command', () => {
|
|
6
|
+
const registry = getRegistry();
|
|
7
|
+
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'activities');
|
|
8
|
+
expect(cmd).toBeDefined();
|
|
9
|
+
});
|
|
10
|
+
it('has expected columns', () => {
|
|
11
|
+
const registry = getRegistry();
|
|
12
|
+
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'activities');
|
|
13
|
+
expect(cmd?.columns).toContain('activity_id');
|
|
14
|
+
expect(cmd?.columns).toContain('title');
|
|
15
|
+
expect(cmd?.columns).toContain('end_time');
|
|
16
|
+
});
|
|
17
|
+
it('uses COOKIE strategy', () => {
|
|
18
|
+
const registry = getRegistry();
|
|
19
|
+
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'activities');
|
|
20
|
+
expect(cmd?.strategy).toBe('cookie');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { browserFetch } from './_shared/browser-fetch.js';
|
|
3
|
+
cli({
|
|
4
|
+
site: 'douyin',
|
|
5
|
+
name: 'collections',
|
|
6
|
+
description: '合集列表',
|
|
7
|
+
domain: 'creator.douyin.com',
|
|
8
|
+
strategy: Strategy.COOKIE,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'limit', type: 'int', default: 20 },
|
|
11
|
+
],
|
|
12
|
+
columns: ['mix_id', 'name', 'item_count'],
|
|
13
|
+
func: async (page, kwargs) => {
|
|
14
|
+
const url = `https://creator.douyin.com/web/api/mix/list/?aid=1128&count=${kwargs.limit}`;
|
|
15
|
+
const res = await browserFetch(page, 'GET', url);
|
|
16
|
+
return (res.mix_list ?? []).map(m => ({
|
|
17
|
+
mix_id: m.mix_id,
|
|
18
|
+
name: m.mix_name,
|
|
19
|
+
item_count: m.item_count,
|
|
20
|
+
}));
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './collections.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import './collections.js';
|
|
4
|
+
describe('douyin collections registration', () => {
|
|
5
|
+
it('registers the collections command', () => {
|
|
6
|
+
const registry = getRegistry();
|
|
7
|
+
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections');
|
|
8
|
+
expect(cmd).toBeDefined();
|
|
9
|
+
expect(cmd?.args.some(a => a.name === 'limit')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it('has expected columns', () => {
|
|
12
|
+
const registry = getRegistry();
|
|
13
|
+
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections');
|
|
14
|
+
expect(cmd?.columns).toContain('mix_id');
|
|
15
|
+
expect(cmd?.columns).toContain('name');
|
|
16
|
+
expect(cmd?.columns).toContain('item_count');
|
|
17
|
+
});
|
|
18
|
+
it('uses COOKIE strategy', () => {
|
|
19
|
+
const registry = getRegistry();
|
|
20
|
+
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections');
|
|
21
|
+
expect(cmd?.strategy).toBe('cookie');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { browserFetch } from './_shared/browser-fetch.js';
|
|
3
|
+
cli({
|
|
4
|
+
site: 'douyin',
|
|
5
|
+
name: 'delete',
|
|
6
|
+
description: '删除作品',
|
|
7
|
+
domain: 'creator.douyin.com',
|
|
8
|
+
strategy: Strategy.COOKIE,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'aweme_id', required: true, positional: true, help: '作品 ID' },
|
|
11
|
+
],
|
|
12
|
+
columns: ['status'],
|
|
13
|
+
func: async (page, kwargs) => {
|
|
14
|
+
const url = 'https://creator.douyin.com/web/api/media/aweme/delete/?aid=1128';
|
|
15
|
+
await browserFetch(page, 'POST', url, { body: { aweme_id: kwargs.aweme_id } });
|
|
16
|
+
return [{ status: `✅ 已删除 ${kwargs.aweme_id}` }];
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './delete.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import './delete.js';
|
|
4
|
+
describe('douyin delete registration', () => {
|
|
5
|
+
it('registers the delete command', () => {
|
|
6
|
+
const registry = getRegistry();
|
|
7
|
+
const values = [...registry.values()];
|
|
8
|
+
const cmd = values.find(c => c.site === 'douyin' && c.name === 'delete');
|
|
9
|
+
expect(cmd).toBeDefined();
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Douyin draft — 6-phase pipeline for saving video as draft.
|
|
3
|
+
*
|
|
4
|
+
* Phases:
|
|
5
|
+
* 1. STS2 credentials
|
|
6
|
+
* 2. Apply TOS upload URL
|
|
7
|
+
* 3. TOS multipart upload
|
|
8
|
+
* 4. Cover upload (optional, via ImageX)
|
|
9
|
+
* 5. Enable video
|
|
10
|
+
* 6. Poll transcode
|
|
11
|
+
* 7. (skipped — no safety check for drafts)
|
|
12
|
+
* 8. create_v2 with is_draft: 1
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Douyin draft — 6-phase pipeline for saving video as draft.
|
|
3
|
+
*
|
|
4
|
+
* Phases:
|
|
5
|
+
* 1. STS2 credentials
|
|
6
|
+
* 2. Apply TOS upload URL
|
|
7
|
+
* 3. TOS multipart upload
|
|
8
|
+
* 4. Cover upload (optional, via ImageX)
|
|
9
|
+
* 5. Enable video
|
|
10
|
+
* 6. Poll transcode
|
|
11
|
+
* 7. (skipped — no safety check for drafts)
|
|
12
|
+
* 8. create_v2 with is_draft: 1
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { cli, Strategy } from '../../registry.js';
|
|
17
|
+
import { ArgumentError, CommandExecutionError } from '../../errors.js';
|
|
18
|
+
import { getSts2Credentials } from './_shared/sts2.js';
|
|
19
|
+
import { tosUpload } from './_shared/tos-upload.js';
|
|
20
|
+
import { imagexUpload } from './_shared/imagex-upload.js';
|
|
21
|
+
import { pollTranscode } from './_shared/transcode.js';
|
|
22
|
+
import { browserFetch } from './_shared/browser-fetch.js';
|
|
23
|
+
import { generateCreationId } from './_shared/creation-id.js';
|
|
24
|
+
import { parseTextExtra, extractHashtagNames } from './_shared/text-extra.js';
|
|
25
|
+
const VISIBILITY_MAP = {
|
|
26
|
+
public: 0,
|
|
27
|
+
friends: 1,
|
|
28
|
+
private: 2,
|
|
29
|
+
};
|
|
30
|
+
const IMAGEX_BASE = 'https://imagex.bytedanceapi.com';
|
|
31
|
+
const IMAGEX_SERVICE_ID = '1147';
|
|
32
|
+
const DEVICE_PARAMS = 'aid=1128&cookie_enabled=true&screen_width=1512&screen_height=982&browser_language=zh-CN&browser_platform=MacIntel&browser_name=Mozilla&browser_online=true&timezone_name=Asia%2FTokyo&support_h265=1';
|
|
33
|
+
const DEFAULT_COVER_TOOLS_INFO = JSON.stringify({
|
|
34
|
+
video_cover_source: 2,
|
|
35
|
+
cover_timestamp: 0,
|
|
36
|
+
recommend_timestamp: 0,
|
|
37
|
+
is_cover_edit: 0,
|
|
38
|
+
is_cover_template: 0,
|
|
39
|
+
cover_template_id: '',
|
|
40
|
+
is_text_template: 0,
|
|
41
|
+
text_template_id: '',
|
|
42
|
+
text_template_content: '',
|
|
43
|
+
is_text: 0,
|
|
44
|
+
text_num: 0,
|
|
45
|
+
text_content: '',
|
|
46
|
+
is_use_sticker: 0,
|
|
47
|
+
sticker_id: '',
|
|
48
|
+
is_use_filter: 0,
|
|
49
|
+
filter_id: '',
|
|
50
|
+
is_cover_modify: 0,
|
|
51
|
+
to_status: 0,
|
|
52
|
+
cover_type: 0,
|
|
53
|
+
initial_cover_uri: '',
|
|
54
|
+
cut_coordinate: '',
|
|
55
|
+
});
|
|
56
|
+
cli({
|
|
57
|
+
site: 'douyin',
|
|
58
|
+
name: 'draft',
|
|
59
|
+
description: '上传视频并保存为草稿',
|
|
60
|
+
domain: 'creator.douyin.com',
|
|
61
|
+
strategy: Strategy.COOKIE,
|
|
62
|
+
args: [
|
|
63
|
+
{ name: 'video', required: true, positional: true, help: '视频文件路径' },
|
|
64
|
+
{ name: 'title', required: true, help: '视频标题(≤30字)' },
|
|
65
|
+
{ name: 'caption', default: '', help: '正文内容(≤1000字,支持 #话题)' },
|
|
66
|
+
{ name: 'cover', default: '', help: '封面图片路径' },
|
|
67
|
+
{ name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] },
|
|
68
|
+
],
|
|
69
|
+
columns: ['status', 'aweme_id'],
|
|
70
|
+
func: async (page, kwargs) => {
|
|
71
|
+
// ── Fail-fast validation ────────────────────────────────────────────
|
|
72
|
+
const videoPath = path.resolve(kwargs.video);
|
|
73
|
+
if (!fs.existsSync(videoPath)) {
|
|
74
|
+
throw new ArgumentError(`视频文件不存在: ${videoPath}`);
|
|
75
|
+
}
|
|
76
|
+
const ext = path.extname(videoPath).toLowerCase();
|
|
77
|
+
if (!['.mp4', '.mov', '.avi', '.webm'].includes(ext)) {
|
|
78
|
+
throw new ArgumentError(`不支持的视频格式: ${ext}(支持 mp4/mov/avi/webm)`);
|
|
79
|
+
}
|
|
80
|
+
const fileSize = fs.statSync(videoPath).size;
|
|
81
|
+
const title = kwargs.title;
|
|
82
|
+
if (title.length > 30) {
|
|
83
|
+
throw new ArgumentError('标题不能超过 30 字');
|
|
84
|
+
}
|
|
85
|
+
const caption = kwargs.caption || '';
|
|
86
|
+
if (caption.length > 1000) {
|
|
87
|
+
throw new ArgumentError('正文不能超过 1000 字');
|
|
88
|
+
}
|
|
89
|
+
const visibilityType = VISIBILITY_MAP[kwargs.visibility] ?? 0;
|
|
90
|
+
const coverPath = kwargs.cover;
|
|
91
|
+
if (coverPath) {
|
|
92
|
+
if (!fs.existsSync(path.resolve(coverPath))) {
|
|
93
|
+
throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// ── Phase 1: STS2 credentials ───────────────────────────────────────
|
|
97
|
+
const credentials = await getSts2Credentials(page);
|
|
98
|
+
// ── Phase 2: Apply TOS upload URL ───────────────────────────────────
|
|
99
|
+
const vodUrl = `https://vod.bytedanceapi.com/?Action=ApplyVideoUpload&ServiceId=1128&Version=2021-01-01&FileType=video&FileSize=${fileSize}`;
|
|
100
|
+
const vodJs = `fetch(${JSON.stringify(vodUrl)}, { credentials: 'include' }).then(r => r.json())`;
|
|
101
|
+
const vodRes = (await page.evaluate(vodJs));
|
|
102
|
+
const { VideoId: videoId, UploadHosts, StoreInfos } = vodRes.Result.UploadAddress;
|
|
103
|
+
const tosUrl = `https://${UploadHosts[0]}/${StoreInfos[0].StoreUri}`;
|
|
104
|
+
const tosUploadInfo = {
|
|
105
|
+
tos_upload_url: tosUrl,
|
|
106
|
+
auth: StoreInfos[0].Auth,
|
|
107
|
+
video_id: videoId,
|
|
108
|
+
};
|
|
109
|
+
// ── Phase 3: TOS upload ─────────────────────────────────────────────
|
|
110
|
+
await tosUpload({
|
|
111
|
+
filePath: videoPath,
|
|
112
|
+
uploadInfo: tosUploadInfo,
|
|
113
|
+
credentials,
|
|
114
|
+
onProgress: (uploaded, total) => {
|
|
115
|
+
const pct = Math.round((uploaded / total) * 100);
|
|
116
|
+
process.stderr.write(`\r 上传进度: ${pct}%`);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
process.stderr.write('\n');
|
|
120
|
+
// ── Phase 4: Cover upload (optional) ────────────────────────────────
|
|
121
|
+
let coverUri = '';
|
|
122
|
+
let coverWidth = 720;
|
|
123
|
+
let coverHeight = 1280;
|
|
124
|
+
if (kwargs.cover) {
|
|
125
|
+
const resolvedCoverPath = path.resolve(kwargs.cover);
|
|
126
|
+
// 4A: Apply ImageX upload
|
|
127
|
+
const applyUrl = `${IMAGEX_BASE}/?Action=ApplyImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01&UploadNum=1`;
|
|
128
|
+
const applyJs = `fetch(${JSON.stringify(applyUrl)}, { credentials: 'include' }).then(r => r.json())`;
|
|
129
|
+
const applyRes = (await page.evaluate(applyJs));
|
|
130
|
+
const { StoreInfos: imgStoreInfos } = applyRes.Result.UploadAddress;
|
|
131
|
+
const imgUploadUrl = `https://${imgStoreInfos[0].UploadHost}/${imgStoreInfos[0].StoreUri}`;
|
|
132
|
+
// 4B: Upload image
|
|
133
|
+
const coverStoreUri = await imagexUpload(resolvedCoverPath, {
|
|
134
|
+
upload_url: imgUploadUrl,
|
|
135
|
+
store_uri: imgStoreInfos[0].StoreUri,
|
|
136
|
+
});
|
|
137
|
+
// 4C: Commit ImageX upload
|
|
138
|
+
const commitUrl = `${IMAGEX_BASE}/?Action=CommitImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01`;
|
|
139
|
+
const commitBody = JSON.stringify({ SuccessObjKeys: [coverStoreUri] });
|
|
140
|
+
const commitJs = `
|
|
141
|
+
fetch(${JSON.stringify(commitUrl)}, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
credentials: 'include',
|
|
144
|
+
headers: { 'Content-Type': 'application/json' },
|
|
145
|
+
body: ${JSON.stringify(commitBody)}
|
|
146
|
+
}).then(r => r.json())
|
|
147
|
+
`;
|
|
148
|
+
await page.evaluate(commitJs);
|
|
149
|
+
coverUri = coverStoreUri;
|
|
150
|
+
}
|
|
151
|
+
// ── Phase 5: Enable video ───────────────────────────────────────────
|
|
152
|
+
const enableUrl = `https://creator.douyin.com/web/api/media/video/enable/?video_id=${videoId}&aid=1128`;
|
|
153
|
+
await browserFetch(page, 'GET', enableUrl);
|
|
154
|
+
// ── Phase 6: Poll transcode ─────────────────────────────────────────
|
|
155
|
+
const transResult = await pollTranscode(page, videoId);
|
|
156
|
+
coverWidth = transResult.width;
|
|
157
|
+
coverHeight = transResult.height;
|
|
158
|
+
if (!coverUri) {
|
|
159
|
+
coverUri = transResult.poster_uri;
|
|
160
|
+
}
|
|
161
|
+
// ── Phase 7: SKIP (no safety check for drafts) ──────────────────────
|
|
162
|
+
// ── Phase 8: create_v2 with is_draft: 1 ────────────────────────────
|
|
163
|
+
const hashtagNames = extractHashtagNames(caption);
|
|
164
|
+
const hashtags = [];
|
|
165
|
+
let searchFrom = 0;
|
|
166
|
+
for (const name of hashtagNames) {
|
|
167
|
+
const idx = caption.indexOf(`#${name}`, searchFrom);
|
|
168
|
+
if (idx === -1)
|
|
169
|
+
continue;
|
|
170
|
+
hashtags.push({ name, id: 0, start: idx, end: idx + name.length + 1 });
|
|
171
|
+
searchFrom = idx + name.length + 1;
|
|
172
|
+
}
|
|
173
|
+
const textExtraArr = parseTextExtra(caption, hashtags);
|
|
174
|
+
const publishBody = {
|
|
175
|
+
item: {
|
|
176
|
+
common: {
|
|
177
|
+
text: caption,
|
|
178
|
+
caption: '',
|
|
179
|
+
item_title: title,
|
|
180
|
+
activity: '[]',
|
|
181
|
+
text_extra: JSON.stringify(textExtraArr),
|
|
182
|
+
challenges: '[]',
|
|
183
|
+
mentions: '[]',
|
|
184
|
+
hashtag_source: '',
|
|
185
|
+
hot_sentence: '',
|
|
186
|
+
interaction_stickers: '[]',
|
|
187
|
+
visibility_type: visibilityType,
|
|
188
|
+
download: 0,
|
|
189
|
+
is_draft: 1,
|
|
190
|
+
creation_id: generateCreationId(),
|
|
191
|
+
media_type: 4,
|
|
192
|
+
video_id: videoId,
|
|
193
|
+
music_source: 0,
|
|
194
|
+
music_id: null,
|
|
195
|
+
},
|
|
196
|
+
cover: {
|
|
197
|
+
poster: coverUri,
|
|
198
|
+
custom_cover_image_height: coverHeight,
|
|
199
|
+
custom_cover_image_width: coverWidth,
|
|
200
|
+
poster_delay: 0,
|
|
201
|
+
cover_tools_info: DEFAULT_COVER_TOOLS_INFO,
|
|
202
|
+
cover_tools_extend_info: '{}',
|
|
203
|
+
},
|
|
204
|
+
mix: {},
|
|
205
|
+
chapter: {
|
|
206
|
+
chapter: JSON.stringify({
|
|
207
|
+
chapter_abstract: '',
|
|
208
|
+
chapter_details: [],
|
|
209
|
+
chapter_type: 0,
|
|
210
|
+
}),
|
|
211
|
+
},
|
|
212
|
+
anchor: {},
|
|
213
|
+
sync: {
|
|
214
|
+
should_sync: false,
|
|
215
|
+
sync_to_toutiao: 0,
|
|
216
|
+
},
|
|
217
|
+
open_platform: {},
|
|
218
|
+
assistant: { is_preview: 0, is_post_assistant: 1 },
|
|
219
|
+
declare: { user_declare_info: '{}' },
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const publishUrl = `https://creator.douyin.com/web/api/media/aweme/create_v2/?read_aid=2906&${DEVICE_PARAMS}`;
|
|
223
|
+
const publishRes = (await browserFetch(page, 'POST', publishUrl, {
|
|
224
|
+
body: publishBody,
|
|
225
|
+
}));
|
|
226
|
+
const awemeId = publishRes.aweme_id;
|
|
227
|
+
if (!awemeId) {
|
|
228
|
+
throw new CommandExecutionError(`草稿保存成功但未返回 aweme_id: ${JSON.stringify(publishRes)}`);
|
|
229
|
+
}
|
|
230
|
+
return [
|
|
231
|
+
{
|
|
232
|
+
status: '✅ 草稿保存成功!',
|
|
233
|
+
aweme_id: awemeId,
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
},
|
|
237
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './draft.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import './draft.js';
|
|
4
|
+
describe('douyin draft registration', () => {
|
|
5
|
+
it('registers the draft command', () => {
|
|
6
|
+
const registry = getRegistry();
|
|
7
|
+
const values = [...registry.values()];
|
|
8
|
+
const cmd = values.find(c => c.site === 'douyin' && c.name === 'draft');
|
|
9
|
+
expect(cmd).toBeDefined();
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { browserFetch } from './_shared/browser-fetch.js';
|
|
3
|
+
cli({
|
|
4
|
+
site: 'douyin',
|
|
5
|
+
name: 'drafts',
|
|
6
|
+
description: '获取草稿列表',
|
|
7
|
+
domain: 'creator.douyin.com',
|
|
8
|
+
strategy: Strategy.COOKIE,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'limit', type: 'int', default: 20 },
|
|
11
|
+
],
|
|
12
|
+
columns: ['aweme_id', 'title', 'create_time'],
|
|
13
|
+
func: async (page, kwargs) => {
|
|
14
|
+
const url = 'https://creator.douyin.com/web/api/media/aweme/draft/?aid=1128';
|
|
15
|
+
const res = (await browserFetch(page, 'GET', url));
|
|
16
|
+
const items = (res.aweme_list ?? []).slice(0, kwargs.limit);
|
|
17
|
+
return items.map((v) => ({
|
|
18
|
+
aweme_id: v.aweme_id,
|
|
19
|
+
title: v.desc,
|
|
20
|
+
create_time: new Date(v.create_time * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo' }),
|
|
21
|
+
}));
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './drafts.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import './drafts.js';
|
|
4
|
+
describe('douyin drafts registration', () => {
|
|
5
|
+
it('registers the drafts command', () => {
|
|
6
|
+
const registry = getRegistry();
|
|
7
|
+
const values = [...registry.values()];
|
|
8
|
+
const cmd = values.find(c => c.site === 'douyin' && c.name === 'drafts');
|
|
9
|
+
expect(cmd).toBeDefined();
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { browserFetch } from './_shared/browser-fetch.js';
|
|
3
|
+
import { ArgumentError } from '../../errors.js';
|
|
4
|
+
cli({
|
|
5
|
+
site: 'douyin',
|
|
6
|
+
name: 'hashtag',
|
|
7
|
+
description: '话题搜索 / AI推荐 / 热点词',
|
|
8
|
+
domain: 'creator.douyin.com',
|
|
9
|
+
strategy: Strategy.COOKIE,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'action', required: true, positional: true, choices: ['search', 'suggest', 'hot'], help: 'search=关键词搜索 suggest=AI推荐 hot=热点词' },
|
|
12
|
+
{ name: 'keyword', default: '', help: '搜索关键词(search/hot 使用)' },
|
|
13
|
+
{ name: 'cover', default: '', help: '封面 URI(suggest 使用)' },
|
|
14
|
+
{ name: 'limit', type: 'int', default: 10 },
|
|
15
|
+
],
|
|
16
|
+
columns: ['name', 'id', 'view_count'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
const action = kwargs.action;
|
|
19
|
+
if (action === 'search') {
|
|
20
|
+
const url = `https://creator.douyin.com/aweme/v1/challenge/search/?keyword=${encodeURIComponent(kwargs.keyword)}&count=${kwargs.limit}&aid=1128`;
|
|
21
|
+
const res = await browserFetch(page, 'GET', url);
|
|
22
|
+
return (res.challenge_list ?? []).map(c => ({
|
|
23
|
+
name: c.challenge_info.cha_name,
|
|
24
|
+
id: c.challenge_info.cid,
|
|
25
|
+
view_count: c.challenge_info.view_count,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
if (action === 'suggest') {
|
|
29
|
+
const url = `https://creator.douyin.com/web/api/media/hashtag/rec/?cover_uri=${encodeURIComponent(kwargs.cover)}&aid=1128`;
|
|
30
|
+
const res = await browserFetch(page, 'GET', url);
|
|
31
|
+
return (res.hashtag_list ?? []).map(h => ({ name: h.name, id: h.id, view_count: h.view_count }));
|
|
32
|
+
}
|
|
33
|
+
if (action === 'hot') {
|
|
34
|
+
const kw = kwargs.keyword;
|
|
35
|
+
const url = `https://creator.douyin.com/aweme/v1/hotspot/recommend/?${kw ? `keyword=${encodeURIComponent(kw)}&` : ''}aid=1128`;
|
|
36
|
+
const res = await browserFetch(page, 'GET', url);
|
|
37
|
+
return (res.hotspot_list ?? []).slice(0, kwargs.limit).map(h => ({
|
|
38
|
+
name: h.sentence,
|
|
39
|
+
id: '',
|
|
40
|
+
view_count: h.hot_value,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
throw new ArgumentError(`未知的 action: ${action}`);
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './hashtag.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import './hashtag.js';
|
|
4
|
+
describe('douyin hashtag registration', () => {
|
|
5
|
+
it('registers the hashtag command', () => {
|
|
6
|
+
const registry = getRegistry();
|
|
7
|
+
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
|
|
8
|
+
expect(cmd).toBeDefined();
|
|
9
|
+
expect(cmd?.args.some(a => a.name === 'action')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it('has all expected args', () => {
|
|
12
|
+
const registry = getRegistry();
|
|
13
|
+
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
|
|
14
|
+
const argNames = cmd?.args.map(a => a.name) ?? [];
|
|
15
|
+
expect(argNames).toContain('action');
|
|
16
|
+
expect(argNames).toContain('keyword');
|
|
17
|
+
expect(argNames).toContain('cover');
|
|
18
|
+
expect(argNames).toContain('limit');
|
|
19
|
+
});
|
|
20
|
+
it('uses COOKIE strategy', () => {
|
|
21
|
+
const registry = getRegistry();
|
|
22
|
+
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
|
|
23
|
+
expect(cmd?.strategy).toBe('cookie');
|
|
24
|
+
});
|
|
25
|
+
});
|