@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.
Files changed (209) hide show
  1. package/.github/actions/setup-chrome/action.yml +5 -4
  2. package/.github/workflows/ci.yml +17 -3
  3. package/.github/workflows/e2e-headed.yml +16 -3
  4. package/CHANGELOG.md +23 -0
  5. package/PRIVACY.md +57 -0
  6. package/README.md +1 -1
  7. package/README.zh-CN.md +1 -1
  8. package/SKILL.md +101 -2
  9. package/dist/cli-manifest.json +720 -32
  10. package/dist/clis/apple-podcasts/search.js +2 -1
  11. package/dist/clis/arxiv/search.js +2 -2
  12. package/dist/clis/bbc/news.js +0 -1
  13. package/dist/clis/ctrip/search.js +0 -1
  14. package/dist/clis/douyin/_shared/browser-fetch.d.ts +10 -0
  15. package/dist/clis/douyin/_shared/browser-fetch.js +30 -0
  16. package/dist/clis/douyin/_shared/browser-fetch.test.d.ts +1 -0
  17. package/dist/clis/douyin/_shared/browser-fetch.test.js +31 -0
  18. package/dist/clis/douyin/_shared/creation-id.d.ts +1 -0
  19. package/dist/clis/douyin/_shared/creation-id.js +5 -0
  20. package/dist/clis/douyin/_shared/creation-id.test.d.ts +1 -0
  21. package/dist/clis/douyin/_shared/creation-id.test.js +22 -0
  22. package/dist/clis/douyin/_shared/imagex-upload.d.ts +20 -0
  23. package/dist/clis/douyin/_shared/imagex-upload.js +53 -0
  24. package/dist/clis/douyin/_shared/imagex-upload.test.d.ts +1 -0
  25. package/dist/clis/douyin/_shared/imagex-upload.test.js +87 -0
  26. package/dist/clis/douyin/_shared/sts2.d.ts +8 -0
  27. package/dist/clis/douyin/_shared/sts2.js +15 -0
  28. package/dist/clis/douyin/_shared/text-extra.d.ts +18 -0
  29. package/dist/clis/douyin/_shared/text-extra.js +15 -0
  30. package/dist/clis/douyin/_shared/text-extra.test.d.ts +1 -0
  31. package/dist/clis/douyin/_shared/text-extra.test.js +37 -0
  32. package/dist/clis/douyin/_shared/timing.d.ts +2 -0
  33. package/dist/clis/douyin/_shared/timing.js +22 -0
  34. package/dist/clis/douyin/_shared/timing.test.d.ts +1 -0
  35. package/dist/clis/douyin/_shared/timing.test.js +28 -0
  36. package/dist/clis/douyin/_shared/tos-upload-short-read.test.d.ts +11 -0
  37. package/dist/clis/douyin/_shared/tos-upload-short-read.test.js +83 -0
  38. package/dist/clis/douyin/_shared/tos-upload.d.ts +53 -0
  39. package/dist/clis/douyin/_shared/tos-upload.js +295 -0
  40. package/dist/clis/douyin/_shared/tos-upload.test.d.ts +1 -0
  41. package/dist/clis/douyin/_shared/tos-upload.test.js +229 -0
  42. package/dist/clis/douyin/_shared/transcode.d.ts +27 -0
  43. package/dist/clis/douyin/_shared/transcode.js +45 -0
  44. package/dist/clis/douyin/_shared/transcode.test.d.ts +1 -0
  45. package/dist/clis/douyin/_shared/transcode.test.js +93 -0
  46. package/dist/clis/douyin/_shared/types.d.ts +26 -0
  47. package/dist/clis/douyin/_shared/types.js +1 -0
  48. package/dist/clis/douyin/activities.d.ts +1 -0
  49. package/dist/clis/douyin/activities.js +20 -0
  50. package/dist/clis/douyin/activities.test.d.ts +1 -0
  51. package/dist/clis/douyin/activities.test.js +22 -0
  52. package/dist/clis/douyin/collections.d.ts +1 -0
  53. package/dist/clis/douyin/collections.js +22 -0
  54. package/dist/clis/douyin/collections.test.d.ts +1 -0
  55. package/dist/clis/douyin/collections.test.js +23 -0
  56. package/dist/clis/douyin/delete.d.ts +1 -0
  57. package/dist/clis/douyin/delete.js +18 -0
  58. package/dist/clis/douyin/delete.test.d.ts +1 -0
  59. package/dist/clis/douyin/delete.test.js +11 -0
  60. package/dist/clis/douyin/draft.d.ts +14 -0
  61. package/dist/clis/douyin/draft.js +237 -0
  62. package/dist/clis/douyin/draft.test.d.ts +1 -0
  63. package/dist/clis/douyin/draft.test.js +11 -0
  64. package/dist/clis/douyin/drafts.d.ts +1 -0
  65. package/dist/clis/douyin/drafts.js +23 -0
  66. package/dist/clis/douyin/drafts.test.d.ts +1 -0
  67. package/dist/clis/douyin/drafts.test.js +11 -0
  68. package/dist/clis/douyin/hashtag.d.ts +1 -0
  69. package/dist/clis/douyin/hashtag.js +45 -0
  70. package/dist/clis/douyin/hashtag.test.d.ts +1 -0
  71. package/dist/clis/douyin/hashtag.test.js +25 -0
  72. package/dist/clis/douyin/location.d.ts +1 -0
  73. package/dist/clis/douyin/location.js +24 -0
  74. package/dist/clis/douyin/location.test.d.ts +1 -0
  75. package/dist/clis/douyin/location.test.js +23 -0
  76. package/dist/clis/douyin/profile.d.ts +1 -0
  77. package/dist/clis/douyin/profile.js +28 -0
  78. package/dist/clis/douyin/profile.test.d.ts +1 -0
  79. package/dist/clis/douyin/profile.test.js +11 -0
  80. package/dist/clis/douyin/publish.d.ts +14 -0
  81. package/dist/clis/douyin/publish.js +288 -0
  82. package/dist/clis/douyin/publish.test.d.ts +1 -0
  83. package/dist/clis/douyin/publish.test.js +38 -0
  84. package/dist/clis/douyin/stats.d.ts +1 -0
  85. package/dist/clis/douyin/stats.js +27 -0
  86. package/dist/clis/douyin/stats.test.d.ts +1 -0
  87. package/dist/clis/douyin/stats.test.js +22 -0
  88. package/dist/clis/douyin/update.d.ts +1 -0
  89. package/dist/clis/douyin/update.js +31 -0
  90. package/dist/clis/douyin/update.test.d.ts +1 -0
  91. package/dist/clis/douyin/update.test.js +11 -0
  92. package/dist/clis/douyin/videos.d.ts +1 -0
  93. package/dist/clis/douyin/videos.js +34 -0
  94. package/dist/clis/douyin/videos.test.d.ts +1 -0
  95. package/dist/clis/douyin/videos.test.js +11 -0
  96. package/dist/clis/hackernews/search.yaml +1 -1
  97. package/dist/clis/instagram/search.yaml +2 -1
  98. package/dist/clis/linux-do/search.yaml +3 -1
  99. package/dist/clis/medium/search.js +1 -1
  100. package/dist/clis/reuters/search.js +0 -1
  101. package/dist/clis/twitter/search.js +5 -3
  102. package/dist/clis/twitter/search.test.js +54 -2
  103. package/dist/clis/weibo/comments.d.ts +1 -0
  104. package/dist/clis/weibo/comments.js +53 -0
  105. package/dist/clis/weibo/feed.d.ts +1 -0
  106. package/dist/clis/weibo/feed.js +56 -0
  107. package/dist/clis/weibo/hot.js +0 -1
  108. package/dist/clis/weibo/me.d.ts +1 -0
  109. package/dist/clis/weibo/me.js +76 -0
  110. package/dist/clis/weibo/post.d.ts +1 -0
  111. package/dist/clis/weibo/post.js +75 -0
  112. package/dist/clis/weibo/user.d.ts +1 -0
  113. package/dist/clis/weibo/user.js +63 -0
  114. package/dist/clis/weibo/utils.d.ts +6 -0
  115. package/dist/clis/weibo/utils.js +30 -0
  116. package/dist/clis/weread/search.js +3 -2
  117. package/dist/clis/xueqiu/search.yaml +2 -1
  118. package/dist/clis/yahoo-finance/quote.js +0 -1
  119. package/dist/clis/youtube/channel.d.ts +1 -0
  120. package/dist/clis/youtube/channel.js +150 -0
  121. package/dist/clis/youtube/comments.d.ts +1 -0
  122. package/dist/clis/youtube/comments.js +95 -0
  123. package/dist/clis/youtube/search.js +0 -1
  124. package/dist/clis/zhihu/search.yaml +2 -1
  125. package/dist/external-clis.yaml +0 -17
  126. package/dist/weread-search-regression.test.d.ts +1 -0
  127. package/dist/weread-search-regression.test.js +39 -0
  128. package/docs/.vitepress/config.mts +13 -0
  129. package/docs/adapters/browser/douyin.md +75 -0
  130. package/docs/adapters/browser/twitter.md +6 -0
  131. package/docs/adapters/index.md +6 -1
  132. package/extension/dist/background.js +508 -518
  133. package/extension/manifest.json +6 -2
  134. package/extension/package.json +1 -1
  135. package/extension/popup.html +84 -0
  136. package/extension/popup.js +25 -0
  137. package/extension/src/background.ts +20 -1
  138. package/package.json +1 -1
  139. package/src/clis/apple-podcasts/search.ts +2 -1
  140. package/src/clis/arxiv/search.ts +2 -2
  141. package/src/clis/bbc/news.ts +0 -1
  142. package/src/clis/ctrip/search.ts +0 -1
  143. package/src/clis/douyin/_shared/browser-fetch.test.ts +38 -0
  144. package/src/clis/douyin/_shared/browser-fetch.ts +45 -0
  145. package/src/clis/douyin/_shared/creation-id.test.ts +26 -0
  146. package/src/clis/douyin/_shared/creation-id.ts +8 -0
  147. package/src/clis/douyin/_shared/imagex-upload.test.ts +113 -0
  148. package/src/clis/douyin/_shared/imagex-upload.ts +76 -0
  149. package/src/clis/douyin/_shared/sts2.ts +20 -0
  150. package/src/clis/douyin/_shared/text-extra.test.ts +42 -0
  151. package/src/clis/douyin/_shared/text-extra.ts +33 -0
  152. package/src/clis/douyin/_shared/timing.test.ts +38 -0
  153. package/src/clis/douyin/_shared/timing.ts +22 -0
  154. package/src/clis/douyin/_shared/tos-upload-short-read.test.ts +102 -0
  155. package/src/clis/douyin/_shared/tos-upload.test.ts +281 -0
  156. package/src/clis/douyin/_shared/tos-upload.ts +444 -0
  157. package/src/clis/douyin/_shared/transcode.test.ts +117 -0
  158. package/src/clis/douyin/_shared/transcode.ts +78 -0
  159. package/src/clis/douyin/_shared/types.ts +29 -0
  160. package/src/clis/douyin/activities.test.ts +25 -0
  161. package/src/clis/douyin/activities.ts +23 -0
  162. package/src/clis/douyin/collections.test.ts +26 -0
  163. package/src/clis/douyin/collections.ts +25 -0
  164. package/src/clis/douyin/delete.test.ts +12 -0
  165. package/src/clis/douyin/delete.ts +20 -0
  166. package/src/clis/douyin/draft.test.ts +12 -0
  167. package/src/clis/douyin/draft.ts +282 -0
  168. package/src/clis/douyin/drafts.test.ts +12 -0
  169. package/src/clis/douyin/drafts.ts +27 -0
  170. package/src/clis/douyin/hashtag.test.ts +28 -0
  171. package/src/clis/douyin/hashtag.ts +56 -0
  172. package/src/clis/douyin/location.test.ts +26 -0
  173. package/src/clis/douyin/location.ts +27 -0
  174. package/src/clis/douyin/profile.test.ts +12 -0
  175. package/src/clis/douyin/profile.ts +37 -0
  176. package/src/clis/douyin/publish.test.ts +45 -0
  177. package/src/clis/douyin/publish.ts +340 -0
  178. package/src/clis/douyin/stats.test.ts +25 -0
  179. package/src/clis/douyin/stats.ts +30 -0
  180. package/src/clis/douyin/update.test.ts +12 -0
  181. package/src/clis/douyin/update.ts +43 -0
  182. package/src/clis/douyin/videos.test.ts +12 -0
  183. package/src/clis/douyin/videos.ts +49 -0
  184. package/src/clis/hackernews/search.yaml +1 -1
  185. package/src/clis/instagram/search.yaml +2 -1
  186. package/src/clis/linux-do/search.yaml +3 -1
  187. package/src/clis/medium/search.ts +1 -1
  188. package/src/clis/reuters/search.ts +0 -1
  189. package/src/clis/twitter/search.test.ts +69 -2
  190. package/src/clis/twitter/search.ts +5 -3
  191. package/src/clis/weibo/comments.ts +54 -0
  192. package/src/clis/weibo/feed.ts +57 -0
  193. package/src/clis/weibo/hot.ts +0 -1
  194. package/src/clis/weibo/me.ts +77 -0
  195. package/src/clis/weibo/post.ts +77 -0
  196. package/src/clis/weibo/user.ts +64 -0
  197. package/src/clis/weibo/utils.ts +32 -0
  198. package/src/clis/weread/search.ts +3 -2
  199. package/src/clis/xueqiu/search.yaml +2 -1
  200. package/src/clis/yahoo-finance/quote.ts +0 -1
  201. package/src/clis/youtube/channel.ts +155 -0
  202. package/src/clis/youtube/comments.ts +97 -0
  203. package/src/clis/youtube/search.ts +0 -1
  204. package/src/clis/zhihu/search.yaml +2 -1
  205. package/src/external-clis.yaml +0 -17
  206. package/src/weread-search-regression.test.ts +44 -0
  207. package/tests/e2e/browser-public-extended.test.ts +162 -0
  208. package/tests/e2e/browser-public.test.ts +7 -146
  209. package/vitest.config.ts +24 -17
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'query', positional: true, required: true, help: 'Search keyword' },
12
12
  { name: 'limit', type: 'int', default: 10, help: 'Max results' },
13
13
  ],
14
- columns: ['id', 'title', 'author', 'episodes', 'genre'],
14
+ columns: ['id', 'title', 'author', 'episodes', 'genre', 'url'],
15
15
  func: async (_page, args) => {
16
16
  const term = encodeURIComponent(args.query);
17
17
  const limit = Math.max(1, Math.min(Number(args.limit), 25));
@@ -24,6 +24,7 @@ cli({
24
24
  author: p.artistName,
25
25
  episodes: p.trackCount ?? '-',
26
26
  genre: p.primaryGenreName ?? '-',
27
+ url: p.collectionViewUrl || '',
27
28
  }));
28
29
  },
29
30
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "attention is all you need")' },
12
12
  { name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
13
13
  ],
14
- columns: ['id', 'title', 'authors', 'published'],
14
+ columns: ['id', 'title', 'authors', 'published', 'url'],
15
15
  func: async (_page, args) => {
16
16
  const limit = Math.max(1, Math.min(Number(args.limit), 25));
17
17
  const query = encodeURIComponent(`all:${args.query}`);
@@ -19,6 +19,6 @@ cli({
19
19
  const entries = parseEntries(xml);
20
20
  if (!entries.length)
21
21
  throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword');
22
- return entries.map(e => ({ id: e.id, title: e.title, authors: e.authors, published: e.published }));
22
+ return entries.map(e => ({ id: e.id, title: e.title, authors: e.authors, published: e.published, url: e.url }));
23
23
  },
24
24
  });
@@ -1,6 +1,5 @@
1
1
  /**
2
2
  * BBC News headlines — public RSS feed, no browser needed.
3
- * Source: bb-sites/bbc/news.js
4
3
  */
5
4
  import { cli, Strategy } from '../../registry.js';
6
5
  cli({
@@ -1,6 +1,5 @@
1
1
  /**
2
2
  * 携程旅行搜索 — browser cookie, multi-strategy.
3
- * Source: bb-sites/ctrip/search.js (simplified to suggestion API)
4
3
  */
5
4
  import { cli, Strategy } from '../../registry.js';
6
5
  cli({
@@ -0,0 +1,10 @@
1
+ import type { IPage } from '../../../types.js';
2
+ export interface FetchOptions {
3
+ body?: unknown;
4
+ headers?: Record<string, string>;
5
+ }
6
+ /**
7
+ * Execute a fetch() call inside the Chrome browser context via page.evaluate.
8
+ * This ensures a_bogus signing and cookies are handled automatically by the browser.
9
+ */
10
+ export declare function browserFetch(page: IPage, method: 'GET' | 'POST', url: string, options?: FetchOptions): Promise<unknown>;
@@ -0,0 +1,30 @@
1
+ import { CommandExecutionError } from '../../../errors.js';
2
+ /**
3
+ * Execute a fetch() call inside the Chrome browser context via page.evaluate.
4
+ * This ensures a_bogus signing and cookies are handled automatically by the browser.
5
+ */
6
+ export async function browserFetch(page, method, url, options = {}) {
7
+ const js = `
8
+ (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
+ return res.json();
19
+ })()
20
+ `;
21
+ const result = await page.evaluate(js);
22
+ if (result && typeof result === 'object' && 'status_code' in result) {
23
+ const code = result.status_code;
24
+ if (code !== 0) {
25
+ const msg = result.status_msg ?? 'unknown error';
26
+ throw new CommandExecutionError(`Douyin API error ${code}: ${msg}`);
27
+ }
28
+ }
29
+ return result;
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { browserFetch } from './browser-fetch.js';
3
+ function makePage(result) {
4
+ return {
5
+ goto: vi.fn(), evaluate: vi.fn().mockResolvedValue(result),
6
+ getCookies: vi.fn(), snapshot: vi.fn(), click: vi.fn(),
7
+ typeText: vi.fn(), pressKey: vi.fn(), scrollTo: vi.fn(),
8
+ getFormState: vi.fn(), wait: vi.fn(), tabs: vi.fn(),
9
+ closeTab: vi.fn(), newTab: vi.fn(), selectTab: vi.fn(),
10
+ networkRequests: vi.fn(), consoleMessages: vi.fn(),
11
+ scroll: vi.fn(), autoScroll: vi.fn(),
12
+ installInterceptor: vi.fn(), getInterceptedRequests: vi.fn(),
13
+ screenshot: vi.fn(),
14
+ };
15
+ }
16
+ describe('browserFetch', () => {
17
+ it('returns parsed JSON on success', async () => {
18
+ const page = makePage({ status_code: 0, data: { ak: 'KEY' } });
19
+ const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
20
+ expect(result).toEqual({ status_code: 0, data: { ak: 'KEY' } });
21
+ });
22
+ it('throws when status_code is non-zero', async () => {
23
+ const page = makePage({ status_code: 8, message: 'fail' });
24
+ await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Douyin API error 8');
25
+ });
26
+ it('returns result even when no status_code field', async () => {
27
+ const page = makePage({ some_field: 'value' });
28
+ const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
29
+ expect(result).toEqual({ some_field: 'value' });
30
+ });
31
+ });
@@ -0,0 +1 @@
1
+ export declare function generateCreationId(): string;
@@ -0,0 +1,5 @@
1
+ const CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
2
+ export function generateCreationId() {
3
+ const random = Array.from({ length: 4 }, () => CHARS[Math.floor(Math.random() * CHARS.length)]).join('');
4
+ return 'pin' + random + Date.now();
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateCreationId } from './creation-id.js';
3
+ describe('generateCreationId', () => {
4
+ it('starts with "pin"', () => {
5
+ expect(generateCreationId()).toMatch(/^pin/);
6
+ });
7
+ it('has 4 random lowercase-alphanumeric chars after "pin"', () => {
8
+ expect(generateCreationId()).toMatch(/^pin[a-z0-9]{4}/);
9
+ });
10
+ it('ends with a numeric timestamp (ms)', () => {
11
+ const before = Date.now();
12
+ const id = generateCreationId();
13
+ const after = Date.now();
14
+ const ts = parseInt(id.replace(/^pin[a-z0-9]{4}/, ''), 10);
15
+ expect(ts).toBeGreaterThanOrEqual(before);
16
+ expect(ts).toBeLessThanOrEqual(after);
17
+ });
18
+ it('generates unique IDs', () => {
19
+ const ids = new Set(Array.from({ length: 100 }, generateCreationId));
20
+ expect(ids.size).toBe(100);
21
+ });
22
+ });
@@ -0,0 +1,20 @@
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
+ export interface ImageXUploadInfo {
8
+ /** Pre-signed PUT target URL (provided by the apply cover upload API) */
9
+ upload_url: string;
10
+ /** Image URI to use in create_v2 (returned from the apply step) */
11
+ store_uri: string;
12
+ }
13
+ /**
14
+ * Upload a cover image to ByteDance ImageX via a pre-signed PUT URL.
15
+ *
16
+ * @param imagePath - Local file path to the image (JPEG/PNG/etc.)
17
+ * @param uploadInfo - Upload URL and store_uri from the apply cover upload API
18
+ * @returns The store_uri (= image_uri for use in create_v2)
19
+ */
20
+ export declare function imagexUpload(imagePath: string, uploadInfo: ImageXUploadInfo): Promise<string>;
@@ -0,0 +1,53 @@
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
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { CommandExecutionError } from '../../../errors.js';
10
+ /**
11
+ * Detect MIME type from file extension.
12
+ * Falls back to image/jpeg for unknown extensions.
13
+ */
14
+ function detectContentType(filePath) {
15
+ const ext = path.extname(filePath).toLowerCase();
16
+ switch (ext) {
17
+ case '.png':
18
+ return 'image/png';
19
+ case '.gif':
20
+ return 'image/gif';
21
+ case '.webp':
22
+ return 'image/webp';
23
+ default:
24
+ return 'image/jpeg';
25
+ }
26
+ }
27
+ /**
28
+ * Upload a cover image to ByteDance ImageX via a pre-signed PUT URL.
29
+ *
30
+ * @param imagePath - Local file path to the image (JPEG/PNG/etc.)
31
+ * @param uploadInfo - Upload URL and store_uri from the apply cover upload API
32
+ * @returns The store_uri (= image_uri for use in create_v2)
33
+ */
34
+ export async function imagexUpload(imagePath, uploadInfo) {
35
+ if (!fs.existsSync(imagePath)) {
36
+ throw new CommandExecutionError(`Cover image file not found: ${imagePath}`, 'Ensure the file path is correct and accessible.');
37
+ }
38
+ const imageBuffer = fs.readFileSync(imagePath);
39
+ const contentType = detectContentType(imagePath);
40
+ const res = await fetch(uploadInfo.upload_url, {
41
+ method: 'PUT',
42
+ headers: {
43
+ 'Content-Type': contentType,
44
+ 'Content-Length': String(imageBuffer.byteLength),
45
+ },
46
+ body: imageBuffer,
47
+ });
48
+ if (!res.ok) {
49
+ const body = await res.text().catch(() => '');
50
+ throw new CommandExecutionError(`ImageX upload failed with status ${res.status}: ${body}`, 'Check that the upload URL is valid and has not expired.');
51
+ }
52
+ return uploadInfo.store_uri;
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
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
+ // ── Helpers ──────────────────────────────────────────────────────────────────
8
+ function makeTempImage(ext = '.jpg') {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'imagex-test-'));
10
+ const filePath = path.join(dir, `cover${ext}`);
11
+ fs.writeFileSync(filePath, Buffer.from([0xff, 0xd8, 0xff, 0xe0])); // minimal JPEG header bytes
12
+ return filePath;
13
+ }
14
+ const FAKE_UPLOAD_INFO = {
15
+ upload_url: 'https://imagex.bytedance.com/upload/presigned/fake',
16
+ store_uri: 'tos-cn-i-alisg.example.com/cover/abc123',
17
+ };
18
+ // ── Tests ─────────────────────────────────────────────────────────────────────
19
+ describe('imagexUpload', () => {
20
+ let imagePath;
21
+ beforeEach(() => {
22
+ imagePath = makeTempImage('.jpg');
23
+ });
24
+ afterEach(() => {
25
+ // Clean up temp files
26
+ try {
27
+ fs.unlinkSync(imagePath);
28
+ fs.rmdirSync(path.dirname(imagePath));
29
+ }
30
+ catch {
31
+ // ignore cleanup errors
32
+ }
33
+ vi.restoreAllMocks();
34
+ });
35
+ it('throws CommandExecutionError when image file does not exist', async () => {
36
+ await expect(imagexUpload('/nonexistent/path/cover.jpg', FAKE_UPLOAD_INFO)).rejects.toThrow(CommandExecutionError);
37
+ await expect(imagexUpload('/nonexistent/path/cover.jpg', FAKE_UPLOAD_INFO)).rejects.toThrow('Cover image file not found');
38
+ });
39
+ it('PUTs the image and returns store_uri on success', async () => {
40
+ const mockFetch = vi.fn().mockResolvedValue({
41
+ ok: true,
42
+ status: 200,
43
+ text: vi.fn().mockResolvedValue(''),
44
+ });
45
+ vi.stubGlobal('fetch', mockFetch);
46
+ const result = await imagexUpload(imagePath, FAKE_UPLOAD_INFO);
47
+ expect(result).toBe(FAKE_UPLOAD_INFO.store_uri);
48
+ expect(mockFetch).toHaveBeenCalledOnce();
49
+ const [url, init] = mockFetch.mock.calls[0];
50
+ expect(url).toBe(FAKE_UPLOAD_INFO.upload_url);
51
+ expect(init.method).toBe('PUT');
52
+ expect(init.headers['Content-Type']).toBe('image/jpeg');
53
+ });
54
+ it('uses image/png Content-Type for .png files', async () => {
55
+ const pngPath = makeTempImage('.png');
56
+ const mockFetch = vi.fn().mockResolvedValue({
57
+ ok: true,
58
+ status: 200,
59
+ text: vi.fn().mockResolvedValue(''),
60
+ });
61
+ vi.stubGlobal('fetch', mockFetch);
62
+ try {
63
+ await imagexUpload(pngPath, FAKE_UPLOAD_INFO);
64
+ const [, init] = mockFetch.mock.calls[0];
65
+ expect(init.headers['Content-Type']).toBe('image/png');
66
+ }
67
+ finally {
68
+ try {
69
+ fs.unlinkSync(pngPath);
70
+ fs.rmdirSync(path.dirname(pngPath));
71
+ }
72
+ catch {
73
+ // ignore
74
+ }
75
+ }
76
+ });
77
+ it('throws CommandExecutionError on non-2xx PUT response', async () => {
78
+ const mockFetch = vi.fn().mockResolvedValue({
79
+ ok: false,
80
+ status: 403,
81
+ text: vi.fn().mockResolvedValue('Forbidden'),
82
+ });
83
+ vi.stubGlobal('fetch', mockFetch);
84
+ await expect(imagexUpload(imagePath, FAKE_UPLOAD_INFO)).rejects.toThrow(CommandExecutionError);
85
+ await expect(imagexUpload(imagePath, FAKE_UPLOAD_INFO)).rejects.toThrow('ImageX upload failed with status 403');
86
+ });
87
+ });
@@ -0,0 +1,8 @@
1
+ import type { IPage } from '../../../types.js';
2
+ import type { Sts2Credentials } from './types.js';
3
+ /**
4
+ * Fetch STS2 temporary credentials from the creator center.
5
+ * These are used to authenticate Node.js-side TOS multipart uploads.
6
+ * Returns: { access_key_id, secret_access_key, session_token, expired_time }
7
+ */
8
+ export declare function getSts2Credentials(page: IPage): Promise<Sts2Credentials>;
@@ -0,0 +1,15 @@
1
+ import { AuthRequiredError } from '../../../errors.js';
2
+ const STS2_URL = 'https://creator.douyin.com/aweme/mid/video/sts2/?scene=web&aid=1128&cookie_enabled=true&device_platform=web';
3
+ /**
4
+ * Fetch STS2 temporary credentials from the creator center.
5
+ * These are used to authenticate Node.js-side TOS multipart uploads.
6
+ * Returns: { access_key_id, secret_access_key, session_token, expired_time }
7
+ */
8
+ export async function getSts2Credentials(page) {
9
+ const js = `fetch(${JSON.stringify(STS2_URL)}, { credentials: 'include' }).then(r => r.json())`;
10
+ const res = await page.evaluate(js);
11
+ if (!res?.data?.access_key_id) {
12
+ throw new AuthRequiredError('creator.douyin.com', 'STS2 credentials missing');
13
+ }
14
+ return res.data;
15
+ }
@@ -0,0 +1,18 @@
1
+ export interface HashtagInfo {
2
+ name: string;
3
+ id: number;
4
+ start: number;
5
+ end: number;
6
+ }
7
+ export interface TextExtraItem {
8
+ type: number;
9
+ hashtag_id: number;
10
+ hashtag_name: string;
11
+ start: number;
12
+ end: number;
13
+ caption_start: number;
14
+ caption_end: number;
15
+ }
16
+ export declare function parseTextExtra(_text: string, hashtags: HashtagInfo[]): TextExtraItem[];
17
+ /** Extract hashtag names from text (e.g. "#话题" → ["话题"]) */
18
+ export declare function extractHashtagNames(text: string): string[];
@@ -0,0 +1,15 @@
1
+ export function parseTextExtra(_text, hashtags) {
2
+ return hashtags.map((h) => ({
3
+ type: 1,
4
+ hashtag_id: h.id,
5
+ hashtag_name: h.name,
6
+ start: h.start,
7
+ end: h.end,
8
+ caption_start: 0,
9
+ caption_end: h.end - h.start,
10
+ }));
11
+ }
12
+ /** Extract hashtag names from text (e.g. "#话题" → ["话题"]) */
13
+ export function extractHashtagNames(text) {
14
+ return [...text.matchAll(/#([^\s#]+)/g)].map((m) => m[1]);
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseTextExtra, extractHashtagNames } from './text-extra.js';
3
+ describe('parseTextExtra', () => {
4
+ it('returns empty array for text with no hashtags', () => {
5
+ const result = parseTextExtra('普通文本内容', []);
6
+ expect(result).toEqual([]);
7
+ });
8
+ it('produces type-1 entry for each hashtag', () => {
9
+ const hashtags = [
10
+ { name: '话题', id: 12345, start: 5, end: 8 },
11
+ ];
12
+ const result = parseTextExtra('普通文本 #话题', hashtags);
13
+ expect(result).toHaveLength(1);
14
+ expect(result[0]).toMatchObject({
15
+ type: 1,
16
+ hashtag_name: '话题',
17
+ hashtag_id: 12345,
18
+ start: 5,
19
+ end: 8,
20
+ });
21
+ });
22
+ it('sets hashtag_id to 0 when not found', () => {
23
+ const hashtags = [
24
+ { name: '未知话题', id: 0, start: 0, end: 5 },
25
+ ];
26
+ const result = parseTextExtra('#未知话题', hashtags);
27
+ expect(result[0].hashtag_id).toBe(0);
28
+ });
29
+ });
30
+ describe('extractHashtagNames', () => {
31
+ it('extracts hashtag names from text', () => {
32
+ expect(extractHashtagNames('hello #foo and #bar')).toEqual(['foo', 'bar']);
33
+ });
34
+ it('returns empty array when no hashtags', () => {
35
+ expect(extractHashtagNames('no hashtags here')).toEqual([]);
36
+ });
37
+ });
@@ -0,0 +1,2 @@
1
+ export declare function validateTiming(unixSeconds: number): void;
2
+ export declare function toUnixSeconds(input: string | number): number;
@@ -0,0 +1,22 @@
1
+ const MIN_OFFSET = 7200; // 2 hours
2
+ const MAX_OFFSET = 14 * 86400; // 14 days
3
+ export function validateTiming(unixSeconds) {
4
+ if (!Number.isFinite(unixSeconds))
5
+ throw new Error(`无效的时间戳: ${unixSeconds}`);
6
+ const now = Math.floor(Date.now() / 1000);
7
+ if (unixSeconds < now + MIN_OFFSET)
8
+ throw new Error(`定时发布时间必须在至少 2 小时后`);
9
+ if (unixSeconds > now + MAX_OFFSET)
10
+ throw new Error(`定时发布时间不能超过 14 天`);
11
+ }
12
+ export function toUnixSeconds(input) {
13
+ if (typeof input === 'number')
14
+ return input;
15
+ if (/^\d+$/.test(input)) {
16
+ return Number(input);
17
+ }
18
+ const ms = new Date(input).getTime();
19
+ if (isNaN(ms))
20
+ throw new Error(`无效的时间格式: "${input}"`);
21
+ return Math.floor(ms / 1000);
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateTiming, toUnixSeconds } from './timing.js';
3
+ describe('validateTiming', () => {
4
+ const now = () => Math.floor(Date.now() / 1000);
5
+ it('accepts a time 3 hours from now', () => {
6
+ expect(() => validateTiming(now() + 3 * 3600)).not.toThrow();
7
+ });
8
+ it('rejects a time less than 2 hours from now', () => {
9
+ expect(() => validateTiming(now() + 3600)).toThrow('至少 2 小时后');
10
+ });
11
+ it('rejects a time more than 14 days from now', () => {
12
+ expect(() => validateTiming(now() + 15 * 86400)).toThrow('不能超过 14 天');
13
+ });
14
+ });
15
+ describe('toUnixSeconds', () => {
16
+ it('passes through a numeric unix timestamp', () => {
17
+ expect(toUnixSeconds(1744070400)).toBe(1744070400);
18
+ });
19
+ it('parses a numeric unix timestamp string', () => {
20
+ expect(toUnixSeconds('1744070400')).toBe(1744070400);
21
+ });
22
+ it('parses ISO8601 string', () => {
23
+ expect(toUnixSeconds('2026-04-08T12:00:00Z')).toBe(Math.floor(new Date('2026-04-08T12:00:00Z').getTime() / 1000));
24
+ });
25
+ it('throws on invalid input', () => {
26
+ expect(() => toUnixSeconds('not-a-date')).toThrow();
27
+ });
28
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Tests for the fs.readSync short-read guard in tosUpload.
3
+ *
4
+ * This file is separate from tos-upload.test.ts because vi.mock is hoisted and
5
+ * would interfere with the real-fs tests there.
6
+ *
7
+ * Strategy:
8
+ * - Use setReadSyncOverride (exported testing seam) to force readSync to return 0
9
+ * - Mock global fetch to satisfy initMultipartUpload so the code path reaches readSync
10
+ */
11
+ export {};
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tests for the fs.readSync short-read guard in tosUpload.
3
+ *
4
+ * This file is separate from tos-upload.test.ts because vi.mock is hoisted and
5
+ * would interfere with the real-fs tests there.
6
+ *
7
+ * Strategy:
8
+ * - Use setReadSyncOverride (exported testing seam) to force readSync to return 0
9
+ * - Mock global fetch to satisfy initMultipartUpload so the code path reaches readSync
10
+ */
11
+ import * as actualFs from 'node:fs';
12
+ import * as os from 'node:os';
13
+ import * as path from 'node:path';
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
15
+ import { CommandExecutionError } from '../../../errors.js';
16
+ import { setReadSyncOverride, tosUpload } from './tos-upload.js';
17
+ /** Build a minimal fetch mock that satisfies initMultipartUpload (POST ?uploads → 200 + UploadId XML). */
18
+ function makeFetchMock() {
19
+ return vi.fn().mockResolvedValue({
20
+ status: 200,
21
+ text: async () => '<InitiateMultipartUploadResult><UploadId>mock-upload-id</UploadId></InitiateMultipartUploadResult>',
22
+ headers: { forEach: (_cb) => { } },
23
+ });
24
+ }
25
+ describe('tosUpload short-read guard', () => {
26
+ let tmpDir;
27
+ let tmpFile;
28
+ let originalFetch;
29
+ beforeEach(() => {
30
+ originalFetch = globalThis.fetch;
31
+ tmpDir = actualFs.mkdtempSync(path.join(os.tmpdir(), 'tos-upload-shortread-'));
32
+ tmpFile = path.join(tmpDir, 'video.mp4');
33
+ // 100-byte file — fits in a single part
34
+ actualFs.writeFileSync(tmpFile, Buffer.alloc(100, 0xff));
35
+ });
36
+ afterEach(() => {
37
+ setReadSyncOverride(null);
38
+ globalThis.fetch = originalFetch;
39
+ actualFs.rmSync(tmpDir, { recursive: true, force: true });
40
+ });
41
+ it('throws CommandExecutionError on short read', async () => {
42
+ // Mock fetch so initMultipartUpload succeeds and code reaches readSync
43
+ globalThis.fetch = makeFetchMock();
44
+ // Override readSync to return 0 (fewer bytes than requested)
45
+ setReadSyncOverride(() => 0);
46
+ const mockCredentials = {
47
+ access_key_id: 'AKIAIOSFODNN7EXAMPLE',
48
+ secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
49
+ session_token: 'test-session-token',
50
+ expired_time: Date.now() / 1000 + 3600,
51
+ };
52
+ const uploadInfo = {
53
+ tos_upload_url: 'https://tos-cn-i-alisg.volces.com/bucket/key',
54
+ auth: 'AWS4-HMAC-SHA256 Credential=test',
55
+ video_id: 'test-video-id',
56
+ };
57
+ await expect(tosUpload({
58
+ filePath: tmpFile,
59
+ uploadInfo,
60
+ credentials: mockCredentials,
61
+ })).rejects.toThrow(CommandExecutionError);
62
+ });
63
+ it('error message identifies the part number and byte counts', async () => {
64
+ globalThis.fetch = makeFetchMock();
65
+ setReadSyncOverride(() => 0);
66
+ const mockCredentials = {
67
+ access_key_id: 'AKIAIOSFODNN7EXAMPLE',
68
+ secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
69
+ session_token: 'test-session-token',
70
+ expired_time: Date.now() / 1000 + 3600,
71
+ };
72
+ const uploadInfo = {
73
+ tos_upload_url: 'https://tos-cn-i-alisg.volces.com/bucket/key',
74
+ auth: 'AWS4-HMAC-SHA256 Credential=test',
75
+ video_id: 'test-video-id',
76
+ };
77
+ await expect(tosUpload({
78
+ filePath: tmpFile,
79
+ uploadInfo,
80
+ credentials: mockCredentials,
81
+ })).rejects.toThrow(/Short read on part 1: expected 100 bytes, got 0/);
82
+ });
83
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * TOS (ByteDance Object Storage) multipart uploader with resume support.
3
+ *
4
+ * Uses AWS Signature V4 (HMAC-SHA256) with STS2 temporary credentials.
5
+ * For the init multipart upload call, the pre-computed auth from TosUploadInfo is used.
6
+ * For PUT part uploads and the final complete call, AWS4 is computed from STS2 credentials.
7
+ */
8
+ import type { Sts2Credentials, TosUploadInfo } from './types.js';
9
+ export interface TosUploadOptions {
10
+ filePath: string;
11
+ uploadInfo: TosUploadInfo;
12
+ credentials: Sts2Credentials;
13
+ onProgress?: (uploaded: number, total: number) => void;
14
+ }
15
+ interface ResumePart {
16
+ partNumber: number;
17
+ etag: string;
18
+ }
19
+ interface ResumeState {
20
+ uploadId: string;
21
+ fileSize: number;
22
+ parts: ResumePart[];
23
+ }
24
+ declare const PART_SIZE: number;
25
+ declare const RESUME_DIR: string;
26
+ declare function getResumeFilePath(filePath: string): string;
27
+ declare function loadResumeState(resumePath: string, fileSize: number): ResumeState | null;
28
+ declare function saveResumeState(resumePath: string, state: ResumeState): void;
29
+ declare function deleteResumeState(resumePath: string): void;
30
+ declare function extractRegionFromHost(host: string): string;
31
+ interface SignedHeaders {
32
+ [key: string]: string;
33
+ }
34
+ /**
35
+ * Compute AWS Signature V4 headers for a TOS request.
36
+ * Returns a Record of all headers to include (including Authorization, x-amz-date, etc.)
37
+ */
38
+ declare function computeAws4Headers(opts: {
39
+ method: string;
40
+ url: string;
41
+ headers: Record<string, string>;
42
+ body: Buffer | string;
43
+ credentials: Sts2Credentials;
44
+ service: string;
45
+ region: string;
46
+ datetime: string;
47
+ }): SignedHeaders;
48
+ type ReadSyncFn = (fd: number, buffer: Buffer, offset: number, length: number, position: number) => number;
49
+ /** @internal — for testing only */
50
+ export declare function setReadSyncOverride(fn: ReadSyncFn | null): void;
51
+ export declare function tosUpload(options: TosUploadOptions): Promise<void>;
52
+ export { PART_SIZE, RESUME_DIR, extractRegionFromHost, getResumeFilePath, loadResumeState, saveResumeState, deleteResumeState, computeAws4Headers, };
53
+ export type { ResumeState, ResumePart };