pplx-npx-search 0.1.0 → 0.2.1

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,51 @@
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.1] - 2026-05-18
9
+
10
+ First public release worth telling people about. (v0.2.0 was unpublished before this release; do not use it.)
11
+
12
+ ### Added
13
+ - **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>`.
14
+ - **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.
15
+ - **`--no-playwright` flag** on `search`, `reason`, and `research` to force HTTP transport when the config file enables Playwright by default.
16
+ - **`--allow-anonymous` flag** to permit anonymous Perplexity responses when cookies are expired (instead of hard-failing).
17
+ - **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.
18
+ - **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.
19
+ - **Defensive `.gitignore` entries** for `cookies.json` and local `.config/` directories.
20
+ - **`test/session.test.js`** covering the `isAuthenticatedSession` helper.
21
+
22
+ ### Changed
23
+ - 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.
24
+ - Token preview line removed from auth success output. Session tokens are credentials and should not be echoed to stdout, even truncated.
25
+ - `--playwright` is opt-in per invocation. Setting `"playwright": true` in the config file no longer makes `pplx auth` use Playwright by default.
26
+
27
+ ### Documentation
28
+ - README restructured around a **Quick Start** that makes the login-first requirement explicit, plus a dedicated **Agent Usage** section for headless and CI use.
29
+ - Added a **Security** callout: never commit `cookies.json`, never paste it into a chat.
30
+ - Added a **Troubleshooting** table covering the common Keychain, browser store, and TLS fingerprinting failures.
31
+
32
+ ## [0.1.1] - 2026-02-04
33
+
34
+ ### Added
35
+ - Codex auto-fallback (HTTP → Playwright → curl-impersonate)
36
+ - Playwright as opt-in default transport
37
+ - Automatic curl-impersonate download when needed
38
+ - Acknowledgement of `helallao/perplexity-ai` upstream project
39
+
40
+ ## [0.1.0] - 2026-02-02
41
+
42
+ ### Added
43
+ - Initial release: cookie-authenticated Perplexity CLI
44
+ - `search`, `reason`, `research`, `labs`, `models` commands
45
+ - Cookie extraction from Chrome on macOS and Linux
46
+ - SSE streaming for real-time answers
47
+ - Optional Playwright and Chrome CDP transports
48
+
49
+ [0.2.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.1.1...v0.2.1
50
+ [0.1.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.1.0...v0.1.1
51
+ [0.1.0]: https://github.com/thatsrajan/pplx-cli/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,91 +1,184 @@
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
+ Recommended agent invocation:
131
+
132
+ ```bash
133
+ pplx search "research this topic" --json --raw --mode pro
134
+ ```
135
+
136
+ `--json --raw` gives a clean, deterministic envelope with no chrome around it:
137
+
138
+ ```json
139
+ {
140
+ "answer": "...",
141
+ "sources": [
142
+ {"title": "...", "url": "..."}
143
+ ],
144
+ "query": "...",
145
+ "mode": "pro",
146
+ "model": "..."
147
+ }
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Options
153
+
154
+ | Flag | Description |
155
+ |---|---|
156
+ | `--mode auto\|pro\|reasoning\|deep-research` | Search mode |
157
+ | `--model claude-3.5-sonnet` | Pin a specific model |
158
+ | `--json` | Single JSON object output |
159
+ | `--raw` | Plain text, no colors, no spinner |
160
+ | `--chrome` | Use Chrome CDP bridge instead of HTTP |
161
+ | `--playwright` | Use Playwright headless Chromium |
162
+ | `--no-playwright` | Force HTTP transport even if config enables Playwright |
163
+ | `--curl` | Force curl-impersonate (auto-downloads if missing) |
164
+ | `--allow-anonymous` | Allow anonymous Perplexity responses when cookies are expired |
165
+ | `--incognito` | Do not save the query to Perplexity history |
166
+ | `--citations-full` | Show full source metadata in the rendered answer |
167
+
168
+ ---
169
+
83
170
  ## Architecture
84
171
 
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
172
+ | Layer | Detail |
173
+ |---|---|
174
+ | **Default transport** | Headless HTTP with stored cookies. No browser launched at runtime. |
175
+ | **Optional transports** | `--chrome` Chrome CDP bridge, `--playwright` Playwright headless Chromium, `--curl` curl-impersonate for TLS fingerprinting. |
176
+ | **Auto fallback** | HTTP → Playwright → curl-impersonate when TLS is blocked. |
177
+ | **Streaming** | Real-time answer streaming via Server-Sent Events. |
178
+ | **Auth** | One-time cookie extraction from Chrome / Chrome Beta / Comet / Dia (macOS Keychain) or `~/.config/google-chrome` (Linux). After that, fully headless. |
179
+ | **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"). |
180
+
181
+ ---
89
182
 
90
183
  ## Configuration
91
184
 
@@ -95,15 +188,39 @@ Optional config file at `~/.config/pplx/config.json`:
95
188
  {
96
189
  "mode": "pro",
97
190
  "model": "claude-3.5-sonnet",
98
- "lang": "en-US"
191
+ "lang": "en-US",
192
+ "playwright": true,
193
+ "playwrightHeadless": false
99
194
  }
100
195
  ```
101
196
 
197
+ Set `"playwright": true` to make Playwright the default transport.
198
+
199
+ Environment overrides:
200
+
201
+ ```bash
202
+ PPLX_CURL_IMPERSONATE=/path/to/curl-impersonate-chrome
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Troubleshooting
208
+
209
+ | Symptom | Fix |
210
+ |---|---|
211
+ | `Cookies are invalid or expired` | Open Perplexity in your browser, confirm you are signed in, then re-run `pplx auth --browser auto`. |
212
+ | `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". |
213
+ | `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>`. |
214
+ | Search hangs or times out | TLS fingerprinting may be in play. Try `pplx search "..." --playwright` or `--curl`. |
215
+ | `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. |
216
+
217
+ ---
218
+
102
219
  ## Acknowledgements
103
220
 
104
221
  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
222
 
106
- pplx-cli is a ground-up Node.js reimplementation for CLI/agentic use cases, but it wouldn't exist without that foundational work.
223
+ 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
224
 
108
225
  ## License
109
226
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
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.1",
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
7
  "pplx": "./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,8 @@
1
1
  import { program } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
- import { extractFromChrome, loadCookies, saveCookies, cookieHeader } from './cookies.js';
4
+ import { extractFromChrome, loadCookies, saveCookies, SUPPORTED_BROWSERS } from './cookies.js';
5
+ import { extractFromPlaywright } from './playwright-auth.js';
5
6
  import { initSession, testAuth } from './session.js';
6
7
  import { search } from './search.js';
7
8
  import { LabsClient } from './labs.js';
@@ -25,6 +26,15 @@ function makeSpinner(text) {
25
26
  return ora(text);
26
27
  }
27
28
 
29
+ function finishSuccess(spinner, message) {
30
+ if (isQuiet()) {
31
+ spinner.stop();
32
+ console.log(chalk.green(`✓ ${message}`));
33
+ return;
34
+ }
35
+ spinner.succeed(message);
36
+ }
37
+
28
38
  // --- Stdin helper ---
29
39
  function readStdin() {
30
40
  return new Promise((resolve, reject) => {
@@ -50,11 +60,31 @@ async function resolveQuery(queryArg) {
50
60
  process.exit(1);
51
61
  }
52
62
 
63
+ async function extractAndValidateBrowser(browser, profile) {
64
+ const cookies = extractFromChrome(profile, browser);
65
+ const count = Object.keys(cookies).length;
66
+ const hasSession = Boolean(cookies['next-auth.session-token'] || cookies['__Secure-next-auth.session-token']);
67
+ if (!hasSession) {
68
+ return { browser, profile, cookies, count, hasSession, ok: false };
69
+ }
70
+
71
+ const session = await initSession(cookies);
72
+ return {
73
+ browser,
74
+ profile,
75
+ cookies: session.cookies,
76
+ count: Object.keys(session.cookies).length,
77
+ hasSession,
78
+ ok: session.ok,
79
+ status: session.status,
80
+ };
81
+ }
82
+
53
83
  // --- Program setup ---
54
84
  program
55
85
  .name('pplx')
56
86
  .description('CLI for Perplexity AI')
57
- .version('0.1.0');
87
+ .version('0.1.1');
58
88
 
59
89
  program.option('--verbose', 'Enable verbose logging');
60
90
  program.option('--proxy <url>', 'Set proxy URL (sets HTTPS_PROXY env var)');
@@ -76,10 +106,17 @@ program.hook('preAction', (thisCmd) => {
76
106
  // Auth command
77
107
  program
78
108
  .command('auth')
79
- .description('Extract and manage cookies from Chrome')
109
+ .description('Extract and manage cookies from supported browsers')
80
110
  .option('--test', 'Test if stored cookies are valid')
81
111
  .option('--profile <name>', 'Chrome profile', 'Default')
112
+ .option('--browser <name>', `Browser store: auto, ${SUPPORTED_BROWSERS.join(', ')}`, 'auto')
113
+ .option('--playwright', 'Use Playwright to login and extract cookies')
114
+ .option('--headless', 'Run Playwright in headless mode (not recommended for login)')
82
115
  .action(async (opts) => {
116
+ const cfg = loadConfig();
117
+ const usePlaywright = opts.playwright === true;
118
+ const playwrightHeadless = opts.headless ?? cfg.playwrightHeadless ?? false;
119
+
83
120
  if (opts.test) {
84
121
  const cookies = loadCookies();
85
122
  if (!cookies) {
@@ -91,6 +128,9 @@ program
91
128
  const ok = await testAuth(cookies);
92
129
  spinner.stop();
93
130
  console.log(ok ? chalk.green('✓ Cookies are valid') : chalk.red('✗ Cookies are invalid or expired'));
131
+ if (!ok) {
132
+ console.log(chalk.dim(' Run: pplx auth --browser auto'));
133
+ }
94
134
  process.exit(ok ? 0 : 1);
95
135
  } catch (e) {
96
136
  spinner.stop();
@@ -100,9 +140,76 @@ program
100
140
  return;
101
141
  }
102
142
 
143
+ if (usePlaywright) {
144
+ const spinner = makeSpinner('Launching Playwright for login...').start();
145
+ try {
146
+ spinner.stop();
147
+ const cookies = await extractFromPlaywright({ headless: playwrightHeadless });
148
+ const count = Object.keys(cookies).length;
149
+ const hasSession = cookies['next-auth.session-token'] || cookies['__Secure-next-auth.session-token'];
150
+
151
+ if (!hasSession) {
152
+ console.log(chalk.yellow(`⚠ Found ${count} cookies but no session token.`));
153
+ console.log(' Make sure you are logged into perplexity.ai in Playwright.');
154
+ if (count > 0) {
155
+ saveCookies(cookies);
156
+ console.log(chalk.dim(' Saved cookies anyway.'));
157
+ }
158
+ return;
159
+ }
160
+
161
+ const { cookies: refreshed, ok } = await initSession(cookies);
162
+ if (!ok) {
163
+ console.log(chalk.red('✗ Login cookies were extracted but are not authenticated.'));
164
+ console.log(chalk.dim(' Try again after logging into Perplexity in the Playwright browser.'));
165
+ process.exit(1);
166
+ }
167
+ saveCookies(refreshed);
168
+ console.log(chalk.green(`✓ Extracted ${Object.keys(refreshed).length} cookies and saved to ~/.config/pplx/cookies.json`));
169
+ return;
170
+ } catch (e) {
171
+ spinner.stop();
172
+ console.error(chalk.red('Failed to extract cookies via Playwright'));
173
+ console.error(chalk.red(e.message));
174
+ process.exit(1);
175
+ }
176
+ }
177
+
103
178
  const spinner = makeSpinner('Extracting cookies from Chrome...').start();
104
179
  try {
105
- const cookies = extractFromChrome(opts.profile);
180
+ const attempts = [];
181
+ let result = null;
182
+
183
+ if (opts.browser === 'auto') {
184
+ for (const browser of SUPPORTED_BROWSERS) {
185
+ try {
186
+ const attempt = await extractAndValidateBrowser(browser, opts.profile);
187
+ attempts.push(attempt);
188
+ if (attempt.ok) {
189
+ result = attempt;
190
+ break;
191
+ }
192
+ } catch (e) {
193
+ attempts.push({
194
+ browser,
195
+ profile: opts.profile,
196
+ count: 0,
197
+ hasSession: false,
198
+ ok: false,
199
+ error: e.message,
200
+ });
201
+ }
202
+ }
203
+ } else {
204
+ result = await extractAndValidateBrowser(opts.browser, opts.profile);
205
+ attempts.push(result);
206
+ }
207
+
208
+ if (!result) {
209
+ result = { browser: opts.browser, profile: opts.profile, cookies: {}, count: 0, hasSession: false, ok: false };
210
+ }
211
+
212
+ const cookies = result.cookies;
106
213
  const count = Object.keys(cookies).length;
107
214
  spinner.text = `Found ${count} cookies. Testing auth...`;
108
215
 
@@ -110,20 +217,33 @@ program
110
217
  if (!hasSession) {
111
218
  spinner.stop();
112
219
  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.'));
220
+ console.log(' Make sure you are logged into perplexity.ai in a supported browser.');
221
+ if (attempts.length) {
222
+ for (const attempt of attempts) {
223
+ const suffix = attempt.error ? ` (${attempt.error.split('\n')[0]})` : '';
224
+ console.log(chalk.dim(` - ${attempt.browser}: ${attempt.count} cookies${suffix}`));
225
+ }
117
226
  }
227
+ console.log(chalk.dim(' Existing cookie file was left unchanged.'));
118
228
  return;
119
229
  }
120
230
 
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`);
231
+ if (!result.ok) {
232
+ spinner.stop();
233
+ console.log(chalk.red(`✗ Found ${count} cookies in ${result.browser}, but they are invalid or expired.`));
234
+ if (attempts.length) {
235
+ for (const attempt of attempts) {
236
+ const status = attempt.ok ? 'valid' : (attempt.hasSession ? 'expired' : 'no session');
237
+ const suffix = attempt.error ? ` (${attempt.error.split('\n')[0]})` : '';
238
+ console.log(chalk.dim(` - ${attempt.browser}: ${attempt.count} cookies, ${status}${suffix}`));
239
+ }
240
+ }
241
+ console.log(chalk.dim(' Existing cookie file was left unchanged.'));
242
+ process.exit(1);
243
+ }
124
244
 
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)}...`));
245
+ saveCookies(cookies);
246
+ finishSuccess(spinner, `Extracted ${Object.keys(cookies).length} cookies from ${result.browser} and saved to ~/.config/pplx/cookies.json`);
127
247
  } catch (e) {
128
248
  spinner.fail('Failed to extract cookies');
129
249
  console.error(chalk.red(e.message));
@@ -140,11 +260,18 @@ async function doSearch(query, opts) {
140
260
  opts = { ...cfg, ...opts };
141
261
  if (opts.curl) setUseCurl(true);
142
262
 
143
- const cookies = loadCookies();
144
- if (!cookies) {
263
+ const cookies = loadCookies() || {};
264
+ if (!opts.chrome && Object.keys(cookies).length === 0) {
145
265
  console.error(chalk.red('No cookies. Run: pplx auth'));
146
266
  process.exit(1);
147
267
  }
268
+ if (!opts.chrome && !opts.allowAnonymous) {
269
+ const ok = await testAuth(cookies);
270
+ if (!ok) {
271
+ console.error(chalk.red('Stored cookies are invalid or expired. Run: pplx auth --browser auto'));
272
+ process.exit(1);
273
+ }
274
+ }
148
275
 
149
276
  const mode = opts.mode || 'pro';
150
277
  const sources = opts.sources ? opts.sources.split(',') : ['web'];
@@ -162,6 +289,8 @@ async function doSearch(query, opts) {
162
289
  language: lang,
163
290
  incognito: opts.incognito,
164
291
  chrome: opts.chrome,
292
+ playwright: opts.playwright,
293
+ curl: opts.curl,
165
294
  })) {
166
295
  lastData = data;
167
296
 
@@ -198,7 +327,7 @@ async function doSearch(query, opts) {
198
327
 
199
328
  if (!lastAnswer) {
200
329
  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.'));
330
+ console.error(chalk.yellow('No answer received. Try re-authing (pplx auth) or use --playwright/--curl.'));
202
331
  process.exit(1);
203
332
  }
204
333
 
@@ -209,7 +338,7 @@ async function doSearch(query, opts) {
209
338
  } catch (e) {
210
339
  console.error(chalk.red('\nError:'), e.message);
211
340
  if (e.message.includes('403')) {
212
- console.log(chalk.yellow('Possible TLS fingerprinting block. Try: pplx search --curl "query"'));
341
+ console.log(chalk.yellow('Possible TLS fingerprinting block. Try: pplx search --curl "query" or --playwright'));
213
342
  }
214
343
  process.exit(1);
215
344
  }
@@ -230,6 +359,9 @@ program
230
359
  .option('--lang <code>', 'Language code', 'en-US')
231
360
  .option('--curl', 'Force curl-impersonate for TLS')
232
361
  .option('--chrome', 'Use Chrome CDP bridge instead of HTTP')
362
+ .option('--playwright', 'Use Playwright headless Chromium instead of HTTP')
363
+ .option('--no-playwright', 'Disable Playwright even if config enables it')
364
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
233
365
  .action(async (queryArg, opts) => {
234
366
  if (opts.raw) { rawMode = true; chalk.level = 0; }
235
367
  const query = await resolveQuery(queryArg);
@@ -244,6 +376,9 @@ program
244
376
  .option('--json', 'Output raw JSON')
245
377
  .option('--curl', 'Force curl-impersonate')
246
378
  .option('--chrome', 'Use Chrome CDP bridge')
379
+ .option('--playwright', 'Use Playwright headless Chromium')
380
+ .option('--no-playwright', 'Disable Playwright even if config enables it')
381
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
247
382
  .action(async (queryArg, opts) => {
248
383
  const query = await resolveQuery(queryArg);
249
384
  await doSearch(query, { ...opts, mode: 'reasoning' });
@@ -256,6 +391,9 @@ program
256
391
  .option('--json', 'Output raw JSON')
257
392
  .option('--curl', 'Force curl-impersonate')
258
393
  .option('--chrome', 'Use Chrome CDP bridge')
394
+ .option('--playwright', 'Use Playwright headless Chromium')
395
+ .option('--no-playwright', 'Disable Playwright even if config enables it')
396
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
259
397
  .action(async (queryArg, opts) => {
260
398
  const query = await resolveQuery(queryArg);
261
399
  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) {
@@ -145,6 +145,57 @@ async function* searchWithChrome(query, cookies, opts) {
145
145
  }
146
146
  }
147
147
 
148
+ async function* searchWithPlaywright(query, cookies, opts) {
149
+ const { PlaywrightBridge } = await import('./playwright-bridge.js');
150
+ const modelPref = resolveModelPref(opts.mode ?? 'auto', opts.model);
151
+ const body = buildSearchBody(query, opts, modelPref);
152
+ const bridge = new PlaywrightBridge({ headless: opts.playwrightHeadless !== false, cookies });
153
+ try {
154
+ await bridge.connect();
155
+
156
+ const results = [];
157
+ const parser = createParser({
158
+ onEvent(event) {
159
+ if (event.data === '{}' || !event.data) return;
160
+ try {
161
+ const json = JSON.parse(event.data);
162
+ parseNestedText(json);
163
+ results.push(json);
164
+ } catch (e) {
165
+ console.error(chalk.yellow('Warning: failed to parse SSE event'));
166
+ if (process.env.PPLX_VERBOSE) {
167
+ console.error('SSE parse error:', e.message);
168
+ }
169
+ }
170
+ }
171
+ });
172
+
173
+ let gotFinal = false;
174
+ for await (const chunk of bridge.fetchSSE(
175
+ `${BASE_URL}/rest/sse/perplexity_ask`,
176
+ {
177
+ method: 'POST',
178
+ headers: { 'content-type': 'application/json' },
179
+ body,
180
+ }
181
+ )) {
182
+ parser.feed(chunk);
183
+ while (results.length > 0) {
184
+ const r = results.shift();
185
+ if (r.final_sse_message) gotFinal = true;
186
+ yield r;
187
+ }
188
+ if (gotFinal) break;
189
+ }
190
+
191
+ while (results.length > 0) {
192
+ yield results.shift();
193
+ }
194
+ } finally {
195
+ await bridge.close();
196
+ }
197
+ }
198
+
148
199
  async function* searchWithHttp(query, cookies, opts) {
149
200
  const { cookies: sessionCookies, ok, status } = await initSession(cookies);
150
201
  if (!ok) {
@@ -230,7 +281,52 @@ export async function* search(query, cookies, opts = {}) {
230
281
  yield* searchWithChrome(query, cookies, opts);
231
282
  return;
232
283
  }
233
- yield* searchWithHttp(query, cookies, opts);
284
+
285
+ const attempts = [];
286
+ if (opts.playwright) {
287
+ attempts.push({ name: 'playwright', run: () => searchWithPlaywright(query, cookies, opts) });
288
+ attempts.push({ name: 'curl', run: () => searchWithHttp(query, cookies, opts), curl: true });
289
+ } else if (opts.curl) {
290
+ attempts.push({ name: 'curl', run: () => searchWithHttp(query, cookies, opts), curl: true });
291
+ } else {
292
+ attempts.push({ name: 'http', run: () => searchWithHttp(query, cookies, opts) });
293
+ attempts.push({ name: 'playwright', run: () => searchWithPlaywright(query, cookies, opts) });
294
+ attempts.push({ name: 'curl', run: () => searchWithHttp(query, cookies, opts), curl: true });
295
+ }
296
+
297
+ const shouldFallbackHttp = (err) => {
298
+ const msg = (err?.message || '').toLowerCase();
299
+ return msg.includes('403') || msg.includes('tls') || msg.includes('cloudflare');
300
+ };
301
+
302
+ let lastErr = null;
303
+ for (let i = 0; i < attempts.length; i++) {
304
+ const attempt = attempts[i];
305
+ const isLast = i === attempts.length - 1;
306
+ const prevCurl = getUseCurl();
307
+ if (attempt.curl) setUseCurl(true);
308
+
309
+ let yielded = false;
310
+ try {
311
+ const gen = attempt.run();
312
+ for await (const item of gen) {
313
+ yielded = true;
314
+ yield item;
315
+ }
316
+ return;
317
+ } catch (e) {
318
+ lastErr = e;
319
+ if (attempt.name === 'http' && !shouldFallbackHttp(e)) throw e;
320
+ if (yielded || isLast) throw e;
321
+ if (process.env.PPLX_VERBOSE) {
322
+ console.error(`Search fallback: ${attempt.name} failed, trying next...`);
323
+ }
324
+ } finally {
325
+ if (attempt.curl) setUseCurl(prevCurl);
326
+ }
327
+ }
328
+
329
+ if (lastErr) throw lastErr;
234
330
  }
235
331
 
236
332
  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
  }