@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.
- package/.github/actions/setup-chrome/action.yml +5 -4
- package/.github/workflows/ci.yml +17 -3
- package/.github/workflows/e2e-headed.yml +16 -3
- package/CHANGELOG.md +23 -0
- package/PRIVACY.md +57 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/SKILL.md +101 -2
- package/dist/cli-manifest.json +720 -32
- package/dist/clis/apple-podcasts/search.js +2 -1
- package/dist/clis/arxiv/search.js +2 -2
- package/dist/clis/bbc/news.js +0 -1
- package/dist/clis/ctrip/search.js +0 -1
- package/dist/clis/douyin/_shared/browser-fetch.d.ts +10 -0
- package/dist/clis/douyin/_shared/browser-fetch.js +30 -0
- package/dist/clis/douyin/_shared/browser-fetch.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/browser-fetch.test.js +31 -0
- package/dist/clis/douyin/_shared/creation-id.d.ts +1 -0
- package/dist/clis/douyin/_shared/creation-id.js +5 -0
- package/dist/clis/douyin/_shared/creation-id.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/creation-id.test.js +22 -0
- package/dist/clis/douyin/_shared/imagex-upload.d.ts +20 -0
- package/dist/clis/douyin/_shared/imagex-upload.js +53 -0
- package/dist/clis/douyin/_shared/imagex-upload.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/imagex-upload.test.js +87 -0
- package/dist/clis/douyin/_shared/sts2.d.ts +8 -0
- package/dist/clis/douyin/_shared/sts2.js +15 -0
- package/dist/clis/douyin/_shared/text-extra.d.ts +18 -0
- package/dist/clis/douyin/_shared/text-extra.js +15 -0
- package/dist/clis/douyin/_shared/text-extra.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/text-extra.test.js +37 -0
- package/dist/clis/douyin/_shared/timing.d.ts +2 -0
- package/dist/clis/douyin/_shared/timing.js +22 -0
- package/dist/clis/douyin/_shared/timing.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/timing.test.js +28 -0
- package/dist/clis/douyin/_shared/tos-upload-short-read.test.d.ts +11 -0
- package/dist/clis/douyin/_shared/tos-upload-short-read.test.js +83 -0
- package/dist/clis/douyin/_shared/tos-upload.d.ts +53 -0
- package/dist/clis/douyin/_shared/tos-upload.js +295 -0
- package/dist/clis/douyin/_shared/tos-upload.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/tos-upload.test.js +229 -0
- package/dist/clis/douyin/_shared/transcode.d.ts +27 -0
- package/dist/clis/douyin/_shared/transcode.js +45 -0
- package/dist/clis/douyin/_shared/transcode.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/transcode.test.js +93 -0
- package/dist/clis/douyin/_shared/types.d.ts +26 -0
- package/dist/clis/douyin/_shared/types.js +1 -0
- package/dist/clis/douyin/activities.d.ts +1 -0
- package/dist/clis/douyin/activities.js +20 -0
- package/dist/clis/douyin/activities.test.d.ts +1 -0
- package/dist/clis/douyin/activities.test.js +22 -0
- package/dist/clis/douyin/collections.d.ts +1 -0
- package/dist/clis/douyin/collections.js +22 -0
- package/dist/clis/douyin/collections.test.d.ts +1 -0
- package/dist/clis/douyin/collections.test.js +23 -0
- package/dist/clis/douyin/delete.d.ts +1 -0
- package/dist/clis/douyin/delete.js +18 -0
- package/dist/clis/douyin/delete.test.d.ts +1 -0
- package/dist/clis/douyin/delete.test.js +11 -0
- package/dist/clis/douyin/draft.d.ts +14 -0
- package/dist/clis/douyin/draft.js +237 -0
- package/dist/clis/douyin/draft.test.d.ts +1 -0
- package/dist/clis/douyin/draft.test.js +11 -0
- package/dist/clis/douyin/drafts.d.ts +1 -0
- package/dist/clis/douyin/drafts.js +23 -0
- package/dist/clis/douyin/drafts.test.d.ts +1 -0
- package/dist/clis/douyin/drafts.test.js +11 -0
- package/dist/clis/douyin/hashtag.d.ts +1 -0
- package/dist/clis/douyin/hashtag.js +45 -0
- package/dist/clis/douyin/hashtag.test.d.ts +1 -0
- package/dist/clis/douyin/hashtag.test.js +25 -0
- package/dist/clis/douyin/location.d.ts +1 -0
- package/dist/clis/douyin/location.js +24 -0
- package/dist/clis/douyin/location.test.d.ts +1 -0
- package/dist/clis/douyin/location.test.js +23 -0
- package/dist/clis/douyin/profile.d.ts +1 -0
- package/dist/clis/douyin/profile.js +28 -0
- package/dist/clis/douyin/profile.test.d.ts +1 -0
- package/dist/clis/douyin/profile.test.js +11 -0
- package/dist/clis/douyin/publish.d.ts +14 -0
- package/dist/clis/douyin/publish.js +288 -0
- package/dist/clis/douyin/publish.test.d.ts +1 -0
- package/dist/clis/douyin/publish.test.js +38 -0
- package/dist/clis/douyin/stats.d.ts +1 -0
- package/dist/clis/douyin/stats.js +27 -0
- package/dist/clis/douyin/stats.test.d.ts +1 -0
- package/dist/clis/douyin/stats.test.js +22 -0
- package/dist/clis/douyin/update.d.ts +1 -0
- package/dist/clis/douyin/update.js +31 -0
- package/dist/clis/douyin/update.test.d.ts +1 -0
- package/dist/clis/douyin/update.test.js +11 -0
- package/dist/clis/douyin/videos.d.ts +1 -0
- package/dist/clis/douyin/videos.js +34 -0
- package/dist/clis/douyin/videos.test.d.ts +1 -0
- package/dist/clis/douyin/videos.test.js +11 -0
- package/dist/clis/hackernews/search.yaml +1 -1
- package/dist/clis/instagram/search.yaml +2 -1
- package/dist/clis/linux-do/search.yaml +3 -1
- package/dist/clis/medium/search.js +1 -1
- package/dist/clis/reuters/search.js +0 -1
- package/dist/clis/twitter/search.js +5 -3
- package/dist/clis/twitter/search.test.js +54 -2
- package/dist/clis/weibo/comments.d.ts +1 -0
- package/dist/clis/weibo/comments.js +53 -0
- package/dist/clis/weibo/feed.d.ts +1 -0
- package/dist/clis/weibo/feed.js +56 -0
- package/dist/clis/weibo/hot.js +0 -1
- package/dist/clis/weibo/me.d.ts +1 -0
- package/dist/clis/weibo/me.js +76 -0
- package/dist/clis/weibo/post.d.ts +1 -0
- package/dist/clis/weibo/post.js +75 -0
- package/dist/clis/weibo/user.d.ts +1 -0
- package/dist/clis/weibo/user.js +63 -0
- package/dist/clis/weibo/utils.d.ts +6 -0
- package/dist/clis/weibo/utils.js +30 -0
- package/dist/clis/weread/search.js +3 -2
- package/dist/clis/xueqiu/search.yaml +2 -1
- package/dist/clis/yahoo-finance/quote.js +0 -1
- package/dist/clis/youtube/channel.d.ts +1 -0
- package/dist/clis/youtube/channel.js +150 -0
- package/dist/clis/youtube/comments.d.ts +1 -0
- package/dist/clis/youtube/comments.js +95 -0
- package/dist/clis/youtube/search.js +0 -1
- package/dist/clis/zhihu/search.yaml +2 -1
- package/dist/external-clis.yaml +0 -17
- package/dist/weread-search-regression.test.d.ts +1 -0
- package/dist/weread-search-regression.test.js +39 -0
- package/docs/.vitepress/config.mts +13 -0
- package/docs/adapters/browser/douyin.md +75 -0
- package/docs/adapters/browser/twitter.md +6 -0
- package/docs/adapters/index.md +6 -1
- package/extension/dist/background.js +508 -518
- package/extension/manifest.json +6 -2
- package/extension/package.json +1 -1
- package/extension/popup.html +84 -0
- package/extension/popup.js +25 -0
- package/extension/src/background.ts +20 -1
- package/package.json +1 -1
- package/src/clis/apple-podcasts/search.ts +2 -1
- package/src/clis/arxiv/search.ts +2 -2
- package/src/clis/bbc/news.ts +0 -1
- package/src/clis/ctrip/search.ts +0 -1
- package/src/clis/douyin/_shared/browser-fetch.test.ts +38 -0
- package/src/clis/douyin/_shared/browser-fetch.ts +45 -0
- package/src/clis/douyin/_shared/creation-id.test.ts +26 -0
- package/src/clis/douyin/_shared/creation-id.ts +8 -0
- package/src/clis/douyin/_shared/imagex-upload.test.ts +113 -0
- package/src/clis/douyin/_shared/imagex-upload.ts +76 -0
- package/src/clis/douyin/_shared/sts2.ts +20 -0
- package/src/clis/douyin/_shared/text-extra.test.ts +42 -0
- package/src/clis/douyin/_shared/text-extra.ts +33 -0
- package/src/clis/douyin/_shared/timing.test.ts +38 -0
- package/src/clis/douyin/_shared/timing.ts +22 -0
- package/src/clis/douyin/_shared/tos-upload-short-read.test.ts +102 -0
- package/src/clis/douyin/_shared/tos-upload.test.ts +281 -0
- package/src/clis/douyin/_shared/tos-upload.ts +444 -0
- package/src/clis/douyin/_shared/transcode.test.ts +117 -0
- package/src/clis/douyin/_shared/transcode.ts +78 -0
- package/src/clis/douyin/_shared/types.ts +29 -0
- package/src/clis/douyin/activities.test.ts +25 -0
- package/src/clis/douyin/activities.ts +23 -0
- package/src/clis/douyin/collections.test.ts +26 -0
- package/src/clis/douyin/collections.ts +25 -0
- package/src/clis/douyin/delete.test.ts +12 -0
- package/src/clis/douyin/delete.ts +20 -0
- package/src/clis/douyin/draft.test.ts +12 -0
- package/src/clis/douyin/draft.ts +282 -0
- package/src/clis/douyin/drafts.test.ts +12 -0
- package/src/clis/douyin/drafts.ts +27 -0
- package/src/clis/douyin/hashtag.test.ts +28 -0
- package/src/clis/douyin/hashtag.ts +56 -0
- package/src/clis/douyin/location.test.ts +26 -0
- package/src/clis/douyin/location.ts +27 -0
- package/src/clis/douyin/profile.test.ts +12 -0
- package/src/clis/douyin/profile.ts +37 -0
- package/src/clis/douyin/publish.test.ts +45 -0
- package/src/clis/douyin/publish.ts +340 -0
- package/src/clis/douyin/stats.test.ts +25 -0
- package/src/clis/douyin/stats.ts +30 -0
- package/src/clis/douyin/update.test.ts +12 -0
- package/src/clis/douyin/update.ts +43 -0
- package/src/clis/douyin/videos.test.ts +12 -0
- package/src/clis/douyin/videos.ts +49 -0
- package/src/clis/hackernews/search.yaml +1 -1
- package/src/clis/instagram/search.yaml +2 -1
- package/src/clis/linux-do/search.yaml +3 -1
- package/src/clis/medium/search.ts +1 -1
- package/src/clis/reuters/search.ts +0 -1
- package/src/clis/twitter/search.test.ts +69 -2
- package/src/clis/twitter/search.ts +5 -3
- package/src/clis/weibo/comments.ts +54 -0
- package/src/clis/weibo/feed.ts +57 -0
- package/src/clis/weibo/hot.ts +0 -1
- package/src/clis/weibo/me.ts +77 -0
- package/src/clis/weibo/post.ts +77 -0
- package/src/clis/weibo/user.ts +64 -0
- package/src/clis/weibo/utils.ts +32 -0
- package/src/clis/weread/search.ts +3 -2
- package/src/clis/xueqiu/search.yaml +2 -1
- package/src/clis/yahoo-finance/quote.ts +0 -1
- package/src/clis/youtube/channel.ts +155 -0
- package/src/clis/youtube/comments.ts +97 -0
- package/src/clis/youtube/search.ts +0 -1
- package/src/clis/zhihu/search.yaml +2 -1
- package/src/external-clis.yaml +0 -17
- package/src/weread-search-regression.test.ts +44 -0
- package/tests/e2e/browser-public-extended.test.ts +162 -0
- package/tests/e2e/browser-public.test.ts +7 -146
- package/vitest.config.ts +24 -17
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "OpenCLI",
|
|
4
|
-
"version": "1.4.
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "1.4.1",
|
|
5
|
+
"description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"debugger",
|
|
8
8
|
"tabs",
|
|
@@ -22,10 +22,14 @@
|
|
|
22
22
|
},
|
|
23
23
|
"action": {
|
|
24
24
|
"default_title": "OpenCLI",
|
|
25
|
+
"default_popup": "popup.html",
|
|
25
26
|
"default_icon": {
|
|
26
27
|
"16": "icons/icon-16.png",
|
|
27
28
|
"32": "icons/icon-32.png"
|
|
28
29
|
}
|
|
29
30
|
},
|
|
31
|
+
"content_security_policy": {
|
|
32
|
+
"extension_pages": "script-src 'self'; object-src 'self'"
|
|
33
|
+
},
|
|
30
34
|
"homepage_url": "https://github.com/jackwener/opencli"
|
|
31
35
|
}
|
package/extension/package.json
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<style>
|
|
6
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
7
|
+
body {
|
|
8
|
+
width: 280px;
|
|
9
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
10
|
+
font-size: 13px;
|
|
11
|
+
color: #333;
|
|
12
|
+
background: #fff;
|
|
13
|
+
padding: 16px;
|
|
14
|
+
}
|
|
15
|
+
.header {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: 8px;
|
|
19
|
+
margin-bottom: 14px;
|
|
20
|
+
}
|
|
21
|
+
.header img { width: 24px; height: 24px; }
|
|
22
|
+
.header h1 { font-size: 15px; font-weight: 600; }
|
|
23
|
+
.status-row {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: 8px;
|
|
27
|
+
padding: 10px 12px;
|
|
28
|
+
border-radius: 8px;
|
|
29
|
+
background: #f5f5f5;
|
|
30
|
+
}
|
|
31
|
+
.dot {
|
|
32
|
+
width: 8px; height: 8px;
|
|
33
|
+
border-radius: 50%;
|
|
34
|
+
flex-shrink: 0;
|
|
35
|
+
}
|
|
36
|
+
.dot.connected { background: #34c759; }
|
|
37
|
+
.dot.disconnected { background: #ff3b30; }
|
|
38
|
+
.dot.connecting { background: #ff9500; }
|
|
39
|
+
.status-text { font-size: 13px; color: #555; }
|
|
40
|
+
.status-text strong { color: #333; }
|
|
41
|
+
.hint {
|
|
42
|
+
margin-top: 10px;
|
|
43
|
+
padding: 8px 10px;
|
|
44
|
+
border-radius: 6px;
|
|
45
|
+
background: #f0f4ff;
|
|
46
|
+
font-size: 11px;
|
|
47
|
+
color: #666;
|
|
48
|
+
line-height: 1.5;
|
|
49
|
+
display: none;
|
|
50
|
+
}
|
|
51
|
+
.hint code {
|
|
52
|
+
background: #e8ecf1;
|
|
53
|
+
padding: 1px 4px;
|
|
54
|
+
border-radius: 3px;
|
|
55
|
+
font-size: 11px;
|
|
56
|
+
}
|
|
57
|
+
.footer {
|
|
58
|
+
margin-top: 14px;
|
|
59
|
+
text-align: center;
|
|
60
|
+
font-size: 11px;
|
|
61
|
+
color: #999;
|
|
62
|
+
}
|
|
63
|
+
.footer a { color: #007aff; text-decoration: none; }
|
|
64
|
+
.footer a:hover { text-decoration: underline; }
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<div class="header">
|
|
69
|
+
<img src="icons/icon-48.png" alt="OpenCLI">
|
|
70
|
+
<h1>OpenCLI</h1>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="status-row">
|
|
73
|
+
<span class="dot disconnected" id="dot"></span>
|
|
74
|
+
<span class="status-text" id="status">Checking...</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="hint" id="hint">
|
|
77
|
+
This is normal. The extension connects automatically when you run any <code>opencli</code> command.
|
|
78
|
+
</div>
|
|
79
|
+
<div class="footer">
|
|
80
|
+
<a href="https://github.com/jackwener/opencli" target="_blank">Documentation</a>
|
|
81
|
+
</div>
|
|
82
|
+
<script src="popup.js"></script>
|
|
83
|
+
</body>
|
|
84
|
+
</html>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Query connection status from background service worker
|
|
2
|
+
chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => {
|
|
3
|
+
const dot = document.getElementById('dot');
|
|
4
|
+
const status = document.getElementById('status');
|
|
5
|
+
const hint = document.getElementById('hint');
|
|
6
|
+
if (chrome.runtime.lastError || !resp) {
|
|
7
|
+
dot.className = 'dot disconnected';
|
|
8
|
+
status.innerHTML = '<strong>No daemon connected</strong>';
|
|
9
|
+
hint.style.display = 'block';
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (resp.connected) {
|
|
13
|
+
dot.className = 'dot connected';
|
|
14
|
+
status.innerHTML = '<strong>Connected to daemon</strong>';
|
|
15
|
+
hint.style.display = 'none';
|
|
16
|
+
} else if (resp.reconnecting) {
|
|
17
|
+
dot.className = 'dot connecting';
|
|
18
|
+
status.innerHTML = '<strong>Reconnecting...</strong>';
|
|
19
|
+
hint.style.display = 'none';
|
|
20
|
+
} else {
|
|
21
|
+
dot.className = 'dot disconnected';
|
|
22
|
+
status.innerHTML = '<strong>No daemon connected</strong>';
|
|
23
|
+
hint.style.display = 'block';
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -74,10 +74,17 @@ function connect(): void {
|
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects.
|
|
79
|
+
* The keepalive alarm (~24s) will still call connect() periodically, but at a
|
|
80
|
+
* much lower frequency — reducing console noise when the daemon is not running.
|
|
81
|
+
*/
|
|
82
|
+
const MAX_EAGER_ATTEMPTS = 6; // 2s, 4s, 8s, 16s, 32s, 60s — then stop
|
|
83
|
+
|
|
77
84
|
function scheduleReconnect(): void {
|
|
78
85
|
if (reconnectTimer) return;
|
|
79
86
|
reconnectAttempts++;
|
|
80
|
-
|
|
87
|
+
if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; // let keepalive alarm handle it
|
|
81
88
|
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
|
82
89
|
reconnectTimer = setTimeout(() => {
|
|
83
90
|
reconnectTimer = null;
|
|
@@ -193,6 +200,18 @@ chrome.alarms.onAlarm.addListener((alarm) => {
|
|
|
193
200
|
if (alarm.name === 'keepalive') connect();
|
|
194
201
|
});
|
|
195
202
|
|
|
203
|
+
// ─── Popup status API ───────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
206
|
+
if (msg?.type === 'getStatus') {
|
|
207
|
+
sendResponse({
|
|
208
|
+
connected: ws?.readyState === WebSocket.OPEN,
|
|
209
|
+
reconnecting: reconnectTimer !== null,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
});
|
|
214
|
+
|
|
196
215
|
// ─── Command dispatcher ─────────────────────────────────────────────
|
|
197
216
|
|
|
198
217
|
async function handleCommand(cmd: Command): Promise<Result> {
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ cli({
|
|
|
12
12
|
{ name: 'query', positional: true, required: true, help: 'Search keyword' },
|
|
13
13
|
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
|
14
14
|
],
|
|
15
|
-
columns: ['id', 'title', 'author', 'episodes', 'genre'],
|
|
15
|
+
columns: ['id', 'title', 'author', 'episodes', 'genre', 'url'],
|
|
16
16
|
func: async (_page, args) => {
|
|
17
17
|
const term = encodeURIComponent(args.query);
|
|
18
18
|
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
|
});
|
package/src/clis/arxiv/search.ts
CHANGED
|
@@ -12,13 +12,13 @@ cli({
|
|
|
12
12
|
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "attention is all you need")' },
|
|
13
13
|
{ name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
|
|
14
14
|
],
|
|
15
|
-
columns: ['id', 'title', 'authors', 'published'],
|
|
15
|
+
columns: ['id', 'title', 'authors', 'published', 'url'],
|
|
16
16
|
func: async (_page, args) => {
|
|
17
17
|
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
|
18
18
|
const query = encodeURIComponent(`all:${args.query}`);
|
|
19
19
|
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
|
|
20
20
|
const entries = parseEntries(xml);
|
|
21
21
|
if (!entries.length) 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
|
});
|
package/src/clis/bbc/news.ts
CHANGED
package/src/clis/ctrip/search.ts
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { IPage } from '../../../types.js';
|
|
3
|
+
import { browserFetch } from './browser-fetch.js';
|
|
4
|
+
|
|
5
|
+
function makePage(result: unknown): IPage {
|
|
6
|
+
return {
|
|
7
|
+
goto: vi.fn(), evaluate: vi.fn().mockResolvedValue(result),
|
|
8
|
+
getCookies: vi.fn(), snapshot: vi.fn(), click: vi.fn(),
|
|
9
|
+
typeText: vi.fn(), pressKey: vi.fn(), scrollTo: vi.fn(),
|
|
10
|
+
getFormState: vi.fn(), wait: vi.fn(), tabs: vi.fn(),
|
|
11
|
+
closeTab: vi.fn(), newTab: vi.fn(), selectTab: vi.fn(),
|
|
12
|
+
networkRequests: vi.fn(), consoleMessages: vi.fn(),
|
|
13
|
+
scroll: vi.fn(), autoScroll: vi.fn(),
|
|
14
|
+
installInterceptor: vi.fn(), getInterceptedRequests: vi.fn(),
|
|
15
|
+
screenshot: vi.fn(),
|
|
16
|
+
} as unknown as IPage;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('browserFetch', () => {
|
|
20
|
+
it('returns parsed JSON on success', async () => {
|
|
21
|
+
const page = makePage({ status_code: 0, data: { ak: 'KEY' } });
|
|
22
|
+
const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
|
|
23
|
+
expect(result).toEqual({ status_code: 0, data: { ak: 'KEY' } });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('throws when status_code is non-zero', async () => {
|
|
27
|
+
const page = makePage({ status_code: 8, message: 'fail' });
|
|
28
|
+
await expect(
|
|
29
|
+
browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')
|
|
30
|
+
).rejects.toThrow('Douyin API error 8');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns result even when no status_code field', async () => {
|
|
34
|
+
const page = makePage({ some_field: 'value' });
|
|
35
|
+
const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
|
|
36
|
+
expect(result).toEqual({ some_field: 'value' });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { IPage } from '../../../types.js';
|
|
2
|
+
import { CommandExecutionError } from '../../../errors.js';
|
|
3
|
+
|
|
4
|
+
export interface FetchOptions {
|
|
5
|
+
body?: unknown;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Execute a fetch() call inside the Chrome browser context via page.evaluate.
|
|
11
|
+
* This ensures a_bogus signing and cookies are handled automatically by the browser.
|
|
12
|
+
*/
|
|
13
|
+
export async function browserFetch(
|
|
14
|
+
page: IPage,
|
|
15
|
+
method: 'GET' | 'POST',
|
|
16
|
+
url: string,
|
|
17
|
+
options: FetchOptions = {}
|
|
18
|
+
): Promise<unknown> {
|
|
19
|
+
const js = `
|
|
20
|
+
(async () => {
|
|
21
|
+
const res = await fetch(${JSON.stringify(url)}, {
|
|
22
|
+
method: ${JSON.stringify(method)},
|
|
23
|
+
credentials: 'include',
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
...${JSON.stringify(options.headers ?? {})}
|
|
27
|
+
},
|
|
28
|
+
${options.body ? `body: JSON.stringify(${JSON.stringify(options.body)}),` : ''}
|
|
29
|
+
});
|
|
30
|
+
return res.json();
|
|
31
|
+
})()
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const result = await page.evaluate(js);
|
|
35
|
+
|
|
36
|
+
if (result && typeof result === 'object' && 'status_code' in result) {
|
|
37
|
+
const code = (result as { status_code: number }).status_code;
|
|
38
|
+
if (code !== 0) {
|
|
39
|
+
const msg = (result as { status_msg?: string }).status_msg ?? 'unknown error';
|
|
40
|
+
throw new CommandExecutionError(`Douyin API error ${code}: ${msg}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { generateCreationId } from './creation-id.js';
|
|
3
|
+
|
|
4
|
+
describe('generateCreationId', () => {
|
|
5
|
+
it('starts with "pin"', () => {
|
|
6
|
+
expect(generateCreationId()).toMatch(/^pin/);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('has 4 random lowercase-alphanumeric chars after "pin"', () => {
|
|
10
|
+
expect(generateCreationId()).toMatch(/^pin[a-z0-9]{4}/);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('ends with a numeric timestamp (ms)', () => {
|
|
14
|
+
const before = Date.now();
|
|
15
|
+
const id = generateCreationId();
|
|
16
|
+
const after = Date.now();
|
|
17
|
+
const ts = parseInt(id.replace(/^pin[a-z0-9]{4}/, ''), 10);
|
|
18
|
+
expect(ts).toBeGreaterThanOrEqual(before);
|
|
19
|
+
expect(ts).toBeLessThanOrEqual(after);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('generates unique IDs', () => {
|
|
23
|
+
const ids = new Set(Array.from({ length: 100 }, generateCreationId));
|
|
24
|
+
expect(ids.size).toBe(100);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { CommandExecutionError } from '../../../errors.js';
|
|
6
|
+
import { imagexUpload } from './imagex-upload.js';
|
|
7
|
+
import type { ImageXUploadInfo } from './imagex-upload.js';
|
|
8
|
+
|
|
9
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function makeTempImage(ext = '.jpg'): string {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'imagex-test-'));
|
|
13
|
+
const filePath = path.join(dir, `cover${ext}`);
|
|
14
|
+
fs.writeFileSync(filePath, Buffer.from([0xff, 0xd8, 0xff, 0xe0])); // minimal JPEG header bytes
|
|
15
|
+
return filePath;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FAKE_UPLOAD_INFO: ImageXUploadInfo = {
|
|
19
|
+
upload_url: 'https://imagex.bytedance.com/upload/presigned/fake',
|
|
20
|
+
store_uri: 'tos-cn-i-alisg.example.com/cover/abc123',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe('imagexUpload', () => {
|
|
26
|
+
let imagePath: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
imagePath = makeTempImage('.jpg');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
// Clean up temp files
|
|
34
|
+
try {
|
|
35
|
+
fs.unlinkSync(imagePath);
|
|
36
|
+
fs.rmdirSync(path.dirname(imagePath));
|
|
37
|
+
} catch {
|
|
38
|
+
// ignore cleanup errors
|
|
39
|
+
}
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('throws CommandExecutionError when image file does not exist', async () => {
|
|
44
|
+
await expect(
|
|
45
|
+
imagexUpload('/nonexistent/path/cover.jpg', FAKE_UPLOAD_INFO),
|
|
46
|
+
).rejects.toThrow(CommandExecutionError);
|
|
47
|
+
|
|
48
|
+
await expect(
|
|
49
|
+
imagexUpload('/nonexistent/path/cover.jpg', FAKE_UPLOAD_INFO),
|
|
50
|
+
).rejects.toThrow('Cover image file not found');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('PUTs the image and returns store_uri on success', async () => {
|
|
54
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
55
|
+
ok: true,
|
|
56
|
+
status: 200,
|
|
57
|
+
text: vi.fn().mockResolvedValue(''),
|
|
58
|
+
});
|
|
59
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
60
|
+
|
|
61
|
+
const result = await imagexUpload(imagePath, FAKE_UPLOAD_INFO);
|
|
62
|
+
|
|
63
|
+
expect(result).toBe(FAKE_UPLOAD_INFO.store_uri);
|
|
64
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
65
|
+
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
66
|
+
expect(url).toBe(FAKE_UPLOAD_INFO.upload_url);
|
|
67
|
+
expect(init.method).toBe('PUT');
|
|
68
|
+
expect((init.headers as Record<string, string>)['Content-Type']).toBe(
|
|
69
|
+
'image/jpeg',
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('uses image/png Content-Type for .png files', async () => {
|
|
74
|
+
const pngPath = makeTempImage('.png');
|
|
75
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
76
|
+
ok: true,
|
|
77
|
+
status: 200,
|
|
78
|
+
text: vi.fn().mockResolvedValue(''),
|
|
79
|
+
});
|
|
80
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await imagexUpload(pngPath, FAKE_UPLOAD_INFO);
|
|
84
|
+
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
85
|
+
expect((init.headers as Record<string, string>)['Content-Type']).toBe(
|
|
86
|
+
'image/png',
|
|
87
|
+
);
|
|
88
|
+
} finally {
|
|
89
|
+
try {
|
|
90
|
+
fs.unlinkSync(pngPath);
|
|
91
|
+
fs.rmdirSync(path.dirname(pngPath));
|
|
92
|
+
} catch {
|
|
93
|
+
// ignore
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('throws CommandExecutionError on non-2xx PUT response', async () => {
|
|
99
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
100
|
+
ok: false,
|
|
101
|
+
status: 403,
|
|
102
|
+
text: vi.fn().mockResolvedValue('Forbidden'),
|
|
103
|
+
});
|
|
104
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
105
|
+
|
|
106
|
+
await expect(imagexUpload(imagePath, FAKE_UPLOAD_INFO)).rejects.toThrow(
|
|
107
|
+
CommandExecutionError,
|
|
108
|
+
);
|
|
109
|
+
await expect(imagexUpload(imagePath, FAKE_UPLOAD_INFO)).rejects.toThrow(
|
|
110
|
+
'ImageX upload failed with status 403',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageX cover image uploader.
|
|
3
|
+
*
|
|
4
|
+
* Uploads a JPEG/PNG image to ByteDance ImageX via a pre-signed PUT URL
|
|
5
|
+
* obtained from the Douyin "apply cover upload" API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { CommandExecutionError } from '../../../errors.js';
|
|
11
|
+
|
|
12
|
+
export interface ImageXUploadInfo {
|
|
13
|
+
/** Pre-signed PUT target URL (provided by the apply cover upload API) */
|
|
14
|
+
upload_url: string;
|
|
15
|
+
/** Image URI to use in create_v2 (returned from the apply step) */
|
|
16
|
+
store_uri: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect MIME type from file extension.
|
|
21
|
+
* Falls back to image/jpeg for unknown extensions.
|
|
22
|
+
*/
|
|
23
|
+
function detectContentType(filePath: string): string {
|
|
24
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
25
|
+
switch (ext) {
|
|
26
|
+
case '.png':
|
|
27
|
+
return 'image/png';
|
|
28
|
+
case '.gif':
|
|
29
|
+
return 'image/gif';
|
|
30
|
+
case '.webp':
|
|
31
|
+
return 'image/webp';
|
|
32
|
+
default:
|
|
33
|
+
return 'image/jpeg';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Upload a cover image to ByteDance ImageX via a pre-signed PUT URL.
|
|
39
|
+
*
|
|
40
|
+
* @param imagePath - Local file path to the image (JPEG/PNG/etc.)
|
|
41
|
+
* @param uploadInfo - Upload URL and store_uri from the apply cover upload API
|
|
42
|
+
* @returns The store_uri (= image_uri for use in create_v2)
|
|
43
|
+
*/
|
|
44
|
+
export async function imagexUpload(
|
|
45
|
+
imagePath: string,
|
|
46
|
+
uploadInfo: ImageXUploadInfo,
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
if (!fs.existsSync(imagePath)) {
|
|
49
|
+
throw new CommandExecutionError(
|
|
50
|
+
`Cover image file not found: ${imagePath}`,
|
|
51
|
+
'Ensure the file path is correct and accessible.',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const imageBuffer = fs.readFileSync(imagePath);
|
|
56
|
+
const contentType = detectContentType(imagePath);
|
|
57
|
+
|
|
58
|
+
const res = await fetch(uploadInfo.upload_url, {
|
|
59
|
+
method: 'PUT',
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': contentType,
|
|
62
|
+
'Content-Length': String(imageBuffer.byteLength),
|
|
63
|
+
},
|
|
64
|
+
body: imageBuffer as unknown as BodyInit,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const body = await res.text().catch(() => '');
|
|
69
|
+
throw new CommandExecutionError(
|
|
70
|
+
`ImageX upload failed with status ${res.status}: ${body}`,
|
|
71
|
+
'Check that the upload URL is valid and has not expired.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return uploadInfo.store_uri;
|
|
76
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { IPage } from '../../../types.js';
|
|
2
|
+
import type { Sts2Credentials } from './types.js';
|
|
3
|
+
import { AuthRequiredError } from '../../../errors.js';
|
|
4
|
+
|
|
5
|
+
const STS2_URL =
|
|
6
|
+
'https://creator.douyin.com/aweme/mid/video/sts2/?scene=web&aid=1128&cookie_enabled=true&device_platform=web';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fetch STS2 temporary credentials from the creator center.
|
|
10
|
+
* These are used to authenticate Node.js-side TOS multipart uploads.
|
|
11
|
+
* Returns: { access_key_id, secret_access_key, session_token, expired_time }
|
|
12
|
+
*/
|
|
13
|
+
export async function getSts2Credentials(page: IPage): Promise<Sts2Credentials> {
|
|
14
|
+
const js = `fetch(${JSON.stringify(STS2_URL)}, { credentials: 'include' }).then(r => r.json())`;
|
|
15
|
+
const res = await page.evaluate(js) as { data: Sts2Credentials };
|
|
16
|
+
if (!res?.data?.access_key_id) {
|
|
17
|
+
throw new AuthRequiredError('creator.douyin.com', 'STS2 credentials missing');
|
|
18
|
+
}
|
|
19
|
+
return res.data;
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseTextExtra, extractHashtagNames, type HashtagInfo } from './text-extra.js';
|
|
3
|
+
|
|
4
|
+
describe('parseTextExtra', () => {
|
|
5
|
+
it('returns empty array for text with no hashtags', () => {
|
|
6
|
+
const result = parseTextExtra('普通文本内容', []);
|
|
7
|
+
expect(result).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('produces type-1 entry for each hashtag', () => {
|
|
11
|
+
const hashtags: HashtagInfo[] = [
|
|
12
|
+
{ name: '话题', id: 12345, start: 5, end: 8 },
|
|
13
|
+
];
|
|
14
|
+
const result = parseTextExtra('普通文本 #话题', hashtags);
|
|
15
|
+
expect(result).toHaveLength(1);
|
|
16
|
+
expect(result[0]).toMatchObject({
|
|
17
|
+
type: 1,
|
|
18
|
+
hashtag_name: '话题',
|
|
19
|
+
hashtag_id: 12345,
|
|
20
|
+
start: 5,
|
|
21
|
+
end: 8,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('sets hashtag_id to 0 when not found', () => {
|
|
26
|
+
const hashtags: HashtagInfo[] = [
|
|
27
|
+
{ name: '未知话题', id: 0, start: 0, end: 5 },
|
|
28
|
+
];
|
|
29
|
+
const result = parseTextExtra('#未知话题', hashtags);
|
|
30
|
+
expect(result[0].hashtag_id).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('extractHashtagNames', () => {
|
|
35
|
+
it('extracts hashtag names from text', () => {
|
|
36
|
+
expect(extractHashtagNames('hello #foo and #bar')).toEqual(['foo', 'bar']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns empty array when no hashtags', () => {
|
|
40
|
+
expect(extractHashtagNames('no hashtags here')).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface HashtagInfo {
|
|
2
|
+
name: string;
|
|
3
|
+
id: number;
|
|
4
|
+
start: number;
|
|
5
|
+
end: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TextExtraItem {
|
|
9
|
+
type: number;
|
|
10
|
+
hashtag_id: number;
|
|
11
|
+
hashtag_name: string;
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
caption_start: number;
|
|
15
|
+
caption_end: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseTextExtra(_text: string, hashtags: HashtagInfo[]): TextExtraItem[] {
|
|
19
|
+
return hashtags.map((h) => ({
|
|
20
|
+
type: 1,
|
|
21
|
+
hashtag_id: h.id,
|
|
22
|
+
hashtag_name: h.name,
|
|
23
|
+
start: h.start,
|
|
24
|
+
end: h.end,
|
|
25
|
+
caption_start: 0,
|
|
26
|
+
caption_end: h.end - h.start,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Extract hashtag names from text (e.g. "#话题" → ["话题"]) */
|
|
31
|
+
export function extractHashtagNames(text: string): string[] {
|
|
32
|
+
return [...text.matchAll(/#([^\s#]+)/g)].map((m) => m[1]);
|
|
33
|
+
}
|