@jackwener/opencli 1.5.2 → 1.5.3

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 (118) hide show
  1. package/.github/workflows/ci.yml +6 -7
  2. package/README.md +21 -362
  3. package/dist/browser/cdp.js +20 -1
  4. package/dist/browser/daemon-client.js +3 -2
  5. package/dist/browser/dom-helpers.d.ts +11 -0
  6. package/dist/browser/dom-helpers.js +42 -0
  7. package/dist/browser/dom-helpers.test.d.ts +1 -0
  8. package/dist/browser/dom-helpers.test.js +92 -0
  9. package/dist/browser/index.d.ts +0 -12
  10. package/dist/browser/index.js +0 -13
  11. package/dist/browser/mcp.js +4 -3
  12. package/dist/browser/page.d.ts +1 -0
  13. package/dist/browser/page.js +14 -1
  14. package/dist/browser.test.js +15 -11
  15. package/dist/clis/36kr/hot.js +1 -1
  16. package/dist/clis/36kr/search.js +1 -1
  17. package/dist/clis/_shared/common.d.ts +8 -0
  18. package/dist/clis/_shared/common.js +10 -0
  19. package/dist/clis/bloomberg/news.js +1 -1
  20. package/dist/clis/douban/utils.js +3 -6
  21. package/dist/clis/medium/utils.js +1 -1
  22. package/dist/clis/producthunt/browse.js +1 -1
  23. package/dist/clis/producthunt/hot.js +1 -1
  24. package/dist/clis/sinablog/utils.js +6 -7
  25. package/dist/clis/substack/utils.js +2 -2
  26. package/dist/clis/twitter/block.js +1 -1
  27. package/dist/clis/twitter/bookmark.js +1 -1
  28. package/dist/clis/twitter/delete.js +1 -1
  29. package/dist/clis/twitter/follow.js +1 -1
  30. package/dist/clis/twitter/followers.js +2 -2
  31. package/dist/clis/twitter/following.js +2 -2
  32. package/dist/clis/twitter/hide-reply.js +1 -1
  33. package/dist/clis/twitter/like.js +1 -1
  34. package/dist/clis/twitter/notifications.js +1 -1
  35. package/dist/clis/twitter/profile.js +1 -1
  36. package/dist/clis/twitter/reply-dm.js +1 -1
  37. package/dist/clis/twitter/reply.js +1 -1
  38. package/dist/clis/twitter/search.js +1 -1
  39. package/dist/clis/twitter/unblock.js +1 -1
  40. package/dist/clis/twitter/unbookmark.js +1 -1
  41. package/dist/clis/twitter/unfollow.js +1 -1
  42. package/dist/clis/xiaohongshu/comments.test.js +1 -0
  43. package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
  44. package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
  45. package/dist/clis/xiaohongshu/publish.test.js +1 -0
  46. package/dist/clis/xiaohongshu/search.test.js +1 -0
  47. package/dist/download/index.js +39 -33
  48. package/dist/download/index.test.js +15 -1
  49. package/dist/execution.js +3 -2
  50. package/dist/main.js +2 -0
  51. package/dist/node-network.d.ts +10 -0
  52. package/dist/node-network.js +174 -0
  53. package/dist/node-network.test.d.ts +1 -0
  54. package/dist/node-network.test.js +55 -0
  55. package/dist/pipeline/executor.test.js +1 -0
  56. package/dist/pipeline/steps/download.test.js +1 -0
  57. package/dist/pipeline/steps/intercept.js +4 -5
  58. package/dist/types.d.ts +2 -0
  59. package/dist/utils.d.ts +2 -0
  60. package/dist/utils.js +4 -0
  61. package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
  62. package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
  63. package/extension/dist/background.js +1 -1
  64. package/extension/manifest.json +1 -1
  65. package/extension/package-lock.json +2 -2
  66. package/extension/package.json +1 -1
  67. package/extension/src/background.ts +1 -1
  68. package/package.json +2 -1
  69. package/src/browser/cdp.ts +21 -0
  70. package/src/browser/daemon-client.ts +3 -2
  71. package/src/browser/dom-helpers.test.ts +100 -0
  72. package/src/browser/dom-helpers.ts +44 -0
  73. package/src/browser/index.ts +0 -15
  74. package/src/browser/mcp.ts +4 -3
  75. package/src/browser/page.ts +16 -0
  76. package/src/browser.test.ts +16 -12
  77. package/src/clis/36kr/hot.ts +1 -1
  78. package/src/clis/36kr/search.ts +1 -1
  79. package/src/clis/_shared/common.ts +11 -0
  80. package/src/clis/bloomberg/news.ts +1 -1
  81. package/src/clis/douban/utils.ts +3 -7
  82. package/src/clis/medium/utils.ts +1 -1
  83. package/src/clis/producthunt/browse.ts +1 -1
  84. package/src/clis/producthunt/hot.ts +1 -1
  85. package/src/clis/sinablog/utils.ts +6 -7
  86. package/src/clis/substack/utils.ts +2 -2
  87. package/src/clis/twitter/block.ts +1 -1
  88. package/src/clis/twitter/bookmark.ts +1 -1
  89. package/src/clis/twitter/delete.ts +1 -1
  90. package/src/clis/twitter/follow.ts +1 -1
  91. package/src/clis/twitter/followers.ts +2 -2
  92. package/src/clis/twitter/following.ts +2 -2
  93. package/src/clis/twitter/hide-reply.ts +1 -1
  94. package/src/clis/twitter/like.ts +1 -1
  95. package/src/clis/twitter/notifications.ts +1 -1
  96. package/src/clis/twitter/profile.ts +1 -1
  97. package/src/clis/twitter/reply-dm.ts +1 -1
  98. package/src/clis/twitter/reply.ts +1 -1
  99. package/src/clis/twitter/search.ts +1 -1
  100. package/src/clis/twitter/unblock.ts +1 -1
  101. package/src/clis/twitter/unbookmark.ts +1 -1
  102. package/src/clis/twitter/unfollow.ts +1 -1
  103. package/src/clis/xiaohongshu/comments.test.ts +1 -0
  104. package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
  105. package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
  106. package/src/clis/xiaohongshu/publish.test.ts +1 -0
  107. package/src/clis/xiaohongshu/search.test.ts +1 -0
  108. package/src/download/index.test.ts +19 -1
  109. package/src/download/index.ts +50 -41
  110. package/src/execution.ts +3 -2
  111. package/src/main.ts +3 -0
  112. package/src/node-network.test.ts +93 -0
  113. package/src/node-network.ts +213 -0
  114. package/src/pipeline/executor.test.ts +1 -0
  115. package/src/pipeline/steps/download.test.ts +1 -0
  116. package/src/pipeline/steps/intercept.ts +4 -5
  117. package/src/types.ts +2 -0
  118. package/src/utils.ts +5 -0
@@ -0,0 +1,1143 @@
1
+ # Performance: Smart Wait & INTERCEPT Fix — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Fix the INTERCEPT strategy correctness bug, add `wait({ selector })` for event-driven waits, and speed up daemon cold-start — eliminating up to 8s of unnecessary fixed sleeps per command.
6
+
7
+ **Architecture:** Three independent layers applied in order: (1) add `waitForCaptureJs` + `waitForSelectorJs` to `dom-helpers.ts` and expose via `IPage`, (2) update `page.ts`/`cdp.ts` implementations, (3) update adapters from the inside out — framework first, then adapters.
8
+
9
+ **Tech Stack:** TypeScript, Vitest (unit + adapter projects), Node.js, browser JS (eval'd strings)
10
+
11
+ ---
12
+
13
+ ## File Map
14
+
15
+ | File | Change |
16
+ |------|--------|
17
+ | `src/browser/dom-helpers.ts` | Add `waitForCaptureJs()`, `waitForSelectorJs()` |
18
+ | `src/browser/dom-helpers.test.ts` | **New** — unit tests for new helpers |
19
+ | `src/types.ts` | Add `selector?` to `WaitOptions`; add `waitForCapture()` to `IPage` |
20
+ | `src/browser/page.ts` | Implement `waitForCapture()`, add `selector` branch to `wait()` |
21
+ | `src/browser/cdp.ts` | Implement `waitForCapture()`, add `selector` branch to `wait()` |
22
+ | `src/pipeline/steps/intercept.ts` | Use `page.installInterceptor()` + `page.waitForCapture()` + `page.getInterceptedRequests()` |
23
+ | `src/browser/mcp.ts` | Exponential backoff in `_ensureDaemon()` |
24
+ | `src/clis/36kr/hot.ts` | `wait(6)` → `waitForCapture(10)` |
25
+ | `src/clis/36kr/search.ts` | `wait(6)` → `waitForCapture(10)` |
26
+ | `src/clis/twitter/search.ts` | `wait(5)` → `waitForCapture(8)` (already INTERCEPT) |
27
+ | `src/clis/twitter/followers.ts` | `wait(5)` → `waitForCapture(8)` (already INTERCEPT) |
28
+ | `src/clis/twitter/following.ts` | `wait(5)` → `waitForCapture(8)` (already INTERCEPT) |
29
+ | `src/clis/twitter/notifications.ts` | `wait(3)` → selector + `wait(5)` → `waitForCapture(8)` |
30
+ | `src/clis/producthunt/hot.ts` | `wait(5)` → `waitForCapture(8)` |
31
+ | `src/clis/producthunt/browse.ts` | `wait(5)` → `waitForCapture(8)` |
32
+ | `src/clis/twitter/reply.ts` | `wait(5)` → `wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 8 })` |
33
+ | `src/clis/twitter/follow.ts` | `wait(5)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
34
+ | `src/clis/twitter/unfollow.ts` | `wait(5)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
35
+ | `src/clis/twitter/like.ts` | `wait(5)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
36
+ | `src/clis/twitter/bookmark.ts` | `wait(5)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
37
+ | `src/clis/twitter/unbookmark.ts` | `wait(5)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
38
+ | `src/clis/twitter/block.ts` | `wait(5)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
39
+ | `src/clis/twitter/unblock.ts` | `wait(5)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
40
+ | `src/clis/twitter/hide-reply.ts` | `wait(5)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
41
+ | `src/clis/twitter/profile.ts` | `wait(5)` + `wait(3)` → selector variants |
42
+ | `src/clis/twitter/thread.ts` | `wait(3)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 4 })` |
43
+ | `src/clis/twitter/timeline.ts` | `wait(3)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 4 })` |
44
+ | `src/clis/twitter/delete.ts` | `wait(5)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
45
+ | `src/clis/twitter/reply-dm.ts` | `wait(5)` + `wait(3)` → selector variants |
46
+ | `src/clis/medium/utils.ts` | `wait(5)` → selector; remove inline `setTimeout(3000)` |
47
+ | `src/clis/substack/utils.ts` | `wait(5)` × 2 → selector; remove inline `setTimeout(3000)` × 2 |
48
+ | `src/clis/bloomberg/news.ts` | `wait(5)` → `wait({ selector: '#__NEXT_DATA__', timeout: 8 })`; `wait(4)` → `wait({ selector: '#__NEXT_DATA__', timeout: 5 })` |
49
+ | `src/clis/sinablog/utils.ts` | `wait(3/5)` → selector; remove inline polling loop |
50
+
51
+ ---
52
+
53
+ ## Task 1: Add `waitForCaptureJs` and `waitForSelectorJs` to dom-helpers.ts
54
+
55
+ **Files:**
56
+ - Modify: `src/browser/dom-helpers.ts`
57
+ - Create: `src/browser/dom-helpers.test.ts`
58
+
59
+ - [ ] **Step 1: Add two new exported functions at the end of `src/browser/dom-helpers.ts`**
60
+
61
+ ```typescript
62
+ /**
63
+ * Generate JS to wait until window.__opencli_xhr has ≥1 captured response.
64
+ * Polls every 100ms. Resolves 'captured' on success; rejects after maxMs.
65
+ * Used after installInterceptor() + goto() instead of a fixed sleep.
66
+ */
67
+ export function waitForCaptureJs(maxMs: number): string {
68
+ return `
69
+ new Promise((resolve, reject) => {
70
+ const deadline = Date.now() + ${maxMs};
71
+ const check = () => {
72
+ if ((window.__opencli_xhr || []).length > 0) return resolve('captured');
73
+ if (Date.now() > deadline) return reject(new Error('No network capture within ${maxMs / 1000}s'));
74
+ setTimeout(check, 100);
75
+ };
76
+ check();
77
+ })
78
+ `;
79
+ }
80
+
81
+ /**
82
+ * Generate JS to wait until document.querySelector(selector) returns a match.
83
+ * Polls every 100ms. Resolves 'found' on success; rejects after timeoutMs.
84
+ */
85
+ export function waitForSelectorJs(selector: string, timeoutMs: number): string {
86
+ return `
87
+ new Promise((resolve, reject) => {
88
+ const deadline = Date.now() + ${timeoutMs};
89
+ const check = () => {
90
+ if (document.querySelector(${JSON.stringify(selector)})) return resolve('found');
91
+ if (Date.now() > deadline) return reject(new Error('Selector not found: ' + ${JSON.stringify(selector)}));
92
+ setTimeout(check, 100);
93
+ };
94
+ check();
95
+ })
96
+ `;
97
+ }
98
+ ```
99
+
100
+ - [ ] **Step 2: Create `src/browser/dom-helpers.test.ts` with failing tests**
101
+
102
+ ```typescript
103
+ import { describe, it, expect } from 'vitest';
104
+ import { waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
105
+
106
+ describe('waitForCaptureJs', () => {
107
+ it('returns a non-empty string', () => {
108
+ const code = waitForCaptureJs(1000);
109
+ expect(typeof code).toBe('string');
110
+ expect(code.length).toBeGreaterThan(0);
111
+ expect(code).toContain('__opencli_xhr');
112
+ expect(code).toContain('resolve');
113
+ expect(code).toContain('reject');
114
+ });
115
+
116
+ it('resolves "captured" when __opencli_xhr is populated before deadline', async () => {
117
+ const g = globalThis as any;
118
+ g.__opencli_xhr = [];
119
+ const code = waitForCaptureJs(1000);
120
+ const promise = eval(code) as Promise<string>;
121
+ g.__opencli_xhr.push({ data: 'test' });
122
+ await expect(promise).resolves.toBe('captured');
123
+ delete g.__opencli_xhr;
124
+ });
125
+
126
+ it('rejects when __opencli_xhr stays empty past deadline', async () => {
127
+ const g = globalThis as any;
128
+ g.__opencli_xhr = [];
129
+ const code = waitForCaptureJs(50); // 50ms timeout
130
+ const promise = eval(code) as Promise<string>;
131
+ await expect(promise).rejects.toThrow('No network capture within 0.05s');
132
+ delete g.__opencli_xhr;
133
+ });
134
+
135
+ it('resolves immediately when __opencli_xhr already has data', async () => {
136
+ const g = globalThis as any;
137
+ g.__opencli_xhr = [{ data: 'already here' }];
138
+ const code = waitForCaptureJs(1000);
139
+ await expect(eval(code) as Promise<string>).resolves.toBe('captured');
140
+ delete g.__opencli_xhr;
141
+ });
142
+ });
143
+
144
+ describe('waitForSelectorJs', () => {
145
+ it('returns a non-empty string', () => {
146
+ const code = waitForSelectorJs('#app', 1000);
147
+ expect(typeof code).toBe('string');
148
+ expect(code).toContain('#app');
149
+ expect(code).toContain('querySelector');
150
+ });
151
+
152
+ it('rejects when document.querySelector returns null within timeout', async () => {
153
+ const g = globalThis as any;
154
+ g.document = { querySelector: (_: string) => null };
155
+ const code = waitForSelectorJs('#missing', 50);
156
+ await expect(eval(code) as Promise<string>).rejects.toThrow('Selector not found: #missing');
157
+ delete g.document;
158
+ });
159
+
160
+ it('resolves "found" when document.querySelector returns an element', async () => {
161
+ const g = globalThis as any;
162
+ const fakeEl = { tagName: 'DIV' };
163
+ g.document = { querySelector: (_: string) => fakeEl };
164
+ const code = waitForSelectorJs('[data-testid="primaryColumn"]', 1000);
165
+ await expect(eval(code) as Promise<string>).resolves.toBe('found');
166
+ delete g.document;
167
+ });
168
+ });
169
+ ```
170
+
171
+ - [ ] **Step 3: Run tests to verify they fail (functions not yet exported)**
172
+
173
+ ```bash
174
+ cd /Users/jakevin/code/opencli
175
+ npx vitest run --project unit src/browser/dom-helpers.test.ts
176
+ ```
177
+ Expected: Tests for `waitForCaptureJs` pass (function exists), tests for `waitForSelectorJs` fail (not yet added).
178
+
179
+ - [ ] **Step 4: Run tests again after Step 1 to verify all pass**
180
+
181
+ ```bash
182
+ npx vitest run --project unit src/browser/dom-helpers.test.ts
183
+ ```
184
+ Expected: All 7 tests PASS.
185
+
186
+ - [ ] **Step 5: Commit**
187
+
188
+ ```bash
189
+ git add src/browser/dom-helpers.ts src/browser/dom-helpers.test.ts
190
+ git commit -m "feat(perf): add waitForCaptureJs and waitForSelectorJs to dom-helpers"
191
+ ```
192
+
193
+ ---
194
+
195
+ ## Task 2: Extend `IPage` interface and `WaitOptions` in `types.ts`
196
+
197
+ **Files:**
198
+ - Modify: `src/types.ts`
199
+
200
+ - [ ] **Step 1: Add `selector` to `WaitOptions` and `waitForCapture` to `IPage`**
201
+
202
+ In `src/types.ts`, find `WaitOptions` and add `selector?`:
203
+
204
+ ```typescript
205
+ export interface WaitOptions {
206
+ text?: string;
207
+ selector?: string; // wait until document.querySelector(selector) matches
208
+ time?: number;
209
+ timeout?: number;
210
+ }
211
+ ```
212
+
213
+ In the same file, find `IPage` and add `waitForCapture` after `getInterceptedRequests`:
214
+
215
+ ```typescript
216
+ installInterceptor(pattern: string): Promise<void>;
217
+ getInterceptedRequests(): Promise<any[]>;
218
+ waitForCapture(timeout?: number): Promise<void>;
219
+ ```
220
+
221
+ - [ ] **Step 2: Run unit tests to confirm no type errors**
222
+
223
+ ```bash
224
+ npx vitest run --project unit
225
+ ```
226
+ Expected: All existing unit tests PASS (no adapter tests broken since IPage is extended, not changed).
227
+
228
+ - [ ] **Step 3: Commit**
229
+
230
+ ```bash
231
+ git add src/types.ts
232
+ git commit -m "feat(perf): extend WaitOptions with selector, add waitForCapture to IPage"
233
+ ```
234
+
235
+ ---
236
+
237
+ ## Task 3: Implement `waitForCapture()` and `wait({ selector })` in `page.ts`
238
+
239
+ **Files:**
240
+ - Modify: `src/browser/page.ts`
241
+
242
+ - [ ] **Step 1: Add `waitForCaptureJs` and `waitForSelectorJs` to the imports at the top of `page.ts`**
243
+
244
+ Find the existing import from `./dom-helpers.js`:
245
+
246
+ ```typescript
247
+ import {
248
+ clickJs,
249
+ typeTextJs,
250
+ pressKeyJs,
251
+ waitForTextJs,
252
+ scrollJs,
253
+ autoScrollJs,
254
+ networkRequestsJs,
255
+ waitForDomStableJs,
256
+ } from './dom-helpers.js';
257
+ ```
258
+
259
+ Replace with:
260
+
261
+ ```typescript
262
+ import {
263
+ clickJs,
264
+ typeTextJs,
265
+ pressKeyJs,
266
+ waitForTextJs,
267
+ waitForCaptureJs,
268
+ waitForSelectorJs,
269
+ scrollJs,
270
+ autoScrollJs,
271
+ networkRequestsJs,
272
+ waitForDomStableJs,
273
+ } from './dom-helpers.js';
274
+ ```
275
+
276
+ - [ ] **Step 2: Add `selector` branch to the existing `wait()` method in `page.ts`**
277
+
278
+ Find the current `wait()` implementation and add the `selector` branch before the `text` branch:
279
+
280
+ ```typescript
281
+ async wait(options: number | WaitOptions): Promise<void> {
282
+ if (typeof options === 'number') {
283
+ if (options >= 1) {
284
+ try {
285
+ const maxMs = options * 1000;
286
+ await sendCommand('exec', {
287
+ code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
288
+ ...this._cmdOpts(),
289
+ });
290
+ return;
291
+ } catch {
292
+ // Fallback: fixed sleep (e.g. if page has no DOM yet)
293
+ }
294
+ }
295
+ await new Promise(resolve => setTimeout(resolve, options * 1000));
296
+ return;
297
+ }
298
+ if (typeof options.time === 'number') {
299
+ await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
300
+ return;
301
+ }
302
+ if (options.selector) {
303
+ const timeout = (options.timeout ?? 10) * 1000;
304
+ const code = waitForSelectorJs(options.selector, timeout);
305
+ await sendCommand('exec', { code, ...this._cmdOpts() });
306
+ return;
307
+ }
308
+ if (options.text) {
309
+ const timeout = (options.timeout ?? 30) * 1000;
310
+ const code = waitForTextJs(options.text, timeout);
311
+ await sendCommand('exec', { code, ...this._cmdOpts() });
312
+ }
313
+ }
314
+ ```
315
+
316
+ - [ ] **Step 3: Add `waitForCapture()` method to `page.ts`, just after `getInterceptedRequests()`**
317
+
318
+ Find `getInterceptedRequests()` at the end of the `Page` class and add after it:
319
+
320
+ ```typescript
321
+ async waitForCapture(timeout: number = 10): Promise<void> {
322
+ const maxMs = timeout * 1000;
323
+ await sendCommand('exec', {
324
+ code: waitForCaptureJs(maxMs),
325
+ ...this._cmdOpts(),
326
+ });
327
+ }
328
+ ```
329
+
330
+ - [ ] **Step 4: Run unit tests**
331
+
332
+ ```bash
333
+ npx vitest run --project unit
334
+ ```
335
+ Expected: All PASS.
336
+
337
+ - [ ] **Step 5: Commit**
338
+
339
+ ```bash
340
+ git add src/browser/page.ts
341
+ git commit -m "feat(perf): implement waitForCapture() and wait({ selector }) in Page"
342
+ ```
343
+
344
+ ---
345
+
346
+ ## Task 4: Implement `waitForCapture()` and `wait({ selector })` in `cdp.ts`
347
+
348
+ **Files:**
349
+ - Modify: `src/browser/cdp.ts`
350
+
351
+ - [ ] **Step 1: Add `waitForCaptureJs` and `waitForSelectorJs` to the imports in `cdp.ts`**
352
+
353
+ Find the existing import from `./dom-helpers.js` in `cdp.ts`:
354
+
355
+ ```typescript
356
+ import {
357
+ clickJs,
358
+ typeTextJs,
359
+ pressKeyJs,
360
+ waitForTextJs,
361
+ scrollJs,
362
+ autoScrollJs,
363
+ networkRequestsJs,
364
+ } from './dom-helpers.js';
365
+ ```
366
+
367
+ Replace with:
368
+
369
+ ```typescript
370
+ import {
371
+ clickJs,
372
+ typeTextJs,
373
+ pressKeyJs,
374
+ waitForTextJs,
375
+ waitForCaptureJs,
376
+ waitForSelectorJs,
377
+ scrollJs,
378
+ autoScrollJs,
379
+ networkRequestsJs,
380
+ } from './dom-helpers.js';
381
+ ```
382
+
383
+ - [ ] **Step 2: Add `selector` branch to `wait()` in `cdp.ts`**
384
+
385
+ Find the current `wait()` in `cdp.ts` and replace it entirely:
386
+
387
+ ```typescript
388
+ async wait(options: number | WaitOptions): Promise<void> {
389
+ if (typeof options === 'number') {
390
+ await new Promise((resolve) => setTimeout(resolve, options * 1000));
391
+ return;
392
+ }
393
+ if (typeof options.time === 'number') {
394
+ const waitTime = options.time;
395
+ await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
396
+ return;
397
+ }
398
+ if (options.selector) {
399
+ const timeout = (options.timeout ?? 10) * 1000;
400
+ await this.evaluate(waitForSelectorJs(options.selector, timeout));
401
+ return;
402
+ }
403
+ if (options.text) {
404
+ const timeout = (options.timeout ?? 30) * 1000;
405
+ await this.evaluate(waitForTextJs(options.text, timeout));
406
+ }
407
+ }
408
+ ```
409
+
410
+ - [ ] **Step 3: Add `waitForCapture()` to `cdp.ts`, just after `getInterceptedRequests()`**
411
+
412
+ Find `getInterceptedRequests()` at the end of the `CDPPage` class and add after it:
413
+
414
+ ```typescript
415
+ async waitForCapture(timeout: number = 10): Promise<void> {
416
+ const maxMs = timeout * 1000;
417
+ await this.evaluate(waitForCaptureJs(maxMs));
418
+ }
419
+ ```
420
+
421
+ - [ ] **Step 4: Run unit tests**
422
+
423
+ ```bash
424
+ npx vitest run --project unit
425
+ ```
426
+ Expected: All PASS.
427
+
428
+ - [ ] **Step 5: Commit**
429
+
430
+ ```bash
431
+ git add src/browser/cdp.ts
432
+ git commit -m "feat(perf): implement waitForCapture() and wait({ selector }) in CDPPage"
433
+ ```
434
+
435
+ ---
436
+
437
+ ## Task 5: Update `stepIntercept` to use unified IPage methods
438
+
439
+ **Files:**
440
+ - Modify: `src/pipeline/steps/intercept.ts`
441
+
442
+ The current `stepIntercept` uses `generateInterceptorJs`/`generateReadInterceptedJs` directly, writing to `__opencli_intercepted`. We unify this to use `page.installInterceptor()` (→ `__opencli_xhr`) + `page.waitForCapture()` + `page.getInterceptedRequests()`.
443
+
444
+ - [ ] **Step 1: Rewrite `src/pipeline/steps/intercept.ts`**
445
+
446
+ ```typescript
447
+ /**
448
+ * Pipeline step: intercept — declarative XHR interception.
449
+ */
450
+
451
+ import type { IPage } from '../../types.js';
452
+ import { render, normalizeEvaluateSource } from '../template.js';
453
+
454
+ export async function stepIntercept(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
455
+ const cfg = typeof params === 'object' ? params : {};
456
+ const trigger = cfg.trigger ?? '';
457
+ const capturePattern = cfg.capture ?? '';
458
+ const timeout = cfg.timeout ?? 8;
459
+ const selectPath = cfg.select ?? null;
460
+
461
+ if (!capturePattern) return data;
462
+
463
+ // Step 1: Install fetch/XHR interceptor BEFORE trigger
464
+ await page!.installInterceptor(capturePattern);
465
+
466
+ // Step 2: Execute the trigger action
467
+ if (trigger.startsWith('navigate:')) {
468
+ const url = render(trigger.slice('navigate:'.length), { args, data });
469
+ await page!.goto(String(url));
470
+ } else if (trigger.startsWith('evaluate:')) {
471
+ const js = trigger.slice('evaluate:'.length);
472
+ await page!.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string));
473
+ } else if (trigger.startsWith('click:')) {
474
+ const ref = render(trigger.slice('click:'.length), { args, data });
475
+ await page!.click(String(ref).replace(/^@/, ''));
476
+ } else if (trigger === 'scroll') {
477
+ await page!.scroll('down');
478
+ }
479
+
480
+ // Step 3: Wait for network capture instead of fixed sleep
481
+ await page!.waitForCapture(timeout);
482
+
483
+ // Step 4: Retrieve captured data
484
+ const matchingResponses = await page!.getInterceptedRequests();
485
+
486
+ // Step 5: Select from response if specified
487
+ let result = matchingResponses.length === 1 ? matchingResponses[0] :
488
+ matchingResponses.length > 1 ? matchingResponses : data;
489
+
490
+ if (selectPath && result) {
491
+ let current = result;
492
+ for (const part of String(selectPath).split('.')) {
493
+ if (current && typeof current === 'object' && !Array.isArray(current)) {
494
+ current = current[part];
495
+ } else break;
496
+ }
497
+ result = current ?? result;
498
+ }
499
+
500
+ return result;
501
+ }
502
+ ```
503
+
504
+ - [ ] **Step 2: Run unit + adapter tests**
505
+
506
+ ```bash
507
+ npx vitest run --project unit --project adapter
508
+ ```
509
+ Expected: All PASS.
510
+
511
+ - [ ] **Step 3: Commit**
512
+
513
+ ```bash
514
+ git add src/pipeline/steps/intercept.ts
515
+ git commit -m "perf(intercept): use installInterceptor+waitForCapture in stepIntercept pipeline step"
516
+ ```
517
+
518
+ ---
519
+
520
+ ## Task 6: Fix INTERCEPT adapters (Layer 1)
521
+
522
+ **Files:** `36kr/hot.ts`, `36kr/search.ts`, `twitter/search.ts`, `twitter/followers.ts`, `twitter/following.ts`, `twitter/notifications.ts`, `producthunt/hot.ts`, `producthunt/browse.ts`
523
+
524
+ - [ ] **Step 1: Fix `src/clis/36kr/hot.ts`**
525
+
526
+ Find:
527
+ ```typescript
528
+ await page.installInterceptor('36kr.com/api');
529
+ await page.goto(url);
530
+ await page.wait(6);
531
+ ```
532
+ Replace with:
533
+ ```typescript
534
+ await page.installInterceptor('36kr.com/api');
535
+ await page.goto(url);
536
+ await page.waitForCapture(10);
537
+ ```
538
+
539
+ - [ ] **Step 2: Fix `src/clis/36kr/search.ts`**
540
+
541
+ Find:
542
+ ```typescript
543
+ await page.installInterceptor('36kr.com/api');
544
+ await page.goto(`https://www.36kr.com/search/articles/${query}`);
545
+ await page.wait(6);
546
+ ```
547
+ Replace with:
548
+ ```typescript
549
+ await page.installInterceptor('36kr.com/api');
550
+ await page.goto(`https://www.36kr.com/search/articles/${query}`);
551
+ await page.waitForCapture(10);
552
+ ```
553
+
554
+ - [ ] **Step 3: Fix `src/clis/twitter/search.ts`**
555
+
556
+ Find the two lines that contain `await page.wait(5)` in the `navigateToSearch` helper:
557
+ ```typescript
558
+ await page.wait(5);
559
+ ```
560
+ (there are two of them: one after `pushState`, one in the retry). Replace both with:
561
+ ```typescript
562
+ await page.waitForCapture(8);
563
+ ```
564
+
565
+ - [ ] **Step 4: Fix `src/clis/twitter/followers.ts`**
566
+
567
+ Find:
568
+ ```typescript
569
+ await page.wait(5);
570
+
571
+ // 4. Scroll to trigger pagination API calls
572
+ ```
573
+ Replace with:
574
+ ```typescript
575
+ await page.waitForCapture(8);
576
+
577
+ // 4. Scroll to trigger pagination API calls
578
+ ```
579
+
580
+ Also find the earlier `wait(5)` after going to profile and `wait(3)` after going to home — those are UI waits (not INTERCEPT), replace with selector:
581
+ ```typescript
582
+ // After page.goto('https://x.com/home'):
583
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 5 });
584
+ // After page.goto(`https://x.com/${targetUser}`):
585
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 4 });
586
+ ```
587
+
588
+ - [ ] **Step 5: Fix `src/clis/twitter/following.ts`**
589
+
590
+ Same pattern as `followers.ts`. Find and apply identically:
591
+ - `wait(5)` after `goto('https://x.com/home')` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 5 })`
592
+ - `wait(3)` after `goto(\`https://x.com/${targetUser}\`)` → `wait({ selector: '[data-testid="primaryColumn"]', timeout: 4 })`
593
+ - `wait(5)` after SPA click that triggers INTERCEPT → `waitForCapture(8)`
594
+
595
+ - [ ] **Step 6: Fix `src/clis/twitter/notifications.ts`**
596
+
597
+ Find:
598
+ ```typescript
599
+ await page.goto('https://x.com/home');
600
+ await page.wait(3);
601
+ ```
602
+ Replace with:
603
+ ```typescript
604
+ await page.goto('https://x.com/home');
605
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 5 });
606
+ ```
607
+
608
+ Find:
609
+ ```typescript
610
+ await page.wait(5);
611
+
612
+ // Verify SPA navigation succeeded
613
+ ```
614
+ Replace with:
615
+ ```typescript
616
+ await page.waitForCapture(8);
617
+
618
+ // Verify SPA navigation succeeded
619
+ ```
620
+
621
+ - [ ] **Step 7: Fix `src/clis/producthunt/hot.ts`**
622
+
623
+ Find:
624
+ ```typescript
625
+ await page.installInterceptor(
626
+ ```
627
+ Look at the full pattern and replace the subsequent `wait(5)` with `waitForCapture(8)`.
628
+
629
+ - [ ] **Step 8: Fix `src/clis/producthunt/browse.ts`**
630
+
631
+ Same as `hot.ts` — replace `wait(5)` after `installInterceptor` + `goto` with `waitForCapture(8)`.
632
+
633
+ - [ ] **Step 9: Run adapter tests**
634
+
635
+ ```bash
636
+ npx vitest run --project adapter
637
+ ```
638
+ Expected: All PASS (adapter tests mock `page.wait` and `page.waitForCapture`; existing mocks will need `waitForCapture: vi.fn()` if not already present).
639
+
640
+ If any adapter test file lacks `waitForCapture` mock, add `waitForCapture: vi.fn().mockResolvedValue(undefined)` to its mock page object.
641
+
642
+ - [ ] **Step 10: Commit**
643
+
644
+ ```bash
645
+ git add src/clis/36kr/hot.ts src/clis/36kr/search.ts \
646
+ src/clis/twitter/search.ts src/clis/twitter/followers.ts \
647
+ src/clis/twitter/following.ts src/clis/twitter/notifications.ts \
648
+ src/clis/producthunt/hot.ts src/clis/producthunt/browse.ts
649
+ git commit -m "perf(intercept): replace wait(N) with waitForCapture() in all INTERCEPT adapters"
650
+ ```
651
+
652
+ ---
653
+
654
+ ## Task 7: Daemon exponential backoff (Layer 3)
655
+
656
+ **Files:**
657
+ - Modify: `src/browser/mcp.ts`
658
+
659
+ - [ ] **Step 1: Replace fixed 300ms poll loop in `_ensureDaemon()`**
660
+
661
+ In `src/browser/mcp.ts`, find:
662
+
663
+ ```typescript
664
+ // Wait for daemon to be ready AND extension to connect
665
+ const deadline = Date.now() + timeoutMs;
666
+ while (Date.now() < deadline) {
667
+ await new Promise(resolve => setTimeout(resolve, 300));
668
+ if (await isExtensionConnected()) return;
669
+ }
670
+ ```
671
+
672
+ Replace with:
673
+
674
+ ```typescript
675
+ // Wait for daemon to be ready AND extension to connect.
676
+ // Exponential backoff: daemon typically ready in 500–800ms,
677
+ // so first check at 50ms then 100ms gets a fast result without hammering.
678
+ const deadline = Date.now() + timeoutMs;
679
+ const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
680
+ let backoffIdx = 0;
681
+ while (Date.now() < deadline) {
682
+ const delay = backoffs[Math.min(backoffIdx++, backoffs.length - 1)];
683
+ await new Promise(resolve => setTimeout(resolve, delay));
684
+ if (await isExtensionConnected()) return;
685
+ }
686
+ ```
687
+
688
+ - [ ] **Step 2: Run unit tests**
689
+
690
+ ```bash
691
+ npx vitest run --project unit
692
+ ```
693
+ Expected: All PASS.
694
+
695
+ - [ ] **Step 3: Commit**
696
+
697
+ ```bash
698
+ git add src/browser/mcp.ts
699
+ git commit -m "perf(daemon): exponential backoff for cold-start extension polling"
700
+ ```
701
+
702
+ ---
703
+
704
+ ## Task 8: Fix Twitter UI adapters with `wait({ selector })` (Layer 2, part 1)
705
+
706
+ **Files:** 13 adapters in `src/clis/twitter/`
707
+
708
+ For all adapters below, the pattern is identical: `await page.goto(url)` followed by `await page.wait(5)` waiting for React to hydrate. Replace `wait(5)` with `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })`.
709
+
710
+ - [ ] **Step 1: Fix `src/clis/twitter/reply.ts`**
711
+
712
+ Find:
713
+ ```typescript
714
+ await page.goto(kwargs.url);
715
+ await page.wait(5); // Wait for the react application to hydrate
716
+ ```
717
+ Replace with:
718
+ ```typescript
719
+ await page.goto(kwargs.url);
720
+ await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 8 });
721
+ ```
722
+ (reply.ts uses the reply textarea directly — more precise than primaryColumn)
723
+
724
+ - [ ] **Step 2: Fix `src/clis/twitter/follow.ts`**
725
+
726
+ Find:
727
+ ```typescript
728
+ await page.goto(`https://x.com/${username}`);
729
+ await page.wait(5);
730
+ ```
731
+ Replace with:
732
+ ```typescript
733
+ await page.goto(`https://x.com/${username}`);
734
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 });
735
+ ```
736
+
737
+ - [ ] **Step 3: Fix `src/clis/twitter/unfollow.ts`**
738
+
739
+ Find:
740
+ ```typescript
741
+ await page.goto(`https://x.com/${username}`);
742
+ await page.wait(5);
743
+ ```
744
+ Replace with:
745
+ ```typescript
746
+ await page.goto(`https://x.com/${username}`);
747
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 });
748
+ ```
749
+
750
+ - [ ] **Step 4: Fix `src/clis/twitter/like.ts`**
751
+
752
+ Find:
753
+ ```typescript
754
+ await page.goto(kwargs.url);
755
+ await page.wait(5); // Wait for tweet to load completely
756
+ ```
757
+ Replace with:
758
+ ```typescript
759
+ await page.goto(kwargs.url);
760
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 });
761
+ ```
762
+
763
+ - [ ] **Step 5: Fix `src/clis/twitter/bookmark.ts`**
764
+
765
+ Find:
766
+ ```typescript
767
+ await page.goto(kwargs.url);
768
+ await page.wait(5);
769
+ ```
770
+ Replace with:
771
+ ```typescript
772
+ await page.goto(kwargs.url);
773
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 });
774
+ ```
775
+
776
+ - [ ] **Step 6: Fix `src/clis/twitter/unbookmark.ts`**
777
+
778
+ Find:
779
+ ```typescript
780
+ await page.goto(kwargs.url);
781
+ await page.wait(5);
782
+ ```
783
+ Replace with:
784
+ ```typescript
785
+ await page.goto(kwargs.url);
786
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 });
787
+ ```
788
+
789
+ - [ ] **Step 7: Fix `src/clis/twitter/block.ts`**
790
+
791
+ Find:
792
+ ```typescript
793
+ await page.goto(`https://x.com/${username}`);
794
+ await page.wait(5);
795
+ ```
796
+ Replace with:
797
+ ```typescript
798
+ await page.goto(`https://x.com/${username}`);
799
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 });
800
+ ```
801
+
802
+ - [ ] **Step 8: Fix `src/clis/twitter/unblock.ts`**
803
+
804
+ Find:
805
+ ```typescript
806
+ await page.goto(`https://x.com/${username}`);
807
+ await page.wait(5);
808
+ ```
809
+ Replace with:
810
+ ```typescript
811
+ await page.goto(`https://x.com/${username}`);
812
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 });
813
+ ```
814
+
815
+ - [ ] **Step 9: Fix `src/clis/twitter/hide-reply.ts`**
816
+
817
+ Find:
818
+ ```typescript
819
+ await page.goto(kwargs.url);
820
+ await page.wait(5);
821
+ ```
822
+ Replace with:
823
+ ```typescript
824
+ await page.goto(kwargs.url);
825
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 });
826
+ ```
827
+
828
+ - [ ] **Step 10: Fix `src/clis/twitter/delete.ts`**
829
+
830
+ Find:
831
+ ```typescript
832
+ await page.goto(kwargs.url);
833
+ await page.wait(5); // Wait for tweet to load completely
834
+ ```
835
+ Replace with:
836
+ ```typescript
837
+ await page.goto(kwargs.url);
838
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 });
839
+ ```
840
+
841
+ - [ ] **Step 11: Fix `src/clis/twitter/profile.ts`**
842
+
843
+ There are two wait calls:
844
+
845
+ Find (detecting logged-in user):
846
+ ```typescript
847
+ await page.goto('https://x.com/home');
848
+ await page.wait(5);
849
+ ```
850
+ Replace with:
851
+ ```typescript
852
+ await page.goto('https://x.com/home');
853
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 });
854
+ ```
855
+
856
+ Find (after going to profile):
857
+ ```typescript
858
+ await page.goto(`https://x.com/${username}`);
859
+ await page.wait(3);
860
+ ```
861
+ Replace with:
862
+ ```typescript
863
+ await page.goto(`https://x.com/${username}`);
864
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 4 });
865
+ ```
866
+
867
+ - [ ] **Step 12: Fix `src/clis/twitter/thread.ts`**
868
+
869
+ Find:
870
+ ```typescript
871
+ await page.goto('https://x.com');
872
+ await page.wait(3);
873
+ ```
874
+ Replace with:
875
+ ```typescript
876
+ await page.goto('https://x.com');
877
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 4 });
878
+ ```
879
+
880
+ - [ ] **Step 13: Fix `src/clis/twitter/timeline.ts`**
881
+
882
+ Find:
883
+ ```typescript
884
+ await page.goto('https://x.com');
885
+ await page.wait(3);
886
+ ```
887
+ Replace with:
888
+ ```typescript
889
+ await page.goto('https://x.com');
890
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 4 });
891
+ ```
892
+
893
+ - [ ] **Step 14: Fix `src/clis/twitter/reply-dm.ts`**
894
+
895
+ Find:
896
+ ```typescript
897
+ await page.goto('https://x.com/messages');
898
+ await page.wait(5);
899
+ ```
900
+ Replace with:
901
+ ```typescript
902
+ await page.goto('https://x.com/messages');
903
+ await page.wait({ selector: '[data-testid="DMDrawer"], [data-testid="primaryColumn"]', timeout: 6 });
904
+ ```
905
+
906
+ Find the second wait in `reply-dm.ts`:
907
+ ```typescript
908
+ await page.goto(convUrl);
909
+ await page.wait(3);
910
+ ```
911
+ Replace with:
912
+ ```typescript
913
+ await page.goto(convUrl);
914
+ await page.wait({ selector: '[data-testid="primaryColumn"]', timeout: 4 });
915
+ ```
916
+
917
+ - [ ] **Step 15: Run adapter tests**
918
+
919
+ ```bash
920
+ npx vitest run --project adapter
921
+ ```
922
+ Expected: All PASS.
923
+
924
+ - [ ] **Step 16: Commit**
925
+
926
+ ```bash
927
+ git add src/clis/twitter/reply.ts src/clis/twitter/follow.ts src/clis/twitter/unfollow.ts \
928
+ src/clis/twitter/like.ts src/clis/twitter/bookmark.ts src/clis/twitter/unbookmark.ts \
929
+ src/clis/twitter/block.ts src/clis/twitter/unblock.ts src/clis/twitter/hide-reply.ts \
930
+ src/clis/twitter/delete.ts src/clis/twitter/profile.ts src/clis/twitter/thread.ts \
931
+ src/clis/twitter/timeline.ts src/clis/twitter/reply-dm.ts
932
+ git commit -m "perf(twitter): replace wait(N) with wait({ selector }) for React hydration waits"
933
+ ```
934
+
935
+ ---
936
+
937
+ ## Task 9: Fix medium, substack, bloomberg, sinablog (Layer 2, part 2)
938
+
939
+ **Files:** `medium/utils.ts`, `substack/utils.ts`, `bloomberg/news.ts`, `sinablog/utils.ts`
940
+
941
+ The pattern for medium/substack: outer `wait(5)` + inner `setTimeout(3000)` in `evaluate()`. Fix: replace outer with `wait({ selector: 'article', timeout: 8 })`, remove inner setTimeout, and let the evaluate run synchronously.
942
+
943
+ - [ ] **Step 1: Fix `src/clis/medium/utils.ts`**
944
+
945
+ Find the `loadMediumPosts` function. Replace:
946
+ ```typescript
947
+ await page.goto(url);
948
+ await page.wait(5);
949
+ const data = await page.evaluate(`
950
+ (async () => {
951
+ await new Promise((resolve) => setTimeout(resolve, 3000));
952
+ ```
953
+ With:
954
+ ```typescript
955
+ await page.goto(url);
956
+ await page.wait({ selector: 'article', timeout: 8 });
957
+ const data = await page.evaluate(`
958
+ (() => {
959
+ ```
960
+ Also remove the closing `})()` (async) and replace with `()()` (sync). The full evaluate becomes a sync IIFE since the inner sleep is removed.
961
+
962
+ **Complete replacement** — find the entire evaluate block starting with `(async () => {` and ending with `})()`:
963
+
964
+ The evaluate body starting line is:
965
+ ```typescript
966
+ const data = await page.evaluate(`
967
+ (async () => {
968
+ await new Promise((resolve) => setTimeout(resolve, 3000));
969
+
970
+ const limit = ${Math.max(1, Math.min(limit, 50))};
971
+ ```
972
+ Replace `(async () => {` with `(() => {` and remove the `await new Promise((resolve) => setTimeout(resolve, 3000));` line (and the blank line after it). Change `})()` closing to `})()`. Remove `async` from the arrow function signature.
973
+
974
+ - [ ] **Step 2: Fix `src/clis/substack/utils.ts` — `loadSubstackFeed`**
975
+
976
+ Find:
977
+ ```typescript
978
+ await page.goto(url);
979
+ await page.wait(5);
980
+ const data = await page.evaluate(`
981
+ (async () => {
982
+ await new Promise((resolve) => setTimeout(resolve, 3000));
983
+ ```
984
+ Replace with:
985
+ ```typescript
986
+ await page.goto(url);
987
+ await page.wait({ selector: 'article, [class*="post"]', timeout: 8 });
988
+ const data = await page.evaluate(`
989
+ (() => {
990
+ ```
991
+ And remove the `await new Promise((resolve) => setTimeout(resolve, 3000));` line. Change `(async () => {` to `(() => {`.
992
+
993
+ - [ ] **Step 3: Fix `src/clis/substack/utils.ts` — `loadSubstackArchive`**
994
+
995
+ Same fix as Step 2 but for `loadSubstackArchive`:
996
+ ```typescript
997
+ await page.goto(`${baseUrl}/archive`);
998
+ await page.wait(5);
999
+ const data = await page.evaluate(`
1000
+ (async () => {
1001
+ await new Promise((resolve) => setTimeout(resolve, 3000));
1002
+ ```
1003
+ Replace with:
1004
+ ```typescript
1005
+ await page.goto(`${baseUrl}/archive`);
1006
+ await page.wait({ selector: 'a[href*="/p/"]', timeout: 8 });
1007
+ const data = await page.evaluate(`
1008
+ (() => {
1009
+ ```
1010
+ Remove inner setTimeout line. Change async to sync.
1011
+
1012
+ - [ ] **Step 4: Fix `src/clis/bloomberg/news.ts`**
1013
+
1014
+ Find:
1015
+ ```typescript
1016
+ await page.goto(url);
1017
+ await page.wait(5);
1018
+ ```
1019
+ Replace with:
1020
+ ```typescript
1021
+ await page.goto(url);
1022
+ await page.wait({ selector: '#__NEXT_DATA__, article', timeout: 8 });
1023
+ ```
1024
+
1025
+ Find the retry wait:
1026
+ ```typescript
1027
+ if (result?.errorCode === 'NO_NEXT_DATA' || result?.errorCode === 'NO_STORY') {
1028
+ await page.wait(4);
1029
+ result = await loadStory();
1030
+ }
1031
+ ```
1032
+ Replace with:
1033
+ ```typescript
1034
+ if (result?.errorCode === 'NO_NEXT_DATA' || result?.errorCode === 'NO_STORY') {
1035
+ await page.wait({ selector: '#__NEXT_DATA__', timeout: 5 });
1036
+ result = await loadStory();
1037
+ }
1038
+ ```
1039
+
1040
+ - [ ] **Step 5: Fix `src/clis/sinablog/utils.ts`**
1041
+
1042
+ `sinablog` has three functions to fix.
1043
+
1044
+ **`loadSinaBlogHot` and `loadSinaBlogUser`** — find their `wait(3)` calls followed by inline `setTimeout(1500)` loops:
1045
+
1046
+ ```typescript
1047
+ await page.goto(url);
1048
+ await page.wait(3);
1049
+ const data = await page.evaluate(`
1050
+ (async () => {
1051
+ await new Promise((resolve) => setTimeout(resolve, 1500));
1052
+ ```
1053
+ Replace with:
1054
+ ```typescript
1055
+ await page.goto(url);
1056
+ await page.wait({ selector: '.article-list, .blog-article, article', timeout: 6 });
1057
+ const data = await page.evaluate(`
1058
+ (() => {
1059
+ ```
1060
+ Remove the inner setTimeout line. Change `async` arrow to sync.
1061
+
1062
+ **`loadSinaBlogSearch`** — find:
1063
+ ```typescript
1064
+ await page.goto(buildSinaBlogSearchUrl(keyword));
1065
+ await page.wait(5);
1066
+ const data = await page.evaluate(`
1067
+ (async () => {
1068
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1069
+ for (let i = 0; i < 20; i += 1) {
1070
+ if (document.querySelector('.result-item')) break;
1071
+ await sleep(500);
1072
+ }
1073
+ ```
1074
+ Replace with:
1075
+ ```typescript
1076
+ await page.goto(buildSinaBlogSearchUrl(keyword));
1077
+ await page.wait({ selector: '.result-item', timeout: 8 });
1078
+ const data = await page.evaluate(`
1079
+ (() => {
1080
+ ```
1081
+ Remove the `sleep` helper definition and the polling loop (they're replaced by the outer `wait({ selector })`). Change `async` to sync.
1082
+
1083
+ - [ ] **Step 6: Run full unit + adapter tests**
1084
+
1085
+ ```bash
1086
+ npx vitest run --project unit --project adapter
1087
+ ```
1088
+ Expected: All PASS.
1089
+
1090
+ - [ ] **Step 7: Commit**
1091
+
1092
+ ```bash
1093
+ git add src/clis/medium/utils.ts src/clis/substack/utils.ts \
1094
+ src/clis/bloomberg/news.ts src/clis/sinablog/utils.ts
1095
+ git commit -m "perf(adapters): replace wait(N)+inline-sleep with wait({ selector }) in medium/substack/bloomberg/sinablog"
1096
+ ```
1097
+
1098
+ ---
1099
+
1100
+ ## Task 10: Final verification and PR
1101
+
1102
+ - [ ] **Step 1: Run full test suite**
1103
+
1104
+ ```bash
1105
+ npx vitest run --project unit --project adapter
1106
+ ```
1107
+ Expected: All tests PASS with no regressions.
1108
+
1109
+ - [ ] **Step 2: TypeScript compile check**
1110
+
1111
+ ```bash
1112
+ npx tsc --noEmit
1113
+ ```
1114
+ Expected: No errors.
1115
+
1116
+ - [ ] **Step 3: Push and create PR**
1117
+
1118
+ ```bash
1119
+ git push -u origin HEAD
1120
+ gh pr create \
1121
+ --title "perf: smart wait — waitForCapture, wait({ selector }), daemon backoff" \
1122
+ --body "$(cat <<'EOF'
1123
+ ## Summary
1124
+
1125
+ Three layered performance + correctness improvements:
1126
+
1127
+ - **Layer 1 — `waitForCapture()`**: Fixes a correctness bug in INTERCEPT adapters where `wait(N)` (now DOM-stable-aware) could return before network captures arrive. Adds `waitForCapture(timeout)` to `IPage` — polls `window.__opencli_xhr` at 100ms intervals, resolves as soon as ≥1 capture exists. Applied to 36kr, twitter/search, followers, following, notifications, producthunt.
1128
+ - **Layer 2 — `wait({ selector })`**: Extends `WaitOptions` with `selector?: string`. Adds `waitForSelectorJs()` to dom-helpers. Applied to 14 Twitter adapters (replacing `wait(5)` "React hydration" waits with precise element checks) and medium/substack/bloomberg/sinablog (removing duplicate inner `setTimeout` inside `evaluate()`).
1129
+ - **Layer 3 — daemon backoff**: Replaces fixed 300ms poll with exponential backoff (50→100→200→400→800ms) in `_ensureDaemon()`. Cold-start first-success at ~150ms vs ~600ms.
1130
+
1131
+ ## Expected gains
1132
+ - 36kr hot/search: 6s → ~1–2s
1133
+ - Twitter INTERCEPT commands: 5–8s → ~1–3s
1134
+ - Twitter UI commands: 5s → ~0.5–2s
1135
+ - Medium/Substack: 8s → ~1–3s
1136
+ - Daemon cold-start: ~600ms → ~150ms
1137
+
1138
+ ## Test plan
1139
+ - [ ] `npx vitest run --project unit --project adapter` — all pass
1140
+ - [ ] `npx tsc --noEmit` — no type errors
1141
+ EOF
1142
+ )"
1143
+ ```