@jackwener/opencli 1.3.1 → 1.3.2

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 (217) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +44 -5
  3. package/README.zh-CN.md +44 -5
  4. package/SKILL.md +317 -5
  5. package/TESTING.md +4 -4
  6. package/dist/browser/errors.d.ts +2 -1
  7. package/dist/browser/errors.js +9 -10
  8. package/dist/build-manifest.js +1 -3
  9. package/dist/cli-manifest.json +2573 -989
  10. package/dist/cli.js +42 -2
  11. package/dist/clis/bilibili/download.js +20 -65
  12. package/dist/clis/bilibili/utils.js +2 -1
  13. package/dist/clis/chaoxing/assignments.js +2 -1
  14. package/dist/clis/doubao/ask.d.ts +1 -0
  15. package/dist/clis/doubao/ask.js +35 -0
  16. package/dist/clis/doubao/common.d.ts +23 -0
  17. package/dist/clis/doubao/common.js +564 -0
  18. package/dist/clis/doubao/new.d.ts +1 -0
  19. package/dist/clis/doubao/new.js +20 -0
  20. package/dist/clis/doubao/read.d.ts +1 -0
  21. package/dist/clis/doubao/read.js +19 -0
  22. package/dist/clis/doubao/send.d.ts +1 -0
  23. package/dist/clis/doubao/send.js +22 -0
  24. package/dist/clis/doubao/status.d.ts +1 -0
  25. package/dist/clis/doubao/status.js +24 -0
  26. package/dist/clis/doubao-app/ask.d.ts +1 -0
  27. package/dist/clis/doubao-app/ask.js +53 -0
  28. package/dist/clis/doubao-app/common.d.ts +37 -0
  29. package/dist/clis/doubao-app/common.js +110 -0
  30. package/dist/clis/doubao-app/dump.d.ts +1 -0
  31. package/dist/clis/doubao-app/dump.js +24 -0
  32. package/dist/clis/doubao-app/new.d.ts +1 -0
  33. package/dist/clis/doubao-app/new.js +20 -0
  34. package/dist/clis/doubao-app/read.d.ts +1 -0
  35. package/dist/clis/doubao-app/read.js +18 -0
  36. package/dist/clis/doubao-app/screenshot.d.ts +1 -0
  37. package/dist/clis/doubao-app/screenshot.js +18 -0
  38. package/dist/clis/doubao-app/send.d.ts +1 -0
  39. package/dist/clis/doubao-app/send.js +27 -0
  40. package/dist/clis/doubao-app/status.d.ts +1 -0
  41. package/dist/clis/doubao-app/status.js +16 -0
  42. package/dist/clis/hackernews/ask.yaml +38 -0
  43. package/dist/clis/hackernews/best.yaml +38 -0
  44. package/dist/clis/hackernews/jobs.yaml +36 -0
  45. package/dist/clis/hackernews/new.yaml +38 -0
  46. package/dist/clis/hackernews/search.yaml +44 -0
  47. package/dist/clis/hackernews/show.yaml +38 -0
  48. package/dist/clis/hackernews/top.yaml +3 -1
  49. package/dist/clis/hackernews/user.yaml +25 -0
  50. package/dist/clis/twitter/download.js +13 -97
  51. package/dist/clis/twitter/thread.js +2 -1
  52. package/dist/clis/v2ex/member.yaml +29 -0
  53. package/dist/clis/v2ex/node.yaml +34 -0
  54. package/dist/clis/v2ex/nodes.yaml +31 -0
  55. package/dist/clis/v2ex/replies.yaml +32 -0
  56. package/dist/clis/v2ex/user.yaml +34 -0
  57. package/dist/clis/weibo/search.d.ts +1 -0
  58. package/dist/clis/weibo/search.js +73 -0
  59. package/dist/clis/weixin/download.d.ts +12 -0
  60. package/dist/clis/weixin/download.js +183 -0
  61. package/dist/clis/xiaohongshu/download.js +12 -60
  62. package/dist/clis/xiaohongshu/publish.d.ts +18 -0
  63. package/dist/clis/xiaohongshu/publish.js +352 -0
  64. package/dist/clis/xiaohongshu/search.js +47 -15
  65. package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
  66. package/dist/clis/xiaohongshu/search.test.js +114 -0
  67. package/dist/clis/yollomi/background.d.ts +4 -0
  68. package/dist/clis/yollomi/background.js +45 -0
  69. package/dist/clis/yollomi/edit.d.ts +5 -0
  70. package/dist/clis/yollomi/edit.js +56 -0
  71. package/dist/clis/yollomi/face-swap.d.ts +5 -0
  72. package/dist/clis/yollomi/face-swap.js +43 -0
  73. package/dist/clis/yollomi/generate.d.ts +9 -0
  74. package/dist/clis/yollomi/generate.js +100 -0
  75. package/dist/clis/yollomi/models.d.ts +1 -0
  76. package/dist/clis/yollomi/models.js +33 -0
  77. package/dist/clis/yollomi/object-remover.d.ts +4 -0
  78. package/dist/clis/yollomi/object-remover.js +42 -0
  79. package/dist/clis/yollomi/remove-bg.d.ts +4 -0
  80. package/dist/clis/yollomi/remove-bg.js +38 -0
  81. package/dist/clis/yollomi/restore.d.ts +4 -0
  82. package/dist/clis/yollomi/restore.js +38 -0
  83. package/dist/clis/yollomi/try-on.d.ts +4 -0
  84. package/dist/clis/yollomi/try-on.js +46 -0
  85. package/dist/clis/yollomi/upload.d.ts +7 -0
  86. package/dist/clis/yollomi/upload.js +71 -0
  87. package/dist/clis/yollomi/upscale.d.ts +4 -0
  88. package/dist/clis/yollomi/upscale.js +53 -0
  89. package/dist/clis/yollomi/utils.d.ts +45 -0
  90. package/dist/clis/yollomi/utils.js +180 -0
  91. package/dist/clis/yollomi/video.d.ts +5 -0
  92. package/dist/clis/yollomi/video.js +56 -0
  93. package/dist/clis/zhihu/download.d.ts +1 -5
  94. package/dist/clis/zhihu/download.js +20 -126
  95. package/dist/clis/zhihu/download.test.js +7 -5
  96. package/dist/clis/zhihu/question.js +2 -1
  97. package/dist/commanderAdapter.js +4 -6
  98. package/dist/daemon.js +5 -2
  99. package/dist/discovery.js +10 -10
  100. package/dist/download/article-download.d.ts +59 -0
  101. package/dist/download/article-download.js +178 -0
  102. package/dist/download/media-download.d.ts +49 -0
  103. package/dist/download/media-download.js +112 -0
  104. package/dist/errors.d.ts +23 -2
  105. package/dist/errors.js +58 -2
  106. package/dist/errors.test.d.ts +1 -0
  107. package/dist/errors.test.js +59 -0
  108. package/dist/execution.js +9 -10
  109. package/dist/explore.js +4 -2
  110. package/dist/external.d.ts +15 -0
  111. package/dist/external.js +48 -2
  112. package/dist/external.test.d.ts +1 -0
  113. package/dist/external.test.js +64 -0
  114. package/dist/main.js +10 -0
  115. package/dist/plugin.d.ts +4 -0
  116. package/dist/plugin.js +45 -23
  117. package/dist/plugin.test.js +6 -1
  118. package/dist/record.d.ts +47 -0
  119. package/dist/record.js +545 -0
  120. package/dist/registry.d.ts +7 -2
  121. package/dist/registry.js +2 -6
  122. package/dist/runtime.d.ts +3 -1
  123. package/dist/runtime.js +10 -3
  124. package/dist/validate.js +1 -3
  125. package/docs/.vitepress/config.mts +1 -0
  126. package/docs/adapters/browser/doubao.md +35 -0
  127. package/docs/adapters/browser/hackernews.md +20 -4
  128. package/docs/adapters/browser/tiktok.md +1 -1
  129. package/docs/adapters/browser/v2ex.md +31 -10
  130. package/docs/adapters/browser/weibo.md +4 -0
  131. package/docs/adapters/browser/weixin.md +33 -0
  132. package/docs/adapters/browser/xiaohongshu.md +8 -6
  133. package/docs/adapters/browser/yollomi.md +69 -0
  134. package/docs/adapters/desktop/doubao-app.md +35 -0
  135. package/docs/adapters/index.md +16 -5
  136. package/docs/advanced/download.md +4 -0
  137. package/package.json +3 -1
  138. package/src/browser/errors.ts +17 -11
  139. package/src/build-manifest.ts +2 -3
  140. package/src/cli.ts +45 -2
  141. package/src/clis/bilibili/download.ts +25 -83
  142. package/src/clis/bilibili/utils.ts +2 -1
  143. package/src/clis/chaoxing/assignments.ts +2 -1
  144. package/src/clis/doubao/ask.ts +40 -0
  145. package/src/clis/doubao/common.ts +619 -0
  146. package/src/clis/doubao/new.ts +22 -0
  147. package/src/clis/doubao/read.ts +20 -0
  148. package/src/clis/doubao/send.ts +25 -0
  149. package/src/clis/doubao/status.ts +27 -0
  150. package/src/clis/doubao-app/ask.ts +60 -0
  151. package/src/clis/doubao-app/common.ts +116 -0
  152. package/src/clis/doubao-app/dump.ts +28 -0
  153. package/src/clis/doubao-app/new.ts +21 -0
  154. package/src/clis/doubao-app/read.ts +21 -0
  155. package/src/clis/doubao-app/screenshot.ts +19 -0
  156. package/src/clis/doubao-app/send.ts +30 -0
  157. package/src/clis/doubao-app/status.ts +17 -0
  158. package/src/clis/hackernews/ask.yaml +38 -0
  159. package/src/clis/hackernews/best.yaml +38 -0
  160. package/src/clis/hackernews/jobs.yaml +36 -0
  161. package/src/clis/hackernews/new.yaml +38 -0
  162. package/src/clis/hackernews/search.yaml +44 -0
  163. package/src/clis/hackernews/show.yaml +38 -0
  164. package/src/clis/hackernews/top.yaml +3 -1
  165. package/src/clis/hackernews/user.yaml +25 -0
  166. package/src/clis/twitter/download.ts +13 -111
  167. package/src/clis/twitter/thread.ts +2 -1
  168. package/src/clis/v2ex/member.yaml +29 -0
  169. package/src/clis/v2ex/node.yaml +34 -0
  170. package/src/clis/v2ex/nodes.yaml +31 -0
  171. package/src/clis/v2ex/replies.yaml +32 -0
  172. package/src/clis/v2ex/user.yaml +34 -0
  173. package/src/clis/weibo/search.ts +78 -0
  174. package/src/clis/weixin/download.ts +199 -0
  175. package/src/clis/xiaohongshu/download.ts +12 -71
  176. package/src/clis/xiaohongshu/publish.ts +392 -0
  177. package/src/clis/xiaohongshu/search.test.ts +134 -0
  178. package/src/clis/xiaohongshu/search.ts +49 -15
  179. package/src/clis/yollomi/background.ts +48 -0
  180. package/src/clis/yollomi/edit.ts +58 -0
  181. package/src/clis/yollomi/face-swap.ts +45 -0
  182. package/src/clis/yollomi/generate.ts +95 -0
  183. package/src/clis/yollomi/models.ts +38 -0
  184. package/src/clis/yollomi/object-remover.ts +44 -0
  185. package/src/clis/yollomi/remove-bg.ts +40 -0
  186. package/src/clis/yollomi/restore.ts +40 -0
  187. package/src/clis/yollomi/try-on.ts +48 -0
  188. package/src/clis/yollomi/upload.ts +78 -0
  189. package/src/clis/yollomi/upscale.ts +49 -0
  190. package/src/clis/yollomi/utils.ts +202 -0
  191. package/src/clis/yollomi/video.ts +61 -0
  192. package/src/clis/zhihu/download.test.ts +7 -5
  193. package/src/clis/zhihu/download.ts +23 -158
  194. package/src/clis/zhihu/question.ts +2 -1
  195. package/src/commanderAdapter.ts +4 -7
  196. package/src/daemon.ts +5 -2
  197. package/src/discovery.ts +26 -26
  198. package/src/download/article-download.ts +272 -0
  199. package/src/download/media-download.ts +178 -0
  200. package/src/errors.test.ts +79 -0
  201. package/src/errors.ts +92 -2
  202. package/src/execution.ts +14 -10
  203. package/src/explore.ts +4 -2
  204. package/src/external.test.ts +88 -0
  205. package/src/external.ts +56 -2
  206. package/src/generate.ts +2 -1
  207. package/src/main.ts +10 -0
  208. package/src/plugin.test.ts +7 -1
  209. package/src/plugin.ts +49 -25
  210. package/src/record.ts +617 -0
  211. package/src/registry.ts +9 -5
  212. package/src/runtime.ts +16 -4
  213. package/src/validate.ts +2 -3
  214. package/tests/e2e/browser-auth.test.ts +10 -1
  215. package/tests/e2e/browser-public.test.ts +13 -8
  216. package/tests/e2e/public-commands.test.ts +209 -21
  217. package/tests/smoke/api-health.test.ts +65 -6
package/dist/record.js ADDED
@@ -0,0 +1,545 @@
1
+ /**
2
+ * Record mode — capture API calls from a live browser session.
3
+ *
4
+ * Flow:
5
+ * 1. Navigate to the target URL in an automation tab
6
+ * 2. Inject a full-capture fetch/XHR interceptor (records url + method + body)
7
+ * 3. Poll every 2s and print newly captured requests
8
+ * 4. User operates the page; press Enter to stop
9
+ * 5. Analyze captured requests → infer capabilities → write YAML candidates
10
+ *
11
+ * Design: no new daemon endpoints, no extension changes.
12
+ * Uses existing exec + navigate actions only.
13
+ */
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import * as readline from 'node:readline';
17
+ import chalk from 'chalk';
18
+ import yaml from 'js-yaml';
19
+ import { sendCommand } from './browser/daemon-client.js';
20
+ import { VOLATILE_PARAMS, SEARCH_PARAMS, PAGINATION_PARAMS, FIELD_ROLES, } from './constants.js';
21
+ // ── Interceptor JS ─────────────────────────────────────────────────────────
22
+ /**
23
+ * Generates a full-capture interceptor that stores {url, method, status, body}
24
+ * for every JSON response. No URL pattern filter — captures everything.
25
+ */
26
+ function generateFullCaptureInterceptorJs() {
27
+ return `
28
+ (() => {
29
+ // Restore original fetch/XHR if previously patched, then re-patch (idempotent injection)
30
+ if (window.__opencli_record_patched) {
31
+ if (window.__opencli_orig_fetch) window.fetch = window.__opencli_orig_fetch;
32
+ if (window.__opencli_orig_xhr_open) XMLHttpRequest.prototype.open = window.__opencli_orig_xhr_open;
33
+ if (window.__opencli_orig_xhr_send) XMLHttpRequest.prototype.send = window.__opencli_orig_xhr_send;
34
+ window.__opencli_record_patched = false;
35
+ }
36
+ // Preserve existing capture buffer across re-injections
37
+ window.__opencli_record = window.__opencli_record || [];
38
+
39
+ const _push = (url, method, body) => {
40
+ try {
41
+ // Only capture JSON-like responses
42
+ if (typeof body !== 'object' || body === null) return;
43
+ // Skip tiny/trivial responses (tracking pixels, empty acks)
44
+ const keys = Object.keys(body);
45
+ if (keys.length < 2) return;
46
+ window.__opencli_record.push({
47
+ url: String(url),
48
+ method: String(method).toUpperCase(),
49
+ status: null,
50
+ body,
51
+ ts: Date.now(),
52
+ });
53
+ } catch {}
54
+ };
55
+
56
+ // Patch fetch — save original for future restore
57
+ window.__opencli_orig_fetch = window.fetch;
58
+ window.fetch = async function(...args) {
59
+ const req = args[0];
60
+ const reqUrl = typeof req === 'string' ? req : (req instanceof Request ? req.url : String(req));
61
+ const method = (args[1]?.method || (req instanceof Request ? req.method : 'GET') || 'GET');
62
+ const res = await window.__opencli_orig_fetch.apply(this, args);
63
+ const ct = res.headers.get('content-type') || '';
64
+ if (ct.includes('json')) {
65
+ try {
66
+ const body = await res.clone().json();
67
+ _push(reqUrl, method, body);
68
+ } catch {}
69
+ }
70
+ return res;
71
+ };
72
+
73
+ // Patch XHR — save originals for future restore
74
+ const _XHR = XMLHttpRequest.prototype;
75
+ window.__opencli_orig_xhr_open = _XHR.open;
76
+ window.__opencli_orig_xhr_send = _XHR.send;
77
+ _XHR.open = function(method, url) {
78
+ this.__rec_url = String(url);
79
+ this.__rec_method = String(method);
80
+ this.__rec_listener_added = false; // reset per open() call
81
+ return window.__opencli_orig_xhr_open.apply(this, arguments);
82
+ };
83
+ _XHR.send = function() {
84
+ // Guard: only add one listener per XHR instance to prevent duplicate captures
85
+ if (!this.__rec_listener_added) {
86
+ this.__rec_listener_added = true;
87
+ this.addEventListener('load', function() {
88
+ const ct = this.getResponseHeader?.('content-type') || '';
89
+ if (ct.includes('json')) {
90
+ try { _push(this.__rec_url, this.__rec_method || 'GET', JSON.parse(this.responseText)); } catch {}
91
+ }
92
+ });
93
+ }
94
+ return window.__opencli_orig_xhr_send.apply(this, arguments);
95
+ };
96
+
97
+ window.__opencli_record_patched = true;
98
+ return 1;
99
+ })()
100
+ `;
101
+ }
102
+ /** Read and clear captured requests from the page */
103
+ function generateReadRecordedJs() {
104
+ return `
105
+ (() => {
106
+ const data = window.__opencli_record || [];
107
+ window.__opencli_record = [];
108
+ return data;
109
+ })()
110
+ `;
111
+ }
112
+ // ── Analysis helpers ───────────────────────────────────────────────────────
113
+ function urlToPattern(url) {
114
+ try {
115
+ const p = new URL(url);
116
+ const pathNorm = p.pathname
117
+ .replace(/\/\d+/g, '/{id}')
118
+ .replace(/\/[0-9a-fA-F]{8,}/g, '/{hex}')
119
+ .replace(/\/BV[a-zA-Z0-9]{10}/g, '/{bvid}');
120
+ const params = [];
121
+ p.searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k))
122
+ params.push(k); });
123
+ return `${p.host}${pathNorm}${params.length ? '?' + params.sort().map(k => `${k}={}`).join('&') : ''}`;
124
+ }
125
+ catch {
126
+ return url;
127
+ }
128
+ }
129
+ function detectAuthIndicators(url, body) {
130
+ const indicators = [];
131
+ // Heuristic: if body contains sign/w_rid fields, it's likely signed
132
+ if (body && typeof body === 'object') {
133
+ const keys = Object.keys(body).map(k => k.toLowerCase());
134
+ if (keys.some(k => k.includes('sign') || k === 'w_rid' || k.includes('token'))) {
135
+ indicators.push('signature');
136
+ }
137
+ }
138
+ // Check URL for common auth patterns
139
+ if (url.includes('/wbi/') || url.includes('w_rid='))
140
+ indicators.push('signature');
141
+ if (url.includes('bearer') || url.includes('access_token'))
142
+ indicators.push('bearer');
143
+ return indicators;
144
+ }
145
+ function findArrayPath(obj, depth = 0) {
146
+ if (depth > 5 || !obj || typeof obj !== 'object')
147
+ return null;
148
+ if (Array.isArray(obj)) {
149
+ if (obj.length >= 2 && obj.some(i => i && typeof i === 'object' && !Array.isArray(i))) {
150
+ return { path: '', items: obj };
151
+ }
152
+ return null;
153
+ }
154
+ let best = null;
155
+ for (const [key, val] of Object.entries(obj)) {
156
+ const found = findArrayPath(val, depth + 1);
157
+ if (found) {
158
+ const fullPath = found.path ? `${key}.${found.path}` : key;
159
+ const candidate = { path: fullPath, items: found.items };
160
+ if (!best || candidate.items.length > best.items.length)
161
+ best = candidate;
162
+ }
163
+ }
164
+ return best;
165
+ }
166
+ function inferCapabilityName(url) {
167
+ const u = url.toLowerCase();
168
+ if (u.includes('hot') || u.includes('popular') || u.includes('ranking') || u.includes('trending'))
169
+ return 'hot';
170
+ if (u.includes('search'))
171
+ return 'search';
172
+ if (u.includes('feed') || u.includes('timeline') || u.includes('dynamic'))
173
+ return 'feed';
174
+ if (u.includes('comment') || u.includes('reply'))
175
+ return 'comments';
176
+ if (u.includes('history'))
177
+ return 'history';
178
+ if (u.includes('profile') || u.includes('me'))
179
+ return 'me';
180
+ if (u.includes('favorite') || u.includes('collect') || u.includes('bookmark'))
181
+ return 'favorite';
182
+ try {
183
+ const segs = new URL(url).pathname
184
+ .split('/')
185
+ .filter(s => s && !s.match(/^\d+$/) && !s.match(/^[0-9a-f]{8,}$/i) && !s.match(/^v\d+$/));
186
+ if (segs.length)
187
+ return segs[segs.length - 1].replace(/[^a-z0-9]/gi, '_').toLowerCase();
188
+ }
189
+ catch { }
190
+ return 'data';
191
+ }
192
+ function inferStrategy(authIndicators) {
193
+ if (authIndicators.includes('signature'))
194
+ return 'intercept';
195
+ if (authIndicators.includes('bearer') || authIndicators.includes('csrf'))
196
+ return 'header';
197
+ return 'cookie';
198
+ }
199
+ function scoreRequest(req, arrayResult) {
200
+ let s = 0;
201
+ if (arrayResult) {
202
+ s += 10;
203
+ s += Math.min(arrayResult.items.length, 10);
204
+ // Bonus for detected semantic fields
205
+ const sample = arrayResult.items[0];
206
+ if (sample && typeof sample === 'object') {
207
+ const keys = Object.keys(sample).map(k => k.toLowerCase());
208
+ for (const aliases of Object.values(FIELD_ROLES)) {
209
+ if (aliases.some(a => keys.includes(a)))
210
+ s += 2;
211
+ }
212
+ }
213
+ }
214
+ if (req.url.includes('/api/'))
215
+ s += 3;
216
+ // Penalize likely tracking / analytics endpoints
217
+ if (req.url.match(/\/(track|log|analytics|beacon|pixel|stats|metric)/i))
218
+ s -= 10;
219
+ if (req.url.match(/\/(ping|heartbeat|keep.?alive)/i))
220
+ s -= 10;
221
+ return s;
222
+ }
223
+ // ── YAML generation ────────────────────────────────────────────────────────
224
+ function buildRecordedYaml(site, pageUrl, req, capName, arrayResult, authIndicators) {
225
+ const strategy = inferStrategy(authIndicators);
226
+ const domain = (() => { try {
227
+ return new URL(pageUrl).hostname;
228
+ }
229
+ catch {
230
+ return '';
231
+ } })();
232
+ // Detect fields from first array item
233
+ const detectedFields = {};
234
+ if (arrayResult?.items[0] && typeof arrayResult.items[0] === 'object') {
235
+ const sampleKeys = Object.keys(arrayResult.items[0]).map(k => k.toLowerCase());
236
+ for (const [role, aliases] of Object.entries(FIELD_ROLES)) {
237
+ const match = aliases.find(a => sampleKeys.includes(a));
238
+ if (match)
239
+ detectedFields[role] = match;
240
+ }
241
+ }
242
+ const itemPath = arrayResult?.path ?? null;
243
+ // When path is '' (root-level array), access data directly; otherwise chain with optional chaining
244
+ const pathChain = itemPath === null
245
+ ? ''
246
+ : itemPath === ''
247
+ ? ''
248
+ : itemPath.split('.').map(p => `?.${p}`).join('');
249
+ // Detect search/limit/page params (must be before fetch URL building to use hasSearch/hasPage)
250
+ const qp = [];
251
+ try {
252
+ new URL(req.url).searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k))
253
+ qp.push(k); });
254
+ }
255
+ catch { }
256
+ const hasSearch = qp.some(p => SEARCH_PARAMS.has(p));
257
+ const hasPage = qp.some(p => PAGINATION_PARAMS.has(p));
258
+ // Build evaluate script
259
+ const mapLines = Object.entries(detectedFields)
260
+ .map(([role, field]) => ` ${role}: item?.${field}`)
261
+ .join(',\n');
262
+ const mapExpr = mapLines
263
+ ? `.map(item => ({\n${mapLines}\n }))`
264
+ : '';
265
+ // Build fetch URL — for search/page args, replace query param values with template vars
266
+ let fetchUrl = req.url;
267
+ try {
268
+ const u = new URL(req.url);
269
+ if (hasSearch) {
270
+ for (const p of SEARCH_PARAMS) {
271
+ if (u.searchParams.has(p)) {
272
+ u.searchParams.set(p, '{{args.keyword}}');
273
+ break;
274
+ }
275
+ }
276
+ }
277
+ if (hasPage) {
278
+ for (const p of PAGINATION_PARAMS) {
279
+ if (u.searchParams.has(p)) {
280
+ u.searchParams.set(p, '{{args.page | default(1)}}');
281
+ break;
282
+ }
283
+ }
284
+ }
285
+ fetchUrl = u.toString();
286
+ }
287
+ catch { }
288
+ // When itemPath is empty, the array IS the response root; otherwise chain with ?.
289
+ const dataAccess = pathChain ? `data${pathChain}` : 'data';
290
+ const evaluateScript = [
291
+ '(async () => {',
292
+ ` const res = await fetch(${JSON.stringify(fetchUrl)}, { credentials: 'include' });`,
293
+ ' const data = await res.json();',
294
+ ` return (${dataAccess} || [])${mapExpr};`,
295
+ '})()',
296
+ ].join('\n');
297
+ const args = {};
298
+ if (hasSearch)
299
+ args['keyword'] = { type: 'str', required: true, description: 'Search keyword', positional: true };
300
+ args['limit'] = { type: 'int', default: 20, description: 'Number of items' };
301
+ if (hasPage)
302
+ args['page'] = { type: 'int', default: 1, description: 'Page number' };
303
+ const columns = ['rank', ...Object.keys(detectedFields).length ? Object.keys(detectedFields) : ['title', 'url']];
304
+ const mapStep = { rank: '${{ index + 1 }}' };
305
+ for (const col of columns.filter(c => c !== 'rank')) {
306
+ mapStep[col] = `\${{ item.${col} }}`;
307
+ }
308
+ const pipeline = [
309
+ { navigate: pageUrl },
310
+ { evaluate: evaluateScript },
311
+ { map: mapStep },
312
+ { limit: '${{ args.limit | default(20) }}' },
313
+ ];
314
+ return {
315
+ name: capName,
316
+ yaml: {
317
+ site,
318
+ name: capName,
319
+ description: `${site} ${capName} (recorded)`,
320
+ domain,
321
+ strategy,
322
+ browser: true,
323
+ args,
324
+ pipeline,
325
+ columns,
326
+ },
327
+ };
328
+ }
329
+ export async function recordSession(opts) {
330
+ const pollMs = opts.pollMs ?? 2000;
331
+ const timeoutMs = opts.timeoutMs ?? 60_000;
332
+ const allRequests = [];
333
+ // Track which tabIds have already had the interceptor injected
334
+ const injectedTabs = new Set();
335
+ // Infer site name from URL
336
+ const site = opts.site ?? (() => {
337
+ try {
338
+ const host = new URL(opts.url).hostname.toLowerCase().replace(/^www\./, '');
339
+ return host.split('.')[0] ?? 'site';
340
+ }
341
+ catch {
342
+ return 'site';
343
+ }
344
+ })();
345
+ const workspace = `record:${site}`;
346
+ console.log(chalk.bold.cyan('\n opencli record'));
347
+ console.log(chalk.dim(` Site: ${site} URL: ${opts.url}`));
348
+ console.log(chalk.dim(` Timeout: ${timeoutMs / 1000}s Poll: ${pollMs}ms`));
349
+ console.log(chalk.dim(' Navigating…'));
350
+ const factory = new opts.BrowserFactory();
351
+ const page = await factory.connect({ timeout: 30, workspace });
352
+ try {
353
+ // Navigate to target
354
+ await page.goto(opts.url);
355
+ // Inject into initial tab
356
+ const initialTabs = await listTabs(workspace);
357
+ for (const tab of initialTabs) {
358
+ await injectIntoTab(workspace, tab.tabId, injectedTabs);
359
+ }
360
+ console.log(chalk.bold('\n Recording. Operate the page in the automation window.'));
361
+ console.log(chalk.dim(` Will auto-stop after ${timeoutMs / 1000}s, or press Enter to stop now.\n`));
362
+ // Race: Enter key vs timeout
363
+ let stopped = false;
364
+ const stop = () => { stopped = true; };
365
+ const { promise: enterPromise, cleanup: cleanupEnter } = waitForEnter();
366
+ const enterRace = enterPromise.then(stop);
367
+ const timeoutPromise = new Promise(r => setTimeout(() => {
368
+ stop();
369
+ cleanupEnter(); // close readline to prevent process from hanging
370
+ r();
371
+ }, timeoutMs));
372
+ // Poll loop: drain captured data + inject interceptor into any new tabs
373
+ const pollInterval = setInterval(async () => {
374
+ if (stopped)
375
+ return;
376
+ try {
377
+ // Discover and inject into any new tabs
378
+ const tabs = await listTabs(workspace);
379
+ for (const tab of tabs) {
380
+ await injectIntoTab(workspace, tab.tabId, injectedTabs);
381
+ }
382
+ // Drain captured data from all known tabs
383
+ for (const tabId of injectedTabs) {
384
+ const batch = await execOnTab(workspace, tabId, generateReadRecordedJs());
385
+ if (Array.isArray(batch) && batch.length > 0) {
386
+ for (const r of batch)
387
+ allRequests.push(r);
388
+ console.log(chalk.dim(` [tab:${tabId}] +${batch.length} captured — total: ${allRequests.length}`));
389
+ }
390
+ }
391
+ }
392
+ catch {
393
+ // Tab may have navigated; keep going
394
+ }
395
+ }, pollMs);
396
+ await Promise.race([enterPromise, timeoutPromise]);
397
+ clearInterval(pollInterval);
398
+ // Final drain from all known tabs
399
+ for (const tabId of injectedTabs) {
400
+ try {
401
+ const last = await execOnTab(workspace, tabId, generateReadRecordedJs());
402
+ if (Array.isArray(last) && last.length > 0) {
403
+ for (const r of last)
404
+ allRequests.push(r);
405
+ }
406
+ }
407
+ catch { }
408
+ }
409
+ console.log(chalk.dim(`\n Stopped. Analyzing ${allRequests.length} captured requests…`));
410
+ const result = analyzeAndWrite(site, opts.url, allRequests, opts.outDir);
411
+ await factory.close().catch(() => { });
412
+ return result;
413
+ }
414
+ catch (err) {
415
+ await factory.close().catch(() => { });
416
+ throw err;
417
+ }
418
+ }
419
+ async function listTabs(workspace) {
420
+ try {
421
+ const result = await sendCommand('tabs', { op: 'list', workspace });
422
+ return Array.isArray(result) ? result.filter(t => t.tabId != null) : [];
423
+ }
424
+ catch {
425
+ return [];
426
+ }
427
+ }
428
+ async function execOnTab(workspace, tabId, code) {
429
+ return sendCommand('exec', { code, workspace, tabId });
430
+ }
431
+ async function injectIntoTab(workspace, tabId, injectedTabs) {
432
+ try {
433
+ await execOnTab(workspace, tabId, generateFullCaptureInterceptorJs());
434
+ if (!injectedTabs.has(tabId)) {
435
+ injectedTabs.add(tabId);
436
+ console.log(chalk.green(` ✓ Interceptor injected into tab:${tabId}`));
437
+ }
438
+ }
439
+ catch {
440
+ // Tab not debuggable (e.g. chrome:// pages) — skip silently
441
+ }
442
+ }
443
+ /**
444
+ * Wait for user to press Enter on stdin.
445
+ * Returns both a promise and a cleanup fn so the caller can close the interface
446
+ * when a timeout fires (preventing the process from hanging on stdin).
447
+ */
448
+ function waitForEnter() {
449
+ let rl = null;
450
+ const promise = new Promise((resolve) => {
451
+ rl = readline.createInterface({ input: process.stdin });
452
+ rl.once('line', () => { rl?.close(); rl = null; resolve(); });
453
+ // Handle Ctrl+C gracefully
454
+ rl.once('SIGINT', () => { rl?.close(); rl = null; resolve(); });
455
+ });
456
+ return {
457
+ promise,
458
+ cleanup: () => { rl?.close(); rl = null; },
459
+ };
460
+ }
461
+ // ── Analysis + output ──────────────────────────────────────────────────────
462
+ function analyzeAndWrite(site, pageUrl, requests, outDir) {
463
+ const targetDir = outDir ?? path.join('.opencli', 'record', site);
464
+ fs.mkdirSync(targetDir, { recursive: true });
465
+ if (requests.length === 0) {
466
+ console.log(chalk.yellow(' No API requests captured.'));
467
+ return { site, url: pageUrl, requests: [], outDir: targetDir, candidateCount: 0, candidates: [] };
468
+ }
469
+ // Deduplicate by pattern
470
+ const seen = new Map();
471
+ for (const req of requests) {
472
+ const pattern = urlToPattern(req.url);
473
+ if (!seen.has(pattern))
474
+ seen.set(pattern, req);
475
+ }
476
+ const scored = [];
477
+ for (const [pattern, req] of seen) {
478
+ const arrayResult = findArrayPath(req.body);
479
+ const authIndicators = detectAuthIndicators(req.url, req.body);
480
+ const score = scoreRequest(req, arrayResult);
481
+ if (score > 0) {
482
+ scored.push({ req, pattern, arrayResult, authIndicators, score });
483
+ }
484
+ }
485
+ scored.sort((a, b) => b.score - a.score);
486
+ // Save raw captured data
487
+ fs.writeFileSync(path.join(targetDir, 'captured.json'), JSON.stringify({ site, url: pageUrl, capturedAt: new Date().toISOString(), requests }, null, 2));
488
+ // Generate candidate YAMLs (top 5)
489
+ const candidates = [];
490
+ const usedNames = new Set();
491
+ console.log(chalk.bold('\n Captured endpoints (scored):\n'));
492
+ for (const entry of scored.slice(0, 8)) {
493
+ const itemCount = entry.arrayResult?.items.length ?? 0;
494
+ const strategy = inferStrategy(entry.authIndicators);
495
+ const marker = entry.score >= 15 ? chalk.green('★') : entry.score >= 8 ? chalk.yellow('◆') : chalk.dim('·');
496
+ console.log(` ${marker} ${chalk.white(entry.pattern)}` +
497
+ chalk.dim(` [${strategy}]`) +
498
+ (itemCount ? chalk.cyan(` ← ${itemCount} items`) : ''));
499
+ }
500
+ console.log();
501
+ const topCandidates = scored.filter(e => e.arrayResult && e.score >= 8).slice(0, 5);
502
+ const candidatesDir = path.join(targetDir, 'candidates');
503
+ fs.mkdirSync(candidatesDir, { recursive: true });
504
+ for (const entry of topCandidates) {
505
+ let capName = inferCapabilityName(entry.req.url);
506
+ if (usedNames.has(capName))
507
+ capName = `${capName}_${usedNames.size + 1}`;
508
+ usedNames.add(capName);
509
+ const strategy = inferStrategy(entry.authIndicators);
510
+ const candidate = buildRecordedYaml(site, pageUrl, entry.req, capName, entry.arrayResult, entry.authIndicators);
511
+ const filePath = path.join(candidatesDir, `${capName}.yaml`);
512
+ fs.writeFileSync(filePath, yaml.dump(candidate.yaml, { sortKeys: false, lineWidth: 120 }));
513
+ candidates.push({ name: capName, path: filePath, strategy });
514
+ console.log(chalk.green(` ✓ Generated: ${chalk.bold(capName)}.yaml [${strategy}]`));
515
+ console.log(chalk.dim(` → ${filePath}`));
516
+ }
517
+ if (candidates.length === 0) {
518
+ console.log(chalk.yellow(' No high-confidence candidates found.'));
519
+ console.log(chalk.dim(' Tip: make sure you triggered JSON API calls (open lists, search, scroll).'));
520
+ }
521
+ return {
522
+ site,
523
+ url: pageUrl,
524
+ requests,
525
+ outDir: targetDir,
526
+ candidateCount: candidates.length,
527
+ candidates,
528
+ };
529
+ }
530
+ export function renderRecordSummary(result) {
531
+ const lines = [
532
+ `\n opencli record: ${result.candidateCount > 0 ? chalk.green('OK') : chalk.yellow('no candidates')}`,
533
+ ` Site: ${result.site}`,
534
+ ` Captured: ${result.requests.length} requests`,
535
+ ` Candidates: ${result.candidateCount}`,
536
+ ];
537
+ for (const c of result.candidates) {
538
+ lines.push(` • ${c.name} [${c.strategy}] → ${c.path}`);
539
+ }
540
+ if (result.candidateCount > 0) {
541
+ lines.push('');
542
+ lines.push(chalk.dim(` Copy a candidate to src/clis/${result.site}/ and run: npm run build`));
543
+ }
544
+ return lines.join('\n');
545
+ }
@@ -18,6 +18,7 @@ export interface Arg {
18
18
  help?: string;
19
19
  choices?: string[];
20
20
  }
21
+ export type CommandArgs = Record<string, any>;
21
22
  export interface CliCommand {
22
23
  site: string;
23
24
  name: string;
@@ -27,11 +28,12 @@ export interface CliCommand {
27
28
  browser?: boolean;
28
29
  args: Arg[];
29
30
  columns?: string[];
30
- func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<unknown>;
31
+ func?: (page: IPage, kwargs: CommandArgs, debug?: boolean) => Promise<unknown>;
31
32
  pipeline?: Record<string, unknown>[];
32
33
  timeoutSeconds?: number;
34
+ /** Origin of this command: 'yaml', 'ts', or plugin name. */
33
35
  source?: string;
34
- footerExtra?: (kwargs: Record<string, any>) => string | undefined;
36
+ footerExtra?: (kwargs: CommandArgs) => string | undefined;
35
37
  /**
36
38
  * Control pre-navigation for cookie/header context before command execution.
37
39
  *
@@ -55,6 +57,9 @@ export interface CliOptions extends Partial<Omit<CliCommand, 'args' | 'descripti
55
57
  description?: string;
56
58
  args?: Arg[];
57
59
  }
60
+ declare global {
61
+ var __opencli_registry__: Map<string, CliCommand> | undefined;
62
+ }
58
63
  export declare function cli(opts: CliOptions): CliCommand;
59
64
  export declare function getRegistry(): Map<string, CliCommand>;
60
65
  export declare function fullName(cmd: CliCommand): string;
package/dist/registry.js CHANGED
@@ -9,11 +9,7 @@ export var Strategy;
9
9
  Strategy["INTERCEPT"] = "intercept";
10
10
  Strategy["UI"] = "ui";
11
11
  })(Strategy || (Strategy = {}));
12
- // Use globalThis to ensure a single shared registry across all module instances.
13
- // This is critical for TS plugins loaded via npm link / peerDependency — without
14
- // this, the plugin's import creates a separate module instance with its own Map.
15
- const REGISTRY_KEY = '__opencli_registry__';
16
- const _registry = globalThis[REGISTRY_KEY] ??= new Map();
12
+ const _registry = globalThis.__opencli_registry__ ??= new Map();
17
13
  export function cli(opts) {
18
14
  const strategy = opts.strategy ?? (opts.browser === false ? Strategy.PUBLIC : Strategy.COOKIE);
19
15
  const browser = opts.browser ?? (strategy !== Strategy.PUBLIC);
@@ -43,7 +39,7 @@ export function fullName(cmd) {
43
39
  return `${cmd.site}/${cmd.name}`;
44
40
  }
45
41
  export function strategyLabel(cmd) {
46
- return cmd.strategy ?? 'public';
42
+ return cmd.strategy ?? Strategy.PUBLIC;
47
43
  }
48
44
  export function registerCommand(cmd) {
49
45
  _registry.set(fullName(cmd), cmd);
package/dist/runtime.d.ts CHANGED
@@ -17,8 +17,10 @@ export declare function runWithTimeout<T>(promise: Promise<T>, opts: {
17
17
  }): Promise<T>;
18
18
  /**
19
19
  * Timeout with milliseconds unit. Used for low-level internal timeouts.
20
+ * Accepts a factory function to create the rejection error, keeping this
21
+ * utility decoupled from specific error types.
20
22
  */
21
- export declare function withTimeoutMs<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
23
+ export declare function withTimeoutMs<T>(promise: Promise<T>, timeoutMs: number, makeError?: string | (() => Error)): Promise<T>;
22
24
  /** Interface for browser factory (BrowserBridge or test mocks) */
23
25
  export interface IBrowserFactory {
24
26
  connect(opts?: {
package/dist/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { BrowserBridge, CDPBridge } from './browser/index.js';
2
+ import { TimeoutError } from './errors.js';
2
3
  /**
3
4
  * Returns the appropriate browser factory based on environment config.
4
5
  * Uses CDPBridge when OPENCLI_CDP_ENDPOINT is set, otherwise BrowserBridge.
@@ -14,14 +15,20 @@ export const DEFAULT_BROWSER_SMOKE_TIMEOUT = parseInt(process.env.OPENCLI_BROWSE
14
15
  * Timeout with seconds unit. Used for high-level command timeouts.
15
16
  */
16
17
  export async function runWithTimeout(promise, opts) {
17
- return withTimeoutMs(promise, opts.timeout * 1000, `${opts.label ?? 'Operation'} timed out after ${opts.timeout}s`);
18
+ const label = opts.label ?? 'Operation';
19
+ return withTimeoutMs(promise, opts.timeout * 1000, () => new TimeoutError(label, opts.timeout));
18
20
  }
19
21
  /**
20
22
  * Timeout with milliseconds unit. Used for low-level internal timeouts.
23
+ * Accepts a factory function to create the rejection error, keeping this
24
+ * utility decoupled from specific error types.
21
25
  */
22
- export function withTimeoutMs(promise, timeoutMs, message) {
26
+ export function withTimeoutMs(promise, timeoutMs, makeError = 'Operation timed out') {
27
+ const reject_ = typeof makeError === 'string'
28
+ ? () => new Error(makeError)
29
+ : makeError;
23
30
  return new Promise((resolve, reject) => {
24
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
31
+ const timer = setTimeout(() => reject(reject_()), timeoutMs);
25
32
  promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
26
33
  });
27
34
  }
package/dist/validate.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import yaml from 'js-yaml';
5
+ import { getErrorMessage } from './errors.js';
5
6
  /** All recognized pipeline step names */
6
7
  const KNOWN_STEP_NAMES = new Set([
7
8
  'navigate', 'click', 'type', 'wait', 'press', 'snapshot', 'scroll',
@@ -12,9 +13,6 @@ const KNOWN_STEP_NAMES = new Set([
12
13
  function isRecord(value) {
13
14
  return typeof value === 'object' && value !== null && !Array.isArray(value);
14
15
  }
15
- function getErrorMessage(error) {
16
- return error instanceof Error ? error.message : String(error);
17
- }
18
16
  export function validateClisWithTarget(dirs, target) {
19
17
  const results = [];
20
18
  let errors = 0;
@@ -64,6 +64,7 @@ export default defineConfig({
64
64
  { text: 'SMZDM', link: '/adapters/browser/smzdm' },
65
65
  { text: 'Jike', link: '/adapters/browser/jike' },
66
66
  { text: 'Jimeng', link: '/adapters/browser/jimeng' },
67
+ { text: 'Yollomi', link: '/adapters/browser/yollomi' },
67
68
  { text: 'LINUX DO', link: '/adapters/browser/linux-do' },
68
69
  { text: 'Chaoxing', link: '/adapters/browser/chaoxing' },
69
70
  { text: 'Grok', link: '/adapters/browser/grok' },