Skip to content

Commit 39ca833

Browse files
nianyi778jackwener
andauthored
feat(douyin): add Douyin creator center adapter (14 commands, 8-phase publish pipeline) (#416)
* feat(douyin): add Douyin creator center adapter (14 commands, 8-phase publish pipeline) - publish: 8-phase pipeline (STS2 → TOS multipart upload w/ resume → ImageX cover → transcode poll → safety check → create_v2) - draft: save as draft (phases 1-6 + is_draft:1, no timing) - videos/drafts/delete/profile/update: content management - hashtag (search/suggest/hot) / location / activities / collections / stats: discovery & analytics - _shared: tos-upload (AWS Sig V4, multipart, resume), imagex-upload, transcode poller (encode=2), browser-fetch, sts2, creation-id, timing, text-extra - 124 tests, tsc clean * fix(douyin): accept unix timestamp strings * docs(douyin): add browser adapter guide --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 9245bf4 commit 39ca833

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2795
-0
lines changed

docs/adapters/browser/douyin.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Douyin (抖音创作者中心)
2+
3+
**Mode**: 🔐 Browser · **Domain**: `creator.douyin.com`
4+
5+
## Commands
6+
7+
| Command | Description |
8+
|---------|-------------|
9+
| `opencli douyin profile` | 获取账号信息 |
10+
| `opencli douyin videos` | 获取作品列表 |
11+
| `opencli douyin drafts` | 获取草稿列表 |
12+
| `opencli douyin draft` | 上传视频并保存为草稿 |
13+
| `opencli douyin publish` | 定时发布视频到抖音 |
14+
| `opencli douyin update` | 更新视频信息 |
15+
| `opencli douyin delete` | 删除作品 |
16+
| `opencli douyin stats` | 查询作品数据分析 |
17+
| `opencli douyin collections` | 获取合集列表 |
18+
| `opencli douyin activities` | 获取官方活动列表 |
19+
| `opencli douyin location` | 搜索发布可用的地理位置 |
20+
| `opencli douyin hashtag search` | 按关键词搜索话题 |
21+
| `opencli douyin hashtag suggest` | 基于封面 URI 推荐话题 |
22+
| `opencli douyin hashtag hot` | 获取热点词 |
23+
24+
## Usage Examples
25+
26+
```bash
27+
# 账号与作品
28+
opencli douyin profile
29+
opencli douyin videos --limit 10
30+
opencli douyin videos --status scheduled
31+
opencli douyin drafts
32+
33+
# 发布前辅助信息
34+
opencli douyin collections
35+
opencli douyin activities
36+
opencli douyin location "东京塔"
37+
opencli douyin hashtag search "春游"
38+
opencli douyin hashtag hot --limit 10
39+
40+
# 保存草稿
41+
opencli douyin draft ./video.mp4 \
42+
--title "春游 vlog" \
43+
--caption "#春游 先存草稿"
44+
45+
# 定时发布
46+
opencli douyin publish ./video.mp4 \
47+
--title "春游 vlog" \
48+
--caption "#春游 今天去看樱花" \
49+
--schedule "2026-04-08T12:00:00+09:00"
50+
51+
# 也支持 Unix 秒字符串
52+
opencli douyin publish ./video.mp4 \
53+
--title "春游 vlog" \
54+
--schedule 1775617200
55+
56+
# 更新与删除
57+
opencli douyin update 1234567890 --caption "更新后的文案"
58+
opencli douyin update 1234567890 --reschedule "2026-04-09T20:00:00+09:00"
59+
opencli douyin delete 1234567890
60+
61+
# JSON 输出
62+
opencli douyin profile -f json
63+
```
64+
65+
## Prerequisites
66+
67+
- Chrome running and **logged into** `creator.douyin.com`
68+
- The logged-in account must have access to Douyin Creator Center publishing features
69+
- [Browser Bridge extension](/guide/browser-bridge) installed
70+
71+
## Notes
72+
73+
- `publish` requires `--schedule` to be at least 2 hours later and no more than 14 days later
74+
- `draft` and `publish` upload the video through Douyin/ByteDance browser-authenticated APIs, so cookies in the active browser session must be valid
75+
- `hashtag suggest` expects a valid `cover`/`cover_uri` value produced during the publish pipeline; for normal manual use, `hashtag search` and `hashtag hot` are usually more convenient
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import type { IPage } from '../../../types.js';
3+
import { browserFetch } from './browser-fetch.js';
4+
5+
function makePage(result: unknown): IPage {
6+
return {
7+
goto: vi.fn(), evaluate: vi.fn().mockResolvedValue(result),
8+
getCookies: vi.fn(), snapshot: vi.fn(), click: vi.fn(),
9+
typeText: vi.fn(), pressKey: vi.fn(), scrollTo: vi.fn(),
10+
getFormState: vi.fn(), wait: vi.fn(), tabs: vi.fn(),
11+
closeTab: vi.fn(), newTab: vi.fn(), selectTab: vi.fn(),
12+
networkRequests: vi.fn(), consoleMessages: vi.fn(),
13+
scroll: vi.fn(), autoScroll: vi.fn(),
14+
installInterceptor: vi.fn(), getInterceptedRequests: vi.fn(),
15+
screenshot: vi.fn(),
16+
} as unknown as IPage;
17+
}
18+
19+
describe('browserFetch', () => {
20+
it('returns parsed JSON on success', async () => {
21+
const page = makePage({ status_code: 0, data: { ak: 'KEY' } });
22+
const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
23+
expect(result).toEqual({ status_code: 0, data: { ak: 'KEY' } });
24+
});
25+
26+
it('throws when status_code is non-zero', async () => {
27+
const page = makePage({ status_code: 8, message: 'fail' });
28+
await expect(
29+
browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')
30+
).rejects.toThrow('Douyin API error 8');
31+
});
32+
33+
it('returns result even when no status_code field', async () => {
34+
const page = makePage({ some_field: 'value' });
35+
const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
36+
expect(result).toEqual({ some_field: 'value' });
37+
});
38+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { IPage } from '../../../types.js';
2+
import { CommandExecutionError } from '../../../errors.js';
3+
4+
export interface FetchOptions {
5+
body?: unknown;
6+
headers?: Record<string, string>;
7+
}
8+
9+
/**
10+
* Execute a fetch() call inside the Chrome browser context via page.evaluate.
11+
* This ensures a_bogus signing and cookies are handled automatically by the browser.
12+
*/
13+
export async function browserFetch(
14+
page: IPage,
15+
method: 'GET' | 'POST',
16+
url: string,
17+
options: FetchOptions = {}
18+
): Promise<unknown> {
19+
const js = `
20+
(async () => {
21+
const res = await fetch(${JSON.stringify(url)}, {
22+
method: ${JSON.stringify(method)},
23+
credentials: 'include',
24+
headers: {
25+
'Content-Type': 'application/json',
26+
...${JSON.stringify(options.headers ?? {})}
27+
},
28+
${options.body ? `body: JSON.stringify(${JSON.stringify(options.body)}),` : ''}
29+
});
30+
return res.json();
31+
})()
32+
`;
33+
34+
const result = await page.evaluate(js);
35+
36+
if (result && typeof result === 'object' && 'status_code' in result) {
37+
const code = (result as { status_code: number }).status_code;
38+
if (code !== 0) {
39+
const msg = (result as { status_msg?: string }).status_msg ?? 'unknown error';
40+
throw new CommandExecutionError(`Douyin API error ${code}: ${msg}`);
41+
}
42+
}
43+
44+
return result;
45+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { generateCreationId } from './creation-id.js';
3+
4+
describe('generateCreationId', () => {
5+
it('starts with "pin"', () => {
6+
expect(generateCreationId()).toMatch(/^pin/);
7+
});
8+
9+
it('has 4 random lowercase-alphanumeric chars after "pin"', () => {
10+
expect(generateCreationId()).toMatch(/^pin[a-z0-9]{4}/);
11+
});
12+
13+
it('ends with a numeric timestamp (ms)', () => {
14+
const before = Date.now();
15+
const id = generateCreationId();
16+
const after = Date.now();
17+
const ts = parseInt(id.replace(/^pin[a-z0-9]{4}/, ''), 10);
18+
expect(ts).toBeGreaterThanOrEqual(before);
19+
expect(ts).toBeLessThanOrEqual(after);
20+
});
21+
22+
it('generates unique IDs', () => {
23+
const ids = new Set(Array.from({ length: 100 }, generateCreationId));
24+
expect(ids.size).toBe(100);
25+
});
26+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
2+
3+
export function generateCreationId(): string {
4+
const random = Array.from({ length: 4 }, () =>
5+
CHARS[Math.floor(Math.random() * CHARS.length)]
6+
).join('');
7+
return 'pin' + random + Date.now();
8+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import * as fs from 'node:fs';
2+
import * as os from 'node:os';
3+
import * as path from 'node:path';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { CommandExecutionError } from '../../../errors.js';
6+
import { imagexUpload } from './imagex-upload.js';
7+
import type { ImageXUploadInfo } from './imagex-upload.js';
8+
9+
// ── Helpers ──────────────────────────────────────────────────────────────────
10+
11+
function makeTempImage(ext = '.jpg'): string {
12+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'imagex-test-'));
13+
const filePath = path.join(dir, `cover${ext}`);
14+
fs.writeFileSync(filePath, Buffer.from([0xff, 0xd8, 0xff, 0xe0])); // minimal JPEG header bytes
15+
return filePath;
16+
}
17+
18+
const FAKE_UPLOAD_INFO: ImageXUploadInfo = {
19+
upload_url: 'https://imagex.bytedance.com/upload/presigned/fake',
20+
store_uri: 'tos-cn-i-alisg.example.com/cover/abc123',
21+
};
22+
23+
// ── Tests ─────────────────────────────────────────────────────────────────────
24+
25+
describe('imagexUpload', () => {
26+
let imagePath: string;
27+
28+
beforeEach(() => {
29+
imagePath = makeTempImage('.jpg');
30+
});
31+
32+
afterEach(() => {
33+
// Clean up temp files
34+
try {
35+
fs.unlinkSync(imagePath);
36+
fs.rmdirSync(path.dirname(imagePath));
37+
} catch {
38+
// ignore cleanup errors
39+
}
40+
vi.restoreAllMocks();
41+
});
42+
43+
it('throws CommandExecutionError when image file does not exist', async () => {
44+
await expect(
45+
imagexUpload('/nonexistent/path/cover.jpg', FAKE_UPLOAD_INFO),
46+
).rejects.toThrow(CommandExecutionError);
47+
48+
await expect(
49+
imagexUpload('/nonexistent/path/cover.jpg', FAKE_UPLOAD_INFO),
50+
).rejects.toThrow('Cover image file not found');
51+
});
52+
53+
it('PUTs the image and returns store_uri on success', async () => {
54+
const mockFetch = vi.fn().mockResolvedValue({
55+
ok: true,
56+
status: 200,
57+
text: vi.fn().mockResolvedValue(''),
58+
});
59+
vi.stubGlobal('fetch', mockFetch);
60+
61+
const result = await imagexUpload(imagePath, FAKE_UPLOAD_INFO);
62+
63+
expect(result).toBe(FAKE_UPLOAD_INFO.store_uri);
64+
expect(mockFetch).toHaveBeenCalledOnce();
65+
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
66+
expect(url).toBe(FAKE_UPLOAD_INFO.upload_url);
67+
expect(init.method).toBe('PUT');
68+
expect((init.headers as Record<string, string>)['Content-Type']).toBe(
69+
'image/jpeg',
70+
);
71+
});
72+
73+
it('uses image/png Content-Type for .png files', async () => {
74+
const pngPath = makeTempImage('.png');
75+
const mockFetch = vi.fn().mockResolvedValue({
76+
ok: true,
77+
status: 200,
78+
text: vi.fn().mockResolvedValue(''),
79+
});
80+
vi.stubGlobal('fetch', mockFetch);
81+
82+
try {
83+
await imagexUpload(pngPath, FAKE_UPLOAD_INFO);
84+
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
85+
expect((init.headers as Record<string, string>)['Content-Type']).toBe(
86+
'image/png',
87+
);
88+
} finally {
89+
try {
90+
fs.unlinkSync(pngPath);
91+
fs.rmdirSync(path.dirname(pngPath));
92+
} catch {
93+
// ignore
94+
}
95+
}
96+
});
97+
98+
it('throws CommandExecutionError on non-2xx PUT response', async () => {
99+
const mockFetch = vi.fn().mockResolvedValue({
100+
ok: false,
101+
status: 403,
102+
text: vi.fn().mockResolvedValue('Forbidden'),
103+
});
104+
vi.stubGlobal('fetch', mockFetch);
105+
106+
await expect(imagexUpload(imagePath, FAKE_UPLOAD_INFO)).rejects.toThrow(
107+
CommandExecutionError,
108+
);
109+
await expect(imagexUpload(imagePath, FAKE_UPLOAD_INFO)).rejects.toThrow(
110+
'ImageX upload failed with status 403',
111+
);
112+
});
113+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* ImageX cover image uploader.
3+
*
4+
* Uploads a JPEG/PNG image to ByteDance ImageX via a pre-signed PUT URL
5+
* obtained from the Douyin "apply cover upload" API.
6+
*/
7+
8+
import * as fs from 'node:fs';
9+
import * as path from 'node:path';
10+
import { CommandExecutionError } from '../../../errors.js';
11+
12+
export interface ImageXUploadInfo {
13+
/** Pre-signed PUT target URL (provided by the apply cover upload API) */
14+
upload_url: string;
15+
/** Image URI to use in create_v2 (returned from the apply step) */
16+
store_uri: string;
17+
}
18+
19+
/**
20+
* Detect MIME type from file extension.
21+
* Falls back to image/jpeg for unknown extensions.
22+
*/
23+
function detectContentType(filePath: string): string {
24+
const ext = path.extname(filePath).toLowerCase();
25+
switch (ext) {
26+
case '.png':
27+
return 'image/png';
28+
case '.gif':
29+
return 'image/gif';
30+
case '.webp':
31+
return 'image/webp';
32+
default:
33+
return 'image/jpeg';
34+
}
35+
}
36+
37+
/**
38+
* Upload a cover image to ByteDance ImageX via a pre-signed PUT URL.
39+
*
40+
* @param imagePath - Local file path to the image (JPEG/PNG/etc.)
41+
* @param uploadInfo - Upload URL and store_uri from the apply cover upload API
42+
* @returns The store_uri (= image_uri for use in create_v2)
43+
*/
44+
export async function imagexUpload(
45+
imagePath: string,
46+
uploadInfo: ImageXUploadInfo,
47+
): Promise<string> {
48+
if (!fs.existsSync(imagePath)) {
49+
throw new CommandExecutionError(
50+
`Cover image file not found: ${imagePath}`,
51+
'Ensure the file path is correct and accessible.',
52+
);
53+
}
54+
55+
const imageBuffer = fs.readFileSync(imagePath);
56+
const contentType = detectContentType(imagePath);
57+
58+
const res = await fetch(uploadInfo.upload_url, {
59+
method: 'PUT',
60+
headers: {
61+
'Content-Type': contentType,
62+
'Content-Length': String(imageBuffer.byteLength),
63+
},
64+
body: imageBuffer as unknown as BodyInit,
65+
});
66+
67+
if (!res.ok) {
68+
const body = await res.text().catch(() => '');
69+
throw new CommandExecutionError(
70+
`ImageX upload failed with status ${res.status}: ${body}`,
71+
'Check that the upload URL is valid and has not expired.',
72+
);
73+
}
74+
75+
return uploadInfo.store_uri;
76+
}

0 commit comments

Comments
 (0)