Skip to content

Commit 4682ffc

Browse files
jackwenermylukin
andauthored
feat(douyin): restore publish and delete flow (#1587)
* feat(douyin): restore publish and delete flow - Use upload auth v5 API instead of legacy STS2 for VOD credentials - Switch TOS upload from AWS4-signature to gateway multipart protocol (init/transfer/finish) - Add ApplyUploadInner → CommitUploadInner pipeline for VOD upload - Bypass enable/transend endpoints that hang for gateway-uploaded videos - Handle fast_detect/pre_check empty responses gracefully with retry+backoff - Add creator backend delete fallback (via work_list id matching) when legacy delete returns permission error - Use CommitUploadInner Vid for create_v2, not completed TOS object key - Accept item_id as fallback when create_v2 returns no aweme_id * fix(douyin): harden publish delete write contracts --------- Co-authored-by: Lukin <mylukin@gmail.com>
1 parent e3140af commit 4682ffc

11 files changed

Lines changed: 926 additions & 140 deletions

cli-manifest.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8266,7 +8266,7 @@
82668266
{
82678267
"site": "douyin",
82688268
"name": "delete",
8269-
"description": "删除作品",
8269+
"description": "删除作品(优先使用创作者后台作品管理;找不到时回退到旧删除接口)",
82708270
"access": "write",
82718271
"domain": "creator.douyin.com",
82728272
"strategy": "cookie",
@@ -8277,7 +8277,7 @@
82778277
"type": "str",
82788278
"required": true,
82798279
"positional": true,
8280-
"help": "作品 ID"
8280+
"help": "作品 ID / item_id"
82818281
}
82828282
],
82838283
"columns": [
@@ -8286,7 +8286,8 @@
82868286
"type": "js",
82878287
"modulePath": "douyin/delete.js",
82888288
"sourceFile": "douyin/delete.js",
8289-
"navigateBefore": "https://creator.douyin.com"
8289+
"navigateBefore": "https://creator.douyin.com",
8290+
"siteSession": "persistent"
82908291
},
82918292
{
82928293
"site": "douyin",

clis/douyin/_shared/browser-fetch.js

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,65 @@
1-
import { CommandExecutionError } from '@jackwener/opencli/errors';
1+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2+
import { unwrapEvaluateResult } from './evaluate-result.js';
3+
4+
function isAuthLikeError(code, message) {
5+
const text = String(message ?? '');
6+
return code === 401 || code === 403 || /login|cookie|auth|captcha|verify|forbidden|permission|||||/i.test(text);
7+
}
8+
29
/**
310
* Execute a fetch() call inside the Chrome browser context via page.evaluate.
411
* This ensures a_bogus signing and cookies are handled automatically by the browser.
512
*/
613
export async function browserFetch(page, method, url, options = {}) {
714
const js = `
815
(async () => {
9-
const res = await fetch(${JSON.stringify(url)}, {
10-
method: ${JSON.stringify(method)},
11-
credentials: 'include',
12-
headers: {
13-
'Content-Type': 'application/json',
14-
...${JSON.stringify(options.headers ?? {})}
15-
},
16-
${options.body ? `body: JSON.stringify(${JSON.stringify(options.body)}),` : ''}
17-
});
18-
const text = await res.text();
19-
if (!text) return null;
20-
return JSON.parse(text);
16+
const controller = new AbortController();
17+
const timer = setTimeout(() => controller.abort(), ${Number(options.timeoutMs ?? 30000)});
18+
try {
19+
const res = await fetch(${JSON.stringify(url)}, {
20+
method: ${JSON.stringify(method)},
21+
credentials: 'include',
22+
signal: controller.signal,
23+
headers: {
24+
'Content-Type': 'application/json',
25+
...${JSON.stringify(options.headers ?? {})}
26+
},
27+
${options.body ? `body: JSON.stringify(${JSON.stringify(options.body)}),` : ''}
28+
});
29+
const text = await res.text();
30+
try {
31+
return JSON.parse(text);
32+
} catch (error) {
33+
return { status_code: res.ok ? -2 : res.status, status_msg: \`JSON parse failed: \${text.slice(0, 500) || String(error && error.message || error)}\` };
34+
}
35+
} catch (error) {
36+
return { status_code: -1, status_msg: String(error && error.message || error) };
37+
} finally {
38+
clearTimeout(timer);
39+
}
2140
})()
2241
`;
2342
let result;
2443
try {
25-
result = await page.evaluate(js);
44+
result = unwrapEvaluateResult(await page.evaluate(js));
2645
}
2746
catch (error) {
28-
const message = error instanceof Error ? error.message : String(error);
29-
throw new CommandExecutionError(`Douyin API request failed: ${message}`);
47+
throw new CommandExecutionError(`Douyin API request failed (${method} ${url}): ${error instanceof Error ? error.message : String(error)}`);
48+
}
49+
if (result == null) {
50+
throw new CommandExecutionError(`Empty response from Douyin API (${method} ${url})`);
3051
}
31-
if (result === null || result === undefined) {
32-
throw new CommandExecutionError('Empty response from Douyin API');
52+
if (Array.isArray(result) || typeof result !== 'object') {
53+
throw new CommandExecutionError(`Malformed response from Douyin API (${method} ${url})`);
3354
}
3455
if (result && typeof result === 'object' && 'status_code' in result) {
3556
const code = result.status_code;
3657
if (code !== 0) {
37-
const msg = result.status_msg ?? 'unknown error';
38-
throw new CommandExecutionError(`Douyin API error ${code}: ${msg}`);
58+
const msg = result.status_msg ?? result.message ?? 'unknown error';
59+
if (isAuthLikeError(code, msg)) {
60+
throw new AuthRequiredError('creator.douyin.com', `Douyin API auth/permission error ${code} at ${method} ${url}: ${msg}`);
61+
}
62+
throw new CommandExecutionError(`Douyin API error ${code} at ${method} ${url}: ${msg}`);
3963
}
4064
}
4165
return result;

clis/douyin/_shared/browser-fetch.test.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it, vi } from 'vitest';
2+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
23
import { browserFetch } from './browser-fetch.js';
34
function makePage(result) {
45
return {
@@ -18,10 +19,20 @@ describe('browserFetch', () => {
1819
const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
1920
expect(result).toEqual({ status_code: 0, data: { ak: 'KEY' } });
2021
});
22+
it('unwraps Browser Bridge {session,data} envelopes', async () => {
23+
const page = makePage({ session: 'site:douyin:test', data: { status_code: 0, data: { ok: true } } });
24+
await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test'))
25+
.resolves.toEqual({ status_code: 0, data: { ok: true } });
26+
});
2127
it('throws when status_code is non-zero', async () => {
2228
const page = makePage({ status_code: 8, message: 'fail' });
2329
await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Douyin API error 8');
2430
});
31+
it('maps auth-like API errors to AuthRequiredError', async () => {
32+
const page = makePage({ status_code: 401, status_msg: 'login required' });
33+
await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test'))
34+
.rejects.toBeInstanceOf(AuthRequiredError);
35+
});
2536
it('returns result even when no status_code field', async () => {
2637
const page = makePage({ some_field: 'value' });
2738
const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
@@ -35,9 +46,19 @@ describe('browserFetch', () => {
3546
const page = makePage(undefined);
3647
await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Empty response from Douyin API');
3748
});
49+
it('throws typed on malformed primitive response body', async () => {
50+
const page = makePage('not-json-object');
51+
await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test'))
52+
.rejects.toBeInstanceOf(CommandExecutionError);
53+
});
54+
it('throws typed when browser fetch returns a non-JSON body', async () => {
55+
const page = makePage({ status_code: -2, status_msg: 'JSON parse failed: <html>not-json</html>' });
56+
await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test'))
57+
.rejects.toThrow('Douyin API error -2');
58+
});
3859
it('wraps browser-side fetch or JSON parse failures', async () => {
3960
const page = makePage(null);
4061
page.evaluate.mockRejectedValueOnce(new SyntaxError('Unexpected token < in JSON'));
41-
await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Douyin API request failed: Unexpected token < in JSON');
62+
await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Douyin API request failed (GET https://creator.douyin.com/api/test): Unexpected token < in JSON');
4263
});
4364
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { CommandExecutionError } from '@jackwener/opencli/errors';
2+
3+
export function unwrapEvaluateResult(payload) {
4+
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
5+
return payload.data;
6+
}
7+
return payload;
8+
}
9+
10+
export function requireObjectEvaluateResult(payload, context) {
11+
const result = unwrapEvaluateResult(payload);
12+
if (!result || Array.isArray(result) || typeof result !== 'object') {
13+
throw new CommandExecutionError(`${context}: malformed evaluate payload`);
14+
}
15+
return result;
16+
}

0 commit comments

Comments
 (0)