pplx-npx-search 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +165 -45
- package/package.json +10 -4
- package/src/cli.js +170 -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 +101 -2
- package/src/session.js +29 -3
- package/src/timeout.js +40 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to pplx-cli will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.2] - 2026-05-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Configurable stream timeout.** `search`, `reason`, and `research` now accept `--timeout-ms <duration>`, with support for raw milliseconds plus `s` and `m` suffixes.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `pplx research` now defaults to a 10-minute stream timeout so Deep Research can finish instead of hitting the old 2-minute ceiling.
|
|
15
|
+
- `pplx --version` now reads from `package.json`, keeping CLI output aligned with npm releases.
|
|
16
|
+
|
|
17
|
+
## [0.2.1] - 2026-05-18
|
|
18
|
+
|
|
19
|
+
First public release worth telling people about. (v0.2.0 was unpublished before this release; do not use it.)
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **Multi-browser cookie auto-detection.** `pplx auth --browser auto` now tries Chrome, Chrome Beta, Comet, and Dia in turn and uses the first browser that yields an authenticated session. Force a specific store with `pplx auth --browser <chrome|chrome-beta|comet|dia>`.
|
|
23
|
+
- **Session-level auth validation.** `pplx auth` and `pplx auth --test` no longer accept "I extracted some bytes" as success. They verify the extracted cookies resolve to a signed-in Perplexity session by hitting `/api/auth/session` and checking for a user identity.
|
|
24
|
+
- **`--no-playwright` flag** on `search`, `reason`, and `research` to force HTTP transport when the config file enables Playwright by default.
|
|
25
|
+
- **`--allow-anonymous` flag** to permit anonymous Perplexity responses when cookies are expired (instead of hard-failing).
|
|
26
|
+
- **Pre-search auth check** with actionable error message. If stored cookies are stale, the CLI now prints `Run: pplx auth --browser auto` instead of silently degrading.
|
|
27
|
+
- **Per-browser diagnostics** in the auth flow. When extraction fails, the CLI lists every browser it tried with cookie count and status (`valid`, `expired`, `no session`) so you can see exactly which store is broken.
|
|
28
|
+
- **Defensive `.gitignore` entries** for `cookies.json` and local `.config/` directories.
|
|
29
|
+
- **`test/session.test.js`** covering the `isAuthenticatedSession` helper.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- Auth error UX is tighter: missing-session and expired-session cases now print distinct messages and exit codes, and never overwrite a working cookie file with a broken one.
|
|
33
|
+
- Token preview line removed from auth success output. Session tokens are credentials and should not be echoed to stdout, even truncated.
|
|
34
|
+
- `--playwright` is opt-in per invocation. Setting `"playwright": true` in the config file no longer makes `pplx auth` use Playwright by default.
|
|
35
|
+
|
|
36
|
+
### Documentation
|
|
37
|
+
- README restructured around a **Quick Start** that makes the login-first requirement explicit, plus a dedicated **Agent Usage** section for headless and CI use.
|
|
38
|
+
- Added a **Security** callout: never commit `cookies.json`, never paste it into a chat.
|
|
39
|
+
- Added a **Troubleshooting** table covering the common Keychain, browser store, and TLS fingerprinting failures.
|
|
40
|
+
|
|
41
|
+
## [0.1.1] - 2026-02-04
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- Codex auto-fallback (HTTP → Playwright → curl-impersonate)
|
|
45
|
+
- Playwright as opt-in default transport
|
|
46
|
+
- Automatic curl-impersonate download when needed
|
|
47
|
+
- Acknowledgement of `helallao/perplexity-ai` upstream project
|
|
48
|
+
|
|
49
|
+
## [0.1.0] - 2026-02-02
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- Initial release: cookie-authenticated Perplexity CLI
|
|
53
|
+
- `search`, `reason`, `research`, `labs`, `models` commands
|
|
54
|
+
- Cookie extraction from Chrome on macOS and Linux
|
|
55
|
+
- SSE streaming for real-time answers
|
|
56
|
+
- Optional Playwright and Chrome CDP transports
|
|
57
|
+
|
|
58
|
+
[0.2.2]: https://github.com/thatsrajan/pplx-cli/compare/v0.2.1...v0.2.2
|
|
59
|
+
[0.2.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.1.1...v0.2.1
|
|
60
|
+
[0.1.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.1.0...v0.1.1
|
|
61
|
+
[0.1.0]: https://github.com/thatsrajan/pplx-cli/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -1,91 +1,187 @@
|
|
|
1
1
|
# pplx-cli
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
Deep Research is slower than normal search. `pplx research` defaults to a 10-minute stream timeout; override it per run with `--timeout-ms 600000`, `--timeout-ms 120s`, or `--timeout-ms 10m`.
|
|
131
|
+
|
|
132
|
+
Recommended agent invocation:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pplx search "research this topic" --json --raw --mode pro
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`--json --raw` gives a clean, deterministic envelope with no chrome around it:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"answer": "...",
|
|
143
|
+
"sources": [
|
|
144
|
+
{"title": "...", "url": "..."}
|
|
145
|
+
],
|
|
146
|
+
"query": "...",
|
|
147
|
+
"mode": "pro",
|
|
148
|
+
"model": "..."
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Options
|
|
155
|
+
|
|
156
|
+
| Flag | Description |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `--mode auto\|pro\|reasoning\|deep-research` | Search mode |
|
|
159
|
+
| `--model claude-3.5-sonnet` | Pin a specific model |
|
|
160
|
+
| `--json` | Single JSON object output |
|
|
161
|
+
| `--raw` | Plain text, no colors, no spinner |
|
|
162
|
+
| `--chrome` | Use Chrome CDP bridge instead of HTTP |
|
|
163
|
+
| `--playwright` | Use Playwright headless Chromium |
|
|
164
|
+
| `--no-playwright` | Force HTTP transport even if config enables Playwright |
|
|
165
|
+
| `--timeout-ms 120000\|120s\|10m` | Overall stream timeout |
|
|
166
|
+
| `--curl` | Force curl-impersonate (auto-downloads if missing) |
|
|
167
|
+
| `--allow-anonymous` | Allow anonymous Perplexity responses when cookies are expired |
|
|
168
|
+
| `--incognito` | Do not save the query to Perplexity history |
|
|
169
|
+
| `--citations-full` | Show full source metadata in the rendered answer |
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
83
173
|
## Architecture
|
|
84
174
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
175
|
+
| Layer | Detail |
|
|
176
|
+
|---|---|
|
|
177
|
+
| **Default transport** | Headless HTTP with stored cookies. No browser launched at runtime. |
|
|
178
|
+
| **Optional transports** | `--chrome` Chrome CDP bridge, `--playwright` Playwright headless Chromium, `--curl` curl-impersonate for TLS fingerprinting. |
|
|
179
|
+
| **Auto fallback** | HTTP → Playwright → curl-impersonate when TLS is blocked. |
|
|
180
|
+
| **Streaming** | Real-time answer streaming via Server-Sent Events. |
|
|
181
|
+
| **Auth** | One-time cookie extraction from Chrome / Chrome Beta / Comet / Dia (macOS Keychain) or `~/.config/google-chrome` (Linux). After that, fully headless. |
|
|
182
|
+
| **Session validation** | `pplx auth --test` and `pplx auth --browser auto` both verify that the extracted cookies resolve to an authenticated session (not just "I extracted some bytes"). |
|
|
183
|
+
|
|
184
|
+
---
|
|
89
185
|
|
|
90
186
|
## Configuration
|
|
91
187
|
|
|
@@ -95,15 +191,39 @@ Optional config file at `~/.config/pplx/config.json`:
|
|
|
95
191
|
{
|
|
96
192
|
"mode": "pro",
|
|
97
193
|
"model": "claude-3.5-sonnet",
|
|
98
|
-
"lang": "en-US"
|
|
194
|
+
"lang": "en-US",
|
|
195
|
+
"playwright": true,
|
|
196
|
+
"playwrightHeadless": false
|
|
99
197
|
}
|
|
100
198
|
```
|
|
101
199
|
|
|
200
|
+
Set `"playwright": true` to make Playwright the default transport.
|
|
201
|
+
|
|
202
|
+
Environment overrides:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
PPLX_CURL_IMPERSONATE=/path/to/curl-impersonate-chrome
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Troubleshooting
|
|
211
|
+
|
|
212
|
+
| Symptom | Fix |
|
|
213
|
+
|---|---|
|
|
214
|
+
| `Cookies are invalid or expired` | Open Perplexity in your browser, confirm you are signed in, then re-run `pplx auth --browser auto`. |
|
|
215
|
+
| `Keychain access denied for "Chrome Safe Storage"` | macOS Keychain is prompting for permission. Run the exact `security find-generic-password` command the error suggests and click "Always Allow". |
|
|
216
|
+
| `Chrome cookie DB not found at ...` | The selected browser is not installed at the default location, or the profile name is wrong. Pass `--browser <name>` or `--profile <name>`. |
|
|
217
|
+
| Search hangs or times out | TLS fingerprinting may be in play. Try `pplx search "..." --playwright` or `--curl`. |
|
|
218
|
+
| `npm install -g` succeeds but `pplx` not found | Your global `npm bin` directory is not on PATH. Find it with `npm bin -g` and add it. |
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
102
222
|
## Acknowledgements
|
|
103
223
|
|
|
104
224
|
This project was inspired by and built upon the reverse-engineering work in [helallao/perplexity-ai](https://github.com/helallao/perplexity-ai) — a Python library for the Perplexity AI API. The authentication flow, SSE protocol handling, and API structure were all derived from studying that project. Big thanks to [@helallao](https://github.com/helallao) for figuring out the hard parts.
|
|
105
225
|
|
|
106
|
-
pplx-cli is a ground-up Node.js reimplementation for CLI
|
|
226
|
+
pplx-cli is a ground-up Node.js reimplementation for CLI and agentic use cases, but it would not exist without that foundational work.
|
|
107
227
|
|
|
108
228
|
## License
|
|
109
229
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pplx-npx-search",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for Perplexity AI with cookie-based auth",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "CLI for Perplexity AI with cookie-based auth. Headless, agent-friendly, no API key required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"pplx": "
|
|
7
|
+
"pplx": "bin/pplx.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/pplx.js",
|
|
@@ -20,12 +20,17 @@
|
|
|
20
20
|
"author": "Rajan Rengasamy",
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
23
|
-
"url": "https://github.com/
|
|
23
|
+
"url": "git+https://github.com/thatsrajan/pplx-cli.git"
|
|
24
24
|
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/thatsrajan/pplx-cli/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/thatsrajan/pplx-cli#readme",
|
|
25
29
|
"files": [
|
|
26
30
|
"bin",
|
|
27
31
|
"src",
|
|
28
32
|
"README.md",
|
|
33
|
+
"CHANGELOG.md",
|
|
29
34
|
"LICENSE"
|
|
30
35
|
],
|
|
31
36
|
"engines": {
|
|
@@ -37,6 +42,7 @@
|
|
|
37
42
|
"commander": "^12.0.0",
|
|
38
43
|
"eventsource-parser": "^3.0.0",
|
|
39
44
|
"ora": "^8.0.0",
|
|
45
|
+
"playwright": "^1.58.1",
|
|
40
46
|
"ws": "^8.16.0"
|
|
41
47
|
}
|
|
42
48
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { program } from 'commander';
|
|
2
3
|
import chalk from 'chalk';
|
|
3
4
|
import ora from 'ora';
|
|
4
|
-
import { extractFromChrome, loadCookies, saveCookies,
|
|
5
|
+
import { extractFromChrome, loadCookies, saveCookies, SUPPORTED_BROWSERS } from './cookies.js';
|
|
6
|
+
import { extractFromPlaywright } from './playwright-auth.js';
|
|
5
7
|
import { initSession, testAuth } from './session.js';
|
|
6
8
|
import { search } from './search.js';
|
|
7
9
|
import { LabsClient } from './labs.js';
|
|
@@ -9,6 +11,9 @@ import { formatSources } from './format.js';
|
|
|
9
11
|
import { LABS_MODELS, MODEL_MAP } from './constants.js';
|
|
10
12
|
import { setUseCurl } from './http.js';
|
|
11
13
|
import { loadConfig } from './config.js';
|
|
14
|
+
import { resolveTimeoutMs } from './timeout.js';
|
|
15
|
+
|
|
16
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
12
17
|
|
|
13
18
|
// --- Output state ---
|
|
14
19
|
let rawMode = false;
|
|
@@ -25,6 +30,15 @@ function makeSpinner(text) {
|
|
|
25
30
|
return ora(text);
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
function finishSuccess(spinner, message) {
|
|
34
|
+
if (isQuiet()) {
|
|
35
|
+
spinner.stop();
|
|
36
|
+
console.log(chalk.green(`✓ ${message}`));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
spinner.succeed(message);
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
// --- Stdin helper ---
|
|
29
43
|
function readStdin() {
|
|
30
44
|
return new Promise((resolve, reject) => {
|
|
@@ -50,11 +64,31 @@ async function resolveQuery(queryArg) {
|
|
|
50
64
|
process.exit(1);
|
|
51
65
|
}
|
|
52
66
|
|
|
67
|
+
async function extractAndValidateBrowser(browser, profile) {
|
|
68
|
+
const cookies = extractFromChrome(profile, browser);
|
|
69
|
+
const count = Object.keys(cookies).length;
|
|
70
|
+
const hasSession = Boolean(cookies['next-auth.session-token'] || cookies['__Secure-next-auth.session-token']);
|
|
71
|
+
if (!hasSession) {
|
|
72
|
+
return { browser, profile, cookies, count, hasSession, ok: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const session = await initSession(cookies);
|
|
76
|
+
return {
|
|
77
|
+
browser,
|
|
78
|
+
profile,
|
|
79
|
+
cookies: session.cookies,
|
|
80
|
+
count: Object.keys(session.cookies).length,
|
|
81
|
+
hasSession,
|
|
82
|
+
ok: session.ok,
|
|
83
|
+
status: session.status,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
53
87
|
// --- Program setup ---
|
|
54
88
|
program
|
|
55
89
|
.name('pplx')
|
|
56
90
|
.description('CLI for Perplexity AI')
|
|
57
|
-
.version(
|
|
91
|
+
.version(pkg.version);
|
|
58
92
|
|
|
59
93
|
program.option('--verbose', 'Enable verbose logging');
|
|
60
94
|
program.option('--proxy <url>', 'Set proxy URL (sets HTTPS_PROXY env var)');
|
|
@@ -76,10 +110,17 @@ program.hook('preAction', (thisCmd) => {
|
|
|
76
110
|
// Auth command
|
|
77
111
|
program
|
|
78
112
|
.command('auth')
|
|
79
|
-
.description('Extract and manage cookies from
|
|
113
|
+
.description('Extract and manage cookies from supported browsers')
|
|
80
114
|
.option('--test', 'Test if stored cookies are valid')
|
|
81
115
|
.option('--profile <name>', 'Chrome profile', 'Default')
|
|
116
|
+
.option('--browser <name>', `Browser store: auto, ${SUPPORTED_BROWSERS.join(', ')}`, 'auto')
|
|
117
|
+
.option('--playwright', 'Use Playwright to login and extract cookies')
|
|
118
|
+
.option('--headless', 'Run Playwright in headless mode (not recommended for login)')
|
|
82
119
|
.action(async (opts) => {
|
|
120
|
+
const cfg = loadConfig();
|
|
121
|
+
const usePlaywright = opts.playwright === true;
|
|
122
|
+
const playwrightHeadless = opts.headless ?? cfg.playwrightHeadless ?? false;
|
|
123
|
+
|
|
83
124
|
if (opts.test) {
|
|
84
125
|
const cookies = loadCookies();
|
|
85
126
|
if (!cookies) {
|
|
@@ -91,6 +132,9 @@ program
|
|
|
91
132
|
const ok = await testAuth(cookies);
|
|
92
133
|
spinner.stop();
|
|
93
134
|
console.log(ok ? chalk.green('✓ Cookies are valid') : chalk.red('✗ Cookies are invalid or expired'));
|
|
135
|
+
if (!ok) {
|
|
136
|
+
console.log(chalk.dim(' Run: pplx auth --browser auto'));
|
|
137
|
+
}
|
|
94
138
|
process.exit(ok ? 0 : 1);
|
|
95
139
|
} catch (e) {
|
|
96
140
|
spinner.stop();
|
|
@@ -100,9 +144,76 @@ program
|
|
|
100
144
|
return;
|
|
101
145
|
}
|
|
102
146
|
|
|
147
|
+
if (usePlaywright) {
|
|
148
|
+
const spinner = makeSpinner('Launching Playwright for login...').start();
|
|
149
|
+
try {
|
|
150
|
+
spinner.stop();
|
|
151
|
+
const cookies = await extractFromPlaywright({ headless: playwrightHeadless });
|
|
152
|
+
const count = Object.keys(cookies).length;
|
|
153
|
+
const hasSession = cookies['next-auth.session-token'] || cookies['__Secure-next-auth.session-token'];
|
|
154
|
+
|
|
155
|
+
if (!hasSession) {
|
|
156
|
+
console.log(chalk.yellow(`⚠ Found ${count} cookies but no session token.`));
|
|
157
|
+
console.log(' Make sure you are logged into perplexity.ai in Playwright.');
|
|
158
|
+
if (count > 0) {
|
|
159
|
+
saveCookies(cookies);
|
|
160
|
+
console.log(chalk.dim(' Saved cookies anyway.'));
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { cookies: refreshed, ok } = await initSession(cookies);
|
|
166
|
+
if (!ok) {
|
|
167
|
+
console.log(chalk.red('✗ Login cookies were extracted but are not authenticated.'));
|
|
168
|
+
console.log(chalk.dim(' Try again after logging into Perplexity in the Playwright browser.'));
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
saveCookies(refreshed);
|
|
172
|
+
console.log(chalk.green(`✓ Extracted ${Object.keys(refreshed).length} cookies and saved to ~/.config/pplx/cookies.json`));
|
|
173
|
+
return;
|
|
174
|
+
} catch (e) {
|
|
175
|
+
spinner.stop();
|
|
176
|
+
console.error(chalk.red('Failed to extract cookies via Playwright'));
|
|
177
|
+
console.error(chalk.red(e.message));
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
103
182
|
const spinner = makeSpinner('Extracting cookies from Chrome...').start();
|
|
104
183
|
try {
|
|
105
|
-
const
|
|
184
|
+
const attempts = [];
|
|
185
|
+
let result = null;
|
|
186
|
+
|
|
187
|
+
if (opts.browser === 'auto') {
|
|
188
|
+
for (const browser of SUPPORTED_BROWSERS) {
|
|
189
|
+
try {
|
|
190
|
+
const attempt = await extractAndValidateBrowser(browser, opts.profile);
|
|
191
|
+
attempts.push(attempt);
|
|
192
|
+
if (attempt.ok) {
|
|
193
|
+
result = attempt;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {
|
|
197
|
+
attempts.push({
|
|
198
|
+
browser,
|
|
199
|
+
profile: opts.profile,
|
|
200
|
+
count: 0,
|
|
201
|
+
hasSession: false,
|
|
202
|
+
ok: false,
|
|
203
|
+
error: e.message,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
result = await extractAndValidateBrowser(opts.browser, opts.profile);
|
|
209
|
+
attempts.push(result);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!result) {
|
|
213
|
+
result = { browser: opts.browser, profile: opts.profile, cookies: {}, count: 0, hasSession: false, ok: false };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const cookies = result.cookies;
|
|
106
217
|
const count = Object.keys(cookies).length;
|
|
107
218
|
spinner.text = `Found ${count} cookies. Testing auth...`;
|
|
108
219
|
|
|
@@ -110,20 +221,33 @@ program
|
|
|
110
221
|
if (!hasSession) {
|
|
111
222
|
spinner.stop();
|
|
112
223
|
console.log(chalk.yellow(`⚠ Found ${count} cookies but no session token.`));
|
|
113
|
-
console.log(' Make sure you are logged into perplexity.ai in
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
224
|
+
console.log(' Make sure you are logged into perplexity.ai in a supported browser.');
|
|
225
|
+
if (attempts.length) {
|
|
226
|
+
for (const attempt of attempts) {
|
|
227
|
+
const suffix = attempt.error ? ` (${attempt.error.split('\n')[0]})` : '';
|
|
228
|
+
console.log(chalk.dim(` - ${attempt.browser}: ${attempt.count} cookies${suffix}`));
|
|
229
|
+
}
|
|
117
230
|
}
|
|
231
|
+
console.log(chalk.dim(' Existing cookie file was left unchanged.'));
|
|
118
232
|
return;
|
|
119
233
|
}
|
|
120
234
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
235
|
+
if (!result.ok) {
|
|
236
|
+
spinner.stop();
|
|
237
|
+
console.log(chalk.red(`✗ Found ${count} cookies in ${result.browser}, but they are invalid or expired.`));
|
|
238
|
+
if (attempts.length) {
|
|
239
|
+
for (const attempt of attempts) {
|
|
240
|
+
const status = attempt.ok ? 'valid' : (attempt.hasSession ? 'expired' : 'no session');
|
|
241
|
+
const suffix = attempt.error ? ` (${attempt.error.split('\n')[0]})` : '';
|
|
242
|
+
console.log(chalk.dim(` - ${attempt.browser}: ${attempt.count} cookies, ${status}${suffix}`));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
console.log(chalk.dim(' Existing cookie file was left unchanged.'));
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
124
248
|
|
|
125
|
-
|
|
126
|
-
|
|
249
|
+
saveCookies(cookies);
|
|
250
|
+
finishSuccess(spinner, `Extracted ${Object.keys(cookies).length} cookies from ${result.browser} and saved to ~/.config/pplx/cookies.json`);
|
|
127
251
|
} catch (e) {
|
|
128
252
|
spinner.fail('Failed to extract cookies');
|
|
129
253
|
console.error(chalk.red(e.message));
|
|
@@ -140,13 +264,27 @@ async function doSearch(query, opts) {
|
|
|
140
264
|
opts = { ...cfg, ...opts };
|
|
141
265
|
if (opts.curl) setUseCurl(true);
|
|
142
266
|
|
|
143
|
-
const cookies = loadCookies();
|
|
144
|
-
if (!cookies) {
|
|
267
|
+
const cookies = loadCookies() || {};
|
|
268
|
+
if (!opts.chrome && Object.keys(cookies).length === 0) {
|
|
145
269
|
console.error(chalk.red('No cookies. Run: pplx auth'));
|
|
146
270
|
process.exit(1);
|
|
147
271
|
}
|
|
272
|
+
if (!opts.chrome && !opts.allowAnonymous) {
|
|
273
|
+
const ok = await testAuth(cookies);
|
|
274
|
+
if (!ok) {
|
|
275
|
+
console.error(chalk.red('Stored cookies are invalid or expired. Run: pplx auth --browser auto'));
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
148
279
|
|
|
149
280
|
const mode = opts.mode || 'pro';
|
|
281
|
+
let timeoutMs;
|
|
282
|
+
try {
|
|
283
|
+
timeoutMs = resolveTimeoutMs({ ...opts, mode });
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.error(chalk.red(e.message));
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
150
288
|
const sources = opts.sources ? opts.sources.split(',') : ['web'];
|
|
151
289
|
const lang = opts.lang || 'en-US';
|
|
152
290
|
|
|
@@ -162,6 +300,9 @@ async function doSearch(query, opts) {
|
|
|
162
300
|
language: lang,
|
|
163
301
|
incognito: opts.incognito,
|
|
164
302
|
chrome: opts.chrome,
|
|
303
|
+
playwright: opts.playwright,
|
|
304
|
+
curl: opts.curl,
|
|
305
|
+
timeoutMs,
|
|
165
306
|
})) {
|
|
166
307
|
lastData = data;
|
|
167
308
|
|
|
@@ -198,7 +339,7 @@ async function doSearch(query, opts) {
|
|
|
198
339
|
|
|
199
340
|
if (!lastAnswer) {
|
|
200
341
|
if (opts._spinner && !spinnerStopped) { opts._spinner.stop(); spinnerStopped = true; }
|
|
201
|
-
console.error(chalk.yellow('No answer received. Try re-authing (pplx auth) or use --curl.'));
|
|
342
|
+
console.error(chalk.yellow('No answer received. Try re-authing (pplx auth) or use --playwright/--curl.'));
|
|
202
343
|
process.exit(1);
|
|
203
344
|
}
|
|
204
345
|
|
|
@@ -209,7 +350,7 @@ async function doSearch(query, opts) {
|
|
|
209
350
|
} catch (e) {
|
|
210
351
|
console.error(chalk.red('\nError:'), e.message);
|
|
211
352
|
if (e.message.includes('403')) {
|
|
212
|
-
console.log(chalk.yellow('Possible TLS fingerprinting block. Try: pplx search --curl "query"'));
|
|
353
|
+
console.log(chalk.yellow('Possible TLS fingerprinting block. Try: pplx search --curl "query" or --playwright'));
|
|
213
354
|
}
|
|
214
355
|
process.exit(1);
|
|
215
356
|
}
|
|
@@ -230,6 +371,10 @@ program
|
|
|
230
371
|
.option('--lang <code>', 'Language code', 'en-US')
|
|
231
372
|
.option('--curl', 'Force curl-impersonate for TLS')
|
|
232
373
|
.option('--chrome', 'Use Chrome CDP bridge instead of HTTP')
|
|
374
|
+
.option('--playwright', 'Use Playwright headless Chromium instead of HTTP')
|
|
375
|
+
.option('--no-playwright', 'Disable Playwright even if config enables it')
|
|
376
|
+
.option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
|
|
377
|
+
.option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
|
|
233
378
|
.action(async (queryArg, opts) => {
|
|
234
379
|
if (opts.raw) { rawMode = true; chalk.level = 0; }
|
|
235
380
|
const query = await resolveQuery(queryArg);
|
|
@@ -244,6 +389,10 @@ program
|
|
|
244
389
|
.option('--json', 'Output raw JSON')
|
|
245
390
|
.option('--curl', 'Force curl-impersonate')
|
|
246
391
|
.option('--chrome', 'Use Chrome CDP bridge')
|
|
392
|
+
.option('--playwright', 'Use Playwright headless Chromium')
|
|
393
|
+
.option('--no-playwright', 'Disable Playwright even if config enables it')
|
|
394
|
+
.option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
|
|
395
|
+
.option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
|
|
247
396
|
.action(async (queryArg, opts) => {
|
|
248
397
|
const query = await resolveQuery(queryArg);
|
|
249
398
|
await doSearch(query, { ...opts, mode: 'reasoning' });
|
|
@@ -256,6 +405,10 @@ program
|
|
|
256
405
|
.option('--json', 'Output raw JSON')
|
|
257
406
|
.option('--curl', 'Force curl-impersonate')
|
|
258
407
|
.option('--chrome', 'Use Chrome CDP bridge')
|
|
408
|
+
.option('--playwright', 'Use Playwright headless Chromium')
|
|
409
|
+
.option('--no-playwright', 'Disable Playwright even if config enables it')
|
|
410
|
+
.option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
|
|
411
|
+
.option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
|
|
259
412
|
.action(async (queryArg, opts) => {
|
|
260
413
|
const query = await resolveQuery(queryArg);
|
|
261
414
|
const spinner = makeSpinner('Deep research in progress...').start();
|
package/src/cookies.js
CHANGED
|
@@ -20,27 +20,68 @@ export function saveCookies(cookies) {
|
|
|
20
20
|
writeFileSync(COOKIES_FILE, JSON.stringify(cookies, null, 2));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
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) {
|
|
@@ -126,6 +126,7 @@ async function* searchWithChrome(query, cookies, opts) {
|
|
|
126
126
|
method: 'POST',
|
|
127
127
|
headers: { 'content-type': 'application/json' },
|
|
128
128
|
body,
|
|
129
|
+
timeout: opts.timeoutMs,
|
|
129
130
|
}
|
|
130
131
|
)) {
|
|
131
132
|
parser.feed(chunk);
|
|
@@ -145,6 +146,58 @@ async function* searchWithChrome(query, cookies, opts) {
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
async function* searchWithPlaywright(query, cookies, opts) {
|
|
150
|
+
const { PlaywrightBridge } = await import('./playwright-bridge.js');
|
|
151
|
+
const modelPref = resolveModelPref(opts.mode ?? 'auto', opts.model);
|
|
152
|
+
const body = buildSearchBody(query, opts, modelPref);
|
|
153
|
+
const bridge = new PlaywrightBridge({ headless: opts.playwrightHeadless !== false, cookies });
|
|
154
|
+
try {
|
|
155
|
+
await bridge.connect();
|
|
156
|
+
|
|
157
|
+
const results = [];
|
|
158
|
+
const parser = createParser({
|
|
159
|
+
onEvent(event) {
|
|
160
|
+
if (event.data === '{}' || !event.data) return;
|
|
161
|
+
try {
|
|
162
|
+
const json = JSON.parse(event.data);
|
|
163
|
+
parseNestedText(json);
|
|
164
|
+
results.push(json);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error(chalk.yellow('Warning: failed to parse SSE event'));
|
|
167
|
+
if (process.env.PPLX_VERBOSE) {
|
|
168
|
+
console.error('SSE parse error:', e.message);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
let gotFinal = false;
|
|
175
|
+
for await (const chunk of bridge.fetchSSE(
|
|
176
|
+
`${BASE_URL}/rest/sse/perplexity_ask`,
|
|
177
|
+
{
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'content-type': 'application/json' },
|
|
180
|
+
body,
|
|
181
|
+
timeout: opts.timeoutMs,
|
|
182
|
+
}
|
|
183
|
+
)) {
|
|
184
|
+
parser.feed(chunk);
|
|
185
|
+
while (results.length > 0) {
|
|
186
|
+
const r = results.shift();
|
|
187
|
+
if (r.final_sse_message) gotFinal = true;
|
|
188
|
+
yield r;
|
|
189
|
+
}
|
|
190
|
+
if (gotFinal) break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
while (results.length > 0) {
|
|
194
|
+
yield results.shift();
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
await bridge.close();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
148
201
|
async function* searchWithHttp(query, cookies, opts) {
|
|
149
202
|
const { cookies: sessionCookies, ok, status } = await initSession(cookies);
|
|
150
203
|
if (!ok) {
|
|
@@ -168,6 +221,7 @@ async function* searchWithHttp(query, cookies, opts) {
|
|
|
168
221
|
'cookie': cookieHeader(sessionCookies),
|
|
169
222
|
},
|
|
170
223
|
body,
|
|
224
|
+
timeout: opts.timeoutMs,
|
|
171
225
|
});
|
|
172
226
|
|
|
173
227
|
if (!resp.ok) {
|
|
@@ -230,7 +284,52 @@ export async function* search(query, cookies, opts = {}) {
|
|
|
230
284
|
yield* searchWithChrome(query, cookies, opts);
|
|
231
285
|
return;
|
|
232
286
|
}
|
|
233
|
-
|
|
287
|
+
|
|
288
|
+
const attempts = [];
|
|
289
|
+
if (opts.playwright) {
|
|
290
|
+
attempts.push({ name: 'playwright', run: () => searchWithPlaywright(query, cookies, opts) });
|
|
291
|
+
attempts.push({ name: 'curl', run: () => searchWithHttp(query, cookies, opts), curl: true });
|
|
292
|
+
} else if (opts.curl) {
|
|
293
|
+
attempts.push({ name: 'curl', run: () => searchWithHttp(query, cookies, opts), curl: true });
|
|
294
|
+
} else {
|
|
295
|
+
attempts.push({ name: 'http', run: () => searchWithHttp(query, cookies, opts) });
|
|
296
|
+
attempts.push({ name: 'playwright', run: () => searchWithPlaywright(query, cookies, opts) });
|
|
297
|
+
attempts.push({ name: 'curl', run: () => searchWithHttp(query, cookies, opts), curl: true });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const shouldFallbackHttp = (err) => {
|
|
301
|
+
const msg = (err?.message || '').toLowerCase();
|
|
302
|
+
return msg.includes('403') || msg.includes('tls') || msg.includes('cloudflare');
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
let lastErr = null;
|
|
306
|
+
for (let i = 0; i < attempts.length; i++) {
|
|
307
|
+
const attempt = attempts[i];
|
|
308
|
+
const isLast = i === attempts.length - 1;
|
|
309
|
+
const prevCurl = getUseCurl();
|
|
310
|
+
if (attempt.curl) setUseCurl(true);
|
|
311
|
+
|
|
312
|
+
let yielded = false;
|
|
313
|
+
try {
|
|
314
|
+
const gen = attempt.run();
|
|
315
|
+
for await (const item of gen) {
|
|
316
|
+
yielded = true;
|
|
317
|
+
yield item;
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
} catch (e) {
|
|
321
|
+
lastErr = e;
|
|
322
|
+
if (attempt.name === 'http' && !shouldFallbackHttp(e)) throw e;
|
|
323
|
+
if (yielded || isLast) throw e;
|
|
324
|
+
if (process.env.PPLX_VERBOSE) {
|
|
325
|
+
console.error(`Search fallback: ${attempt.name} failed, trying next...`);
|
|
326
|
+
}
|
|
327
|
+
} finally {
|
|
328
|
+
if (attempt.curl) setUseCurl(prevCurl);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (lastErr) throw lastErr;
|
|
234
333
|
}
|
|
235
334
|
|
|
236
335
|
export function parseNestedText(json) {
|
package/src/session.js
CHANGED
|
@@ -3,10 +3,28 @@ import { cookieHeader } from './cookies.js';
|
|
|
3
3
|
import { request } from './http.js';
|
|
4
4
|
import { withRetry } from './retry.js';
|
|
5
5
|
|
|
6
|
+
function parseSessionPayload(text) {
|
|
7
|
+
if (!text) return {};
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(text);
|
|
10
|
+
} catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isAuthenticatedSession(session) {
|
|
16
|
+
return Boolean(
|
|
17
|
+
session?.user?.email ||
|
|
18
|
+
session?.user?.id ||
|
|
19
|
+
session?.user?.name
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
6
23
|
export async function initSession(cookies) {
|
|
7
24
|
const resp = await withRetry(() => request(`${BASE_URL}/api/auth/session`, {
|
|
8
25
|
headers: {
|
|
9
26
|
...HEADERS,
|
|
27
|
+
accept: 'application/json',
|
|
10
28
|
cookie: cookieHeader(cookies),
|
|
11
29
|
},
|
|
12
30
|
redirect: 'manual',
|
|
@@ -22,10 +40,18 @@ export async function initSession(cookies) {
|
|
|
22
40
|
}
|
|
23
41
|
}
|
|
24
42
|
|
|
25
|
-
|
|
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
|
}
|
package/src/timeout.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const DEFAULT_SEARCH_TIMEOUT_MS = 120000;
|
|
2
|
+
export const DEFAULT_RESEARCH_TIMEOUT_MS = 600000;
|
|
3
|
+
|
|
4
|
+
export function parseTimeoutMs(value, label = 'timeout') {
|
|
5
|
+
if (value == null || value === '') return null;
|
|
6
|
+
|
|
7
|
+
if (typeof value === 'number') {
|
|
8
|
+
if (Number.isFinite(value) && value > 0) return Math.trunc(value);
|
|
9
|
+
throw new Error(`${label} must be a positive number of milliseconds`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const text = String(value).trim().toLowerCase();
|
|
13
|
+
const match = text.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
|
|
14
|
+
if (!match) {
|
|
15
|
+
throw new Error(`${label} must be a positive duration like 120000, 120s, or 10m`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const amount = Number(match[1]);
|
|
19
|
+
const unit = match[2] ?? 'ms';
|
|
20
|
+
const multipliers = { ms: 1, s: 1000, m: 60000 };
|
|
21
|
+
const timeoutMs = amount * multipliers[unit];
|
|
22
|
+
|
|
23
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
24
|
+
throw new Error(`${label} must be a positive duration`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return Math.trunc(timeoutMs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveTimeoutMs(opts = {}) {
|
|
31
|
+
const explicit = parseTimeoutMs(opts.timeoutMs, '--timeout-ms');
|
|
32
|
+
if (explicit != null) return explicit;
|
|
33
|
+
|
|
34
|
+
const configured = parseTimeoutMs(opts.timeout, 'config timeout');
|
|
35
|
+
if (configured != null) return configured;
|
|
36
|
+
|
|
37
|
+
return opts.mode === 'deep-research'
|
|
38
|
+
? DEFAULT_RESEARCH_TIMEOUT_MS
|
|
39
|
+
: DEFAULT_SEARCH_TIMEOUT_MS;
|
|
40
|
+
}
|