@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.
- package/.github/workflows/ci.yml +6 -7
- package/README.md +21 -362
- package/dist/browser/cdp.js +20 -1
- package/dist/browser/daemon-client.js +3 -2
- package/dist/browser/dom-helpers.d.ts +11 -0
- package/dist/browser/dom-helpers.js +42 -0
- package/dist/browser/dom-helpers.test.d.ts +1 -0
- package/dist/browser/dom-helpers.test.js +92 -0
- package/dist/browser/index.d.ts +0 -12
- package/dist/browser/index.js +0 -13
- package/dist/browser/mcp.js +4 -3
- package/dist/browser/page.d.ts +1 -0
- package/dist/browser/page.js +14 -1
- package/dist/browser.test.js +15 -11
- package/dist/clis/36kr/hot.js +1 -1
- package/dist/clis/36kr/search.js +1 -1
- package/dist/clis/_shared/common.d.ts +8 -0
- package/dist/clis/_shared/common.js +10 -0
- package/dist/clis/bloomberg/news.js +1 -1
- package/dist/clis/douban/utils.js +3 -6
- package/dist/clis/medium/utils.js +1 -1
- package/dist/clis/producthunt/browse.js +1 -1
- package/dist/clis/producthunt/hot.js +1 -1
- package/dist/clis/sinablog/utils.js +6 -7
- package/dist/clis/substack/utils.js +2 -2
- package/dist/clis/twitter/block.js +1 -1
- package/dist/clis/twitter/bookmark.js +1 -1
- package/dist/clis/twitter/delete.js +1 -1
- package/dist/clis/twitter/follow.js +1 -1
- package/dist/clis/twitter/followers.js +2 -2
- package/dist/clis/twitter/following.js +2 -2
- package/dist/clis/twitter/hide-reply.js +1 -1
- package/dist/clis/twitter/like.js +1 -1
- package/dist/clis/twitter/notifications.js +1 -1
- package/dist/clis/twitter/profile.js +1 -1
- package/dist/clis/twitter/reply-dm.js +1 -1
- package/dist/clis/twitter/reply.js +1 -1
- package/dist/clis/twitter/search.js +1 -1
- package/dist/clis/twitter/unblock.js +1 -1
- package/dist/clis/twitter/unbookmark.js +1 -1
- package/dist/clis/twitter/unfollow.js +1 -1
- package/dist/clis/xiaohongshu/comments.test.js +1 -0
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
- package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +1 -0
- package/dist/clis/xiaohongshu/search.test.js +1 -0
- package/dist/download/index.js +39 -33
- package/dist/download/index.test.js +15 -1
- package/dist/execution.js +3 -2
- package/dist/main.js +2 -0
- package/dist/node-network.d.ts +10 -0
- package/dist/node-network.js +174 -0
- package/dist/node-network.test.d.ts +1 -0
- package/dist/node-network.test.js +55 -0
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/download.test.js +1 -0
- package/dist/pipeline/steps/intercept.js +4 -5
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +4 -0
- package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
- package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
- package/extension/dist/background.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +1 -1
- package/package.json +2 -1
- package/src/browser/cdp.ts +21 -0
- package/src/browser/daemon-client.ts +3 -2
- package/src/browser/dom-helpers.test.ts +100 -0
- package/src/browser/dom-helpers.ts +44 -0
- package/src/browser/index.ts +0 -15
- package/src/browser/mcp.ts +4 -3
- package/src/browser/page.ts +16 -0
- package/src/browser.test.ts +16 -12
- package/src/clis/36kr/hot.ts +1 -1
- package/src/clis/36kr/search.ts +1 -1
- package/src/clis/_shared/common.ts +11 -0
- package/src/clis/bloomberg/news.ts +1 -1
- package/src/clis/douban/utils.ts +3 -7
- package/src/clis/medium/utils.ts +1 -1
- package/src/clis/producthunt/browse.ts +1 -1
- package/src/clis/producthunt/hot.ts +1 -1
- package/src/clis/sinablog/utils.ts +6 -7
- package/src/clis/substack/utils.ts +2 -2
- package/src/clis/twitter/block.ts +1 -1
- package/src/clis/twitter/bookmark.ts +1 -1
- package/src/clis/twitter/delete.ts +1 -1
- package/src/clis/twitter/follow.ts +1 -1
- package/src/clis/twitter/followers.ts +2 -2
- package/src/clis/twitter/following.ts +2 -2
- package/src/clis/twitter/hide-reply.ts +1 -1
- package/src/clis/twitter/like.ts +1 -1
- package/src/clis/twitter/notifications.ts +1 -1
- package/src/clis/twitter/profile.ts +1 -1
- package/src/clis/twitter/reply-dm.ts +1 -1
- package/src/clis/twitter/reply.ts +1 -1
- package/src/clis/twitter/search.ts +1 -1
- package/src/clis/twitter/unblock.ts +1 -1
- package/src/clis/twitter/unbookmark.ts +1 -1
- package/src/clis/twitter/unfollow.ts +1 -1
- package/src/clis/xiaohongshu/comments.test.ts +1 -0
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
- package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
- package/src/clis/xiaohongshu/publish.test.ts +1 -0
- package/src/clis/xiaohongshu/search.test.ts +1 -0
- package/src/download/index.test.ts +19 -1
- package/src/download/index.ts +50 -41
- package/src/execution.ts +3 -2
- package/src/main.ts +3 -0
- package/src/node-network.test.ts +93 -0
- package/src/node-network.ts +213 -0
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/download.test.ts +1 -0
- package/src/pipeline/steps/intercept.ts +4 -5
- package/src/types.ts +2 -0
- 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
|
+
```
|