surf-cli 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,561 @@
1
+ /**
2
+ * Perplexity Web Client for surf-cli
3
+ *
4
+ * CDP-based client for perplexity.ai using browser automation.
5
+ * Similar approach to the ChatGPT client.
6
+ */
7
+
8
+ const PERPLEXITY_URL = "https://www.perplexity.ai/";
9
+
10
+ // ============================================================================
11
+ // Helpers
12
+ // ============================================================================
13
+
14
+ function delay(ms) {
15
+ return new Promise(resolve => setTimeout(resolve, ms));
16
+ }
17
+
18
+ function buildClickDispatcher() {
19
+ return `function dispatchClickSequence(target) {
20
+ if (!target || !(target instanceof EventTarget)) return false;
21
+ const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
22
+ for (const type of types) {
23
+ const common = { bubbles: true, cancelable: true, view: window };
24
+ let event;
25
+ if (type.startsWith('pointer') && 'PointerEvent' in window) {
26
+ event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
27
+ } else {
28
+ event = new MouseEvent(type, common);
29
+ }
30
+ target.dispatchEvent(event);
31
+ }
32
+ return true;
33
+ }`;
34
+ }
35
+
36
+ async function evaluate(cdp, expression) {
37
+ const result = await cdp(expression);
38
+ if (result.exceptionDetails) {
39
+ const desc = result.exceptionDetails.exception?.description ||
40
+ result.exceptionDetails.text ||
41
+ "Evaluation failed";
42
+ throw new Error(desc);
43
+ }
44
+ if (result.error) {
45
+ throw new Error(result.error);
46
+ }
47
+ return result.result?.value;
48
+ }
49
+
50
+ // ============================================================================
51
+ // Page State Functions
52
+ // ============================================================================
53
+
54
+ async function waitForPageLoad(cdp, timeoutMs = 30000) {
55
+ const deadline = Date.now() + timeoutMs;
56
+ while (Date.now() < deadline) {
57
+ const ready = await evaluate(cdp, "document.readyState");
58
+ if (ready === "complete" || ready === "interactive") {
59
+ // Extra wait for Perplexity's React app to hydrate
60
+ await delay(1000);
61
+ return;
62
+ }
63
+ await delay(100);
64
+ }
65
+ throw new Error("Page did not load in time");
66
+ }
67
+
68
+ async function checkLoginStatus(cdp) {
69
+ const result = await evaluate(cdp, `(() => {
70
+ const buttons = Array.from(document.querySelectorAll('button, a'));
71
+
72
+ // Look for sign-in indicators (not logged in)
73
+ const hasSignIn = buttons.some(b => {
74
+ const text = (b.textContent || '').toLowerCase().trim();
75
+ return text === 'sign in' || text === 'log in';
76
+ });
77
+
78
+ // Look for account menu (logged in)
79
+ const hasAccount = buttons.some(b => {
80
+ const text = (b.textContent || '').toLowerCase();
81
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
82
+ return text.includes('account') || label.includes('account') || label.includes('profile');
83
+ });
84
+
85
+ // Check for upgrade button (logged in but not Pro)
86
+ const hasUpgrade = buttons.some(b => {
87
+ const text = (b.textContent || '').toLowerCase().trim();
88
+ return text === 'upgrade';
89
+ });
90
+
91
+ return {
92
+ loggedIn: hasAccount || hasUpgrade || !hasSignIn,
93
+ isPro: hasAccount && !hasUpgrade,
94
+ };
95
+ })()`);
96
+
97
+ return result || { loggedIn: false, isPro: false };
98
+ }
99
+
100
+ async function waitForPromptReady(cdp, timeoutMs = 20000) {
101
+ // Wait for page to be interactive and Perplexity's React app to hydrate
102
+ // Instead of complex element detection, just wait for the page to settle
103
+ const deadline = Date.now() + timeoutMs;
104
+
105
+ // First wait for basic page ready
106
+ while (Date.now() < deadline) {
107
+ const state = await evaluate(cdp, `document.readyState`);
108
+ if (state === 'complete') break;
109
+ await delay(200);
110
+ }
111
+
112
+ // Extra wait for React hydration
113
+ await delay(2000);
114
+
115
+ // Try to verify the page has the expected structure
116
+ const verified = await evaluate(cdp, `(() => {
117
+ // Check if we're on Perplexity and the page is loaded
118
+ const isPerplexity = location.hostname.includes('perplexity');
119
+ const hasInput = document.body.innerText.includes('Ask anything') ||
120
+ document.body.innerText.includes('Ask a follow-up');
121
+ return { ready: isPerplexity || hasInput, url: location.href };
122
+ })()`);
123
+
124
+ if (verified && verified.ready) {
125
+ return verified;
126
+ }
127
+
128
+ // Even if verification fails, proceed anyway after timeout
129
+ // since the page might have different text
130
+ return { ready: true, fallback: true };
131
+ }
132
+
133
+ // ============================================================================
134
+ // Mode and Model Selection
135
+ // ============================================================================
136
+
137
+ async function selectMode(cdp, mode) {
138
+ const normalizedMode = mode.toLowerCase();
139
+
140
+ const result = await evaluate(cdp, `(() => {
141
+ ${buildClickDispatcher()}
142
+
143
+ const targetMode = ${JSON.stringify(normalizedMode)};
144
+ const radios = document.querySelectorAll('[role=radio]');
145
+
146
+ for (const radio of radios) {
147
+ const text = (radio.textContent || '').toLowerCase().trim();
148
+ if (text.includes(targetMode)) {
149
+ if (radio.getAttribute('aria-checked') === 'true') {
150
+ return { success: true, alreadySelected: true, mode: text };
151
+ }
152
+ if (radio.hasAttribute('disabled')) {
153
+ return { success: false, error: 'Mode is disabled (may require Pro)', mode: text };
154
+ }
155
+ dispatchClickSequence(radio);
156
+ return { success: true, mode: text };
157
+ }
158
+ }
159
+
160
+ return { success: false, error: 'Mode not found' };
161
+ })()`);
162
+
163
+ if (!result || !result.success) {
164
+ throw new Error(`Failed to select mode: ${result?.error || 'unknown'}`);
165
+ }
166
+
167
+ await delay(300);
168
+ return result.mode;
169
+ }
170
+
171
+ async function selectModel(cdp, model, timeoutMs = 8000) {
172
+ // Click the model selector button
173
+ const buttonClicked = await evaluate(cdp, `(() => {
174
+ ${buildClickDispatcher()}
175
+
176
+ const buttons = Array.from(document.querySelectorAll('button'));
177
+ const modelBtn = buttons.find(b => {
178
+ const text = (b.textContent || '').toLowerCase();
179
+ return text.includes('choose a model') ||
180
+ text.includes('model') ||
181
+ (text.includes('sonar') || text.includes('gpt') || text.includes('claude'));
182
+ });
183
+
184
+ if (!modelBtn) return { success: false, error: 'Model button not found' };
185
+
186
+ dispatchClickSequence(modelBtn);
187
+ return { success: true };
188
+ })()`);
189
+
190
+ if (!buttonClicked || !buttonClicked.success) {
191
+ throw new Error(`Model selector not found: ${buttonClicked?.error}`);
192
+ }
193
+
194
+ await delay(500);
195
+
196
+ // Select from menu
197
+ const normalizedModel = model.toLowerCase().replace(/[^a-z0-9]/g, '');
198
+
199
+ const result = await evaluate(cdp, `(async () => {
200
+ ${buildClickDispatcher()}
201
+
202
+ const targetModel = ${JSON.stringify(normalizedModel)};
203
+ const normalize = (text) => (text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
204
+ const deadline = Date.now() + ${timeoutMs};
205
+
206
+ while (Date.now() < deadline) {
207
+ const menuItems = document.querySelectorAll('[role=menuitem], [role=menuitemradio], [role=option]');
208
+
209
+ if (menuItems.length === 0) {
210
+ await new Promise(r => setTimeout(r, 100));
211
+ continue;
212
+ }
213
+
214
+ let bestMatch = null;
215
+ let bestScore = 0;
216
+
217
+ for (const item of menuItems) {
218
+ const text = normalize(item.textContent || '');
219
+ let score = 0;
220
+
221
+ if (text.includes(targetModel)) score = 100;
222
+ else if (targetModel.includes(text) && text.length > 3) score = 50;
223
+
224
+ if (score > bestScore) {
225
+ bestScore = score;
226
+ bestMatch = item;
227
+ }
228
+ }
229
+
230
+ if (bestMatch) {
231
+ dispatchClickSequence(bestMatch);
232
+ await new Promise(r => setTimeout(r, 200));
233
+ return { success: true, model: bestMatch.textContent?.trim() };
234
+ }
235
+
236
+ await new Promise(r => setTimeout(r, 100));
237
+ }
238
+
239
+ // Close menu by clicking elsewhere
240
+ document.body.click();
241
+ return { success: false, error: 'Model not found in menu' };
242
+ })()`);
243
+
244
+ if (!result || !result.success) {
245
+ throw new Error(`Failed to select model: ${result?.error}`);
246
+ }
247
+
248
+ return result.model;
249
+ }
250
+
251
+ // ============================================================================
252
+ // Input and Submission
253
+ // ============================================================================
254
+
255
+ async function typePrompt(cdp, inputCdp, prompt) {
256
+ // Click on the input area to focus it
257
+ // Perplexity uses a complex input - just click in the general area
258
+ const clicked = await evaluate(cdp, `(() => {
259
+ ${buildClickDispatcher()}
260
+
261
+ // Strategy 1: Find element with "Ask anything" placeholder text
262
+ const allElements = document.querySelectorAll('*');
263
+ for (const el of allElements) {
264
+ const text = el.textContent || '';
265
+ const placeholder = el.getAttribute('placeholder') || el.getAttribute('aria-placeholder') || '';
266
+ if ((placeholder.includes('Ask') || text === 'Ask anything') && el.offsetParent) {
267
+ dispatchClickSequence(el);
268
+ el.focus?.();
269
+ return { success: true, method: 'placeholder' };
270
+ }
271
+ }
272
+
273
+ // Strategy 2: Find textarea or contenteditable
274
+ const inputs = document.querySelectorAll('textarea, [contenteditable=true], [role=textbox]');
275
+ for (const el of inputs) {
276
+ if (el.offsetParent) {
277
+ dispatchClickSequence(el);
278
+ el.focus?.();
279
+ return { success: true, method: 'input' };
280
+ }
281
+ }
282
+
283
+ // Strategy 3: Click in the center of the page (where input usually is)
284
+ const centerX = window.innerWidth / 2;
285
+ const centerY = window.innerHeight / 2;
286
+ const centerEl = document.elementFromPoint(centerX, centerY);
287
+ if (centerEl) {
288
+ dispatchClickSequence(centerEl);
289
+ return { success: true, method: 'center' };
290
+ }
291
+
292
+ return { success: false, error: 'Could not find input' };
293
+ })()`);
294
+
295
+ await delay(500);
296
+
297
+ // Type using CDP Input API (this works regardless of element type)
298
+ await inputCdp("Input.insertText", { text: prompt });
299
+ await delay(300);
300
+
301
+ // Backspace then re-add last char to reveal submit button
302
+ await inputCdp("Input.dispatchKeyEvent", {
303
+ type: "keyDown",
304
+ key: "Backspace",
305
+ code: "Backspace",
306
+ windowsVirtualKeyCode: 8,
307
+ nativeVirtualKeyCode: 8,
308
+ });
309
+ await inputCdp("Input.dispatchKeyEvent", {
310
+ type: "keyUp",
311
+ key: "Backspace",
312
+ code: "Backspace",
313
+ windowsVirtualKeyCode: 8,
314
+ nativeVirtualKeyCode: 8,
315
+ });
316
+ await delay(100);
317
+
318
+ // Re-add last character
319
+ const lastChar = prompt.slice(-1);
320
+ await inputCdp("Input.insertText", { text: lastChar });
321
+ await delay(300);
322
+ }
323
+
324
+ async function submitPrompt(cdp, inputCdp) {
325
+ // Get submit button coordinates
326
+ const btnInfo = await evaluate(cdp, "(function() { const btn = document.querySelector('button[aria-label=Submit]'); if (!btn) return null; const r = btn.getBoundingClientRect(); return { x: r.x + r.width/2, y: r.y + r.height/2 }; })()");
327
+
328
+ if (btnInfo && btnInfo.x && btnInfo.y) {
329
+ // Click using CDP
330
+ await inputCdp("Input.dispatchMouseEvent", {
331
+ type: "mousePressed",
332
+ x: btnInfo.x,
333
+ y: btnInfo.y,
334
+ button: "left",
335
+ clickCount: 1
336
+ });
337
+ await inputCdp("Input.dispatchMouseEvent", {
338
+ type: "mouseReleased",
339
+ x: btnInfo.x,
340
+ y: btnInfo.y,
341
+ button: "left",
342
+ clickCount: 1
343
+ });
344
+ } else {
345
+ // Fallback: press Enter
346
+ await inputCdp("Input.dispatchKeyEvent", {
347
+ type: "keyDown",
348
+ key: "Enter",
349
+ code: "Enter",
350
+ windowsVirtualKeyCode: 13,
351
+ nativeVirtualKeyCode: 13,
352
+ text: "\r",
353
+ });
354
+ await inputCdp("Input.dispatchKeyEvent", {
355
+ type: "keyUp",
356
+ key: "Enter",
357
+ code: "Enter",
358
+ windowsVirtualKeyCode: 13,
359
+ nativeVirtualKeyCode: 13,
360
+ });
361
+ }
362
+
363
+ await delay(500);
364
+ }
365
+
366
+ // ============================================================================
367
+ // Response Handling
368
+ // ============================================================================
369
+
370
+ async function waitForResponse(cdp, timeoutMs = 120000) {
371
+ const deadline = Date.now() + timeoutMs;
372
+ let previousText = '';
373
+ let stableCycles = 0;
374
+ const requiredStableCycles = 10;
375
+ let lastChangeAt = Date.now();
376
+ const minStableMs = 2500;
377
+
378
+ // First, wait for navigation to search results page
379
+ const navDeadline = Date.now() + 15000;
380
+ while (Date.now() < navDeadline) {
381
+ const url = await evaluate(cdp, 'location.href');
382
+ if (url && url.includes('/search/')) {
383
+ break;
384
+ }
385
+ await delay(200);
386
+ }
387
+
388
+ // Wait a bit for the response area to render
389
+ await delay(1000);
390
+
391
+ // Now poll for response completion
392
+ while (Date.now() < deadline) {
393
+ const snapshot = await evaluate(cdp, `(function() {
394
+ const prose = document.querySelector('.prose');
395
+ const text = prose ? prose.innerText : '';
396
+ const hasStop = !!document.querySelector('button[aria-label*=stop], button[aria-label*=Stop]');
397
+ const hasCopy = !!document.querySelector('button[aria-label*=copy], button[aria-label*=Copy]');
398
+ const hasRelated = document.body.innerText.indexOf('Related') > -1;
399
+ return {
400
+ text: text,
401
+ generating: hasStop,
402
+ hasActions: hasCopy,
403
+ hasRelated: hasRelated,
404
+ hasFollowUp: false,
405
+ sourcesCount: 0,
406
+ url: location.href
407
+ };
408
+ })()`);
409
+
410
+ if (!snapshot) {
411
+ await delay(300);
412
+ continue;
413
+ }
414
+
415
+ const currentText = snapshot.text || '';
416
+
417
+ // Track text changes
418
+ if (currentText !== previousText && currentText.length > previousText.length) {
419
+ previousText = currentText;
420
+ stableCycles = 0;
421
+ lastChangeAt = Date.now();
422
+ } else {
423
+ stableCycles++;
424
+ }
425
+
426
+ const stableMs = Date.now() - lastChangeAt;
427
+
428
+ // Response is complete if:
429
+ // 1. Not generating (no stop button)
430
+ // 2. Has action buttons OR Related section OR follow-up input OR stable for long enough
431
+ // 3. Has meaningful content
432
+ const isStable = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
433
+ const hasCompletionIndicators = snapshot.hasActions || snapshot.hasRelated || snapshot.hasFollowUp;
434
+ const isDone = !snapshot.generating && (hasCompletionIndicators || isStable);
435
+
436
+ if (isDone && currentText.length > 20) {
437
+ // Clean up the response text
438
+ let cleanText = currentText;
439
+
440
+ // Remove "Related" section if present at the end
441
+ const relatedIdx = cleanText.lastIndexOf('\nRelated\n');
442
+ if (relatedIdx > 0) {
443
+ cleanText = cleanText.substring(0, relatedIdx).trim();
444
+ }
445
+
446
+ return {
447
+ text: cleanText,
448
+ sources: snapshot.sourcesCount,
449
+ url: snapshot.url,
450
+ };
451
+ }
452
+
453
+ await delay(300);
454
+ }
455
+
456
+ // Timeout - return whatever we have
457
+ if (previousText.length > 20) {
458
+ return {
459
+ text: previousText,
460
+ sources: 0,
461
+ url: await evaluate(cdp, 'location.href'),
462
+ partial: true,
463
+ };
464
+ }
465
+
466
+ throw new Error("Response timeout - Perplexity did not complete in time");
467
+ }
468
+
469
+ // ============================================================================
470
+ // Main Query Function
471
+ // ============================================================================
472
+
473
+ async function query(options) {
474
+ const {
475
+ prompt,
476
+ model,
477
+ mode = 'search',
478
+ timeout = 120000,
479
+ createTab,
480
+ closeTab,
481
+ cdpEvaluate,
482
+ cdpCommand,
483
+ log = () => {},
484
+ } = options;
485
+
486
+ const startTime = Date.now();
487
+ log("Starting Perplexity query");
488
+
489
+ // Create tab
490
+ const tabInfo = await createTab();
491
+ log(`createTab returned: ${JSON.stringify(tabInfo)}`);
492
+ const { tabId } = tabInfo || {};
493
+
494
+ if (!tabId) {
495
+ throw new Error(`Failed to create Perplexity tab: ${JSON.stringify(tabInfo)}`);
496
+ }
497
+ log(`Created tab ${tabId}`);
498
+
499
+ const cdp = (expr) => cdpEvaluate(tabId, expr);
500
+ const inputCdp = (method, params) => cdpCommand(tabId, method, params);
501
+
502
+ try {
503
+ // Wait for page load
504
+ await waitForPageLoad(cdp);
505
+ log("Page loaded");
506
+
507
+ // Check login status (informational)
508
+ const loginStatus = await checkLoginStatus(cdp);
509
+ log(`Login: ${loginStatus.loggedIn ? 'yes' : 'anonymous'}${loginStatus.isPro ? ' (Pro)' : ''}`);
510
+
511
+ // Wait for input
512
+ await waitForPromptReady(cdp);
513
+ log("Prompt ready");
514
+
515
+ // Select mode if not default
516
+ if (mode && mode.toLowerCase() !== 'search') {
517
+ try {
518
+ const selectedMode = await selectMode(cdp, mode);
519
+ log(`Mode: ${selectedMode}`);
520
+ } catch (e) {
521
+ log(`Mode selection failed: ${e.message}`);
522
+ }
523
+ }
524
+
525
+ // Select model if specified
526
+ if (model) {
527
+ try {
528
+ const selectedModel = await selectModel(cdp, model);
529
+ log(`Model: ${selectedModel}`);
530
+ } catch (e) {
531
+ log(`Model selection failed: ${e.message}`);
532
+ }
533
+ }
534
+
535
+ // Type prompt
536
+ await typePrompt(cdp, inputCdp, prompt);
537
+ log("Prompt typed");
538
+
539
+ // Submit
540
+ await submitPrompt(cdp, inputCdp);
541
+ log("Submitted, waiting for response...");
542
+
543
+ // Wait for response
544
+ const response = await waitForResponse(cdp, timeout);
545
+ log(`Response: ${response.text.length} chars, ${response.sources} sources${response.partial ? ' (partial)' : ''}`);
546
+
547
+ return {
548
+ response: response.text,
549
+ sources: response.sources,
550
+ url: response.url,
551
+ model: model || 'default',
552
+ mode: mode || 'search',
553
+ partial: response.partial || false,
554
+ tookMs: Date.now() - startTime,
555
+ };
556
+ } finally {
557
+ await closeTab(tabId).catch(() => {});
558
+ }
559
+ }
560
+
561
+ module.exports = { query, PERPLEXITY_URL };
@@ -0,0 +1,27 @@
1
+ const encodeMessage = (obj) => {
2
+ const json = JSON.stringify(obj);
3
+ const buf = Buffer.alloc(4 + Buffer.byteLength(json));
4
+ buf.writeUInt32LE(Buffer.byteLength(json), 0);
5
+ buf.write(json, 4);
6
+ return buf;
7
+ };
8
+
9
+ const createMessageReader = (onMessage) => {
10
+ let buffer = Buffer.alloc(0);
11
+ return (chunk) => {
12
+ buffer = Buffer.concat([buffer, chunk]);
13
+ while (buffer.length >= 4) {
14
+ const msgLen = buffer.readUInt32LE(0);
15
+ if (buffer.length < 4 + msgLen) break;
16
+ const json = buffer.slice(4, 4 + msgLen).toString();
17
+ buffer = buffer.slice(4 + msgLen);
18
+ try {
19
+ onMessage(JSON.parse(json));
20
+ } catch {
21
+ onMessage({ error: "Invalid JSON" });
22
+ }
23
+ }
24
+ };
25
+ };
26
+
27
+ module.exports = { encodeMessage, createMessageReader };
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+ import struct
4
+ import json
5
+
6
+ # Log to file
7
+ with open("/tmp/pi-chrome-host.log", "a") as f:
8
+ f.write("Python host starting...\n")
9
+
10
+ def send_message(message):
11
+ encoded = json.dumps(message).encode('utf-8')
12
+ sys.stdout.buffer.write(struct.pack('I', len(encoded)))
13
+ sys.stdout.buffer.write(encoded)
14
+ sys.stdout.buffer.flush()
15
+
16
+ # Send ready message
17
+ send_message({"type": "HOST_READY"})
18
+
19
+ with open("/tmp/pi-chrome-host.log", "a") as f:
20
+ f.write("Sent HOST_READY, waiting for messages...\n")
21
+
22
+ # Read messages
23
+ while True:
24
+ try:
25
+ length_bytes = sys.stdin.buffer.read(4)
26
+ if len(length_bytes) == 0:
27
+ break
28
+ length = struct.unpack('I', length_bytes)[0]
29
+ message = sys.stdin.buffer.read(length).decode('utf-8')
30
+ data = json.loads(message)
31
+ with open("/tmp/pi-chrome-host.log", "a") as f:
32
+ f.write(f"Received: {data}\n")
33
+ # Echo back
34
+ send_message({"id": data.get("id"), "success": True})
35
+ except Exception as e:
36
+ with open("/tmp/pi-chrome-host.log", "a") as f:
37
+ f.write(f"Error: {e}\n")
38
+ break
39
+
40
+ with open("/tmp/pi-chrome-host.log", "a") as f:
41
+ f.write("Host exiting\n")