@jackwener/opencli 1.7.22 → 1.8.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/README.md +35 -194
- package/README.zh-CN.md +42 -260
- package/cli-manifest.json +8160 -4392
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +231 -0
- package/clis/suno/generate.test.js +252 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +63 -0
- package/clis/suno/utils.js +549 -0
- package/clis/suno/utils.test.js +329 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +13 -6
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +7 -3
- package/clis/twitter/search.test.js +41 -0
- package/clis/twitter/shared.js +155 -0
- package/clis/twitter/shared.test.js +465 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +98 -11
- package/clis/weread/search.js +32 -9
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +166 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +252 -2
- package/clis/xiaohongshu/creator-notes.test.js +90 -1
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +280 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +2 -19
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +17 -16
- package/clis/zhihu/collection.test.js +50 -3
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -17
- package/clis/zhihu/question.test.js +113 -11
- package/clis/zhihu/search.js +195 -43
- package/clis/zhihu/search.test.js +198 -0
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +112 -0
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { atlassianRequest, parseLimit, queryString, requireNonEmptyRows, requireString } from '../_atlassian/shared.js';
|
|
3
|
+
import { confluenceConfig, confluenceResults, normalizeSearchResult, withSpaceCql } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'confluence',
|
|
7
|
+
name: 'search',
|
|
8
|
+
access: 'read',
|
|
9
|
+
description: 'Search Confluence content with CQL',
|
|
10
|
+
domain: 'atlassian.net',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: false,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'cql', positional: true, required: true, help: 'CQL query, e.g. "type = page and title ~ \\"RCA\\""' },
|
|
15
|
+
{ name: 'space', type: 'string', help: 'Limit search to a Confluence space key' },
|
|
16
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results to return (1-100)' },
|
|
17
|
+
],
|
|
18
|
+
columns: ['id', 'title', 'type', 'spaceKey', 'status', 'lastModified', 'url'],
|
|
19
|
+
func: async (args) => {
|
|
20
|
+
const config = confluenceConfig();
|
|
21
|
+
const cql = withSpaceCql(requireString(args.cql, 'CQL'), args.space);
|
|
22
|
+
const limit = parseLimit(args.limit, 20, 100, 'confluence limit');
|
|
23
|
+
// CQL search is still exposed through Confluence REST v1 for Cloud;
|
|
24
|
+
// page CRUD uses v2 where available.
|
|
25
|
+
const path = `/rest/api/search${queryString({ cql, limit })}`;
|
|
26
|
+
const data = await atlassianRequest(config, path, { label: 'confluence search' });
|
|
27
|
+
const results = confluenceResults(data, 'confluence search');
|
|
28
|
+
return requireNonEmptyRows(
|
|
29
|
+
results.map((result) => normalizeSearchResult(result, config)),
|
|
30
|
+
'confluence search',
|
|
31
|
+
`No Confluence content matched "${cql}".`,
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
atlassianRequest,
|
|
3
|
+
getConfluenceConfig,
|
|
4
|
+
htmlToMarkdown,
|
|
5
|
+
markdownToConfluenceStorage,
|
|
6
|
+
queryString,
|
|
7
|
+
requirePayloadArray,
|
|
8
|
+
requirePayloadObject,
|
|
9
|
+
requirePayloadString,
|
|
10
|
+
readUtf8File,
|
|
11
|
+
requireString,
|
|
12
|
+
} from '../_atlassian/shared.js';
|
|
13
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
14
|
+
|
|
15
|
+
export function confluenceConfig() {
|
|
16
|
+
return getConfluenceConfig();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function confluenceUrl(config, link) {
|
|
20
|
+
if (!link) return '';
|
|
21
|
+
if (/^https?:\/\//i.test(link)) return link;
|
|
22
|
+
return `${config.baseUrl}${link.startsWith('/') ? link : `/${link}`}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pageStorageBody(page) {
|
|
26
|
+
return page?.body?.storage?.value
|
|
27
|
+
?? page?.body?.view?.value
|
|
28
|
+
?? '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeConfluencePage(page, config) {
|
|
32
|
+
const row = requirePayloadObject(page, 'confluence page');
|
|
33
|
+
const id = requirePayloadString(row.id, 'page id', 'confluence page');
|
|
34
|
+
const title = requirePayloadString(row.title, 'title', 'confluence page');
|
|
35
|
+
const storage = pageStorageBody(row);
|
|
36
|
+
const version = row.version?.number != null ? Number(row.version.number) : undefined;
|
|
37
|
+
const links = row._links && typeof row._links === 'object' && !Array.isArray(row._links) ? row._links : {};
|
|
38
|
+
const webui = links.webui ?? links.tinyui ?? '';
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
title,
|
|
42
|
+
status: String(row.status ?? ''),
|
|
43
|
+
spaceId: row.spaceId != null ? String(row.spaceId) : undefined,
|
|
44
|
+
spaceKey: row.space?.key ? String(row.space.key) : undefined,
|
|
45
|
+
parentId: row.parentId != null ? String(row.parentId) : undefined,
|
|
46
|
+
version,
|
|
47
|
+
createdAt: row.createdAt ? String(row.createdAt) : undefined,
|
|
48
|
+
updatedAt: row.version?.createdAt ?? row.version?.when ?? undefined,
|
|
49
|
+
url: confluenceUrl(config, webui),
|
|
50
|
+
body: {
|
|
51
|
+
storage,
|
|
52
|
+
markdown: htmlToMarkdown(storage),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function getPage(config, pageId) {
|
|
58
|
+
if (config.deployment === 'cloud') {
|
|
59
|
+
const page = await atlassianRequest(config, `/api/v2/pages/${encodeURIComponent(pageId)}${queryString({ 'body-format': 'storage' })}`, {
|
|
60
|
+
label: `confluence page ${pageId}`,
|
|
61
|
+
});
|
|
62
|
+
return requirePayloadObject(page, `confluence page ${pageId}`);
|
|
63
|
+
}
|
|
64
|
+
const page = await atlassianRequest(config, `/rest/api/content/${encodeURIComponent(pageId)}${queryString({ expand: 'body.storage,version,space,ancestors' })}`, {
|
|
65
|
+
label: `confluence page ${pageId}`,
|
|
66
|
+
});
|
|
67
|
+
return requirePayloadObject(page, `confluence page ${pageId}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function readPageBodyFile(args) {
|
|
71
|
+
const text = await readUtf8File(args.file);
|
|
72
|
+
if (args.representation === 'storage') return text;
|
|
73
|
+
return markdownToConfluenceStorage(text);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createPagePayload(config, args, storage) {
|
|
77
|
+
const title = requireString(args.title, 'Confluence page title');
|
|
78
|
+
const space = requireString(args.space, 'Confluence space');
|
|
79
|
+
if (config.deployment === 'cloud') {
|
|
80
|
+
return {
|
|
81
|
+
spaceId: space,
|
|
82
|
+
status: 'current',
|
|
83
|
+
title,
|
|
84
|
+
...(args.parent ? { parentId: String(args.parent) } : {}),
|
|
85
|
+
body: { representation: 'storage', value: storage },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
type: 'page',
|
|
90
|
+
status: 'current',
|
|
91
|
+
title,
|
|
92
|
+
space: { key: space },
|
|
93
|
+
...(args.parent ? { ancestors: [{ id: String(args.parent) }] } : {}),
|
|
94
|
+
body: { storage: { representation: 'storage', value: storage } },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function updatePagePayload(config, current, args, storage) {
|
|
99
|
+
const page = requirePayloadObject(current, 'confluence current page');
|
|
100
|
+
const id = requirePayloadString(page.id, 'page id', 'confluence current page');
|
|
101
|
+
const title = args.title ? requireString(args.title, 'Confluence page title') : requirePayloadString(page.title, 'title', 'confluence current page');
|
|
102
|
+
const currentVersion = Number(page.version?.number);
|
|
103
|
+
if (!Number.isSafeInteger(currentVersion) || currentVersion < 1) {
|
|
104
|
+
throw new CommandExecutionError('confluence update could not determine the current page version.');
|
|
105
|
+
}
|
|
106
|
+
const nextVersion = currentVersion + 1;
|
|
107
|
+
if (config.deployment === 'cloud') {
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
status: 'current',
|
|
111
|
+
title,
|
|
112
|
+
body: { representation: 'storage', value: storage },
|
|
113
|
+
version: {
|
|
114
|
+
number: nextVersion,
|
|
115
|
+
...(args['version-message'] ? { message: String(args['version-message']) } : {}),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
type: 'page',
|
|
122
|
+
status: 'current',
|
|
123
|
+
title,
|
|
124
|
+
body: { storage: { representation: 'storage', value: storage } },
|
|
125
|
+
version: {
|
|
126
|
+
number: nextVersion,
|
|
127
|
+
...(args['version-message'] ? { message: String(args['version-message']) } : {}),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function normalizeSearchResult(result, config) {
|
|
133
|
+
const row = requirePayloadObject(result, 'confluence search result');
|
|
134
|
+
const content = row.content ?? row;
|
|
135
|
+
const contentObject = requirePayloadObject(content, 'confluence search result content');
|
|
136
|
+
const id = requirePayloadString(contentObject.id, 'content id', 'confluence search result');
|
|
137
|
+
const title = requirePayloadString(row.title ?? contentObject.title, 'title', 'confluence search result');
|
|
138
|
+
const space = row.space ?? contentObject.space ?? {};
|
|
139
|
+
return {
|
|
140
|
+
id,
|
|
141
|
+
title,
|
|
142
|
+
type: String(contentObject.type ?? row.entityType ?? ''),
|
|
143
|
+
spaceKey: String(space?.key ?? ''),
|
|
144
|
+
status: String(contentObject.status ?? ''),
|
|
145
|
+
lastModified: String(row.lastModified ?? contentObject.version?.when ?? contentObject.version?.createdAt ?? ''),
|
|
146
|
+
url: confluenceUrl(config, row.url ?? contentObject._links?.webui ?? ''),
|
|
147
|
+
excerpt: row.excerpt ? htmlToMarkdown(row.excerpt) : '',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function confluenceResults(data, label) {
|
|
152
|
+
const payload = requirePayloadObject(data, label);
|
|
153
|
+
return requirePayloadArray(payload.results, label);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function withSpaceCql(cql, space) {
|
|
157
|
+
const q = String(cql ?? '').trim();
|
|
158
|
+
const s = String(space ?? '').trim();
|
|
159
|
+
if (!s) return q;
|
|
160
|
+
const escaped = s.replace(/"/g, '\\"');
|
|
161
|
+
if (!q) return `space = "${escaped}"`;
|
|
162
|
+
return `space = "${escaped}" and (${q})`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const __test__ = {
|
|
166
|
+
createPagePayload,
|
|
167
|
+
getPage,
|
|
168
|
+
normalizeConfluencePage,
|
|
169
|
+
normalizeSearchResult,
|
|
170
|
+
readPageBodyFile,
|
|
171
|
+
updatePagePayload,
|
|
172
|
+
withSpaceCql,
|
|
173
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { atlassianRequest, requireExecute, requirePayloadObject, requireString } from '../_atlassian/shared.js';
|
|
3
|
+
import { confluenceConfig, getPage, normalizeConfluencePage, readPageBodyFile, updatePagePayload } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'confluence',
|
|
7
|
+
name: 'update',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Update a Confluence page body from Markdown or storage XHTML',
|
|
10
|
+
domain: 'atlassian.net',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: false,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'id', positional: true, required: true, help: 'Confluence page id' },
|
|
15
|
+
{ name: 'file', type: 'string', required: true, help: 'Markdown file path' },
|
|
16
|
+
{ name: 'title', type: 'string', help: 'Optional replacement title; defaults to current title' },
|
|
17
|
+
{ name: 'version-message', type: 'string', help: 'Confluence version message' },
|
|
18
|
+
{ name: 'representation', type: 'string', default: 'markdown', choices: ['markdown', 'storage'], help: 'Input file format' },
|
|
19
|
+
{ name: 'execute', type: 'boolean', help: 'Actually update the remote page' },
|
|
20
|
+
],
|
|
21
|
+
columns: ['status', 'id', 'title', 'spaceId', 'spaceKey', 'version', 'url'],
|
|
22
|
+
func: async (args) => {
|
|
23
|
+
requireExecute(args, 'confluence update');
|
|
24
|
+
const config = confluenceConfig();
|
|
25
|
+
const id = requireString(args.id, 'Confluence page id');
|
|
26
|
+
const current = await getPage(config, id);
|
|
27
|
+
const storage = await readPageBodyFile(args);
|
|
28
|
+
const payload = updatePagePayload(config, current, args, storage);
|
|
29
|
+
const path = config.deployment === 'cloud' ? `/api/v2/pages/${encodeURIComponent(id)}` : `/rest/api/content/${encodeURIComponent(id)}`;
|
|
30
|
+
const page = requirePayloadObject(await atlassianRequest(config, path, {
|
|
31
|
+
method: 'PUT',
|
|
32
|
+
body: payload,
|
|
33
|
+
label: `confluence update ${id}`,
|
|
34
|
+
}), `confluence update ${id}`);
|
|
35
|
+
const normalized = normalizeConfluencePage(page, config);
|
|
36
|
+
return [{ ...normalized, pageStatus: normalized.status, status: 'updated' }];
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -1,4 +1,11 @@
|
|
|
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
|
+
|
|
2
9
|
/**
|
|
3
10
|
* Execute a fetch() call inside the Chrome browser context via page.evaluate.
|
|
4
11
|
* This ensures a_bogus signing and cookies are handled automatically by the browser.
|
|
@@ -6,36 +13,53 @@ import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
|
6
13
|
export async function browserFetch(page, method, url, options = {}) {
|
|
7
14
|
const js = `
|
|
8
15
|
(async () => {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
}
|
|
21
40
|
})()
|
|
22
41
|
`;
|
|
23
42
|
let result;
|
|
24
43
|
try {
|
|
25
|
-
result = await page.evaluate(js);
|
|
44
|
+
result = unwrapEvaluateResult(await page.evaluate(js));
|
|
26
45
|
}
|
|
27
46
|
catch (error) {
|
|
28
|
-
|
|
29
|
-
|
|
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})`);
|
|
30
51
|
}
|
|
31
|
-
if (result
|
|
32
|
-
throw new CommandExecutionError(
|
|
52
|
+
if (Array.isArray(result) || typeof result !== 'object') {
|
|
53
|
+
throw new CommandExecutionError(`Malformed response from Douyin API (${method} ${url})`);
|
|
33
54
|
}
|
|
34
55
|
if (result && typeof result === 'object' && 'status_code' in result) {
|
|
35
56
|
const code = result.status_code;
|
|
36
57
|
if (code !== 0) {
|
|
37
|
-
const msg = result.status_msg ?? 'unknown error';
|
|
38
|
-
|
|
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}`);
|
|
39
63
|
}
|
|
40
64
|
}
|
|
41
65
|
return result;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
3
|
import { browserFetch } from './browser-fetch.js';
|
|
3
4
|
function makePage(result) {
|
|
4
5
|
return {
|
|
@@ -18,10 +19,20 @@ describe('browserFetch', () => {
|
|
|
18
19
|
const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
|
|
19
20
|
expect(result).toEqual({ status_code: 0, data: { ak: 'KEY' } });
|
|
20
21
|
});
|
|
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
|
+
});
|
|
21
27
|
it('throws when status_code is non-zero', async () => {
|
|
22
28
|
const page = makePage({ status_code: 8, message: 'fail' });
|
|
23
29
|
await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Douyin API error 8');
|
|
24
30
|
});
|
|
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
|
+
});
|
|
25
36
|
it('returns result even when no status_code field', async () => {
|
|
26
37
|
const page = makePage({ some_field: 'value' });
|
|
27
38
|
const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
|
|
@@ -35,9 +46,19 @@ describe('browserFetch', () => {
|
|
|
35
46
|
const page = makePage(undefined);
|
|
36
47
|
await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Empty response from Douyin API');
|
|
37
48
|
});
|
|
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
|
+
});
|
|
38
59
|
it('wraps browser-side fetch or JSON parse failures', async () => {
|
|
39
60
|
const page = makePage(null);
|
|
40
61
|
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');
|
|
42
63
|
});
|
|
43
64
|
});
|
|
@@ -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
|
+
}
|
|
@@ -56,6 +56,31 @@ function sha256Hex(data) {
|
|
|
56
56
|
}
|
|
57
57
|
return hash.digest('hex');
|
|
58
58
|
}
|
|
59
|
+
const CRC32_TABLE = new Uint32Array(256).map((_, index) => {
|
|
60
|
+
let value = index;
|
|
61
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
62
|
+
value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
|
|
63
|
+
}
|
|
64
|
+
return value >>> 0;
|
|
65
|
+
});
|
|
66
|
+
function crc32Hex(data) {
|
|
67
|
+
let crc = 0xffffffff;
|
|
68
|
+
for (const byte of data) {
|
|
69
|
+
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
70
|
+
}
|
|
71
|
+
return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
|
|
72
|
+
}
|
|
73
|
+
function gatewayBaseUrl(tosUrl) {
|
|
74
|
+
const parsedUrl = new URL(tosUrl);
|
|
75
|
+
return `https://${parsedUrl.host}/upload/v1${parsedUrl.pathname}`;
|
|
76
|
+
}
|
|
77
|
+
function gatewayHeaders(auth, uploadHeader, userId = '') {
|
|
78
|
+
return {
|
|
79
|
+
Authorization: auth,
|
|
80
|
+
'X-Storage-U': encodeURIComponent(userId),
|
|
81
|
+
...(uploadHeader ?? {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
59
84
|
function extractRegionFromHost(host) {
|
|
60
85
|
// e.g. "tos-cn-i-alisg.volces.com" → "cn-i-alisg"
|
|
61
86
|
// e.g. "tos-cn-beijing.ivolces.com" → "cn-beijing"
|
|
@@ -129,6 +154,7 @@ async function tosRequest(opts) {
|
|
|
129
154
|
method,
|
|
130
155
|
headers,
|
|
131
156
|
body: fetchBody,
|
|
157
|
+
signal: AbortSignal.timeout(60000),
|
|
132
158
|
});
|
|
133
159
|
const responseBody = await res.text();
|
|
134
160
|
const responseHeaders = {};
|
|
@@ -140,86 +166,95 @@ async function tosRequest(opts) {
|
|
|
140
166
|
function nowDatetime() {
|
|
141
167
|
return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
142
168
|
}
|
|
169
|
+
function extractUploadId(body) {
|
|
170
|
+
const xmlMatch = body.match(/<UploadId>([^<]+)<\/UploadId>/i);
|
|
171
|
+
if (xmlMatch) return xmlMatch[1];
|
|
172
|
+
try {
|
|
173
|
+
const json = JSON.parse(body);
|
|
174
|
+
return json?.payload?.uploadID
|
|
175
|
+
|| json?.payload?.uploadId
|
|
176
|
+
|| json?.payload?.UploadID
|
|
177
|
+
|| json?.payload?.UploadId
|
|
178
|
+
|| json?.data?.uploadid
|
|
179
|
+
|| json?.data?.uploadID
|
|
180
|
+
|| json?.data?.uploadId
|
|
181
|
+
|| json?.data?.UploadID
|
|
182
|
+
|| json?.data?.UploadId
|
|
183
|
+
|| json?.UploadID
|
|
184
|
+
|| json?.UploadId
|
|
185
|
+
|| json?.uploadID
|
|
186
|
+
|| json?.uploadId
|
|
187
|
+
|| null;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
143
193
|
// ── Phase 1: Init multipart upload ───────────────────────────────────────────
|
|
144
|
-
async function initMultipartUpload(tosUrl, auth,
|
|
145
|
-
const initUrl = `${tosUrl}?
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
'x-amz-security-token': credentials.session_token,
|
|
152
|
-
'content-type': 'application/octet-stream',
|
|
153
|
-
};
|
|
154
|
-
const res = await tosRequest({ method: 'POST', url: initUrl, headers });
|
|
194
|
+
async function initMultipartUpload(tosUrl, auth, uploadHeader, userId) {
|
|
195
|
+
const initUrl = `${gatewayBaseUrl(tosUrl)}?uploadmode=part&phase=init`;
|
|
196
|
+
const res = await tosRequest({
|
|
197
|
+
method: 'POST',
|
|
198
|
+
url: initUrl,
|
|
199
|
+
headers: gatewayHeaders(auth, uploadHeader, userId),
|
|
200
|
+
});
|
|
155
201
|
if (res.status !== 200) {
|
|
156
|
-
throw new CommandExecutionError(`TOS init multipart upload failed with status ${res.status}: ${res.body}`, 'Check that TOS
|
|
202
|
+
throw new CommandExecutionError(`TOS init multipart upload failed with status ${res.status}: ${res.body}`, 'Check that TOS upload authorization is valid and not expired.');
|
|
157
203
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (!match) {
|
|
204
|
+
const uploadId = extractUploadId(res.body);
|
|
205
|
+
if (!uploadId) {
|
|
161
206
|
throw new CommandExecutionError(`TOS init response missing UploadId: ${res.body}`);
|
|
162
207
|
}
|
|
163
|
-
return
|
|
208
|
+
return uploadId;
|
|
164
209
|
}
|
|
165
210
|
// ── Phase 2: Upload a single part ────────────────────────────────────────────
|
|
166
|
-
async function uploadPart(tosUrl, partNumber, uploadId, data,
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
datetime,
|
|
181
|
-
});
|
|
182
|
-
const res = await tosRequest({ method: 'PUT', url, headers, body: data });
|
|
183
|
-
if (res.status !== 200) {
|
|
184
|
-
throw new CommandExecutionError(`TOS upload part ${partNumber} failed with status ${res.status}: ${res.body}`, 'Check that STS2 credentials are valid and not expired.');
|
|
211
|
+
async function uploadPart(tosUrl, partNumber, uploadId, data, auth, uploadHeader, userId) {
|
|
212
|
+
const crc32 = crc32Hex(data);
|
|
213
|
+
const url = `${gatewayBaseUrl(tosUrl)}?uploadid=${encodeURIComponent(uploadId)}&part_number=${partNumber}&phase=transfer`;
|
|
214
|
+
const headers = {
|
|
215
|
+
...gatewayHeaders(auth, uploadHeader, userId),
|
|
216
|
+
'Content-CRC32': crc32,
|
|
217
|
+
'Content-Type': 'application/octet-stream',
|
|
218
|
+
'X-Use-Init-Upload-Optimize': '1',
|
|
219
|
+
'X-Use-Large-Local-Cache': '1',
|
|
220
|
+
};
|
|
221
|
+
const res = await tosRequest({ method: 'POST', url, headers, body: data });
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = JSON.parse(res.body);
|
|
185
225
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
throw new CommandExecutionError(`TOS upload part ${partNumber} response missing ETag header`);
|
|
226
|
+
catch {
|
|
227
|
+
parsed = null;
|
|
189
228
|
}
|
|
190
|
-
|
|
229
|
+
if (res.status !== 200 || parsed?.code !== 2000) {
|
|
230
|
+
throw new CommandExecutionError(`TOS upload part ${partNumber} failed with status ${res.status}: ${res.body}`, 'Check that TOS upload authorization is valid and not expired.');
|
|
231
|
+
}
|
|
232
|
+
return parsed?.data?.crc32 || crc32;
|
|
191
233
|
}
|
|
192
234
|
// ── Phase 3: Complete multipart upload ───────────────────────────────────────
|
|
193
|
-
async function completeMultipartUpload(tosUrl, uploadId, parts,
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
.sort((a, b) => a.partNumber - b.partNumber)
|
|
200
|
-
.map(p => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${p.etag}</ETag></Part>`)
|
|
201
|
-
.join('') +
|
|
202
|
-
'</CompleteMultipartUpload>';
|
|
203
|
-
const datetime = nowDatetime();
|
|
204
|
-
const headers = computeAws4Headers({
|
|
205
|
-
method: 'POST',
|
|
206
|
-
url,
|
|
207
|
-
headers: { 'content-type': 'application/xml' },
|
|
208
|
-
body: xmlBody,
|
|
209
|
-
credentials,
|
|
210
|
-
service: 'tos',
|
|
211
|
-
region,
|
|
212
|
-
datetime,
|
|
213
|
-
});
|
|
235
|
+
async function completeMultipartUpload(tosUrl, uploadId, parts, auth, uploadHeader, userId) {
|
|
236
|
+
const url = `${gatewayBaseUrl(tosUrl)}?uploadmode=part&phase=finish&uploadid=${encodeURIComponent(uploadId)}`;
|
|
237
|
+
const body = parts
|
|
238
|
+
.sort((a, b) => a.partNumber - b.partNumber)
|
|
239
|
+
.map(p => `${p.partNumber}:${p.crc32}`)
|
|
240
|
+
.join(',');
|
|
214
241
|
const res = await tosRequest({
|
|
215
242
|
method: 'POST',
|
|
216
243
|
url,
|
|
217
|
-
headers,
|
|
218
|
-
body
|
|
244
|
+
headers: gatewayHeaders(auth, uploadHeader, userId),
|
|
245
|
+
body,
|
|
219
246
|
});
|
|
220
|
-
|
|
247
|
+
let parsed;
|
|
248
|
+
try {
|
|
249
|
+
parsed = JSON.parse(res.body);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
parsed = null;
|
|
253
|
+
}
|
|
254
|
+
if (res.status !== 200 || parsed?.code !== 2000) {
|
|
221
255
|
throw new CommandExecutionError(`TOS complete multipart upload failed with status ${res.status}: ${res.body}`, 'Check that all parts were uploaded successfully.');
|
|
222
256
|
}
|
|
257
|
+
return parsed?.data?.key || null;
|
|
223
258
|
}
|
|
224
259
|
let _readSyncOverride = null;
|
|
225
260
|
/** @internal — for testing only */
|
|
@@ -237,7 +272,7 @@ export async function tosUpload(options) {
|
|
|
237
272
|
if (fileSize === 0) {
|
|
238
273
|
throw new CommandExecutionError(`Video file is empty: ${filePath}`);
|
|
239
274
|
}
|
|
240
|
-
const { tos_upload_url: tosUrl, auth } = uploadInfo;
|
|
275
|
+
const { tos_upload_url: tosUrl, auth, upload_header: uploadHeader, user_id: userId } = uploadInfo;
|
|
241
276
|
const parsedTosUrl = new URL(tosUrl);
|
|
242
277
|
const region = extractRegionFromHost(parsedTosUrl.host);
|
|
243
278
|
const resumePath = getResumeFilePath(filePath);
|
|
@@ -251,7 +286,7 @@ export async function tosUpload(options) {
|
|
|
251
286
|
}
|
|
252
287
|
else {
|
|
253
288
|
// Start fresh
|
|
254
|
-
uploadId = await initMultipartUpload(tosUrl, auth,
|
|
289
|
+
uploadId = await initMultipartUpload(tosUrl, auth, uploadHeader, userId);
|
|
255
290
|
completedParts = [];
|
|
256
291
|
saveResumeState(resumePath, { uploadId, fileSize, parts: completedParts });
|
|
257
292
|
}
|
|
@@ -277,8 +312,8 @@ export async function tosUpload(options) {
|
|
|
277
312
|
if (bytesRead !== chunkSize) {
|
|
278
313
|
throw new CommandExecutionError(`Short read on part ${partNumber}: expected ${chunkSize} bytes, got ${bytesRead}`);
|
|
279
314
|
}
|
|
280
|
-
const
|
|
281
|
-
completedParts.push({ partNumber,
|
|
315
|
+
const crc32 = await uploadPart(tosUrl, partNumber, uploadId, buffer, auth, uploadHeader, userId);
|
|
316
|
+
completedParts.push({ partNumber, crc32 });
|
|
282
317
|
saveResumeState(resumePath, { uploadId, fileSize, parts: completedParts });
|
|
283
318
|
uploadedBytes = Math.min(offset + chunkSize, fileSize);
|
|
284
319
|
if (onProgress)
|
|
@@ -288,8 +323,9 @@ export async function tosUpload(options) {
|
|
|
288
323
|
finally {
|
|
289
324
|
fs.closeSync(fd);
|
|
290
325
|
}
|
|
291
|
-
await completeMultipartUpload(tosUrl, uploadId, completedParts,
|
|
326
|
+
const completedKey = await completeMultipartUpload(tosUrl, uploadId, completedParts, auth, uploadHeader, userId);
|
|
292
327
|
deleteResumeState(resumePath);
|
|
328
|
+
return completedKey;
|
|
293
329
|
}
|
|
294
330
|
// ── Internal exports for testing ─────────────────────────────────────────────
|
|
295
|
-
export { PART_SIZE, RESUME_DIR, extractRegionFromHost, getResumeFilePath, loadResumeState, saveResumeState, deleteResumeState, computeAws4Headers, };
|
|
331
|
+
export { PART_SIZE, RESUME_DIR, extractRegionFromHost, getResumeFilePath, loadResumeState, saveResumeState, deleteResumeState, computeAws4Headers, extractUploadId, crc32Hex, gatewayBaseUrl, gatewayHeaders, };
|