pplx-npx-search 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to pplx-cli will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.2] - 2026-05-21
9
+
10
+ ### Added
11
+ - **Configurable stream timeout.** `search`, `reason`, and `research` now accept `--timeout-ms <duration>`, with support for raw milliseconds plus `s` and `m` suffixes.
12
+
13
+ ### Changed
14
+ - `pplx research` now defaults to a 10-minute stream timeout so Deep Research can finish instead of hitting the old 2-minute ceiling.
15
+ - `pplx --version` now reads from `package.json`, keeping CLI output aligned with npm releases.
16
+
17
+ ## [0.2.1] - 2026-05-18
18
+
19
+ First public release worth telling people about. (v0.2.0 was unpublished before this release; do not use it.)
20
+
21
+ ### Added
22
+ - **Multi-browser cookie auto-detection.** `pplx auth --browser auto` now tries Chrome, Chrome Beta, Comet, and Dia in turn and uses the first browser that yields an authenticated session. Force a specific store with `pplx auth --browser <chrome|chrome-beta|comet|dia>`.
23
+ - **Session-level auth validation.** `pplx auth` and `pplx auth --test` no longer accept "I extracted some bytes" as success. They verify the extracted cookies resolve to a signed-in Perplexity session by hitting `/api/auth/session` and checking for a user identity.
24
+ - **`--no-playwright` flag** on `search`, `reason`, and `research` to force HTTP transport when the config file enables Playwright by default.
25
+ - **`--allow-anonymous` flag** to permit anonymous Perplexity responses when cookies are expired (instead of hard-failing).
26
+ - **Pre-search auth check** with actionable error message. If stored cookies are stale, the CLI now prints `Run: pplx auth --browser auto` instead of silently degrading.
27
+ - **Per-browser diagnostics** in the auth flow. When extraction fails, the CLI lists every browser it tried with cookie count and status (`valid`, `expired`, `no session`) so you can see exactly which store is broken.
28
+ - **Defensive `.gitignore` entries** for `cookies.json` and local `.config/` directories.
29
+ - **`test/session.test.js`** covering the `isAuthenticatedSession` helper.
30
+
31
+ ### Changed
32
+ - Auth error UX is tighter: missing-session and expired-session cases now print distinct messages and exit codes, and never overwrite a working cookie file with a broken one.
33
+ - Token preview line removed from auth success output. Session tokens are credentials and should not be echoed to stdout, even truncated.
34
+ - `--playwright` is opt-in per invocation. Setting `"playwright": true` in the config file no longer makes `pplx auth` use Playwright by default.
35
+
36
+ ### Documentation
37
+ - README restructured around a **Quick Start** that makes the login-first requirement explicit, plus a dedicated **Agent Usage** section for headless and CI use.
38
+ - Added a **Security** callout: never commit `cookies.json`, never paste it into a chat.
39
+ - Added a **Troubleshooting** table covering the common Keychain, browser store, and TLS fingerprinting failures.
40
+
41
+ ## [0.1.1] - 2026-02-04
42
+
43
+ ### Added
44
+ - Codex auto-fallback (HTTP → Playwright → curl-impersonate)
45
+ - Playwright as opt-in default transport
46
+ - Automatic curl-impersonate download when needed
47
+ - Acknowledgement of `helallao/perplexity-ai` upstream project
48
+
49
+ ## [0.1.0] - 2026-02-02
50
+
51
+ ### Added
52
+ - Initial release: cookie-authenticated Perplexity CLI
53
+ - `search`, `reason`, `research`, `labs`, `models` commands
54
+ - Cookie extraction from Chrome on macOS and Linux
55
+ - SSE streaming for real-time answers
56
+ - Optional Playwright and Chrome CDP transports
57
+
58
+ [0.2.2]: https://github.com/thatsrajan/pplx-cli/compare/v0.2.1...v0.2.2
59
+ [0.2.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.1.1...v0.2.1
60
+ [0.1.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.1.0...v0.1.1
61
+ [0.1.0]: https://github.com/thatsrajan/pplx-cli/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,91 +1,187 @@
1
1
  # pplx-cli
2
2
 
3
- CLI for Perplexity AI with cookie-based authentication. Designed for headless/agent-first usage — no browser required at runtime.
3
+ [![npm version](https://img.shields.io/npm/v/pplx-npx-search.svg)](https://www.npmjs.com/package/pplx-npx-search)
4
+ [![license](https://img.shields.io/npm/l/pplx-npx-search.svg)](./LICENSE)
5
+
6
+ A cookie-authenticated CLI for **Perplexity AI**. Built for headless and agentic usage — no browser required at runtime, no API key, no per-call billing beyond your existing Perplexity Pro subscription.
7
+
8
+ ```bash
9
+ npm install -g pplx-npx-search
10
+ pplx auth --browser auto
11
+ pplx auth --test
12
+ pplx search "what shipped in Claude 4.7 this week" --json --raw
13
+ ```
14
+
15
+ That's the whole loop. Cookies live at `~/.config/pplx/cookies.json`, never paste them by hand.
16
+
17
+ ---
18
+
19
+ ## Why this exists
20
+
21
+ Perplexity has no public consumer-tier API. Power users who already pay for Pro want shell and agent access without paying again per-call for the API. pplx-cli reads the cookies from your already-signed-in browser and uses them headlessly, so:
22
+
23
+ - **No API key.** Cookies come from your real session.
24
+ - **No re-billing.** Calls count against your existing Pro quota.
25
+ - **Agent-friendly.** `--json` and `--raw` flags are designed for piping into LLMs, agents, and scripts.
26
+ - **TLS fingerprinting fallback.** When Cloudflare gets aggressive, the CLI auto-falls back to Playwright or curl-impersonate.
27
+
28
+ This is a ground-up Node.js reimplementation inspired by the reverse-engineering work in [helallao/perplexity-ai](https://github.com/helallao/perplexity-ai) — see [Acknowledgements](#acknowledgements).
29
+
30
+ ---
4
31
 
5
32
  ## Prerequisites
6
33
 
7
- - Node.js 20+
8
- - Google Chrome (only for initial cookie extraction via `pplx auth`)
34
+ - Node.js 20 or newer
35
+ - A logged-in session in **Chrome, Chrome Beta, Comet, or Dia** (macOS) or **Google Chrome** (Linux)
36
+ - Optional: Playwright Chromium (`npx playwright install chromium`) for the `--playwright` transport
37
+
38
+ ---
39
+
40
+ ## Quick Start
41
+
42
+ ### 1. Log into Perplexity in your browser
43
+
44
+ Before anything else, open one of the supported browsers (Chrome, Chrome Beta, Comet, or Dia) and **make sure you are signed into perplexity.ai**. The CLI extracts cookies from your real session; if you are not logged in, there is nothing to extract.
45
+
46
+ ### 2. Install the CLI
9
47
 
10
- ## Installation
48
+ ```bash
49
+ npm install -g pplx-npx-search
50
+
51
+ # or run once without installing
52
+ npx pplx-npx-search search "what is quantum computing"
53
+ ```
54
+
55
+ If you would rather build from source:
11
56
 
12
57
  ```bash
13
- git clone https://github.com/rajanrengasamy/pplx-cli.git
58
+ git clone https://github.com/thatsrajan/pplx-cli.git
14
59
  cd pplx-cli
15
60
  npm install
16
61
  npm link
17
62
  ```
18
63
 
19
- ## Authentication
20
-
21
- Extract cookies from Chrome (one-time setup):
64
+ ### 3. Extract cookies once
22
65
 
23
66
  ```bash
24
- pplx auth
25
- pplx auth --test # verify cookies work
67
+ pplx auth --browser auto
26
68
  ```
27
69
 
28
- Cookies are stored at `~/.config/pplx/cookies.json`.
70
+ `--browser auto` tries every supported browser in turn and uses the first one that yields a valid signed-in session. Force a specific browser if you have multiple installed:
71
+
72
+ ```bash
73
+ pplx auth --browser chrome
74
+ pplx auth --browser chrome-beta
75
+ pplx auth --browser comet
76
+ pplx auth --browser dia
77
+ ```
29
78
 
30
- ## Usage
79
+ If the browser stores are locked down (corporate machine, weird Keychain ACL, etc.) you can fall back to Playwright:
31
80
 
32
81
  ```bash
33
- # Search (default: pro mode, headless HTTP)
34
- pplx search "what is quantum computing"
82
+ pplx auth --playwright
83
+ ```
35
84
 
36
- # Reasoning mode
37
- pplx reason "explain the Riemann hypothesis"
85
+ Cookies are written to `~/.config/pplx/cookies.json`.
38
86
 
39
- # Deep research
40
- pplx research "compare React vs Vue in 2026"
87
+ > **Security:** never commit `cookies.json` to a repo or paste its contents into a chat. The file is a complete session credential. The CLI never asks you to type cookies by hand and never logs them to stdout.
41
88
 
42
- # Labs (free, no auth needed)
43
- pplx labs "hello world"
89
+ ### 4. Verify
44
90
 
45
- # List models
46
- pplx models
91
+ ```bash
92
+ pplx auth --test
47
93
  ```
48
94
 
49
- ### Options
95
+ This must report `✓ Cookies are valid` before agents start calling the CLI. If it fails, repeat step 1 (you may be signed out) then step 3.
96
+
97
+ ### 5. Use it
50
98
 
51
99
  ```bash
52
- pplx search "query" --mode auto|pro|reasoning|deep-research
53
- pplx search "query" --model claude-3.5-sonnet
54
- pplx search "query" --json # single JSON object output
55
- pplx search "query" --raw # plain text, no colors/spinner
56
- pplx search "query" --chrome # use Chrome CDP bridge instead of HTTP
57
- pplx search "query" --curl # force curl-impersonate for TLS
58
- pplx search "query" --incognito # don't save to Perplexity history
59
- pplx search "query" --citations-full # show full source details
100
+ pplx search "what is quantum computing"
101
+ pplx reason "explain the Riemann hypothesis"
102
+ pplx research "compare React vs Vue in 2026"
103
+ pplx labs "hello world" # free, no auth needed
104
+ pplx models # list available models
60
105
  ```
61
106
 
62
- ## Agent/Automation Usage
107
+ ---
108
+
109
+ ## Agent Usage
63
110
 
64
- pplx-cli is designed to work in automated pipelines and with AI agents:
111
+ pplx-cli is designed to be the Perplexity transport for AI agents, CI jobs, and headless scripts.
65
112
 
66
113
  ```bash
67
- # Plain text output (no colors, no spinner)
114
+ # Plain text output, no colors, no spinner
68
115
  pplx search "query" --raw
69
116
 
117
+ # Single JSON object: { answer, sources, query, mode, model }
118
+ pplx search "query" --json
119
+
70
120
  # Read query from stdin
71
121
  echo "what is 2+2" | pplx search -
72
122
 
73
- # JSON output (single object: {answer, sources, query, mode, model})
74
- pplx search "query" --json
75
-
76
- # Pipe-friendly (auto-detects non-TTY)
123
+ # Pipe-friendly: auto-detects non-TTY
77
124
  pplx search "query" | head -1
78
125
 
79
- # Non-zero exit code when no answer is returned
126
+ # Non-zero exit when no answer is returned
80
127
  pplx search "query" --json || echo "failed"
81
128
  ```
82
129
 
130
+ Deep Research is slower than normal search. `pplx research` defaults to a 10-minute stream timeout; override it per run with `--timeout-ms 600000`, `--timeout-ms 120s`, or `--timeout-ms 10m`.
131
+
132
+ Recommended agent invocation:
133
+
134
+ ```bash
135
+ pplx search "research this topic" --json --raw --mode pro
136
+ ```
137
+
138
+ `--json --raw` gives a clean, deterministic envelope with no chrome around it:
139
+
140
+ ```json
141
+ {
142
+ "answer": "...",
143
+ "sources": [
144
+ {"title": "...", "url": "..."}
145
+ ],
146
+ "query": "...",
147
+ "mode": "pro",
148
+ "model": "..."
149
+ }
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Options
155
+
156
+ | Flag | Description |
157
+ |---|---|
158
+ | `--mode auto\|pro\|reasoning\|deep-research` | Search mode |
159
+ | `--model claude-3.5-sonnet` | Pin a specific model |
160
+ | `--json` | Single JSON object output |
161
+ | `--raw` | Plain text, no colors, no spinner |
162
+ | `--chrome` | Use Chrome CDP bridge instead of HTTP |
163
+ | `--playwright` | Use Playwright headless Chromium |
164
+ | `--no-playwright` | Force HTTP transport even if config enables Playwright |
165
+ | `--timeout-ms 120000\|120s\|10m` | Overall stream timeout |
166
+ | `--curl` | Force curl-impersonate (auto-downloads if missing) |
167
+ | `--allow-anonymous` | Allow anonymous Perplexity responses when cookies are expired |
168
+ | `--incognito` | Do not save the query to Perplexity history |
169
+ | `--citations-full` | Show full source metadata in the rendered answer |
170
+
171
+ ---
172
+
83
173
  ## Architecture
84
174
 
85
- - **Default mode:** Headless HTTP with stored cookies (no browser needed)
86
- - **Optional:** `--chrome` flag uses Chrome CDP bridge for TLS fingerprinting bypass
87
- - **SSE streaming:** Real-time answer streaming via Server-Sent Events
88
- - **Cookie auth:** One-time extraction from Chrome, then fully headless
175
+ | Layer | Detail |
176
+ |---|---|
177
+ | **Default transport** | Headless HTTP with stored cookies. No browser launched at runtime. |
178
+ | **Optional transports** | `--chrome` Chrome CDP bridge, `--playwright` Playwright headless Chromium, `--curl` curl-impersonate for TLS fingerprinting. |
179
+ | **Auto fallback** | HTTP → Playwright → curl-impersonate when TLS is blocked. |
180
+ | **Streaming** | Real-time answer streaming via Server-Sent Events. |
181
+ | **Auth** | One-time cookie extraction from Chrome / Chrome Beta / Comet / Dia (macOS Keychain) or `~/.config/google-chrome` (Linux). After that, fully headless. |
182
+ | **Session validation** | `pplx auth --test` and `pplx auth --browser auto` both verify that the extracted cookies resolve to an authenticated session (not just "I extracted some bytes"). |
183
+
184
+ ---
89
185
 
90
186
  ## Configuration
91
187
 
@@ -95,15 +191,39 @@ Optional config file at `~/.config/pplx/config.json`:
95
191
  {
96
192
  "mode": "pro",
97
193
  "model": "claude-3.5-sonnet",
98
- "lang": "en-US"
194
+ "lang": "en-US",
195
+ "playwright": true,
196
+ "playwrightHeadless": false
99
197
  }
100
198
  ```
101
199
 
200
+ Set `"playwright": true` to make Playwright the default transport.
201
+
202
+ Environment overrides:
203
+
204
+ ```bash
205
+ PPLX_CURL_IMPERSONATE=/path/to/curl-impersonate-chrome
206
+ ```
207
+
208
+ ---
209
+
210
+ ## Troubleshooting
211
+
212
+ | Symptom | Fix |
213
+ |---|---|
214
+ | `Cookies are invalid or expired` | Open Perplexity in your browser, confirm you are signed in, then re-run `pplx auth --browser auto`. |
215
+ | `Keychain access denied for "Chrome Safe Storage"` | macOS Keychain is prompting for permission. Run the exact `security find-generic-password` command the error suggests and click "Always Allow". |
216
+ | `Chrome cookie DB not found at ...` | The selected browser is not installed at the default location, or the profile name is wrong. Pass `--browser <name>` or `--profile <name>`. |
217
+ | Search hangs or times out | TLS fingerprinting may be in play. Try `pplx search "..." --playwright` or `--curl`. |
218
+ | `npm install -g` succeeds but `pplx` not found | Your global `npm bin` directory is not on PATH. Find it with `npm bin -g` and add it. |
219
+
220
+ ---
221
+
102
222
  ## Acknowledgements
103
223
 
104
224
  This project was inspired by and built upon the reverse-engineering work in [helallao/perplexity-ai](https://github.com/helallao/perplexity-ai) — a Python library for the Perplexity AI API. The authentication flow, SSE protocol handling, and API structure were all derived from studying that project. Big thanks to [@helallao](https://github.com/helallao) for figuring out the hard parts.
105
225
 
106
- pplx-cli is a ground-up Node.js reimplementation for CLI/agentic use cases, but it wouldn't exist without that foundational work.
226
+ pplx-cli is a ground-up Node.js reimplementation for CLI and agentic use cases, but it would not exist without that foundational work.
107
227
 
108
228
  ## License
109
229
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "pplx-npx-search",
3
- "version": "0.1.0",
4
- "description": "CLI for Perplexity AI with cookie-based auth",
3
+ "version": "0.2.2",
4
+ "description": "CLI for Perplexity AI with cookie-based auth. Headless, agent-friendly, no API key required.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "pplx": "./bin/pplx.js"
7
+ "pplx": "bin/pplx.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/pplx.js",
@@ -20,12 +20,17 @@
20
20
  "author": "Rajan Rengasamy",
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "https://github.com/rajanrengasamy/pplx-cli"
23
+ "url": "git+https://github.com/thatsrajan/pplx-cli.git"
24
24
  },
25
+ "bugs": {
26
+ "url": "https://github.com/thatsrajan/pplx-cli/issues"
27
+ },
28
+ "homepage": "https://github.com/thatsrajan/pplx-cli#readme",
25
29
  "files": [
26
30
  "bin",
27
31
  "src",
28
32
  "README.md",
33
+ "CHANGELOG.md",
29
34
  "LICENSE"
30
35
  ],
31
36
  "engines": {
@@ -37,6 +42,7 @@
37
42
  "commander": "^12.0.0",
38
43
  "eventsource-parser": "^3.0.0",
39
44
  "ora": "^8.0.0",
45
+ "playwright": "^1.58.1",
40
46
  "ws": "^8.16.0"
41
47
  }
42
48
  }
package/src/cli.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { program } from 'commander';
2
3
  import chalk from 'chalk';
3
4
  import ora from 'ora';
4
- import { extractFromChrome, loadCookies, saveCookies, cookieHeader } from './cookies.js';
5
+ import { extractFromChrome, loadCookies, saveCookies, SUPPORTED_BROWSERS } from './cookies.js';
6
+ import { extractFromPlaywright } from './playwright-auth.js';
5
7
  import { initSession, testAuth } from './session.js';
6
8
  import { search } from './search.js';
7
9
  import { LabsClient } from './labs.js';
@@ -9,6 +11,9 @@ import { formatSources } from './format.js';
9
11
  import { LABS_MODELS, MODEL_MAP } from './constants.js';
10
12
  import { setUseCurl } from './http.js';
11
13
  import { loadConfig } from './config.js';
14
+ import { resolveTimeoutMs } from './timeout.js';
15
+
16
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
12
17
 
13
18
  // --- Output state ---
14
19
  let rawMode = false;
@@ -25,6 +30,15 @@ function makeSpinner(text) {
25
30
  return ora(text);
26
31
  }
27
32
 
33
+ function finishSuccess(spinner, message) {
34
+ if (isQuiet()) {
35
+ spinner.stop();
36
+ console.log(chalk.green(`✓ ${message}`));
37
+ return;
38
+ }
39
+ spinner.succeed(message);
40
+ }
41
+
28
42
  // --- Stdin helper ---
29
43
  function readStdin() {
30
44
  return new Promise((resolve, reject) => {
@@ -50,11 +64,31 @@ async function resolveQuery(queryArg) {
50
64
  process.exit(1);
51
65
  }
52
66
 
67
+ async function extractAndValidateBrowser(browser, profile) {
68
+ const cookies = extractFromChrome(profile, browser);
69
+ const count = Object.keys(cookies).length;
70
+ const hasSession = Boolean(cookies['next-auth.session-token'] || cookies['__Secure-next-auth.session-token']);
71
+ if (!hasSession) {
72
+ return { browser, profile, cookies, count, hasSession, ok: false };
73
+ }
74
+
75
+ const session = await initSession(cookies);
76
+ return {
77
+ browser,
78
+ profile,
79
+ cookies: session.cookies,
80
+ count: Object.keys(session.cookies).length,
81
+ hasSession,
82
+ ok: session.ok,
83
+ status: session.status,
84
+ };
85
+ }
86
+
53
87
  // --- Program setup ---
54
88
  program
55
89
  .name('pplx')
56
90
  .description('CLI for Perplexity AI')
57
- .version('0.1.0');
91
+ .version(pkg.version);
58
92
 
59
93
  program.option('--verbose', 'Enable verbose logging');
60
94
  program.option('--proxy <url>', 'Set proxy URL (sets HTTPS_PROXY env var)');
@@ -76,10 +110,17 @@ program.hook('preAction', (thisCmd) => {
76
110
  // Auth command
77
111
  program
78
112
  .command('auth')
79
- .description('Extract and manage cookies from Chrome')
113
+ .description('Extract and manage cookies from supported browsers')
80
114
  .option('--test', 'Test if stored cookies are valid')
81
115
  .option('--profile <name>', 'Chrome profile', 'Default')
116
+ .option('--browser <name>', `Browser store: auto, ${SUPPORTED_BROWSERS.join(', ')}`, 'auto')
117
+ .option('--playwright', 'Use Playwright to login and extract cookies')
118
+ .option('--headless', 'Run Playwright in headless mode (not recommended for login)')
82
119
  .action(async (opts) => {
120
+ const cfg = loadConfig();
121
+ const usePlaywright = opts.playwright === true;
122
+ const playwrightHeadless = opts.headless ?? cfg.playwrightHeadless ?? false;
123
+
83
124
  if (opts.test) {
84
125
  const cookies = loadCookies();
85
126
  if (!cookies) {
@@ -91,6 +132,9 @@ program
91
132
  const ok = await testAuth(cookies);
92
133
  spinner.stop();
93
134
  console.log(ok ? chalk.green('✓ Cookies are valid') : chalk.red('✗ Cookies are invalid or expired'));
135
+ if (!ok) {
136
+ console.log(chalk.dim(' Run: pplx auth --browser auto'));
137
+ }
94
138
  process.exit(ok ? 0 : 1);
95
139
  } catch (e) {
96
140
  spinner.stop();
@@ -100,9 +144,76 @@ program
100
144
  return;
101
145
  }
102
146
 
147
+ if (usePlaywright) {
148
+ const spinner = makeSpinner('Launching Playwright for login...').start();
149
+ try {
150
+ spinner.stop();
151
+ const cookies = await extractFromPlaywright({ headless: playwrightHeadless });
152
+ const count = Object.keys(cookies).length;
153
+ const hasSession = cookies['next-auth.session-token'] || cookies['__Secure-next-auth.session-token'];
154
+
155
+ if (!hasSession) {
156
+ console.log(chalk.yellow(`⚠ Found ${count} cookies but no session token.`));
157
+ console.log(' Make sure you are logged into perplexity.ai in Playwright.');
158
+ if (count > 0) {
159
+ saveCookies(cookies);
160
+ console.log(chalk.dim(' Saved cookies anyway.'));
161
+ }
162
+ return;
163
+ }
164
+
165
+ const { cookies: refreshed, ok } = await initSession(cookies);
166
+ if (!ok) {
167
+ console.log(chalk.red('✗ Login cookies were extracted but are not authenticated.'));
168
+ console.log(chalk.dim(' Try again after logging into Perplexity in the Playwright browser.'));
169
+ process.exit(1);
170
+ }
171
+ saveCookies(refreshed);
172
+ console.log(chalk.green(`✓ Extracted ${Object.keys(refreshed).length} cookies and saved to ~/.config/pplx/cookies.json`));
173
+ return;
174
+ } catch (e) {
175
+ spinner.stop();
176
+ console.error(chalk.red('Failed to extract cookies via Playwright'));
177
+ console.error(chalk.red(e.message));
178
+ process.exit(1);
179
+ }
180
+ }
181
+
103
182
  const spinner = makeSpinner('Extracting cookies from Chrome...').start();
104
183
  try {
105
- const cookies = extractFromChrome(opts.profile);
184
+ const attempts = [];
185
+ let result = null;
186
+
187
+ if (opts.browser === 'auto') {
188
+ for (const browser of SUPPORTED_BROWSERS) {
189
+ try {
190
+ const attempt = await extractAndValidateBrowser(browser, opts.profile);
191
+ attempts.push(attempt);
192
+ if (attempt.ok) {
193
+ result = attempt;
194
+ break;
195
+ }
196
+ } catch (e) {
197
+ attempts.push({
198
+ browser,
199
+ profile: opts.profile,
200
+ count: 0,
201
+ hasSession: false,
202
+ ok: false,
203
+ error: e.message,
204
+ });
205
+ }
206
+ }
207
+ } else {
208
+ result = await extractAndValidateBrowser(opts.browser, opts.profile);
209
+ attempts.push(result);
210
+ }
211
+
212
+ if (!result) {
213
+ result = { browser: opts.browser, profile: opts.profile, cookies: {}, count: 0, hasSession: false, ok: false };
214
+ }
215
+
216
+ const cookies = result.cookies;
106
217
  const count = Object.keys(cookies).length;
107
218
  spinner.text = `Found ${count} cookies. Testing auth...`;
108
219
 
@@ -110,20 +221,33 @@ program
110
221
  if (!hasSession) {
111
222
  spinner.stop();
112
223
  console.log(chalk.yellow(`⚠ Found ${count} cookies but no session token.`));
113
- console.log(' Make sure you are logged into perplexity.ai in Chrome.');
114
- if (count > 0) {
115
- saveCookies(cookies);
116
- console.log(chalk.dim(' Saved cookies anyway.'));
224
+ console.log(' Make sure you are logged into perplexity.ai in a supported browser.');
225
+ if (attempts.length) {
226
+ for (const attempt of attempts) {
227
+ const suffix = attempt.error ? ` (${attempt.error.split('\n')[0]})` : '';
228
+ console.log(chalk.dim(` - ${attempt.browser}: ${attempt.count} cookies${suffix}`));
229
+ }
117
230
  }
231
+ console.log(chalk.dim(' Existing cookie file was left unchanged.'));
118
232
  return;
119
233
  }
120
234
 
121
- const { cookies: refreshed } = await initSession(cookies);
122
- saveCookies(refreshed);
123
- spinner.succeed(`Extracted ${Object.keys(refreshed).length} cookies and saved to ~/.config/pplx/cookies.json`);
235
+ if (!result.ok) {
236
+ spinner.stop();
237
+ console.log(chalk.red(`✗ Found ${count} cookies in ${result.browser}, but they are invalid or expired.`));
238
+ if (attempts.length) {
239
+ for (const attempt of attempts) {
240
+ const status = attempt.ok ? 'valid' : (attempt.hasSession ? 'expired' : 'no session');
241
+ const suffix = attempt.error ? ` (${attempt.error.split('\n')[0]})` : '';
242
+ console.log(chalk.dim(` - ${attempt.browser}: ${attempt.count} cookies, ${status}${suffix}`));
243
+ }
244
+ }
245
+ console.log(chalk.dim(' Existing cookie file was left unchanged.'));
246
+ process.exit(1);
247
+ }
124
248
 
125
- const token = refreshed['__Secure-next-auth.session-token'] || refreshed['next-auth.session-token'];
126
- console.log(chalk.dim(` Session token: ${token?.slice(0, 20)}...`));
249
+ saveCookies(cookies);
250
+ finishSuccess(spinner, `Extracted ${Object.keys(cookies).length} cookies from ${result.browser} and saved to ~/.config/pplx/cookies.json`);
127
251
  } catch (e) {
128
252
  spinner.fail('Failed to extract cookies');
129
253
  console.error(chalk.red(e.message));
@@ -140,13 +264,27 @@ async function doSearch(query, opts) {
140
264
  opts = { ...cfg, ...opts };
141
265
  if (opts.curl) setUseCurl(true);
142
266
 
143
- const cookies = loadCookies();
144
- if (!cookies) {
267
+ const cookies = loadCookies() || {};
268
+ if (!opts.chrome && Object.keys(cookies).length === 0) {
145
269
  console.error(chalk.red('No cookies. Run: pplx auth'));
146
270
  process.exit(1);
147
271
  }
272
+ if (!opts.chrome && !opts.allowAnonymous) {
273
+ const ok = await testAuth(cookies);
274
+ if (!ok) {
275
+ console.error(chalk.red('Stored cookies are invalid or expired. Run: pplx auth --browser auto'));
276
+ process.exit(1);
277
+ }
278
+ }
148
279
 
149
280
  const mode = opts.mode || 'pro';
281
+ let timeoutMs;
282
+ try {
283
+ timeoutMs = resolveTimeoutMs({ ...opts, mode });
284
+ } catch (e) {
285
+ console.error(chalk.red(e.message));
286
+ process.exit(1);
287
+ }
150
288
  const sources = opts.sources ? opts.sources.split(',') : ['web'];
151
289
  const lang = opts.lang || 'en-US';
152
290
 
@@ -162,6 +300,9 @@ async function doSearch(query, opts) {
162
300
  language: lang,
163
301
  incognito: opts.incognito,
164
302
  chrome: opts.chrome,
303
+ playwright: opts.playwright,
304
+ curl: opts.curl,
305
+ timeoutMs,
165
306
  })) {
166
307
  lastData = data;
167
308
 
@@ -198,7 +339,7 @@ async function doSearch(query, opts) {
198
339
 
199
340
  if (!lastAnswer) {
200
341
  if (opts._spinner && !spinnerStopped) { opts._spinner.stop(); spinnerStopped = true; }
201
- console.error(chalk.yellow('No answer received. Try re-authing (pplx auth) or use --curl.'));
342
+ console.error(chalk.yellow('No answer received. Try re-authing (pplx auth) or use --playwright/--curl.'));
202
343
  process.exit(1);
203
344
  }
204
345
 
@@ -209,7 +350,7 @@ async function doSearch(query, opts) {
209
350
  } catch (e) {
210
351
  console.error(chalk.red('\nError:'), e.message);
211
352
  if (e.message.includes('403')) {
212
- console.log(chalk.yellow('Possible TLS fingerprinting block. Try: pplx search --curl "query"'));
353
+ console.log(chalk.yellow('Possible TLS fingerprinting block. Try: pplx search --curl "query" or --playwright'));
213
354
  }
214
355
  process.exit(1);
215
356
  }
@@ -230,6 +371,10 @@ program
230
371
  .option('--lang <code>', 'Language code', 'en-US')
231
372
  .option('--curl', 'Force curl-impersonate for TLS')
232
373
  .option('--chrome', 'Use Chrome CDP bridge instead of HTTP')
374
+ .option('--playwright', 'Use Playwright headless Chromium instead of HTTP')
375
+ .option('--no-playwright', 'Disable Playwright even if config enables it')
376
+ .option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
377
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
233
378
  .action(async (queryArg, opts) => {
234
379
  if (opts.raw) { rawMode = true; chalk.level = 0; }
235
380
  const query = await resolveQuery(queryArg);
@@ -244,6 +389,10 @@ program
244
389
  .option('--json', 'Output raw JSON')
245
390
  .option('--curl', 'Force curl-impersonate')
246
391
  .option('--chrome', 'Use Chrome CDP bridge')
392
+ .option('--playwright', 'Use Playwright headless Chromium')
393
+ .option('--no-playwright', 'Disable Playwright even if config enables it')
394
+ .option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
395
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
247
396
  .action(async (queryArg, opts) => {
248
397
  const query = await resolveQuery(queryArg);
249
398
  await doSearch(query, { ...opts, mode: 'reasoning' });
@@ -256,6 +405,10 @@ program
256
405
  .option('--json', 'Output raw JSON')
257
406
  .option('--curl', 'Force curl-impersonate')
258
407
  .option('--chrome', 'Use Chrome CDP bridge')
408
+ .option('--playwright', 'Use Playwright headless Chromium')
409
+ .option('--no-playwright', 'Disable Playwright even if config enables it')
410
+ .option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
411
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
259
412
  .action(async (queryArg, opts) => {
260
413
  const query = await resolveQuery(queryArg);
261
414
  const spinner = makeSpinner('Deep research in progress...').start();
package/src/cookies.js CHANGED
@@ -20,27 +20,68 @@ export function saveCookies(cookies) {
20
20
  writeFileSync(COOKIES_FILE, JSON.stringify(cookies, null, 2));
21
21
  }
22
22
 
23
- function getChromeDir(profile) {
23
+ export const BROWSER_SOURCES = {
24
+ chrome: {
25
+ label: 'Google Chrome',
26
+ root: () => join(homedir(), 'Library/Application Support/Google/Chrome'),
27
+ keychainService: 'Chrome Safe Storage',
28
+ keychainAccounts: ['Chrome', 'Google Chrome'],
29
+ },
30
+ 'chrome-beta': {
31
+ label: 'Google Chrome Beta',
32
+ root: () => join(homedir(), 'Library/Application Support/Google/Chrome Beta'),
33
+ keychainService: 'Chrome Safe Storage',
34
+ keychainAccounts: ['Chrome', 'Google Chrome'],
35
+ },
36
+ comet: {
37
+ label: 'Comet',
38
+ root: () => join(homedir(), 'Library/Application Support/Comet'),
39
+ keychainService: 'Comet Safe Storage',
40
+ keychainAccounts: ['Comet'],
41
+ },
42
+ dia: {
43
+ label: 'Dia',
44
+ root: () => join(homedir(), 'Library/Application Support/Dia/User Data'),
45
+ keychainService: 'Dia Safe Storage',
46
+ keychainAccounts: ['Dia'],
47
+ },
48
+ };
49
+
50
+ export const SUPPORTED_BROWSERS = Object.keys(BROWSER_SOURCES);
51
+
52
+ function getBrowserSource(browser = 'chrome') {
53
+ const source = BROWSER_SOURCES[browser];
54
+ if (!source) {
55
+ throw new Error(`Unsupported browser: ${browser}. Supported: ${SUPPORTED_BROWSERS.join(', ')}`);
56
+ }
57
+ return source;
58
+ }
59
+
60
+ function getChromeDir(profile, browser = 'chrome') {
24
61
  const platform = process.platform;
25
62
  if (platform === 'darwin') {
26
- return join(homedir(), 'Library/Application Support/Google/Chrome', profile);
63
+ return join(getBrowserSource(browser).root(), profile);
27
64
  } else if (platform === 'linux') {
65
+ if (browser !== 'chrome') {
66
+ throw new Error(`Unsupported browser on Linux: ${browser}. Only chrome is supported.`);
67
+ }
28
68
  return join(homedir(), '.config/google-chrome', profile);
29
69
  }
30
70
  throw new Error(`Unsupported platform: ${platform}. Only macOS and Linux are supported.`);
31
71
  }
32
72
 
33
- function getChromeKey() {
73
+ function getChromeKey(browser = 'chrome') {
34
74
  if (process.platform === 'linux') {
35
75
  return crypto.pbkdf2Sync('peanuts', 'saltysalt', 1, 16, 'sha1');
36
76
  }
37
77
  // macOS: Keychain
38
- const accounts = ['Chrome', 'Google Chrome'];
78
+ const source = getBrowserSource(browser);
79
+ const accounts = source.keychainAccounts;
39
80
  let lastErr = null;
40
81
  for (const account of accounts) {
41
82
  try {
42
83
  const raw = execSync(
43
- `security find-generic-password -w -s "Chrome Safe Storage" -a "${account}"`,
84
+ `security find-generic-password -w -s "${source.keychainService}" -a "${account}"`,
44
85
  { encoding: 'utf-8' }
45
86
  ).trim();
46
87
  if (raw) {
@@ -51,9 +92,9 @@ function getChromeKey() {
51
92
  }
52
93
  }
53
94
  const error = new Error(
54
- 'Keychain access denied for "Chrome Safe Storage". Run: ' +
55
- 'security find-generic-password -w -s "Chrome Safe Storage" -a "Chrome" ' +
56
- '(or -a "Google Chrome") and allow access.'
95
+ `Keychain access denied for "${source.keychainService}". Run: ` +
96
+ `security find-generic-password -w -s "${source.keychainService}" -a "${accounts[0]}" ` +
97
+ 'and allow access.'
57
98
  );
58
99
  if (lastErr) error.cause = lastErr;
59
100
  throw error;
@@ -90,8 +131,8 @@ function decryptValue(encrypted, key) {
90
131
  }
91
132
  }
92
133
 
93
- export function extractFromChrome(profile = 'Default') {
94
- const chromeDir = getChromeDir(profile);
134
+ export function extractFromChrome(profile = 'Default', browser = 'chrome') {
135
+ const chromeDir = getChromeDir(profile, browser);
95
136
  const cookieDb = join(chromeDir, 'Cookies');
96
137
 
97
138
  if (!existsSync(cookieDb)) {
@@ -110,16 +151,24 @@ export function extractFromChrome(profile = 'Default') {
110
151
  if (existsSync(walPath)) copyFileSync(walPath, tmpWal);
111
152
  if (existsSync(shmPath)) copyFileSync(shmPath, tmpShm);
112
153
 
113
- const key = getChromeKey();
114
- const db = new Database(tmpDb, { readonly: true });
154
+ const key = getChromeKey(browser);
155
+ let db;
156
+ try {
157
+ db = new Database(tmpDb, { readonly: true });
158
+ } catch (e) {
159
+ if (e.message.includes('NODE_MODULE_VERSION')) {
160
+ throw new Error(`${e.message}\nRun: npm rebuild better-sqlite3`);
161
+ }
162
+ throw e;
163
+ }
115
164
 
116
165
  const rows = db.prepare(
117
- "SELECT name, encrypted_value FROM cookies WHERE host_key LIKE '%perplexity.ai'"
166
+ "SELECT name, value, encrypted_value FROM cookies WHERE host_key LIKE '%perplexity.ai' OR host_key LIKE '%perplexity.com'"
118
167
  ).all();
119
168
 
120
169
  const cookies = {};
121
170
  for (const row of rows) {
122
- const val = decryptValue(row.encrypted_value, key);
171
+ const val = row.value || decryptValue(row.encrypted_value, key);
123
172
  if (val) cookies[row.name] = val;
124
173
  }
125
174
 
package/src/http.js CHANGED
@@ -3,16 +3,98 @@
3
3
  */
4
4
  import { execSync, spawn } from 'child_process';
5
5
  import { PassThrough, Readable } from 'stream';
6
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'fs';
7
+ import { homedir } from 'os';
8
+ import { join } from 'path';
6
9
  import { withRetry } from './retry.js';
7
10
 
8
11
  let useCurl = false;
9
12
  let curlBinary = null;
13
+ let warnedNoCurl = false;
14
+
15
+ const CURL_IMPERSONATE_VERSION = 'v0.6.1';
16
+ const CURL_IMPERSONATE_BASE = `https://github.com/lwthiker/curl-impersonate/releases/download/${CURL_IMPERSONATE_VERSION}`;
17
+ const CURL_CACHE_DIR = join(homedir(), '.cache', 'pplx', 'curl-impersonate', CURL_IMPERSONATE_VERSION);
18
+ const CURL_CANDIDATES = [
19
+ 'curl-impersonate-chrome',
20
+ 'curl_chrome120',
21
+ 'curl_chrome116',
22
+ 'curl_chrome110',
23
+ 'curl_chrome107',
24
+ 'curl_chrome104',
25
+ 'curl_chrome101',
26
+ 'curl_chrome100',
27
+ 'curl_chrome99',
28
+ ];
10
29
 
11
30
  export function setUseCurl(val) { useCurl = val; }
12
31
  export function getUseCurl() { return useCurl; }
13
32
 
33
+ function resolveCurlAsset() {
34
+ const platform = process.platform;
35
+ const arch = process.arch;
36
+ if (platform === 'linux') {
37
+ if (arch === 'x64') return `curl-impersonate-${CURL_IMPERSONATE_VERSION}.x86_64-linux-gnu.tar.gz`;
38
+ if (arch === 'arm64') return `curl-impersonate-${CURL_IMPERSONATE_VERSION}.aarch64-linux-gnu.tar.gz`;
39
+ if (arch === 'arm') return `curl-impersonate-${CURL_IMPERSONATE_VERSION}.arm-linux-gnueabihf.tar.gz`;
40
+ }
41
+ if (platform === 'darwin') {
42
+ if (arch === 'x64') return `curl-impersonate-${CURL_IMPERSONATE_VERSION}.x86_64-macos.tar.gz`;
43
+ if (arch === 'arm64') return `curl-impersonate-${CURL_IMPERSONATE_VERSION}.x86_64-macos.tar.gz`;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function findBinaryInDir(dir) {
49
+ for (const name of CURL_CANDIDATES) {
50
+ const p = join(dir, name);
51
+ if (existsSync(p)) return p;
52
+ }
53
+ try {
54
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
55
+ if (!entry.isFile()) continue;
56
+ if (entry.name.startsWith('curl_chrome') || entry.name.startsWith('curl-impersonate')) {
57
+ return join(dir, entry.name);
58
+ }
59
+ }
60
+ } catch {}
61
+ return null;
62
+ }
63
+
64
+ function downloadCurlImpersonate() {
65
+ const asset = resolveCurlAsset();
66
+ if (!asset) return false;
67
+
68
+ const binPath = join(CURL_CACHE_DIR, 'curl-impersonate-chrome');
69
+ if (existsSync(binPath)) return binPath;
70
+
71
+ const url = `${CURL_IMPERSONATE_BASE}/${asset}`;
72
+ const tmpDir = join(CURL_CACHE_DIR, `tmp-${Date.now()}`);
73
+ try {
74
+ mkdirSync(tmpDir, { recursive: true });
75
+ mkdirSync(CURL_CACHE_DIR, { recursive: true });
76
+ const tgz = join(tmpDir, asset);
77
+ execSync(`curl -L -o '${tgz}' '${url}'`, { stdio: 'ignore' });
78
+ execSync(`tar -xzf '${tgz}' -C '${tmpDir}'`, { stdio: 'ignore' });
79
+ const found = findBinaryInDir(tmpDir);
80
+ if (!found) return false;
81
+ copyFileSync(found, binPath);
82
+ chmodSync(binPath, 0o755);
83
+ return binPath;
84
+ } catch {
85
+ return false;
86
+ } finally {
87
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
88
+ }
89
+ }
90
+
14
91
  function findCurlImpersonate() {
15
92
  if (curlBinary !== null) return curlBinary;
93
+ const envPath = process.env.PPLX_CURL_IMPERSONATE;
94
+ if (envPath && existsSync(envPath)) {
95
+ curlBinary = envPath;
96
+ return curlBinary;
97
+ }
16
98
  for (const name of ['curl-impersonate-chrome', 'curl_chrome116', 'curl_chrome120']) {
17
99
  try {
18
100
  execSync(`which ${name}`, { stdio: 'pipe' });
@@ -20,6 +102,11 @@ function findCurlImpersonate() {
20
102
  return curlBinary;
21
103
  } catch {}
22
104
  }
105
+ const downloaded = downloadCurlImpersonate();
106
+ if (downloaded) {
107
+ curlBinary = downloaded;
108
+ return curlBinary;
109
+ }
23
110
  curlBinary = false;
24
111
  return false;
25
112
  }
@@ -54,8 +141,13 @@ export async function request(url, opts = {}) {
54
141
  // curl-impersonate fallback
55
142
  const bin = findCurlImpersonate();
56
143
  if (!bin) {
57
- console.error('curl-impersonate not found. Install: brew install nicholasgasior/tap/curl-impersonate');
58
- console.error('Falling back to native fetch...');
144
+ if (!warnedNoCurl) {
145
+ warnedNoCurl = true;
146
+ console.error('curl-impersonate not found.');
147
+ console.error(' Install: brew install nicholasgasior/tap/curl-impersonate');
148
+ console.error(' Or set PPLX_CURL_IMPERSONATE=/path/to/curl-impersonate-chrome');
149
+ console.error(' Falling back to native fetch...');
150
+ }
59
151
  return fetch(url, { ...opts, signal: opts.signal ?? AbortSignal.timeout(opts.timeout ?? 30000) });
60
152
  }
61
153
 
@@ -129,8 +221,13 @@ export async function streamingFetch(url, opts) {
129
221
 
130
222
  const bin = findCurlImpersonate();
131
223
  if (!bin) {
132
- console.error('curl-impersonate not found. Install: https://github.com/lwthiker/curl-impersonate');
133
- console.error('Falling back to native fetch...');
224
+ if (!warnedNoCurl) {
225
+ warnedNoCurl = true;
226
+ console.error('curl-impersonate not found.');
227
+ console.error(' Install: brew install nicholasgasior/tap/curl-impersonate');
228
+ console.error(' Or set PPLX_CURL_IMPERSONATE=/path/to/curl-impersonate-chrome');
229
+ console.error(' Falling back to native fetch...');
230
+ }
134
231
  return fetch(url, { ...opts, signal: opts.signal ?? AbortSignal.timeout(opts.timeout ?? 120000) });
135
232
  }
136
233
 
@@ -0,0 +1,37 @@
1
+ import { chromium } from 'playwright';
2
+ import { BASE_URL, HEADERS } from './constants.js';
3
+ import readline from 'readline';
4
+
5
+ function waitForEnter(prompt) {
6
+ return new Promise((resolve) => {
7
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
8
+ rl.question(prompt, () => {
9
+ rl.close();
10
+ resolve();
11
+ });
12
+ });
13
+ }
14
+
15
+ export async function extractFromPlaywright(opts = {}) {
16
+ const headless = opts.headless === true;
17
+ const browser = await chromium.launch({ headless });
18
+ const context = await browser.newContext({
19
+ userAgent: HEADERS['user-agent'],
20
+ });
21
+ const page = await context.newPage();
22
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
23
+
24
+ if (!headless) {
25
+ await waitForEnter('Log in to Perplexity in the opened browser, then press Enter here to continue...');
26
+ } else {
27
+ // Give headless a moment in case cookies are already present.
28
+ await page.waitForTimeout(3000);
29
+ }
30
+
31
+ const cookies = await context.cookies(BASE_URL);
32
+ await browser.close();
33
+
34
+ const out = {};
35
+ for (const c of cookies) out[c.name] = c.value;
36
+ return out;
37
+ }
@@ -0,0 +1,116 @@
1
+ import { chromium } from 'playwright';
2
+ import { BASE_URL, HEADERS } from './constants.js';
3
+
4
+ function toPlaywrightCookies(cookies) {
5
+ return Object.entries(cookies || {}).map(([name, value]) => ({
6
+ name,
7
+ value: String(value),
8
+ domain: '.perplexity.ai',
9
+ path: '/',
10
+ secure: name.startsWith('__Secure-') || name.startsWith('__Host-'),
11
+ httpOnly: false,
12
+ sameSite: 'Lax',
13
+ }));
14
+ }
15
+
16
+ export class PlaywrightBridge {
17
+ constructor(opts = {}) {
18
+ this.headless = opts.headless !== false;
19
+ this.cookies = opts.cookies || {};
20
+ this.browser = null;
21
+ this.context = null;
22
+ this.page = null;
23
+ }
24
+
25
+ async connect() {
26
+ this.browser = await chromium.launch({ headless: this.headless });
27
+ this.context = await this.browser.newContext({
28
+ userAgent: HEADERS['user-agent'],
29
+ });
30
+
31
+ const pwCookies = toPlaywrightCookies(this.cookies);
32
+ if (pwCookies.length > 0) {
33
+ await this.context.addCookies(pwCookies);
34
+ }
35
+
36
+ this.page = await this.context.newPage();
37
+ await this.page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
38
+ }
39
+
40
+ async evaluate(expression) {
41
+ return this.page.evaluate((expr) => eval(expr), expression);
42
+ }
43
+
44
+ /**
45
+ * Execute a streaming fetch — stores chunks in the page, polls them back.
46
+ * Returns an async generator of SSE text chunks.
47
+ */
48
+ async *fetchSSE(url, fetchOpts = {}) {
49
+ const storeId = '_pplx_' + Date.now();
50
+
51
+ await this.evaluate(`
52
+ (async () => {
53
+ window.${storeId} = { chunks: [], done: false, error: null, status: 0 };
54
+ try {
55
+ const r = await fetch(${JSON.stringify(url)}, ${JSON.stringify({
56
+ ...fetchOpts,
57
+ credentials: 'include',
58
+ })});
59
+ window.${storeId}.status = r.status;
60
+ if (!r.ok) {
61
+ window.${storeId}.error = 'HTTP ' + r.status + ': ' + (await r.text()).substring(0, 500);
62
+ window.${storeId}.done = true;
63
+ return;
64
+ }
65
+ const reader = r.body.getReader();
66
+ const decoder = new TextDecoder();
67
+ while (true) {
68
+ const { done, value } = await reader.read();
69
+ if (done) break;
70
+ window.${storeId}.chunks.push(decoder.decode(value, { stream: true }));
71
+ }
72
+ } catch (e) {
73
+ window.${storeId}.error = e.message;
74
+ }
75
+ window.${storeId}.done = true;
76
+ })();
77
+ 'started'
78
+ `);
79
+
80
+ const deadline = Date.now() + (fetchOpts.timeout ?? 120000);
81
+ await new Promise(r => setTimeout(r, 200));
82
+ try {
83
+ while (true) {
84
+ if (Date.now() > deadline) throw new Error('SSE polling timeout');
85
+ const stateJson = await this.evaluate(`
86
+ (() => {
87
+ const s = window['${storeId}'];
88
+ if (!s) return JSON.stringify({ chunks: [], done: true, error: 'store missing' });
89
+ const c = s.chunks.splice(0);
90
+ return JSON.stringify({ chunks: c, done: s.done, error: s.error, status: s.status });
91
+ })()
92
+ `);
93
+
94
+ const state = JSON.parse(stateJson);
95
+
96
+ if (state.error) {
97
+ throw new Error(state.error);
98
+ }
99
+
100
+ for (const chunk of state.chunks) {
101
+ yield chunk;
102
+ }
103
+
104
+ if (state.done) break;
105
+ await new Promise(r => setTimeout(r, 100));
106
+ }
107
+ } finally {
108
+ await this.evaluate(`delete window['${storeId}']`).catch(() => {});
109
+ }
110
+ }
111
+
112
+ async close() {
113
+ try { await this.context?.close(); } catch {}
114
+ try { await this.browser?.close(); } catch {}
115
+ }
116
+ }
package/src/search.js CHANGED
@@ -4,7 +4,7 @@ import { createParser } from 'eventsource-parser';
4
4
  import { BASE_URL, MODEL_MAP, HEADERS } from './constants.js';
5
5
  import { cookieHeader } from './cookies.js';
6
6
  import { initSession } from './session.js';
7
- import { request, streamingFetch } from './http.js';
7
+ import { request, streamingFetch, getUseCurl, setUseCurl } from './http.js';
8
8
  import { withRetry } from './retry.js';
9
9
 
10
10
  export function resolveModelPref(mode, model) {
@@ -126,6 +126,7 @@ async function* searchWithChrome(query, cookies, opts) {
126
126
  method: 'POST',
127
127
  headers: { 'content-type': 'application/json' },
128
128
  body,
129
+ timeout: opts.timeoutMs,
129
130
  }
130
131
  )) {
131
132
  parser.feed(chunk);
@@ -145,6 +146,58 @@ async function* searchWithChrome(query, cookies, opts) {
145
146
  }
146
147
  }
147
148
 
149
+ async function* searchWithPlaywright(query, cookies, opts) {
150
+ const { PlaywrightBridge } = await import('./playwright-bridge.js');
151
+ const modelPref = resolveModelPref(opts.mode ?? 'auto', opts.model);
152
+ const body = buildSearchBody(query, opts, modelPref);
153
+ const bridge = new PlaywrightBridge({ headless: opts.playwrightHeadless !== false, cookies });
154
+ try {
155
+ await bridge.connect();
156
+
157
+ const results = [];
158
+ const parser = createParser({
159
+ onEvent(event) {
160
+ if (event.data === '{}' || !event.data) return;
161
+ try {
162
+ const json = JSON.parse(event.data);
163
+ parseNestedText(json);
164
+ results.push(json);
165
+ } catch (e) {
166
+ console.error(chalk.yellow('Warning: failed to parse SSE event'));
167
+ if (process.env.PPLX_VERBOSE) {
168
+ console.error('SSE parse error:', e.message);
169
+ }
170
+ }
171
+ }
172
+ });
173
+
174
+ let gotFinal = false;
175
+ for await (const chunk of bridge.fetchSSE(
176
+ `${BASE_URL}/rest/sse/perplexity_ask`,
177
+ {
178
+ method: 'POST',
179
+ headers: { 'content-type': 'application/json' },
180
+ body,
181
+ timeout: opts.timeoutMs,
182
+ }
183
+ )) {
184
+ parser.feed(chunk);
185
+ while (results.length > 0) {
186
+ const r = results.shift();
187
+ if (r.final_sse_message) gotFinal = true;
188
+ yield r;
189
+ }
190
+ if (gotFinal) break;
191
+ }
192
+
193
+ while (results.length > 0) {
194
+ yield results.shift();
195
+ }
196
+ } finally {
197
+ await bridge.close();
198
+ }
199
+ }
200
+
148
201
  async function* searchWithHttp(query, cookies, opts) {
149
202
  const { cookies: sessionCookies, ok, status } = await initSession(cookies);
150
203
  if (!ok) {
@@ -168,6 +221,7 @@ async function* searchWithHttp(query, cookies, opts) {
168
221
  'cookie': cookieHeader(sessionCookies),
169
222
  },
170
223
  body,
224
+ timeout: opts.timeoutMs,
171
225
  });
172
226
 
173
227
  if (!resp.ok) {
@@ -230,7 +284,52 @@ export async function* search(query, cookies, opts = {}) {
230
284
  yield* searchWithChrome(query, cookies, opts);
231
285
  return;
232
286
  }
233
- yield* searchWithHttp(query, cookies, opts);
287
+
288
+ const attempts = [];
289
+ if (opts.playwright) {
290
+ attempts.push({ name: 'playwright', run: () => searchWithPlaywright(query, cookies, opts) });
291
+ attempts.push({ name: 'curl', run: () => searchWithHttp(query, cookies, opts), curl: true });
292
+ } else if (opts.curl) {
293
+ attempts.push({ name: 'curl', run: () => searchWithHttp(query, cookies, opts), curl: true });
294
+ } else {
295
+ attempts.push({ name: 'http', run: () => searchWithHttp(query, cookies, opts) });
296
+ attempts.push({ name: 'playwright', run: () => searchWithPlaywright(query, cookies, opts) });
297
+ attempts.push({ name: 'curl', run: () => searchWithHttp(query, cookies, opts), curl: true });
298
+ }
299
+
300
+ const shouldFallbackHttp = (err) => {
301
+ const msg = (err?.message || '').toLowerCase();
302
+ return msg.includes('403') || msg.includes('tls') || msg.includes('cloudflare');
303
+ };
304
+
305
+ let lastErr = null;
306
+ for (let i = 0; i < attempts.length; i++) {
307
+ const attempt = attempts[i];
308
+ const isLast = i === attempts.length - 1;
309
+ const prevCurl = getUseCurl();
310
+ if (attempt.curl) setUseCurl(true);
311
+
312
+ let yielded = false;
313
+ try {
314
+ const gen = attempt.run();
315
+ for await (const item of gen) {
316
+ yielded = true;
317
+ yield item;
318
+ }
319
+ return;
320
+ } catch (e) {
321
+ lastErr = e;
322
+ if (attempt.name === 'http' && !shouldFallbackHttp(e)) throw e;
323
+ if (yielded || isLast) throw e;
324
+ if (process.env.PPLX_VERBOSE) {
325
+ console.error(`Search fallback: ${attempt.name} failed, trying next...`);
326
+ }
327
+ } finally {
328
+ if (attempt.curl) setUseCurl(prevCurl);
329
+ }
330
+ }
331
+
332
+ if (lastErr) throw lastErr;
234
333
  }
235
334
 
236
335
  export function parseNestedText(json) {
package/src/session.js CHANGED
@@ -3,10 +3,28 @@ import { cookieHeader } from './cookies.js';
3
3
  import { request } from './http.js';
4
4
  import { withRetry } from './retry.js';
5
5
 
6
+ function parseSessionPayload(text) {
7
+ if (!text) return {};
8
+ try {
9
+ return JSON.parse(text);
10
+ } catch {
11
+ return {};
12
+ }
13
+ }
14
+
15
+ export function isAuthenticatedSession(session) {
16
+ return Boolean(
17
+ session?.user?.email ||
18
+ session?.user?.id ||
19
+ session?.user?.name
20
+ );
21
+ }
22
+
6
23
  export async function initSession(cookies) {
7
24
  const resp = await withRetry(() => request(`${BASE_URL}/api/auth/session`, {
8
25
  headers: {
9
26
  ...HEADERS,
27
+ accept: 'application/json',
10
28
  cookie: cookieHeader(cookies),
11
29
  },
12
30
  redirect: 'manual',
@@ -22,10 +40,18 @@ export async function initSession(cookies) {
22
40
  }
23
41
  }
24
42
 
25
- return { cookies, status: resp.status, ok: resp.ok };
43
+ const text = await resp.text();
44
+ const session = parseSessionPayload(text);
45
+
46
+ return {
47
+ cookies,
48
+ status: resp.status,
49
+ ok: resp.ok && isAuthenticatedSession(session),
50
+ session,
51
+ };
26
52
  }
27
53
 
28
54
  export async function testAuth(cookies) {
29
- const { status } = await initSession(cookies);
30
- return status === 200;
55
+ const { ok } = await initSession(cookies);
56
+ return ok;
31
57
  }
package/src/timeout.js ADDED
@@ -0,0 +1,40 @@
1
+ export const DEFAULT_SEARCH_TIMEOUT_MS = 120000;
2
+ export const DEFAULT_RESEARCH_TIMEOUT_MS = 600000;
3
+
4
+ export function parseTimeoutMs(value, label = 'timeout') {
5
+ if (value == null || value === '') return null;
6
+
7
+ if (typeof value === 'number') {
8
+ if (Number.isFinite(value) && value > 0) return Math.trunc(value);
9
+ throw new Error(`${label} must be a positive number of milliseconds`);
10
+ }
11
+
12
+ const text = String(value).trim().toLowerCase();
13
+ const match = text.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
14
+ if (!match) {
15
+ throw new Error(`${label} must be a positive duration like 120000, 120s, or 10m`);
16
+ }
17
+
18
+ const amount = Number(match[1]);
19
+ const unit = match[2] ?? 'ms';
20
+ const multipliers = { ms: 1, s: 1000, m: 60000 };
21
+ const timeoutMs = amount * multipliers[unit];
22
+
23
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
24
+ throw new Error(`${label} must be a positive duration`);
25
+ }
26
+
27
+ return Math.trunc(timeoutMs);
28
+ }
29
+
30
+ export function resolveTimeoutMs(opts = {}) {
31
+ const explicit = parseTimeoutMs(opts.timeoutMs, '--timeout-ms');
32
+ if (explicit != null) return explicit;
33
+
34
+ const configured = parseTimeoutMs(opts.timeout, 'config timeout');
35
+ if (configured != null) return configured;
36
+
37
+ return opts.mode === 'deep-research'
38
+ ? DEFAULT_RESEARCH_TIMEOUT_MS
39
+ : DEFAULT_SEARCH_TIMEOUT_MS;
40
+ }