From 1d3c2e3d03dbe0da4be03b2c5c51d66c7e0217d6 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 00:21:00 +0800 Subject: [PATCH 1/3] feat: add tiktok creator-videos command TikTok Studio creator content list with views/likes/comments/saves/shares. Hits the Studio item_list endpoint (https://www.tiktok.com/tiktok/creator/manage/item_list/v1/?aid=1988) from a logged-in /tiktokstudio/content session and pages with cursor until limit is satisfied (server caps size at 50). Username for the resulting video URL is extracted from the user_text= query param on play_addr / download_info entries, falling back to scraping a[href*="/video/"] from the Studio page DOM. Co-Authored-By: Claude Opus 4.7 (1M context) --- clis/tiktok/creator-videos.js | 111 ++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 clis/tiktok/creator-videos.js diff --git a/clis/tiktok/creator-videos.js b/clis/tiktok/creator-videos.js new file mode 100644 index 000000000..629d8a558 --- /dev/null +++ b/clis/tiktok/creator-videos.js @@ -0,0 +1,111 @@ +import { cli } from '@jackwener/opencli/registry'; + +const ITEM_LIST_API_PATH = '/tiktok/creator/manage/item_list/v1/'; + +cli({ + site: 'tiktok', + name: 'creator-videos', + access: 'read', + description: 'TikTok Studio creator content list (views/likes/comments/saves/shares)', + domain: 'www.tiktok.com', + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of creator videos to return' }, + { name: 'cursor', type: 'string', default: '0', help: 'Pagination cursor' }, + ], + columns: ['title', 'date', 'views', 'likes', 'comments', 'saves', 'shares', 'url'], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/tiktokstudio/content', settleMs: 6000 } }, + { + evaluate: `(async () => { + const limit = Math.max(1, Number(\${{ args.limit }}) || 20); + const cursor = \${{ args.cursor | json }}; + const apiPath = '${ITEM_LIST_API_PATH}'; + const apiUrl = apiPath + '?aid=1988'; + const pageSize = Math.min(Math.max(limit, 1), 50); + const maxPages = Math.max(1, Math.ceil(limit / pageSize)); + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + function toNumber(value) { + const n = Number(value); + return Number.isFinite(n) ? n : 0; + } + + function formatDate(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) return ''; + return new Date(seconds * 1000).toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + hour12: false, + }); + } + + function extractUsername(item) { + const blobs = [ + ...(Array.isArray(item.play_addr) ? item.play_addr : []), + ...(item.download_info && Array.isArray(item.download_info.download_urls) ? item.download_info.download_urls : []), + ]; + for (const raw of blobs) { + try { + const match = String(raw).match(/[?&]user_text=([^&]+)/); + if (match) return decodeURIComponent(match[1]); + } catch (_) {} + } + return ''; + } + + function videoUrl(item) { + const id = item.item_id || item.id || ''; + if (!id) return ''; + const anchor = document.querySelector('a[href*="/video/' + CSS.escape(String(id)) + '"]'); + if (anchor) return new URL(anchor.getAttribute('href'), location.origin).href; + const username = extractUsername(item); + return username ? 'https://www.tiktok.com/@' + encodeURIComponent(username) + '/video/' + encodeURIComponent(id) : ''; + } + + async function fetchPage(nextCursor) { + const body = { + cursor: Number(nextCursor) || 0, + size: pageSize, + query: { + conditions: [], + sort_orders: [{ field_name: 'create_time', order: 2 }], + }, + }; + const res = await fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('TikTok Studio item_list HTTP ' + res.status); + const data = await res.json(); + if (data.status_msg) throw new Error('TikTok Studio item_list failed: ' + data.status_msg); + return data; + } + + const rows = []; + let nextCursor = cursor; + for (let page = 0; page < maxPages && rows.length < limit; page++) { + const data = await fetchPage(nextCursor); + const items = Array.isArray(data.item_list) ? data.item_list : []; + rows.push(...items.map((item) => ({ + title: (item.desc || item.title || '').replace(/\\s+/g, ' ').trim(), + date: formatDate(item.post_time || item.create_time || item.schedule_time), + views: toNumber(item.play_count), + likes: toNumber(item.like_count), + comments: toNumber(item.comment_count), + saves: toNumber(item.favorite_count), + shares: toNumber(item.share_count), + url: videoUrl(item), + }))); + if (!data.has_more || !items.length) break; + nextCursor = data.cursor; + await wait(250); + } + + if (!rows.length) throw new Error('No TikTok Studio creator videos found; check login/session and Studio content page.'); + return rows.slice(0, limit); +})() +` }, + ], +}); \ No newline at end of file From aa2e4c3df55e0bc1467123cd7c80e55a40251f78 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 00:26:19 +0800 Subject: [PATCH 2/3] fix(tiktok): regen manifest + replace silent-clamp with ArgumentError - Regenerate cli-manifest.json (CI gate: must match `npm run build` output) - Replace `Math.max(1, Number(args.limit) || 20)` and `Math.min(Math.max(limit, 1), 50)` with an explicit positive-integer guard + a server-cap-only ternary, per the silent-clamp guidance in references/typed-errors.md (typed-error-lint baseline is unchanged) Co-Authored-By: Claude Opus 4.7 (1M context) --- cli-manifest.json | 39 +++++++++++++++++++++++++++++++++++ clis/tiktok/creator-videos.js | 10 ++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/cli-manifest.json b/cli-manifest.json index cfe4f521a..eb5165534 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -17614,6 +17614,45 @@ "sourceFile": "tiktok/comment.js", "navigateBefore": "https://www.tiktok.com" }, + { + "site": "tiktok", + "name": "creator-videos", + "description": "TikTok Studio creator content list (views/likes/comments/saves/shares)", + "access": "read", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of creator videos to return" + }, + { + "name": "cursor", + "type": "string", + "default": "0", + "required": false, + "help": "Pagination cursor" + } + ], + "columns": [ + "title", + "date", + "views", + "likes", + "comments", + "saves", + "shares", + "url" + ], + "type": "js", + "modulePath": "tiktok/creator-videos.js", + "sourceFile": "tiktok/creator-videos.js", + "navigateBefore": "https://www.tiktok.com" + }, { "site": "tiktok", "name": "explore", diff --git a/clis/tiktok/creator-videos.js b/clis/tiktok/creator-videos.js index 629d8a558..9de171070 100644 --- a/clis/tiktok/creator-videos.js +++ b/clis/tiktok/creator-videos.js @@ -17,12 +17,16 @@ cli({ { navigate: { url: 'https://www.tiktok.com/tiktokstudio/content', settleMs: 6000 } }, { evaluate: `(async () => { - const limit = Math.max(1, Number(\${{ args.limit }}) || 20); + const limit = Number(\${{ args.limit }}); + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('limit must be a positive integer'); + } const cursor = \${{ args.cursor | json }}; const apiPath = '${ITEM_LIST_API_PATH}'; const apiUrl = apiPath + '?aid=1988'; - const pageSize = Math.min(Math.max(limit, 1), 50); - const maxPages = Math.max(1, Math.ceil(limit / pageSize)); + const SERVER_PAGE_MAX = 50; + const pageSize = limit < SERVER_PAGE_MAX ? limit : SERVER_PAGE_MAX; + const maxPages = Math.ceil(limit / pageSize); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function toNumber(value) { From 96a8bfa282ed1fff6ceb707e77549455cb397f3d Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 6 May 2026 00:53:23 +0800 Subject: [PATCH 3/3] fix(tiktok): tighten creator videos contract --- cli-manifest.json | 7 +- clis/tiktok/creator-videos.js | 337 +++++++++++++++++++++-------- clis/tiktok/creator-videos.test.js | 113 ++++++++++ docs/adapters/browser/tiktok.md | 5 + 4 files changed, 368 insertions(+), 94 deletions(-) create mode 100644 clis/tiktok/creator-videos.test.js diff --git a/cli-manifest.json b/cli-manifest.json index eb5165534..f4355459e 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -17628,17 +17628,18 @@ "type": "int", "default": 20, "required": false, - "help": "Number of creator videos to return" + "help": "Number of creator videos to return (max 250)" }, { "name": "cursor", "type": "string", "default": "0", "required": false, - "help": "Pagination cursor" + "help": "Non-negative TikTok Studio pagination cursor" } ], "columns": [ + "video_id", "title", "date", "views", @@ -17651,7 +17652,7 @@ "type": "js", "modulePath": "tiktok/creator-videos.js", "sourceFile": "tiktok/creator-videos.js", - "navigateBefore": "https://www.tiktok.com" + "navigateBefore": "https://www.tiktok.com/tiktokstudio/content" }, { "site": "tiktok", diff --git a/clis/tiktok/creator-videos.js b/clis/tiktok/creator-videos.js index 9de171070..e23834de2 100644 --- a/clis/tiktok/creator-videos.js +++ b/clis/tiktok/creator-videos.js @@ -1,115 +1,270 @@ -import { cli } from '@jackwener/opencli/registry'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + ArgumentError, + AuthRequiredError, + CommandExecutionError, + EmptyResultError, + getErrorMessage, +} from '@jackwener/opencli/errors'; +const STUDIO_CONTENT_URL = 'https://www.tiktok.com/tiktokstudio/content'; const ITEM_LIST_API_PATH = '/tiktok/creator/manage/item_list/v1/'; +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 250; +const SERVER_PAGE_MAX = 50; -cli({ - site: 'tiktok', - name: 'creator-videos', - access: 'read', - description: 'TikTok Studio creator content list (views/likes/comments/saves/shares)', - domain: 'www.tiktok.com', - args: [ - { name: 'limit', type: 'int', default: 20, help: 'Number of creator videos to return' }, - { name: 'cursor', type: 'string', default: '0', help: 'Pagination cursor' }, - ], - columns: ['title', 'date', 'views', 'likes', 'comments', 'saves', 'shares', 'url'], - pipeline: [ - { navigate: { url: 'https://www.tiktok.com/tiktokstudio/content', settleMs: 6000 } }, - { - evaluate: `(async () => { - const limit = Number(\${{ args.limit }}); - if (!Number.isInteger(limit) || limit <= 0) { - throw new Error('limit must be a positive integer'); +function requirePositiveInt(value, label, defaultValue, maxValue) { + const raw = value ?? defaultValue; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new ArgumentError(`${label} must be a positive integer`, `Example: opencli tiktok creator-videos --${label} ${defaultValue}`); + } + if (parsed > maxValue) { + throw new ArgumentError(`${label} must be <= ${maxValue}`, `Example: opencli tiktok creator-videos --${label} ${maxValue}`); + } + return parsed; +} + +function requireCursor(value) { + const raw = value ?? '0'; + const text = String(raw).trim(); + if (!/^\d+$/.test(text)) { + throw new ArgumentError('cursor must be a non-negative integer string', 'Example: opencli tiktok creator-videos --cursor 0'); + } + const cursor = Number(text); + if (!Number.isSafeInteger(cursor)) { + throw new ArgumentError('cursor must be a safe integer', 'Example: opencli tiktok creator-videos --cursor 0'); + } + return cursor; +} + +function buildItemListRequest(cursor, size) { + return { + cursor, + size, + query: { + conditions: [], + sort_orders: [{ field_name: 'create_time', order: 2 }], + }, + }; +} + +function buildFetchItemListScript(body) { + const request = { + url: `${ITEM_LIST_API_PATH}?aid=1988`, + body, + }; + return ` +(async () => { + const request = ${JSON.stringify(request)}; + try { + const res = await fetch(request.url, { + method: 'POST', + credentials: 'include', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(request.body), + }); + const text = await res.text(); + let data = null; + if (text.trim()) { + try { + data = JSON.parse(text); + } catch (error) { + return { + ok: false, + status: res.status, + statusText: res.statusText, + parseError: error instanceof Error ? error.message : String(error), + text: text.slice(0, 500), + }; + } + } + return { + ok: res.ok, + status: res.status, + statusText: res.statusText, + data, + text: text.slice(0, 500), + }; + } catch (error) { + return { + ok: false, + status: 0, + statusText: '', + networkError: error instanceof Error ? error.message : String(error), + }; } - const cursor = \${{ args.cursor | json }}; - const apiPath = '${ITEM_LIST_API_PATH}'; - const apiUrl = apiPath + '?aid=1988'; - const SERVER_PAGE_MAX = 50; - const pageSize = limit < SERVER_PAGE_MAX ? limit : SERVER_PAGE_MAX; - const maxPages = Math.ceil(limit / pageSize); - const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - - function toNumber(value) { +})() +`; +} + +function looksAuthFailure(message) { + return /\b(auth|login|log in|permission|unauthori[sz]ed|forbidden)\b/i.test(message); +} + +function unwrapPayload(data) { + if (!data || typeof data !== 'object') { + throw new CommandExecutionError('TikTok Studio item_list returned an empty response'); + } + return data.data && typeof data.data === 'object' ? data.data : data; +} + +function assertApiSuccess(data) { + const statusCode = data.status_code ?? data.statusCode; + const statusMsg = String(data.status_msg ?? data.statusMsg ?? '').trim(); + if (statusCode !== undefined && Number(statusCode) !== 0) { + if (looksAuthFailure(statusMsg)) { + throw new AuthRequiredError('www.tiktok.com', `TikTok Studio item_list requires login: ${statusMsg || statusCode}`); + } + throw new CommandExecutionError(`TikTok Studio item_list failed: ${statusMsg || statusCode}`); + } + if (statusMsg && !/^(success|ok)$/i.test(statusMsg)) { + if (looksAuthFailure(statusMsg)) { + throw new AuthRequiredError('www.tiktok.com', `TikTok Studio item_list requires login: ${statusMsg}`); + } + throw new CommandExecutionError(`TikTok Studio item_list failed: ${statusMsg}`); + } +} + +function normalizeNumber(value) { const n = Number(value); return Number.isFinite(n) ? n : 0; - } +} - function formatDate(value) { +function formatDate(value) { const seconds = Number(value); if (!Number.isFinite(seconds) || seconds <= 0) return ''; return new Date(seconds * 1000).toLocaleString('zh-CN', { - timeZone: 'Asia/Shanghai', - hour12: false, + timeZone: 'Asia/Shanghai', + hour12: false, }); - } +} + +function extractUsername(item) { + const direct = item.author?.unique_id ?? item.author?.uniqueId ?? item.author_unique_id ?? item.authorUniqueId ?? item.user_name ?? item.username; + if (direct) return String(direct); - function extractUsername(item) { const blobs = [ - ...(Array.isArray(item.play_addr) ? item.play_addr : []), - ...(item.download_info && Array.isArray(item.download_info.download_urls) ? item.download_info.download_urls : []), + ...(Array.isArray(item.play_addr) ? item.play_addr : []), + ...(item.download_info && Array.isArray(item.download_info.download_urls) ? item.download_info.download_urls : []), ]; for (const raw of blobs) { - try { - const match = String(raw).match(/[?&]user_text=([^&]+)/); - if (match) return decodeURIComponent(match[1]); - } catch (_) {} + try { + const match = String(raw).match(/[?&]user_text=([^&]+)/); + if (match) return decodeURIComponent(match[1]); + } catch { + // Keep scanning other candidate URLs. + } } return ''; - } +} - function videoUrl(item) { - const id = item.item_id || item.id || ''; - if (!id) return ''; - const anchor = document.querySelector('a[href*="/video/' + CSS.escape(String(id)) + '"]'); - if (anchor) return new URL(anchor.getAttribute('href'), location.origin).href; +function normalizeRow(item) { + if (!item || typeof item !== 'object') return null; + const videoId = String(item.item_id ?? item.id ?? '').trim(); + if (!videoId) return null; const username = extractUsername(item); - return username ? 'https://www.tiktok.com/@' + encodeURIComponent(username) + '/video/' + encodeURIComponent(id) : ''; - } - - async function fetchPage(nextCursor) { - const body = { - cursor: Number(nextCursor) || 0, - size: pageSize, - query: { - conditions: [], - sort_orders: [{ field_name: 'create_time', order: 2 }], - }, + const url = username + ? `https://www.tiktok.com/@${encodeURIComponent(username)}/video/${encodeURIComponent(videoId)}` + : ''; + return { + video_id: videoId, + title: String(item.desc ?? item.title ?? '').replace(/\s+/g, ' ').trim(), + date: formatDate(item.post_time ?? item.create_time ?? item.schedule_time), + views: normalizeNumber(item.play_count), + likes: normalizeNumber(item.like_count), + comments: normalizeNumber(item.comment_count), + saves: normalizeNumber(item.favorite_count), + shares: normalizeNumber(item.share_count), + url, }; - const res = await fetch(apiUrl, { - method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), +} + +async function fetchCreatorVideosPage(page, cursor, size) { + const result = await page.evaluate(buildFetchItemListScript(buildItemListRequest(cursor, size))).catch((error) => { + throw new CommandExecutionError(`Failed to fetch TikTok Studio item_list: ${getErrorMessage(error)}`); }); - if (!res.ok) throw new Error('TikTok Studio item_list HTTP ' + res.status); - const data = await res.json(); - if (data.status_msg) throw new Error('TikTok Studio item_list failed: ' + data.status_msg); - return data; - } + if (!result || typeof result !== 'object') { + throw new CommandExecutionError('TikTok Studio item_list returned an unreadable response'); + } + if (result.networkError) { + throw new CommandExecutionError(`TikTok Studio item_list network failure: ${result.networkError}`); + } + if (result.status === 401 || result.status === 403) { + throw new AuthRequiredError('www.tiktok.com', `TikTok Studio item_list requires login (HTTP ${result.status})`); + } + if (!result.ok) { + const detail = result.parseError + ? `invalid JSON (${result.parseError})` + : `HTTP ${result.status || 0}${result.statusText ? ` ${result.statusText}` : ''}`; + throw new CommandExecutionError(`TikTok Studio item_list failed: ${detail}`, result.text ? `Response preview: ${result.text}` : undefined); + } + const payload = unwrapPayload(result.data); + assertApiSuccess(payload); + return payload; +} - const rows = []; - let nextCursor = cursor; - for (let page = 0; page < maxPages && rows.length < limit; page++) { - const data = await fetchPage(nextCursor); - const items = Array.isArray(data.item_list) ? data.item_list : []; - rows.push(...items.map((item) => ({ - title: (item.desc || item.title || '').replace(/\\s+/g, ' ').trim(), - date: formatDate(item.post_time || item.create_time || item.schedule_time), - views: toNumber(item.play_count), - likes: toNumber(item.like_count), - comments: toNumber(item.comment_count), - saves: toNumber(item.favorite_count), - shares: toNumber(item.share_count), - url: videoUrl(item), - }))); - if (!data.has_more || !items.length) break; - nextCursor = data.cursor; - await wait(250); - } +async function listCreatorVideos(page, args) { + const limit = requirePositiveInt(args.limit, 'limit', DEFAULT_LIMIT, MAX_LIMIT); + let nextCursor = requireCursor(args.cursor); + const rows = []; + let skippedMissingId = 0; + const pageSize = limit > SERVER_PAGE_MAX ? SERVER_PAGE_MAX : limit; + const maxPages = Math.ceil(limit / pageSize); - if (!rows.length) throw new Error('No TikTok Studio creator videos found; check login/session and Studio content page.'); - return rows.slice(0, limit); -})() -` }, + await page.goto(STUDIO_CONTENT_URL, { waitUntil: 'load', settleMs: 6000 }); + + for (let pageIndex = 0; pageIndex < maxPages && rows.length < limit; pageIndex += 1) { + const data = await fetchCreatorVideosPage(page, nextCursor, pageSize); + const items = Array.isArray(data.item_list) ? data.item_list : []; + for (const item of items) { + const row = normalizeRow(item); + if (!row) { + skippedMissingId += 1; + continue; + } + rows.push(row); + if (rows.length >= limit) break; + } + if (!data.has_more || items.length === 0) break; + nextCursor = requireCursor(data.cursor); + await page.wait(250); + } + + if (rows.length === 0 && skippedMissingId > 0) { + throw new CommandExecutionError('TikTok Studio item_list returned videos without stable video_id'); + } + if (rows.length === 0) { + throw new EmptyResultError('tiktok creator-videos', 'No creator videos were returned. Confirm the current Chrome profile is logged in to TikTok Studio and has published content.'); + } + return rows.slice(0, limit); +} + +export const creatorVideosCommand = cli({ + site: 'tiktok', + name: 'creator-videos', + access: 'read', + description: 'TikTok Studio creator content list (views/likes/comments/saves/shares)', + domain: 'www.tiktok.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: STUDIO_CONTENT_URL, + args: [ + { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of creator videos to return (max ${MAX_LIMIT})` }, + { name: 'cursor', type: 'string', default: '0', help: 'Non-negative TikTok Studio pagination cursor' }, ], -}); \ No newline at end of file + columns: ['video_id', 'title', 'date', 'views', 'likes', 'comments', 'saves', 'shares', 'url'], + func: listCreatorVideos, +}); + +export const __test__ = { + buildFetchItemListScript, + buildItemListRequest, + extractUsername, + normalizeRow, + requireCursor, + requirePositiveInt, +}; diff --git a/clis/tiktok/creator-videos.test.js b/clis/tiktok/creator-videos.test.js new file mode 100644 index 000000000..9ff96529d --- /dev/null +++ b/clis/tiktok/creator-videos.test.js @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { creatorVideosCommand, __test__ } from './creator-videos.js'; + +function makePage(evaluateResults = []) { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + evaluate.mockResolvedValue({ ok: true, data: { item_list: [], has_more: false } }); + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate, + }; +} + +const apiItem = { + item_id: '7350000000000000000', + desc: 'hello\nworld', + create_time: 1710000000, + play_count: '123', + like_count: '12', + comment_count: '3', + favorite_count: '4', + share_count: '5', + author: { uniqueId: 'creator' }, +}; + +describe('tiktok/creator-videos', () => { + it('registers a read-only browser command with stable video_id column', () => { + expect(creatorVideosCommand.access).toBe('read'); + expect(creatorVideosCommand.browser).toBe(true); + expect(creatorVideosCommand.columns).toEqual([ + 'video_id', + 'title', + 'date', + 'views', + 'likes', + 'comments', + 'saves', + 'shares', + 'url', + ]); + }); + + it('validates args before navigating', async () => { + const page = makePage(); + + await expect(creatorVideosCommand.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError); + await expect(creatorVideosCommand.func(page, { limit: 251 })).rejects.toBeInstanceOf(ArgumentError); + await expect(creatorVideosCommand.func(page, { cursor: 'abc' })).rejects.toBeInstanceOf(ArgumentError); + + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('maps TikTok Studio API rows and keeps video_id even when URL can be built', async () => { + const page = makePage([ + { ok: true, data: { status_code: 0, status_msg: 'success', item_list: [apiItem], has_more: false } }, + ]); + + const rows = await creatorVideosCommand.func(page, { limit: 1 }); + + expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/tiktokstudio/content', { waitUntil: 'load', settleMs: 6000 }); + expect(rows).toEqual([{ + video_id: '7350000000000000000', + title: 'hello world', + date: expect.any(String), + views: 123, + likes: 12, + comments: 3, + saves: 4, + shares: 5, + url: 'https://www.tiktok.com/@creator/video/7350000000000000000', + }]); + }); + + it('uses explicit cursor validation for follow-up pages instead of fallback-to-zero', async () => { + const page = makePage([ + { ok: true, data: { item_list: [apiItem], has_more: true, cursor: 'bad-cursor' } }, + ]); + + await expect(creatorVideosCommand.func(page, { limit: 51 })).rejects.toBeInstanceOf(ArgumentError); + }); + + it('maps auth, API, empty, and missing-id states to typed errors', async () => { + await expect(creatorVideosCommand.func(makePage([ + { ok: false, status: 403, statusText: 'Forbidden' }, + ]), { limit: 1 })).rejects.toBeInstanceOf(AuthRequiredError); + + await expect(creatorVideosCommand.func(makePage([ + { ok: true, data: { status_code: 1001, status_msg: 'creator permission denied' } }, + ]), { limit: 1 })).rejects.toBeInstanceOf(AuthRequiredError); + + await expect(creatorVideosCommand.func(makePage([ + { ok: true, data: { status_code: 500, status_msg: 'internal error' } }, + ]), { limit: 1 })).rejects.toBeInstanceOf(CommandExecutionError); + + await expect(creatorVideosCommand.func(makePage([ + { ok: true, data: { item_list: [], has_more: false } }, + ]), { limit: 1 })).rejects.toBeInstanceOf(EmptyResultError); + + await expect(creatorVideosCommand.func(makePage([ + { ok: true, data: { item_list: [{ desc: 'missing id' }], has_more: false } }, + ]), { limit: 1 })).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('extracts username from TikTok media URLs when author fields are absent', () => { + expect(__test__.extractUsername({ + play_addr: ['https://example.invalid/video?user_text=test_user&x=1'], + })).toBe('test_user'); + }); +}); diff --git a/docs/adapters/browser/tiktok.md b/docs/adapters/browser/tiktok.md index 1905d93b9..5143ba6cf 100644 --- a/docs/adapters/browser/tiktok.md +++ b/docs/adapters/browser/tiktok.md @@ -14,6 +14,7 @@ | `opencli tiktok friends` | Friend suggestions | | `opencli tiktok live` | Browse live streams | | `opencli tiktok notifications` | Get notifications | +| `opencli tiktok creator-videos` | List TikTok Studio creator videos and metrics | | `opencli tiktok like` | Like a video | | `opencli tiktok unlike` | Unlike a video | | `opencli tiktok save` | Add to Favorites | @@ -40,6 +41,9 @@ opencli tiktok live --limit 10 # List who you follow opencli tiktok following +# List your TikTok Studio creator videos +opencli tiktok creator-videos --limit 20 + # Friend suggestions opencli tiktok friends --limit 10 @@ -66,3 +70,4 @@ opencli tiktok profile --username tiktok -f json - Chrome running and **logged into** tiktok.com - [Browser Bridge extension](/guide/browser-bridge) installed +- `creator-videos` requires access to TikTok Studio for the logged-in creator account