@jackwener/opencli 1.5.8 → 1.6.0

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 (220) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +35 -1
  3. package/README.zh-CN.md +17 -1
  4. package/SKILL.md +31 -851
  5. package/autoresearch/baseline-browse.txt +1 -0
  6. package/autoresearch/baseline-skill.txt +1 -0
  7. package/autoresearch/browse-tasks.json +688 -0
  8. package/autoresearch/eval-browse.ts +185 -0
  9. package/autoresearch/eval-skill.ts +248 -0
  10. package/autoresearch/run-browse.sh +9 -0
  11. package/autoresearch/run-skill.sh +9 -0
  12. package/dist/browser/base-page.d.ts +48 -0
  13. package/dist/browser/base-page.js +160 -0
  14. package/dist/browser/cdp.js +4 -106
  15. package/dist/browser/daemon-client.d.ts +20 -7
  16. package/dist/browser/daemon-client.js +39 -39
  17. package/dist/browser/daemon-client.test.js +77 -0
  18. package/dist/browser/discover.d.ts +1 -4
  19. package/dist/browser/discover.js +9 -23
  20. package/dist/browser/errors.d.ts +4 -0
  21. package/dist/browser/errors.js +20 -0
  22. package/dist/browser/index.d.ts +1 -1
  23. package/dist/browser/index.js +1 -1
  24. package/dist/browser/page.d.ts +10 -35
  25. package/dist/browser/page.js +55 -187
  26. package/dist/browser/tabs.js +5 -5
  27. package/dist/browser.test.js +15 -15
  28. package/dist/cli-manifest.json +294 -22
  29. package/dist/cli.js +392 -0
  30. package/dist/clis/amazon/bestsellers.d.ts +21 -0
  31. package/dist/clis/amazon/bestsellers.js +130 -0
  32. package/dist/clis/amazon/bestsellers.test.js +20 -0
  33. package/dist/clis/amazon/discussion.d.ts +20 -0
  34. package/dist/clis/amazon/discussion.js +91 -0
  35. package/dist/clis/amazon/discussion.test.d.ts +1 -0
  36. package/dist/clis/amazon/discussion.test.js +36 -0
  37. package/dist/clis/amazon/offer.d.ts +23 -0
  38. package/dist/clis/amazon/offer.js +140 -0
  39. package/dist/clis/amazon/offer.test.d.ts +1 -0
  40. package/dist/clis/amazon/offer.test.js +29 -0
  41. package/dist/clis/amazon/product.d.ts +18 -0
  42. package/dist/clis/amazon/product.js +92 -0
  43. package/dist/clis/amazon/product.test.d.ts +1 -0
  44. package/dist/clis/amazon/product.test.js +24 -0
  45. package/dist/clis/amazon/search.d.ts +18 -0
  46. package/dist/clis/amazon/search.js +87 -0
  47. package/dist/clis/amazon/search.test.d.ts +1 -0
  48. package/dist/clis/amazon/search.test.js +22 -0
  49. package/dist/clis/amazon/shared.d.ts +64 -0
  50. package/dist/clis/amazon/shared.js +255 -0
  51. package/dist/clis/amazon/shared.test.d.ts +1 -0
  52. package/dist/clis/amazon/shared.test.js +33 -0
  53. package/dist/clis/gemini/ask.d.ts +1 -0
  54. package/dist/clis/gemini/ask.js +40 -0
  55. package/dist/clis/gemini/image.d.ts +1 -0
  56. package/dist/clis/gemini/image.js +105 -0
  57. package/dist/clis/gemini/new.d.ts +1 -0
  58. package/dist/clis/gemini/new.js +20 -0
  59. package/dist/clis/gemini/utils.d.ts +34 -0
  60. package/dist/clis/gemini/utils.js +463 -0
  61. package/dist/clis/gemini/utils.test.d.ts +1 -0
  62. package/dist/clis/gemini/utils.test.js +31 -0
  63. package/dist/clis/notebooklm/compat.test.d.ts +1 -1
  64. package/dist/clis/notebooklm/compat.test.js +3 -3
  65. package/dist/clis/notebooklm/current.js +2 -3
  66. package/dist/clis/notebooklm/get.js +2 -3
  67. package/dist/clis/notebooklm/history.js +2 -3
  68. package/dist/clis/notebooklm/note-list.js +2 -3
  69. package/dist/clis/notebooklm/notes-get.js +2 -3
  70. package/dist/clis/notebooklm/open.d.ts +1 -0
  71. package/dist/clis/notebooklm/open.js +41 -0
  72. package/dist/clis/notebooklm/open.test.d.ts +1 -0
  73. package/dist/clis/notebooklm/open.test.js +63 -0
  74. package/dist/clis/notebooklm/source-fulltext.js +2 -3
  75. package/dist/clis/notebooklm/source-get.js +2 -3
  76. package/dist/clis/notebooklm/source-guide.js +2 -3
  77. package/dist/clis/notebooklm/source-list.js +2 -3
  78. package/dist/clis/notebooklm/status.js +1 -2
  79. package/dist/clis/notebooklm/summary.js +2 -3
  80. package/dist/clis/notebooklm/utils.d.ts +2 -1
  81. package/dist/clis/notebooklm/utils.js +20 -21
  82. package/dist/clis/twitter/article.js +28 -1
  83. package/dist/clis/xiaohongshu/creator-note-detail.test.js +11 -11
  84. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +6 -6
  85. package/dist/clis/xiaohongshu/creator-notes.test.js +22 -22
  86. package/dist/clis/xiaohongshu/note.js +11 -0
  87. package/dist/clis/xiaohongshu/note.test.js +49 -0
  88. package/dist/commanderAdapter.js +7 -4
  89. package/dist/commanderAdapter.test.js +76 -0
  90. package/dist/commands/daemon.js +8 -47
  91. package/dist/commands/daemon.test.js +45 -70
  92. package/dist/discovery.js +27 -0
  93. package/dist/doctor.d.ts +1 -2
  94. package/dist/doctor.js +7 -8
  95. package/dist/explore.js +1 -1
  96. package/dist/output.js +28 -0
  97. package/dist/output.test.js +15 -0
  98. package/dist/pipeline/executor.js +2 -7
  99. package/dist/pipeline/steps/browser.js +1 -1
  100. package/dist/pipeline/template.js +25 -3
  101. package/dist/record.d.ts +50 -0
  102. package/dist/record.js +298 -57
  103. package/dist/record.test.d.ts +1 -0
  104. package/dist/record.test.js +293 -0
  105. package/dist/registry.d.ts +2 -0
  106. package/dist/registry.js +1 -0
  107. package/dist/registry.test.js +10 -0
  108. package/dist/runtime.js +3 -3
  109. package/dist/snapshotFormatter.d.ts +1 -1
  110. package/dist/snapshotFormatter.js +4 -4
  111. package/dist/snapshotFormatter.test.d.ts +1 -1
  112. package/dist/snapshotFormatter.test.js +2 -2
  113. package/dist/types.d.ts +11 -1
  114. package/dist/types.js +1 -1
  115. package/docs/.vitepress/config.mts +2 -0
  116. package/docs/adapters/browser/amazon.md +53 -0
  117. package/docs/adapters/browser/gemini.md +72 -0
  118. package/docs/adapters/browser/notebooklm.md +5 -5
  119. package/docs/adapters/index.md +3 -1
  120. package/docs/guide/getting-started.md +21 -0
  121. package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
  122. package/docs/zh/guide/getting-started.md +21 -0
  123. package/extension/package-lock.json +2 -2
  124. package/extension/src/background.test.ts +7 -163
  125. package/extension/src/background.ts +58 -161
  126. package/extension/src/cdp.ts +77 -124
  127. package/extension/src/protocol.ts +5 -5
  128. package/package.json +1 -1
  129. package/skills/opencli-explorer/SKILL.md +853 -0
  130. package/skills/opencli-oneshot/SKILL.md +222 -0
  131. package/skills/opencli-operate/SKILL.md +213 -0
  132. package/skills/opencli-usage/SKILL.md +152 -0
  133. package/skills/opencli-usage/browser.md +429 -0
  134. package/skills/opencli-usage/desktop.md +118 -0
  135. package/skills/opencli-usage/plugins.md +82 -0
  136. package/skills/opencli-usage/public-api.md +149 -0
  137. package/src/browser/base-page.ts +197 -0
  138. package/src/browser/cdp.ts +7 -131
  139. package/src/browser/daemon-client.test.ts +103 -0
  140. package/src/browser/daemon-client.ts +55 -43
  141. package/src/browser/discover.ts +9 -21
  142. package/src/browser/errors.ts +22 -0
  143. package/src/browser/index.ts +1 -1
  144. package/src/browser/page.ts +57 -209
  145. package/src/browser/tabs.ts +5 -5
  146. package/src/browser.test.ts +15 -15
  147. package/src/cli.ts +392 -0
  148. package/src/clis/amazon/bestsellers.test.ts +22 -0
  149. package/src/clis/amazon/bestsellers.ts +180 -0
  150. package/src/clis/amazon/discussion.test.ts +38 -0
  151. package/src/clis/amazon/discussion.ts +131 -0
  152. package/src/clis/amazon/offer.test.ts +35 -0
  153. package/src/clis/amazon/offer.ts +185 -0
  154. package/src/clis/amazon/product.test.ts +26 -0
  155. package/src/clis/amazon/product.ts +131 -0
  156. package/src/clis/amazon/search.test.ts +24 -0
  157. package/src/clis/amazon/search.ts +128 -0
  158. package/src/clis/amazon/shared.test.ts +37 -0
  159. package/src/clis/amazon/shared.ts +316 -0
  160. package/src/clis/gemini/ask.ts +46 -0
  161. package/src/clis/gemini/image.ts +115 -0
  162. package/src/clis/gemini/new.ts +22 -0
  163. package/src/clis/gemini/utils.test.ts +36 -0
  164. package/src/clis/gemini/utils.ts +523 -0
  165. package/src/clis/notebooklm/compat.test.ts +3 -3
  166. package/src/clis/notebooklm/current.ts +2 -3
  167. package/src/clis/notebooklm/get.ts +1 -3
  168. package/src/clis/notebooklm/history.ts +1 -3
  169. package/src/clis/notebooklm/note-list.ts +1 -3
  170. package/src/clis/notebooklm/notes-get.ts +1 -3
  171. package/src/clis/notebooklm/open.test.ts +78 -0
  172. package/src/clis/notebooklm/open.ts +61 -0
  173. package/src/clis/notebooklm/source-fulltext.ts +1 -3
  174. package/src/clis/notebooklm/source-get.ts +1 -3
  175. package/src/clis/notebooklm/source-guide.ts +1 -3
  176. package/src/clis/notebooklm/source-list.ts +1 -3
  177. package/src/clis/notebooklm/status.ts +1 -2
  178. package/src/clis/notebooklm/summary.ts +1 -3
  179. package/src/clis/notebooklm/utils.ts +29 -20
  180. package/src/clis/twitter/article.ts +31 -1
  181. package/src/clis/xiaohongshu/creator-note-detail.test.ts +11 -11
  182. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +6 -6
  183. package/src/clis/xiaohongshu/creator-notes.test.ts +22 -22
  184. package/src/clis/xiaohongshu/note.test.ts +51 -0
  185. package/src/clis/xiaohongshu/note.ts +18 -0
  186. package/src/commanderAdapter.test.ts +109 -0
  187. package/src/commanderAdapter.ts +8 -4
  188. package/src/commands/daemon.test.ts +50 -84
  189. package/src/commands/daemon.ts +8 -56
  190. package/src/discovery.ts +22 -0
  191. package/src/doctor.ts +8 -9
  192. package/src/explore.ts +1 -1
  193. package/src/output.test.ts +17 -0
  194. package/src/output.ts +27 -0
  195. package/src/pipeline/executor.ts +2 -7
  196. package/src/pipeline/steps/browser.ts +1 -1
  197. package/src/pipeline/template.ts +27 -4
  198. package/src/record.test.ts +362 -0
  199. package/src/record.ts +341 -62
  200. package/src/registry.test.ts +12 -0
  201. package/src/registry.ts +3 -0
  202. package/src/runtime.ts +3 -3
  203. package/src/snapshotFormatter.test.ts +2 -2
  204. package/src/snapshotFormatter.ts +4 -4
  205. package/src/types.ts +11 -1
  206. package/.agents/skills/cross-project-adapter-migration/SKILL.md +0 -249
  207. package/.agents/workflows/cross-project-adapter-migration.md +0 -54
  208. package/dist/clis/notebooklm/bind-current.js +0 -29
  209. package/dist/clis/notebooklm/bind-current.test.d.ts +0 -1
  210. package/dist/clis/notebooklm/bind-current.test.js +0 -35
  211. package/dist/clis/notebooklm/binding.test.js +0 -44
  212. package/extension/dist/background.js +0 -819
  213. package/src/clis/notebooklm/bind-current.test.ts +0 -43
  214. package/src/clis/notebooklm/bind-current.ts +0 -36
  215. package/src/clis/notebooklm/binding.test.ts +0 -53
  216. /package/dist/browser/{mcp.d.ts → bridge.d.ts} +0 -0
  217. /package/dist/browser/{mcp.js → bridge.js} +0 -0
  218. /package/dist/{clis/notebooklm/bind-current.d.ts → browser/daemon-client.test.d.ts} +0 -0
  219. /package/dist/clis/{notebooklm/binding.test.d.ts → amazon/bestsellers.test.d.ts} +0 -0
  220. /package/src/browser/{mcp.ts → bridge.ts} +0 -0
@@ -0,0 +1,523 @@
1
+ import type { IPage } from '../../types.js';
2
+
3
+ export const GEMINI_DOMAIN = 'gemini.google.com';
4
+ export const GEMINI_APP_URL = 'https://gemini.google.com/app';
5
+
6
+ export interface GeminiPageState {
7
+ url: string;
8
+ title: string;
9
+ isSignedIn: boolean | null;
10
+ composerLabel: string;
11
+ canSend: boolean;
12
+ }
13
+
14
+ export interface GeminiTurn {
15
+ Role: 'User' | 'Assistant' | 'System';
16
+ Text: string;
17
+ }
18
+
19
+ const GEMINI_RESPONSE_NOISE_PATTERNS = [
20
+ /Gemini can make mistakes\.?/gi,
21
+ /Google Terms/gi,
22
+ /Google Privacy Policy/gi,
23
+ /Opens in a new window/gi,
24
+ ];
25
+
26
+ export function sanitizeGeminiResponseText(value: string, promptText: string): string {
27
+ let sanitized = value;
28
+ for (const pattern of GEMINI_RESPONSE_NOISE_PATTERNS) {
29
+ sanitized = sanitized.replace(pattern, '');
30
+ }
31
+ sanitized = sanitized.trim();
32
+
33
+ const prompt = promptText.trim();
34
+ if (!prompt) return sanitized;
35
+ if (sanitized === prompt) return '';
36
+
37
+ for (const separator of ['\n\n', '\n', '\r\n\r\n', '\r\n']) {
38
+ const prefix = `${prompt}${separator}`;
39
+ if (sanitized.startsWith(prefix)) {
40
+ return sanitized.slice(prefix.length).trim();
41
+ }
42
+ }
43
+
44
+ return sanitized;
45
+ }
46
+
47
+ export function collectGeminiTranscriptAdditions(
48
+ beforeLines: string[],
49
+ currentLines: string[],
50
+ promptText: string,
51
+ ): string {
52
+ const beforeSet = new Set(beforeLines);
53
+ const additions = currentLines
54
+ .filter((line) => !beforeSet.has(line))
55
+ .map((line) => sanitizeGeminiResponseText(line, promptText))
56
+ .filter((line) => line && line !== promptText);
57
+
58
+ return additions.join('\n').trim();
59
+ }
60
+
61
+ function getStateScript(): string {
62
+ return `
63
+ (() => {
64
+ const signInNode = Array.from(document.querySelectorAll('a, button')).find((node) => {
65
+ const text = (node.textContent || '').trim().toLowerCase();
66
+ const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
67
+ const href = node.getAttribute('href') || '';
68
+ return text === 'sign in'
69
+ || aria === 'sign in'
70
+ || href.includes('accounts.google.com/ServiceLogin');
71
+ });
72
+
73
+ const composer = document.querySelector('[aria-label="Enter a prompt for Gemini"], [aria-label*="prompt for Gemini"], .ql-editor[aria-label*="Gemini"], [contenteditable="true"][aria-label*="Gemini"]');
74
+ const sendButton = document.querySelector('button[aria-label="Send message"]');
75
+
76
+ return {
77
+ url: window.location.href,
78
+ title: document.title || '',
79
+ isSignedIn: signInNode ? false : (composer ? true : null),
80
+ composerLabel: composer?.getAttribute('aria-label') || '',
81
+ canSend: !!(sendButton && !sendButton.disabled),
82
+ };
83
+ })()
84
+ `;
85
+ }
86
+
87
+ function getTranscriptLinesScript(): string {
88
+ return `
89
+ (() => {
90
+ const clean = (value) => (value || '')
91
+ .replace(/\\u00a0/g, ' ')
92
+ .replace(/\\n{3,}/g, '\\n\\n')
93
+ .trim();
94
+
95
+ const main = document.querySelector('main') || document.body;
96
+ const root = main.cloneNode(true);
97
+
98
+ const removableSelectors = [
99
+ 'button',
100
+ 'nav',
101
+ 'header',
102
+ 'footer',
103
+ '[aria-label="Enter a prompt for Gemini"]',
104
+ '[aria-label*="prompt for Gemini"]',
105
+ '.input-area-container',
106
+ '.input-wrapper',
107
+ '.textbox-container',
108
+ '.ql-toolbar',
109
+ '.send-button',
110
+ '.main-menu-button',
111
+ '.sign-in-button',
112
+ ];
113
+
114
+ for (const selector of removableSelectors) {
115
+ root.querySelectorAll(selector).forEach((node) => node.remove());
116
+ }
117
+ root.querySelectorAll('script, style, noscript').forEach((node) => node.remove());
118
+
119
+ const stopLines = new Set([
120
+ 'Gemini',
121
+ 'Google Terms',
122
+ 'Google Privacy Policy',
123
+ 'Meet Gemini, your personal AI assistant',
124
+ 'Conversation with Gemini',
125
+ 'Ask Gemini 3',
126
+ 'Write',
127
+ 'Plan',
128
+ 'Research',
129
+ 'Learn',
130
+ 'Fast',
131
+ 'send',
132
+ 'Microphone',
133
+ 'Main menu',
134
+ 'New chat',
135
+ 'Sign in',
136
+ 'Google Terms Opens in a new window',
137
+ 'Google Privacy Policy Opens in a new window',
138
+ ]);
139
+
140
+ const noisyPatterns = [
141
+ /^Google Terms$/,
142
+ /^Google Privacy Policy$/,
143
+ /^Gemini is AI and can make mistakes\.?$/,
144
+ /^and the$/,
145
+ /^apply\.$/,
146
+ /^Opens in a new window$/,
147
+ /^Open mode picker$/,
148
+ /^Open upload file menu$/,
149
+ /^Tools$/,
150
+ ];
151
+
152
+ return clean(root.innerText || root.textContent || '')
153
+ .split('\\n')
154
+ .map((line) => clean(line))
155
+ .filter((line) => line
156
+ && line.length <= 4000
157
+ && !stopLines.has(line)
158
+ && !noisyPatterns.some((pattern) => pattern.test(line)));
159
+ })()
160
+ `;
161
+ }
162
+
163
+ function getTurnsScript(): string {
164
+ return `
165
+ (() => {
166
+ const clean = (value) => (value || '')
167
+ .replace(/\\u00a0/g, ' ')
168
+ .replace(/\\n{3,}/g, '\\n\\n')
169
+ .trim();
170
+
171
+ const isVisible = (el) => {
172
+ if (!(el instanceof HTMLElement)) return false;
173
+ const style = window.getComputedStyle(el);
174
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
175
+ const rect = el.getBoundingClientRect();
176
+ return rect.width > 0 && rect.height > 0;
177
+ };
178
+
179
+ const selectors = [
180
+ '[data-testid*="message"]',
181
+ '[data-test-id*="message"]',
182
+ '[class*="message"]',
183
+ '[class*="conversation-turn"]',
184
+ '[class*="query-text"]',
185
+ '[class*="response-text"]',
186
+ ];
187
+
188
+ const roots = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
189
+ const unique = roots.filter((el, index, all) => all.indexOf(el) === index).filter(isVisible);
190
+
191
+ const turns = unique.map((el) => {
192
+ const text = clean(el.innerText || el.textContent || '');
193
+ if (!text) return null;
194
+
195
+ const roleAttr = [
196
+ el.getAttribute('data-message-author-role'),
197
+ el.getAttribute('data-role'),
198
+ el.getAttribute('aria-label'),
199
+ el.getAttribute('class'),
200
+ ].filter(Boolean).join(' ').toLowerCase();
201
+
202
+ let role = '';
203
+ if (roleAttr.includes('user') || roleAttr.includes('query')) role = 'User';
204
+ else if (roleAttr.includes('assistant') || roleAttr.includes('model') || roleAttr.includes('response') || roleAttr.includes('gemini')) role = 'Assistant';
205
+
206
+ return role ? { Role: role, Text: text } : null;
207
+ }).filter(Boolean);
208
+
209
+ const deduped = [];
210
+ const seen = new Set();
211
+ for (const turn of turns) {
212
+ const key = turn.Role + '::' + turn.Text;
213
+ if (seen.has(key)) continue;
214
+ seen.add(key);
215
+ deduped.push(turn);
216
+ }
217
+ return deduped;
218
+ })()
219
+ `;
220
+ }
221
+
222
+ function fillAndSubmitComposerScript(text: string): string {
223
+ return `
224
+ ((inputText) => {
225
+ const cleanInsert = (el) => {
226
+ if (!(el instanceof HTMLElement)) throw new Error('Composer is not editable');
227
+ el.focus();
228
+ const selection = window.getSelection();
229
+ const range = document.createRange();
230
+ range.selectNodeContents(el);
231
+ range.collapse(false);
232
+ selection?.removeAllRanges();
233
+ selection?.addRange(range);
234
+ el.textContent = '';
235
+ document.execCommand('insertText', false, inputText);
236
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, data: inputText, inputType: 'insertText' }));
237
+ };
238
+
239
+ const composer = document.querySelector('[aria-label="Enter a prompt for Gemini"], [aria-label*="prompt for Gemini"], .ql-editor[aria-label*="Gemini"], [contenteditable="true"][aria-label*="Gemini"]');
240
+ if (!(composer instanceof HTMLElement)) {
241
+ throw new Error('Could not find Gemini composer');
242
+ }
243
+
244
+ cleanInsert(composer);
245
+
246
+ const sendButton = document.querySelector('button[aria-label="Send message"]');
247
+ if (sendButton instanceof HTMLButtonElement && !sendButton.disabled) {
248
+ sendButton.click();
249
+ return 'button';
250
+ }
251
+
252
+ composer.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
253
+ composer.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
254
+ return 'enter';
255
+ })(${JSON.stringify(text)})
256
+ `;
257
+ }
258
+
259
+ function clickNewChatScript(): string {
260
+ return `
261
+ (() => {
262
+ const isVisible = (el) => {
263
+ if (!(el instanceof HTMLElement)) return false;
264
+ const style = window.getComputedStyle(el);
265
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
266
+ const rect = el.getBoundingClientRect();
267
+ return rect.width > 0 && rect.height > 0;
268
+ };
269
+
270
+ const candidates = Array.from(document.querySelectorAll('button, a')).filter((node) => {
271
+ const text = (node.textContent || '').trim().toLowerCase();
272
+ const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
273
+ return isVisible(node) && (text === 'new chat' || aria === 'new chat');
274
+ });
275
+
276
+ const target = candidates.find((node) => !node.hasAttribute('disabled')) || candidates[0];
277
+ if (target instanceof HTMLElement) {
278
+ target.click();
279
+ return 'clicked';
280
+ }
281
+ return 'navigate';
282
+ })()
283
+ `;
284
+ }
285
+
286
+ function currentUrlScript(): string {
287
+ return 'window.location.href';
288
+ }
289
+
290
+ export async function isOnGemini(page: IPage): Promise<boolean> {
291
+ const url = await page.evaluate(currentUrlScript()).catch(() => '');
292
+ if (typeof url !== 'string' || !url) return false;
293
+ try {
294
+ const hostname = new URL(url).hostname;
295
+ return hostname === GEMINI_DOMAIN || hostname.endsWith(`.${GEMINI_DOMAIN}`);
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+
301
+ export async function ensureGeminiPage(page: IPage): Promise<void> {
302
+ if (!(await isOnGemini(page))) {
303
+ await page.goto(GEMINI_APP_URL, { waitUntil: 'load', settleMs: 2500 });
304
+ await page.wait(1);
305
+ }
306
+ }
307
+
308
+ export async function getGeminiPageState(page: IPage): Promise<GeminiPageState> {
309
+ await ensureGeminiPage(page);
310
+ return await page.evaluate(getStateScript()) as GeminiPageState;
311
+ }
312
+
313
+ export async function startNewGeminiChat(page: IPage): Promise<'clicked' | 'navigate'> {
314
+ await ensureGeminiPage(page);
315
+ const action = await page.evaluate(clickNewChatScript()) as 'clicked' | 'navigate';
316
+ if (action === 'navigate') {
317
+ await page.goto(GEMINI_APP_URL, { waitUntil: 'load', settleMs: 2500 });
318
+ }
319
+ await page.wait(1);
320
+ return action;
321
+ }
322
+
323
+ export async function getGeminiVisibleTurns(page: IPage): Promise<GeminiTurn[]> {
324
+ await ensureGeminiPage(page);
325
+ const turns = await page.evaluate(getTurnsScript()) as GeminiTurn[];
326
+ if (Array.isArray(turns) && turns.length > 0) return turns;
327
+
328
+ const lines = await getGeminiTranscriptLines(page);
329
+ return lines.map((line) => ({ Role: 'System', Text: line }));
330
+ }
331
+
332
+ export async function getGeminiTranscriptLines(page: IPage): Promise<string[]> {
333
+ await ensureGeminiPage(page);
334
+ return await page.evaluate(getTranscriptLinesScript()) as string[];
335
+ }
336
+
337
+ export async function sendGeminiMessage(page: IPage, text: string): Promise<'button' | 'enter'> {
338
+ await ensureGeminiPage(page);
339
+ const submittedBy = await page.evaluate(fillAndSubmitComposerScript(text)) as 'button' | 'enter';
340
+ await page.wait(1);
341
+ return submittedBy;
342
+ }
343
+
344
+
345
+
346
+ export async function getGeminiVisibleImageUrls(page: IPage): Promise<string[]> {
347
+ await ensureGeminiPage(page);
348
+ return await page.evaluate(`
349
+ (() => {
350
+ const isVisible = (el) => {
351
+ if (!(el instanceof HTMLElement)) return false;
352
+ const style = window.getComputedStyle(el);
353
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
354
+ const rect = el.getBoundingClientRect();
355
+ return rect.width > 32 && rect.height > 32;
356
+ };
357
+
358
+ const imgs = Array.from(document.querySelectorAll('main img')).filter((img) => img instanceof HTMLImageElement && isVisible(img));
359
+ const urls = [];
360
+ const seen = new Set();
361
+
362
+ for (const img of imgs) {
363
+ const src = img.currentSrc || img.src || '';
364
+ const alt = (img.getAttribute('alt') || '').toLowerCase();
365
+ const width = img.naturalWidth || img.width || 0;
366
+ const height = img.naturalHeight || img.height || 0;
367
+ if (!src) continue;
368
+ if (alt.includes('avatar') || alt.includes('logo') || alt.includes('icon')) continue;
369
+ if (width < 128 && height < 128) continue;
370
+ if (seen.has(src)) continue;
371
+ seen.add(src);
372
+ urls.push(src);
373
+ }
374
+ return urls;
375
+ })()
376
+ `) as string[];
377
+ }
378
+
379
+ export async function waitForGeminiImages(
380
+ page: IPage,
381
+ beforeUrls: string[],
382
+ timeoutSeconds: number,
383
+ ): Promise<string[]> {
384
+ const beforeSet = new Set(beforeUrls);
385
+ const pollIntervalSeconds = 3;
386
+ const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
387
+ let lastUrls: string[] = [];
388
+ let stableCount = 0;
389
+
390
+ for (let index = 0; index < maxPolls; index += 1) {
391
+ await page.wait(index === 0 ? 2 : pollIntervalSeconds);
392
+ const urls = (await getGeminiVisibleImageUrls(page)).filter((url) => !beforeSet.has(url));
393
+ if (urls.length === 0) continue;
394
+
395
+ const key = urls.join('\n');
396
+ const prevKey = lastUrls.join('\n');
397
+ if (key == prevKey) stableCount += 1;
398
+ else {
399
+ lastUrls = urls;
400
+ stableCount = 1;
401
+ }
402
+
403
+ if (stableCount >= 2 || index === maxPolls - 1) return lastUrls;
404
+ }
405
+
406
+ return lastUrls;
407
+ }
408
+
409
+ export interface GeminiImageAsset {
410
+ url: string;
411
+ dataUrl: string;
412
+ mimeType: string;
413
+ width: number;
414
+ height: number;
415
+ }
416
+
417
+ export async function exportGeminiImages(page: IPage, urls: string[]): Promise<GeminiImageAsset[]> {
418
+ await ensureGeminiPage(page);
419
+ const urlsJson = JSON.stringify(urls);
420
+ return await page.evaluate(`
421
+ (async (targetUrls) => {
422
+ const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
423
+ const reader = new FileReader();
424
+ reader.onloadend = () => resolve(String(reader.result || ''));
425
+ reader.onerror = () => reject(new Error('Failed to read blob'));
426
+ reader.readAsDataURL(blob);
427
+ });
428
+
429
+ const inferMime = (value, fallbackUrl) => {
430
+ if (value) return value;
431
+ const lower = String(fallbackUrl || '').toLowerCase();
432
+ if (lower.includes('.png')) return 'image/png';
433
+ if (lower.includes('.webp')) return 'image/webp';
434
+ if (lower.includes('.gif')) return 'image/gif';
435
+ return 'image/jpeg';
436
+ };
437
+
438
+ const images = Array.from(document.querySelectorAll('main img'));
439
+ const results = [];
440
+
441
+ for (const targetUrl of targetUrls) {
442
+ const img = images.find((node) => (node.currentSrc || node.src || '') === targetUrl);
443
+ let dataUrl = '';
444
+ let mimeType = 'image/jpeg';
445
+ const width = img?.naturalWidth || img?.width || 0;
446
+ const height = img?.naturalHeight || img?.height || 0;
447
+
448
+ try {
449
+ if (String(targetUrl).startsWith('data:')) {
450
+ dataUrl = String(targetUrl);
451
+ mimeType = (String(targetUrl).match(/^data:([^;]+);/i) || [])[1] || 'image/png';
452
+ } else {
453
+ const res = await fetch(String(targetUrl), { credentials: 'include' });
454
+ if (res.ok) {
455
+ const blob = await res.blob();
456
+ mimeType = inferMime(blob.type, targetUrl);
457
+ dataUrl = await blobToDataUrl(blob);
458
+ }
459
+ }
460
+ } catch {}
461
+
462
+ if (!dataUrl && img instanceof HTMLImageElement) {
463
+ try {
464
+ const canvas = document.createElement('canvas');
465
+ canvas.width = img.naturalWidth || img.width;
466
+ canvas.height = img.naturalHeight || img.height;
467
+ const ctx = canvas.getContext('2d');
468
+ if (ctx) {
469
+ ctx.drawImage(img, 0, 0);
470
+ dataUrl = canvas.toDataURL('image/png');
471
+ mimeType = 'image/png';
472
+ }
473
+ } catch {}
474
+ }
475
+
476
+ if (dataUrl) {
477
+ results.push({ url: String(targetUrl), dataUrl, mimeType, width, height });
478
+ }
479
+ }
480
+
481
+ return results;
482
+ })(${urlsJson})
483
+ `) as GeminiImageAsset[];
484
+ }
485
+ export async function waitForGeminiResponse(
486
+ page: IPage,
487
+ beforeLines: string[],
488
+ promptText: string,
489
+ timeoutSeconds: number,
490
+ ): Promise<string> {
491
+ const getCandidate = async (): Promise<string> => {
492
+ const turns = await getGeminiVisibleTurns(page);
493
+ const assistantCandidate = [...turns].reverse().find((turn) => turn.Role === 'Assistant');
494
+ const visibleCandidate = assistantCandidate
495
+ ? sanitizeGeminiResponseText(assistantCandidate.Text, promptText)
496
+ : '';
497
+ if (visibleCandidate && visibleCandidate !== promptText) return visibleCandidate;
498
+
499
+ const lines = await getGeminiTranscriptLines(page);
500
+ return collectGeminiTranscriptAdditions(beforeLines, lines, promptText);
501
+ };
502
+
503
+ const pollIntervalSeconds = 2;
504
+ const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
505
+ let lastCandidate = '';
506
+ let stableCount = 0;
507
+
508
+ for (let index = 0; index < maxPolls; index += 1) {
509
+ await page.wait(index === 0 ? 1.5 : pollIntervalSeconds);
510
+ const candidate = await getCandidate();
511
+ if (!candidate) continue;
512
+
513
+ if (candidate === lastCandidate) stableCount += 1;
514
+ else {
515
+ lastCandidate = candidate;
516
+ stableCount = 1;
517
+ }
518
+
519
+ if (stableCount >= 2 || index === maxPolls - 1) return candidate;
520
+ }
521
+
522
+ return lastCandidate;
523
+ }
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { getRegistry } from '../../registry.js';
3
- import './bind-current.js';
4
3
  import './get.js';
5
4
  import './note-list.js';
5
+ import './open.js';
6
6
 
7
7
  describe('notebooklm compatibility aliases', () => {
8
- it('registers use as a compatibility alias for bind-current', () => {
9
- expect(getRegistry().get('notebooklm/use')).toBe(getRegistry().get('notebooklm/bind-current'));
8
+ it('registers select as a compatibility alias for open', () => {
9
+ expect(getRegistry().get('notebooklm/select')).toBe(getRegistry().get('notebooklm/open'));
10
10
  });
11
11
 
12
12
  it('registers metadata as a compatibility alias for get', () => {
@@ -2,7 +2,7 @@ import { cli, Strategy } from '../../registry.js';
2
2
  import { EmptyResultError } from '../../errors.js';
3
3
  import type { IPage } from '../../types.js';
4
4
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
5
- import { ensureNotebooklmNotebookBinding, getNotebooklmPageState, readCurrentNotebooklm, requireNotebooklmSession } from './utils.js';
5
+ import { getNotebooklmPageState, readCurrentNotebooklm, requireNotebooklmSession } from './utils.js';
6
6
 
7
7
  cli({
8
8
  site: NOTEBOOKLM_SITE,
@@ -15,13 +15,12 @@ cli({
15
15
  args: [],
16
16
  columns: ['id', 'title', 'url', 'source'],
17
17
  func: async (page: IPage) => {
18
- await ensureNotebooklmNotebookBinding(page);
19
18
  await requireNotebooklmSession(page);
20
19
  const state = await getNotebooklmPageState(page);
21
20
  if (state.kind !== 'notebook') {
22
21
  throw new EmptyResultError(
23
22
  'opencli notebooklm current',
24
- 'Open a specific NotebookLM notebook tab first, then retry.',
23
+ 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.',
25
24
  );
26
25
  }
27
26
 
@@ -3,7 +3,6 @@ import type { IPage } from '../../types.js';
3
3
  import { EmptyResultError } from '../../errors.js';
4
4
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
5
5
  import {
6
- ensureNotebooklmNotebookBinding,
7
6
  getNotebooklmDetailViaRpc,
8
7
  getNotebooklmPageState,
9
8
  readCurrentNotebooklm,
@@ -22,13 +21,12 @@ cli({
22
21
  args: [],
23
22
  columns: ['id', 'title', 'emoji', 'source_count', 'created_at', 'updated_at', 'url', 'source'],
24
23
  func: async (page: IPage) => {
25
- await ensureNotebooklmNotebookBinding(page);
26
24
  await requireNotebooklmSession(page);
27
25
  const state = await getNotebooklmPageState(page);
28
26
  if (state.kind !== 'notebook') {
29
27
  throw new EmptyResultError(
30
28
  'opencli notebooklm get',
31
- 'Open a specific NotebookLM notebook tab first, then retry.',
29
+ 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.',
32
30
  );
33
31
  }
34
32
 
@@ -3,7 +3,6 @@ import type { IPage } from '../../types.js';
3
3
  import { EmptyResultError } from '../../errors.js';
4
4
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
5
5
  import {
6
- ensureNotebooklmNotebookBinding,
7
6
  getNotebooklmPageState,
8
7
  listNotebooklmHistoryViaRpc,
9
8
  requireNotebooklmSession,
@@ -20,13 +19,12 @@ cli({
20
19
  args: [],
21
20
  columns: ['thread_id', 'item_count', 'preview', 'source', 'notebook_id', 'url'],
22
21
  func: async (page: IPage) => {
23
- await ensureNotebooklmNotebookBinding(page);
24
22
  await requireNotebooklmSession(page);
25
23
  const state = await getNotebooklmPageState(page);
26
24
  if (state.kind !== 'notebook') {
27
25
  throw new EmptyResultError(
28
26
  'opencli notebooklm history',
29
- 'Open a specific NotebookLM notebook tab first, then retry.',
27
+ 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.',
30
28
  );
31
29
  }
32
30
 
@@ -3,7 +3,6 @@ import type { IPage } from '../../types.js';
3
3
  import { EmptyResultError } from '../../errors.js';
4
4
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
5
5
  import {
6
- ensureNotebooklmNotebookBinding,
7
6
  getNotebooklmPageState,
8
7
  listNotebooklmNotesFromPage,
9
8
  requireNotebooklmSession,
@@ -21,13 +20,12 @@ cli({
21
20
  args: [],
22
21
  columns: ['title', 'created_at', 'source', 'url'],
23
22
  func: async (page: IPage) => {
24
- await ensureNotebooklmNotebookBinding(page);
25
23
  await requireNotebooklmSession(page);
26
24
  const state = await getNotebooklmPageState(page);
27
25
  if (state.kind !== 'notebook') {
28
26
  throw new EmptyResultError(
29
27
  'opencli notebooklm note-list',
30
- 'Open a specific NotebookLM notebook tab first, then retry.',
28
+ 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.',
31
29
  );
32
30
  }
33
31
 
@@ -3,7 +3,6 @@ import type { IPage } from '../../types.js';
3
3
  import { EmptyResultError } from '../../errors.js';
4
4
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
5
5
  import {
6
- ensureNotebooklmNotebookBinding,
7
6
  findNotebooklmNoteRow,
8
7
  getNotebooklmPageState,
9
8
  listNotebooklmNotesFromPage,
@@ -36,13 +35,12 @@ cli({
36
35
  ],
37
36
  columns: ['title', 'content', 'source', 'url'],
38
37
  func: async (page: IPage, kwargs) => {
39
- await ensureNotebooklmNotebookBinding(page);
40
38
  await requireNotebooklmSession(page);
41
39
  const state = await getNotebooklmPageState(page);
42
40
  if (state.kind !== 'notebook') {
43
41
  throw new EmptyResultError(
44
42
  'opencli notebooklm notes-get',
45
- 'Open a specific NotebookLM notebook tab first, then retry.',
43
+ 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.',
46
44
  );
47
45
  }
48
46