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 +51 -0
- package/README.md +162 -45
- package/package.json +9 -3
- package/src/cli.js +155 -17
- package/src/cookies.js +63 -14
- package/src/http.js +101 -4
- package/src/playwright-auth.js +37 -0
- package/src/playwright-bridge.js +116 -0
- package/src/search.js +98 -2
- package/src/session.js +29 -3
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
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/pplx-npx-search)
|
|
4
|
+
[](./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
|
-
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
82
|
+
pplx auth --playwright
|
|
83
|
+
```
|
|
35
84
|
|
|
36
|
-
|
|
37
|
-
pplx reason "explain the Riemann hypothesis"
|
|
85
|
+
Cookies are written to `~/.config/pplx/cookies.json`.
|
|
38
86
|
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
pplx labs "hello world"
|
|
89
|
+
### 4. Verify
|
|
44
90
|
|
|
45
|
-
|
|
46
|
-
pplx
|
|
91
|
+
```bash
|
|
92
|
+
pplx auth --test
|
|
47
93
|
```
|
|
48
94
|
|
|
49
|
-
|
|
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 "
|
|
53
|
-
pplx
|
|
54
|
-
pplx
|
|
55
|
-
pplx
|
|
56
|
-
pplx
|
|
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
|
-
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Agent Usage
|
|
63
110
|
|
|
64
|
-
pplx-cli is designed to
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
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/
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 "
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
30
|
-
return
|
|
55
|
+
const { ok } = await initSession(cookies);
|
|
56
|
+
return ok;
|
|
31
57
|
}
|