perplexity-webui-scraper 1.0.0__tar.gz → 1.0.2__tar.gz
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.
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/PKG-INFO +1 -1
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/pyproject.toml +2 -1
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_static/models.json +15 -15
- perplexity_webui_scraper-1.0.2/src/perplexity_webui_scraper/api/cli.py +65 -0
- perplexity_webui_scraper-1.0.2/src/perplexity_webui_scraper/api/launcher.py +65 -0
- perplexity_webui_scraper-1.0.2/src/perplexity_webui_scraper/cli/commands/get_session_token.py +162 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/mcp/server.py +2 -2
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/mcp/tools/__init__.py +2 -5
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/models/registry.py +2 -2
- perplexity_webui_scraper-1.0.0/src/perplexity_webui_scraper/api/cli.py +0 -73
- perplexity_webui_scraper-1.0.0/src/perplexity_webui_scraper/api/launcher.py +0 -73
- perplexity_webui_scraper-1.0.0/src/perplexity_webui_scraper/cli/commands/get_session_token.py +0 -137
- perplexity_webui_scraper-1.0.0/src/perplexity_webui_scraper/cli/get_perplexity_session_token.py +0 -209
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/README.md +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_internal/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_internal/constants.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_internal/exceptions.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_internal/logging.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_internal/types.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_static/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/__main__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/app.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/auth.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/conversation_cache.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/helpers.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/models.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/routes/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/routes/completions.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/routes/models.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/schemas/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/schemas/errors.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/schemas/request.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/schemas/response.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/server.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/cli/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/cli/__main__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/cli/commands/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/config/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/config/client.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/config/conversation.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/client.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/conversation.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/files.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/parser.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/payload.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/response.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/http/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/http/client.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/http/fingerprint.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/http/resilience.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/mcp/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/mcp/__main__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/mcp/tools/ask.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/models/__init__.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/models/types.py +0 -0
- {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: perplexity-webui-scraper
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: An advanced, high-performance Python client, MCP server, and REST API for reverse-engineering Perplexity AI's WebUI.
|
|
5
5
|
Keywords: perplexity,ai,scraper,mcp,fastapi,openai-compatible
|
|
6
6
|
Author: Henrique Moreira
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "perplexity-webui-scraper"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.2"
|
|
4
4
|
description = "An advanced, high-performance Python client, MCP server, and REST API for reverse-engineering Perplexity AI's WebUI."
|
|
5
5
|
authors = [{ name = "Henrique Moreira", email = "github@henriquecoder.com" }]
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -60,6 +60,7 @@ dev = [
|
|
|
60
60
|
docs = [
|
|
61
61
|
"mkdocs~=1.6.0",
|
|
62
62
|
"mkdocs-material~=9.7.0",
|
|
63
|
+
"mkdocstrings[python]~=1.0.0",
|
|
63
64
|
]
|
|
64
65
|
lint = [
|
|
65
66
|
"ruff~=0.15.0",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[
|
|
2
2
|
{
|
|
3
3
|
"id": "perplexity/best",
|
|
4
|
-
"name": "
|
|
5
|
-
"description": "Perplexity
|
|
4
|
+
"name": "Best",
|
|
5
|
+
"description": "Perplexity Best (Auto-select).",
|
|
6
6
|
"identifier": "default",
|
|
7
|
-
"tool_name": "
|
|
7
|
+
"tool_name": "pplx_best",
|
|
8
8
|
"min_tier": "pro",
|
|
9
9
|
"mode": "search"
|
|
10
10
|
},
|
|
@@ -72,21 +72,21 @@
|
|
|
72
72
|
"mode": "copilot"
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
|
-
"id": "anthropic/claude-
|
|
76
|
-
"name": "Claude
|
|
77
|
-
"description": "Anthropic Claude
|
|
78
|
-
"identifier": "
|
|
79
|
-
"tool_name": "
|
|
80
|
-
"min_tier": "
|
|
75
|
+
"id": "anthropic/claude-sonnet-4.6",
|
|
76
|
+
"name": "Claude Sonnet 4.6",
|
|
77
|
+
"description": "Anthropic Claude Sonnet 4.6.",
|
|
78
|
+
"identifier": "claude46sonnet",
|
|
79
|
+
"tool_name": "pplx_claude_s46",
|
|
80
|
+
"min_tier": "pro",
|
|
81
81
|
"mode": "copilot"
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
|
-
"id": "anthropic/claude-
|
|
85
|
-
"name": "Claude
|
|
86
|
-
"description": "Anthropic Claude
|
|
87
|
-
"identifier": "
|
|
88
|
-
"tool_name": "
|
|
89
|
-
"min_tier": "
|
|
84
|
+
"id": "anthropic/claude-sonnet-4.6-thinking",
|
|
85
|
+
"name": "Claude Sonnet 4.6 Thinking",
|
|
86
|
+
"description": "Anthropic Claude Sonnet 4.6 (Thinking).",
|
|
87
|
+
"identifier": "claude46sonnetthinking",
|
|
88
|
+
"tool_name": "pplx_claude_s46_think",
|
|
89
|
+
"min_tier": "pro",
|
|
90
90
|
"mode": "copilot"
|
|
91
91
|
},
|
|
92
92
|
{
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Typer CLI for the OpenAI-compatible Perplexity API server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from typer import Option, Typer
|
|
11
|
+
from uvicorn import run as uvicorn_run
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
console = Console(stderr=True, soft_wrap=True)
|
|
15
|
+
|
|
16
|
+
app = Typer(
|
|
17
|
+
name="perplexity-webui-scraper-api",
|
|
18
|
+
help="OpenAI-compatible API server powered by Perplexity WebUI Scraper.",
|
|
19
|
+
add_completion=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback(invoke_without_command=True)
|
|
24
|
+
def main(
|
|
25
|
+
host: Annotated[
|
|
26
|
+
str,
|
|
27
|
+
Option("--host", "-H", help="Host address to bind the server to."),
|
|
28
|
+
] = "127.0.0.1",
|
|
29
|
+
port: Annotated[
|
|
30
|
+
int,
|
|
31
|
+
Option("--port", "-p", help="Port to listen on."),
|
|
32
|
+
] = 8000,
|
|
33
|
+
reload: Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
Option("--reload", help="Enable auto-reload for development."),
|
|
36
|
+
] = False,
|
|
37
|
+
log_level: Annotated[
|
|
38
|
+
str,
|
|
39
|
+
Option("--log-level", help="Uvicorn log level."),
|
|
40
|
+
] = "info",
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Start the OpenAI-compatible API server.
|
|
43
|
+
|
|
44
|
+
Authentication is done per-request via the Authorization: Bearer header.
|
|
45
|
+
Pass your Perplexity session token as the API key in every request.
|
|
46
|
+
"""
|
|
47
|
+
info = Text.assemble(
|
|
48
|
+
("🌐 URL: ", "bold cyan"),
|
|
49
|
+
(f"http://{host}:{port}\n", "white"),
|
|
50
|
+
("📖 Docs: ", "bold cyan"),
|
|
51
|
+
(f"http://{host}:{port}/docs\n", "white"),
|
|
52
|
+
("📘 ReDoc: ", "bold cyan"),
|
|
53
|
+
(f"http://{host}:{port}/redoc\n", "white"),
|
|
54
|
+
("🔑 Auth: ", "bold cyan"),
|
|
55
|
+
("Authorization: Bearer <your_session_token>", "dim white"),
|
|
56
|
+
)
|
|
57
|
+
console.print(Panel(info, title="[bold green]Perplexity API Server[/bold green]", expand=False))
|
|
58
|
+
|
|
59
|
+
uvicorn_run(
|
|
60
|
+
"perplexity_webui_scraper.api.server:app",
|
|
61
|
+
host=host,
|
|
62
|
+
port=port,
|
|
63
|
+
reload=reload,
|
|
64
|
+
log_level=log_level.lower(),
|
|
65
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Typer CLI for launching the OpenAI-compatible Perplexity API server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from typer import Option, Typer
|
|
11
|
+
from uvicorn import run as uvicorn_run
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
console = Console(stderr=True, soft_wrap=True)
|
|
15
|
+
|
|
16
|
+
app = Typer(
|
|
17
|
+
name="perplexity-webui-scraper-api",
|
|
18
|
+
help="OpenAI-compatible API server powered by Perplexity WebUI Scraper.",
|
|
19
|
+
add_completion=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback(invoke_without_command=True)
|
|
24
|
+
def main(
|
|
25
|
+
host: Annotated[
|
|
26
|
+
str,
|
|
27
|
+
Option("--host", "-H", help="Host address to bind the server to."),
|
|
28
|
+
] = "127.0.0.1",
|
|
29
|
+
port: Annotated[
|
|
30
|
+
int,
|
|
31
|
+
Option("--port", "-p", help="Port to listen on."),
|
|
32
|
+
] = 8000,
|
|
33
|
+
reload: Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
Option("--reload", help="Enable auto-reload for development."),
|
|
36
|
+
] = False,
|
|
37
|
+
log_level: Annotated[
|
|
38
|
+
str,
|
|
39
|
+
Option("--log-level", help="Uvicorn log level."),
|
|
40
|
+
] = "info",
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Start the OpenAI-compatible Perplexity API server.
|
|
43
|
+
|
|
44
|
+
Authentication is done per-request via the ``Authorization: Bearer`` header.
|
|
45
|
+
Pass your Perplexity session token as the API key in every request.
|
|
46
|
+
"""
|
|
47
|
+
info = Text.assemble(
|
|
48
|
+
("🌐 URL: ", "bold cyan"),
|
|
49
|
+
(f"http://{host}:{port}\n", "white"),
|
|
50
|
+
("📖 Docs: ", "bold cyan"),
|
|
51
|
+
(f"http://{host}:{port}/docs\n", "white"),
|
|
52
|
+
("📘 ReDoc: ", "bold cyan"),
|
|
53
|
+
(f"http://{host}:{port}/redoc\n", "white"),
|
|
54
|
+
("🔑 Auth: ", "bold cyan"),
|
|
55
|
+
("Authorization: Bearer <your_session_token>", "dim white"),
|
|
56
|
+
)
|
|
57
|
+
console.print(Panel(info, title="[bold green]Perplexity API Server[/bold green]", expand=False))
|
|
58
|
+
|
|
59
|
+
uvicorn_run(
|
|
60
|
+
"perplexity_webui_scraper.api.app:app",
|
|
61
|
+
host=host,
|
|
62
|
+
port=port,
|
|
63
|
+
reload=reload,
|
|
64
|
+
log_level=log_level.lower(),
|
|
65
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""get-session-token CLI command — extracts the Perplexity session cookie.
|
|
2
|
+
|
|
3
|
+
Uses the email → OTP code → redirect-link → cookie extraction flow via curl-cffi.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
from curl_cffi.requests import Session
|
|
11
|
+
from pyperclip import PyperclipException, copy
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.prompt import Confirm, Prompt
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from perplexity_webui_scraper._internal.constants import (
|
|
18
|
+
API_BASE_URL,
|
|
19
|
+
ENDPOINT_AUTH_CSRF,
|
|
20
|
+
ENDPOINT_AUTH_OTP_REDIRECT,
|
|
21
|
+
ENDPOINT_AUTH_SIGNIN,
|
|
22
|
+
SESSION_COOKIE_NAME,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_DEFAULT_HEADERS: dict[str, str] = {
|
|
27
|
+
"Referer": f"{API_BASE_URL}/",
|
|
28
|
+
"Origin": API_BASE_URL,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console = Console(stderr=True, soft_wrap=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _show_header() -> None:
|
|
35
|
+
"""Display the welcome header panel."""
|
|
36
|
+
console.print(
|
|
37
|
+
Panel(
|
|
38
|
+
"[bold white]Perplexity WebUI Scraper[/bold white]\n\n"
|
|
39
|
+
"Automatic session token generator via email authentication.\n"
|
|
40
|
+
"[dim]All session data will be cleared on exit.[/dim]",
|
|
41
|
+
title="🔐 Token Generator",
|
|
42
|
+
border_style="cyan",
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _show_exit_message() -> None:
|
|
48
|
+
"""Display the security note and wait for the user to press ENTER before clearing the screen."""
|
|
49
|
+
console.print("\n[bold yellow]⚠️ Security Note:[/bold yellow]")
|
|
50
|
+
console.print("Press [bold white]ENTER[/bold white] to clear screen and exit.")
|
|
51
|
+
console.input()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run(
|
|
55
|
+
email: Annotated[str | None, typer.Argument(help="Your Perplexity account email.")] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Extract your Perplexity session token using email OTP authentication.
|
|
58
|
+
|
|
59
|
+
Guides the user through email-based sign-in (OTP or magic link),
|
|
60
|
+
displays the extracted session token, and offers to copy it to the
|
|
61
|
+
clipboard. The screen is cleared on exit for security.
|
|
62
|
+
"""
|
|
63
|
+
with console.screen():
|
|
64
|
+
try:
|
|
65
|
+
_show_header()
|
|
66
|
+
|
|
67
|
+
if not email:
|
|
68
|
+
console.print("\n[bold cyan]Step 1: Email Verification[/bold cyan]")
|
|
69
|
+
email = Prompt.ask(" Enter your Perplexity email", console=console)
|
|
70
|
+
else:
|
|
71
|
+
console.print(f"\n[bold cyan]Step 1: Email Verification[/bold cyan] (using [white]{email}[/white])")
|
|
72
|
+
|
|
73
|
+
email = email.strip()
|
|
74
|
+
|
|
75
|
+
if not email or "@" not in email:
|
|
76
|
+
raise ValueError("Invalid email address.")
|
|
77
|
+
|
|
78
|
+
with Session(impersonate="chrome", headers=_DEFAULT_HEADERS) as session:
|
|
79
|
+
# Step 1: Obtain CSRF token
|
|
80
|
+
with console.status("[bold green]Initializing secure connection...", spinner="dots"):
|
|
81
|
+
session.get(API_BASE_URL)
|
|
82
|
+
csrf_response = session.get(f"{API_BASE_URL}{ENDPOINT_AUTH_CSRF}")
|
|
83
|
+
csrf_response.raise_for_status()
|
|
84
|
+
csrf_token: str = csrf_response.json().get("csrfToken", "")
|
|
85
|
+
|
|
86
|
+
if not csrf_token:
|
|
87
|
+
raise ValueError("Failed to obtain CSRF token.")
|
|
88
|
+
|
|
89
|
+
# Step 2: Send OTP email
|
|
90
|
+
with console.status("[bold green]Sending verification code...", spinner="dots"):
|
|
91
|
+
signin_response = session.post(
|
|
92
|
+
f"{API_BASE_URL}{ENDPOINT_AUTH_SIGNIN}?version=2.18&source=default",
|
|
93
|
+
json={
|
|
94
|
+
"email": email,
|
|
95
|
+
"csrfToken": csrf_token,
|
|
96
|
+
"useNumericOtp": "true",
|
|
97
|
+
"json": "true",
|
|
98
|
+
"callbackUrl": f"{API_BASE_URL}/?login-source=floatingSignup",
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
signin_response.raise_for_status()
|
|
102
|
+
|
|
103
|
+
console.print("\n[bold cyan]Step 2: Verification[/bold cyan]")
|
|
104
|
+
console.print(" Check your email for a [bold]6-digit code[/bold] or [bold]magic link[/bold].")
|
|
105
|
+
|
|
106
|
+
# Step 3: Prompt user for OTP code
|
|
107
|
+
otp_code = Prompt.ask(" Enter code or paste link", console=console).strip()
|
|
108
|
+
|
|
109
|
+
if not otp_code:
|
|
110
|
+
raise ValueError("OTP code cannot be empty.")
|
|
111
|
+
|
|
112
|
+
# Step 4: Convert OTP to redirect URL
|
|
113
|
+
with console.status("[bold green]Validating...", spinner="dots"):
|
|
114
|
+
if otp_code.startswith("http"):
|
|
115
|
+
redirect_url = otp_code
|
|
116
|
+
else:
|
|
117
|
+
otp_response = session.post(
|
|
118
|
+
f"{API_BASE_URL}{ENDPOINT_AUTH_OTP_REDIRECT}",
|
|
119
|
+
json={
|
|
120
|
+
"email": email,
|
|
121
|
+
"otp": otp_code,
|
|
122
|
+
"redirectUrl": f"{API_BASE_URL}/?login-source=floatingSignup",
|
|
123
|
+
"emailLoginMethod": "web-otp",
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
otp_response.raise_for_status()
|
|
127
|
+
|
|
128
|
+
redirect_path = otp_response.json().get("redirect", "")
|
|
129
|
+
if not redirect_path:
|
|
130
|
+
raise ValueError("No redirect URL received.")
|
|
131
|
+
|
|
132
|
+
redirect_url = (
|
|
133
|
+
f"{API_BASE_URL}{redirect_path}" if redirect_path.startswith("/") else redirect_path
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Step 5: Follow redirect to set session cookie
|
|
137
|
+
session.get(redirect_url)
|
|
138
|
+
|
|
139
|
+
# Step 6: Extract session cookie
|
|
140
|
+
session_token = session.cookies.get(SESSION_COOKIE_NAME)
|
|
141
|
+
|
|
142
|
+
if not session_token:
|
|
143
|
+
raise ValueError("Authentication successful, but token not found.")
|
|
144
|
+
|
|
145
|
+
console.print("\n[bold green]✅ Token generated successfully![/bold green]")
|
|
146
|
+
console.print(f"\n[bold white]Your session token:[/bold white]\n[green]{session_token}[/green]\n")
|
|
147
|
+
|
|
148
|
+
if Confirm.ask("Copy token to clipboard?", default=False, console=console):
|
|
149
|
+
try:
|
|
150
|
+
copy(session_token)
|
|
151
|
+
console.print("[dim]Token copied to clipboard.[/dim]")
|
|
152
|
+
except PyperclipException as error:
|
|
153
|
+
console.print(f"[red]Could not copy to clipboard: {error}[/red]")
|
|
154
|
+
|
|
155
|
+
_show_exit_message()
|
|
156
|
+
|
|
157
|
+
except KeyboardInterrupt:
|
|
158
|
+
raise typer.Exit(code=0) from None
|
|
159
|
+
except Exception as error:
|
|
160
|
+
console.print(f"\n[bold red]⛔ Error:[/bold red] {error}")
|
|
161
|
+
console.input("[dim]Press ENTER to exit...[/dim]")
|
|
162
|
+
raise typer.Exit(code=1) from error
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
from os import environ
|
|
6
6
|
|
|
7
7
|
from fastmcp import FastMCP
|
|
8
8
|
|
|
@@ -30,7 +30,7 @@ def _get_client() -> Perplexity:
|
|
|
30
30
|
global _client # noqa: PLW0603
|
|
31
31
|
|
|
32
32
|
if _client is None:
|
|
33
|
-
token =
|
|
33
|
+
token = environ.get("PERPLEXITY_SESSION_TOKEN", "")
|
|
34
34
|
|
|
35
35
|
if not token:
|
|
36
36
|
raise RuntimeError(
|
|
@@ -3,12 +3,9 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Callable # noqa: TC003
|
|
6
|
-
from typing import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if TYPE_CHECKING:
|
|
10
|
-
from perplexity_webui_scraper._internal.types import SearchFocus, SourceFocus, TimeRange
|
|
6
|
+
from typing import Any
|
|
11
7
|
|
|
8
|
+
from perplexity_webui_scraper._internal.types import SearchFocus, SourceFocus, TimeRange # noqa: TC001
|
|
12
9
|
from perplexity_webui_scraper.core.client import Perplexity # noqa: TC001
|
|
13
10
|
from perplexity_webui_scraper.mcp.tools.ask import _ask
|
|
14
11
|
from perplexity_webui_scraper.models.registry import MODELS
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from importlib.resources import files
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
from orjson import loads
|
|
8
8
|
|
|
9
9
|
from perplexity_webui_scraper.models.types import Model
|
|
10
10
|
|
|
@@ -33,7 +33,7 @@ class ModelRegistry:
|
|
|
33
33
|
models_file = static_pkg.joinpath("models.json")
|
|
34
34
|
|
|
35
35
|
raw: bytes = models_file.read_bytes() # type: ignore[arg-type]
|
|
36
|
-
data: list[dict[str, object]] =
|
|
36
|
+
data: list[dict[str, object]] = loads(raw)
|
|
37
37
|
|
|
38
38
|
for item in data:
|
|
39
39
|
model = Model.model_validate(item)
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
"""Typer CLI for the OpenAI-compatible Perplexity API server."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Annotated
|
|
6
|
-
|
|
7
|
-
import typer
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
try:
|
|
11
|
-
import uvicorn as _uvicorn # noqa: F401
|
|
12
|
-
|
|
13
|
-
_HAS_UVICORN = True
|
|
14
|
-
except ImportError:
|
|
15
|
-
_HAS_UVICORN = False
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
app = typer.Typer(
|
|
19
|
-
name="perplexity-webui-scraper-api",
|
|
20
|
-
help="OpenAI-compatible API server powered by Perplexity WebUI Scraper.",
|
|
21
|
-
add_completion=False,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@app.callback(invoke_without_command=True)
|
|
26
|
-
def main(
|
|
27
|
-
host: Annotated[
|
|
28
|
-
str,
|
|
29
|
-
typer.Option("--host", "-H", help="Host address to bind the server to."),
|
|
30
|
-
] = "127.0.0.1",
|
|
31
|
-
port: Annotated[
|
|
32
|
-
int,
|
|
33
|
-
typer.Option("--port", "-p", help="Port to listen on."),
|
|
34
|
-
] = 8000,
|
|
35
|
-
reload: Annotated[
|
|
36
|
-
bool,
|
|
37
|
-
typer.Option("--reload", help="Enable auto-reload for development."),
|
|
38
|
-
] = False,
|
|
39
|
-
log_level: Annotated[
|
|
40
|
-
str,
|
|
41
|
-
typer.Option("--log-level", help="Uvicorn log level."),
|
|
42
|
-
] = "info",
|
|
43
|
-
) -> None:
|
|
44
|
-
"""Start the OpenAI-compatible API server.
|
|
45
|
-
|
|
46
|
-
Authentication is done per-request via the Authorization: Bearer header.
|
|
47
|
-
Pass your Perplexity session token as the API key in every request.
|
|
48
|
-
"""
|
|
49
|
-
if not _HAS_UVICORN:
|
|
50
|
-
typer.echo(
|
|
51
|
-
"Error: uvicorn is not installed. Install the 'api' extras:\n\n"
|
|
52
|
-
" uv sync --extra api\n"
|
|
53
|
-
" pip install perplexity-webui-scraper[api]",
|
|
54
|
-
err=True,
|
|
55
|
-
)
|
|
56
|
-
raise typer.Exit(code=1)
|
|
57
|
-
|
|
58
|
-
import uvicorn # noqa: PLC0415
|
|
59
|
-
|
|
60
|
-
typer.echo(
|
|
61
|
-
f"Starting Perplexity API server at http://{host}:{port}\n"
|
|
62
|
-
f" Docs: http://{host}:{port}/docs\n"
|
|
63
|
-
f" ReDoc: http://{host}:{port}/redoc\n"
|
|
64
|
-
f" Auth: Authorization: Bearer <your_session_token>"
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
uvicorn.run(
|
|
68
|
-
"perplexity_webui_scraper.api.server:app",
|
|
69
|
-
host=host,
|
|
70
|
-
port=port,
|
|
71
|
-
reload=reload,
|
|
72
|
-
log_level=log_level.lower(),
|
|
73
|
-
)
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
"""Typer CLI for launching the OpenAI-compatible Perplexity API server."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Annotated
|
|
6
|
-
|
|
7
|
-
import typer
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
try:
|
|
11
|
-
import uvicorn as _uvicorn # noqa: F401
|
|
12
|
-
|
|
13
|
-
_HAS_UVICORN = True
|
|
14
|
-
except ImportError:
|
|
15
|
-
_HAS_UVICORN = False
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
app = typer.Typer(
|
|
19
|
-
name="perplexity-webui-scraper-api",
|
|
20
|
-
help="OpenAI-compatible API server powered by Perplexity WebUI Scraper.",
|
|
21
|
-
add_completion=False,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@app.callback(invoke_without_command=True)
|
|
26
|
-
def main(
|
|
27
|
-
host: Annotated[
|
|
28
|
-
str,
|
|
29
|
-
typer.Option("--host", "-H", help="Host address to bind the server to."),
|
|
30
|
-
] = "127.0.0.1",
|
|
31
|
-
port: Annotated[
|
|
32
|
-
int,
|
|
33
|
-
typer.Option("--port", "-p", help="Port to listen on."),
|
|
34
|
-
] = 8000,
|
|
35
|
-
reload: Annotated[
|
|
36
|
-
bool,
|
|
37
|
-
typer.Option("--reload", help="Enable auto-reload for development."),
|
|
38
|
-
] = False,
|
|
39
|
-
log_level: Annotated[
|
|
40
|
-
str,
|
|
41
|
-
typer.Option("--log-level", help="Uvicorn log level."),
|
|
42
|
-
] = "info",
|
|
43
|
-
) -> None:
|
|
44
|
-
"""Start the OpenAI-compatible Perplexity API server.
|
|
45
|
-
|
|
46
|
-
Authentication is done per-request via the ``Authorization: Bearer`` header.
|
|
47
|
-
Pass your Perplexity session token as the API key in every request.
|
|
48
|
-
"""
|
|
49
|
-
if not _HAS_UVICORN:
|
|
50
|
-
typer.echo(
|
|
51
|
-
"Error: uvicorn is not installed. Install the 'api' extras:\n\n"
|
|
52
|
-
" uv sync --extra api\n"
|
|
53
|
-
" pip install perplexity-webui-scraper[api]",
|
|
54
|
-
err=True,
|
|
55
|
-
)
|
|
56
|
-
raise typer.Exit(code=1)
|
|
57
|
-
|
|
58
|
-
import uvicorn # noqa: PLC0415
|
|
59
|
-
|
|
60
|
-
typer.echo(
|
|
61
|
-
f"Starting Perplexity API server at http://{host}:{port}\n"
|
|
62
|
-
f" Docs: http://{host}:{port}/docs\n"
|
|
63
|
-
f" ReDoc: http://{host}:{port}/redoc\n"
|
|
64
|
-
f" Auth: Authorization: Bearer <your_session_token>"
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
uvicorn.run(
|
|
68
|
-
"perplexity_webui_scraper.api.app:app",
|
|
69
|
-
host=host,
|
|
70
|
-
port=port,
|
|
71
|
-
reload=reload,
|
|
72
|
-
log_level=log_level.lower(),
|
|
73
|
-
)
|
perplexity_webui_scraper-1.0.0/src/perplexity_webui_scraper/cli/commands/get_session_token.py
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
"""get-session-token CLI command — extracts the Perplexity session cookie.
|
|
2
|
-
|
|
3
|
-
Uses the email → OTP code → redirect-link → cookie extraction flow via
|
|
4
|
-
curl-cffi (no Playwright/browser automation required).
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from time import sleep
|
|
10
|
-
from typing import Annotated
|
|
11
|
-
|
|
12
|
-
from curl_cffi.requests import Session
|
|
13
|
-
import typer
|
|
14
|
-
|
|
15
|
-
from perplexity_webui_scraper._internal.constants import (
|
|
16
|
-
API_BASE_URL,
|
|
17
|
-
ENDPOINT_AUTH_CSRF,
|
|
18
|
-
ENDPOINT_AUTH_OTP_REDIRECT,
|
|
19
|
-
ENDPOINT_AUTH_SIGNIN,
|
|
20
|
-
SESSION_COOKIE_NAME,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
_DEFAULT_HEADERS: dict[str, str] = {
|
|
25
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
26
|
-
"Referer": f"{API_BASE_URL}/",
|
|
27
|
-
"Origin": API_BASE_URL,
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def run(
|
|
32
|
-
email: Annotated[str | None, typer.Argument(help="Your Perplexity account email.")] = None,
|
|
33
|
-
) -> None:
|
|
34
|
-
"""Extract your Perplexity session token using email OTP authentication.
|
|
35
|
-
|
|
36
|
-
If email is not provided as an argument, you will be prompted interactively.
|
|
37
|
-
The session token is printed to stdout so it can be piped or exported::
|
|
38
|
-
|
|
39
|
-
export PERPLEXITY_SESSION_TOKEN=$(get-perplexity-session-token you@example.com)
|
|
40
|
-
"""
|
|
41
|
-
if email is None:
|
|
42
|
-
email = typer.prompt("Enter your Perplexity account email")
|
|
43
|
-
|
|
44
|
-
email = email.strip()
|
|
45
|
-
|
|
46
|
-
if not email or "@" not in email:
|
|
47
|
-
typer.echo("Error: invalid email address.", err=True)
|
|
48
|
-
raise typer.Exit(code=1)
|
|
49
|
-
|
|
50
|
-
typer.echo(f"→ Sending OTP to {email} …", err=True)
|
|
51
|
-
|
|
52
|
-
with Session(impersonate="chrome", headers=_DEFAULT_HEADERS) as session:
|
|
53
|
-
# Step 1: Obtain CSRF token
|
|
54
|
-
try:
|
|
55
|
-
csrf_response = session.get(f"{API_BASE_URL}{ENDPOINT_AUTH_CSRF}")
|
|
56
|
-
csrf_response.raise_for_status()
|
|
57
|
-
csrf_token: str = csrf_response.json().get("csrfToken", "")
|
|
58
|
-
except Exception as exc:
|
|
59
|
-
typer.echo(f"Error: failed to fetch CSRF token: {exc}", err=True)
|
|
60
|
-
raise typer.Exit(code=1) from exc
|
|
61
|
-
|
|
62
|
-
if not csrf_token:
|
|
63
|
-
typer.echo("Error: could not obtain CSRF token.", err=True)
|
|
64
|
-
raise typer.Exit(code=1)
|
|
65
|
-
|
|
66
|
-
# Step 2: Send OTP email
|
|
67
|
-
try:
|
|
68
|
-
signin_response = session.post(
|
|
69
|
-
f"{API_BASE_URL}{ENDPOINT_AUTH_SIGNIN}",
|
|
70
|
-
data={
|
|
71
|
-
"email": email,
|
|
72
|
-
"csrfToken": csrf_token,
|
|
73
|
-
"callbackUrl": f"{API_BASE_URL}/",
|
|
74
|
-
"json": "true",
|
|
75
|
-
},
|
|
76
|
-
)
|
|
77
|
-
signin_response.raise_for_status()
|
|
78
|
-
except Exception as exc:
|
|
79
|
-
typer.echo(f"Error: failed to initiate sign-in: {exc}", err=True)
|
|
80
|
-
raise typer.Exit(code=1) from exc
|
|
81
|
-
|
|
82
|
-
typer.echo("→ OTP email sent. Check your inbox.", err=True)
|
|
83
|
-
|
|
84
|
-
# Step 3: Prompt user for OTP code
|
|
85
|
-
otp_code: str = typer.prompt("Enter the 6-digit OTP code from your email").strip()
|
|
86
|
-
|
|
87
|
-
if not otp_code:
|
|
88
|
-
typer.echo("Error: OTP code cannot be empty.", err=True)
|
|
89
|
-
raise typer.Exit(code=1)
|
|
90
|
-
|
|
91
|
-
# Step 4: Convert OTP to redirect URL
|
|
92
|
-
typer.echo("→ Verifying OTP …", err=True)
|
|
93
|
-
|
|
94
|
-
try:
|
|
95
|
-
otp_response = session.get(
|
|
96
|
-
f"{API_BASE_URL}{ENDPOINT_AUTH_OTP_REDIRECT}",
|
|
97
|
-
params={"email": email, "token": otp_code},
|
|
98
|
-
)
|
|
99
|
-
otp_response.raise_for_status()
|
|
100
|
-
redirect_url: str = otp_response.json().get("url", "")
|
|
101
|
-
except Exception as exc:
|
|
102
|
-
typer.echo(f"Error: OTP verification failed: {exc}", err=True)
|
|
103
|
-
raise typer.Exit(code=1) from exc
|
|
104
|
-
|
|
105
|
-
if not redirect_url:
|
|
106
|
-
typer.echo("Error: OTP verification returned no redirect URL.", err=True)
|
|
107
|
-
raise typer.Exit(code=1)
|
|
108
|
-
|
|
109
|
-
# Step 5: Follow redirect to set session cookie
|
|
110
|
-
typer.echo("→ Completing authentication …", err=True)
|
|
111
|
-
|
|
112
|
-
try:
|
|
113
|
-
sleep(1) # brief pause to let the session cookie propagate
|
|
114
|
-
session.get(redirect_url, allow_redirects=True)
|
|
115
|
-
except Exception as exc:
|
|
116
|
-
typer.echo(f"Warning: redirect failed, but cookie may still be set: {exc}", err=True)
|
|
117
|
-
|
|
118
|
-
# Step 6: Extract session cookie
|
|
119
|
-
session_token: str | None = session.cookies.get(SESSION_COOKIE_NAME)
|
|
120
|
-
|
|
121
|
-
if not session_token:
|
|
122
|
-
typer.echo(
|
|
123
|
-
f"Error: session cookie '{SESSION_COOKIE_NAME}' not found. "
|
|
124
|
-
"The OTP may have expired or been entered incorrectly.",
|
|
125
|
-
err=True,
|
|
126
|
-
)
|
|
127
|
-
raise typer.Exit(code=1)
|
|
128
|
-
|
|
129
|
-
# Output only the token to stdout for shell capture
|
|
130
|
-
typer.echo(session_token)
|
|
131
|
-
|
|
132
|
-
typer.echo(
|
|
133
|
-
"\n✓ Session token extracted successfully.\n"
|
|
134
|
-
" Store it as an environment variable:\n\n"
|
|
135
|
-
f" export PERPLEXITY_SESSION_TOKEN={session_token!r}",
|
|
136
|
-
err=True,
|
|
137
|
-
)
|
perplexity_webui_scraper-1.0.0/src/perplexity_webui_scraper/cli/get_perplexity_session_token.py
DELETED
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
"""CLI utility for secure Perplexity authentication and session extraction."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from sys import exit
|
|
6
|
-
from typing import NoReturn
|
|
7
|
-
|
|
8
|
-
from curl_cffi import Session
|
|
9
|
-
from orjson import loads
|
|
10
|
-
from pyperclip import PyperclipException, copy
|
|
11
|
-
from rich.console import Console
|
|
12
|
-
from rich.panel import Panel
|
|
13
|
-
from rich.prompt import Confirm, Prompt
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
BASE_URL: str = "https://www.perplexity.ai"
|
|
17
|
-
|
|
18
|
-
console = Console(stderr=True, soft_wrap=True)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _initialize_session() -> tuple[Session, str]:
|
|
22
|
-
"""Initialize session and obtain CSRF token.
|
|
23
|
-
|
|
24
|
-
Returns:
|
|
25
|
-
A tuple of the initialized session and the CSRF token string.
|
|
26
|
-
|
|
27
|
-
Raises:
|
|
28
|
-
ValueError: If the CSRF token cannot be obtained from the API.
|
|
29
|
-
"""
|
|
30
|
-
session = Session(impersonate="chrome", headers={"Referer": BASE_URL, "Origin": BASE_URL})
|
|
31
|
-
|
|
32
|
-
with console.status("[bold green]Initializing secure connection...", spinner="dots"):
|
|
33
|
-
session.get(BASE_URL)
|
|
34
|
-
csrf_data = loads(session.get(f"{BASE_URL}/api/auth/csrf").content)
|
|
35
|
-
csrf = csrf_data.get("csrfToken")
|
|
36
|
-
|
|
37
|
-
if not csrf:
|
|
38
|
-
raise ValueError("Failed to obtain CSRF token.")
|
|
39
|
-
|
|
40
|
-
return session, csrf
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _request_verification_code(session: Session, csrf: str, email: str) -> None:
|
|
44
|
-
"""Send a verification code to the user's email address.
|
|
45
|
-
|
|
46
|
-
Args:
|
|
47
|
-
session: The active curl-cffi session.
|
|
48
|
-
csrf: The CSRF token for the request.
|
|
49
|
-
email: The user's Perplexity account email address.
|
|
50
|
-
|
|
51
|
-
Raises:
|
|
52
|
-
ValueError: If the authentication request returns a non-200 status.
|
|
53
|
-
"""
|
|
54
|
-
with console.status("[bold green]Sending verification code...", spinner="dots"):
|
|
55
|
-
response = session.post(
|
|
56
|
-
f"{BASE_URL}/api/auth/signin/email?version=2.18&source=default",
|
|
57
|
-
json={
|
|
58
|
-
"email": email,
|
|
59
|
-
"csrfToken": csrf,
|
|
60
|
-
"useNumericOtp": "true",
|
|
61
|
-
"json": "true",
|
|
62
|
-
"callbackUrl": f"{BASE_URL}/?login-source=floatingSignup",
|
|
63
|
-
},
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
if response.status_code != 200:
|
|
67
|
-
raise ValueError(f"Authentication request failed: {response.text}")
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _validate_and_get_redirect_url(session: Session, email: str, user_input: str) -> str:
|
|
71
|
-
"""Validate the OTP or magic link and return the authentication redirect URL.
|
|
72
|
-
|
|
73
|
-
Args:
|
|
74
|
-
session: The active curl-cffi session.
|
|
75
|
-
email: The user's Perplexity account email address.
|
|
76
|
-
user_input: Either a 6-digit OTP code or a magic link URL.
|
|
77
|
-
|
|
78
|
-
Returns:
|
|
79
|
-
The full redirect URL to complete authentication.
|
|
80
|
-
|
|
81
|
-
Raises:
|
|
82
|
-
ValueError: If the code is invalid or no redirect URL is returned.
|
|
83
|
-
"""
|
|
84
|
-
with console.status("[bold green]Validating...", spinner="dots"):
|
|
85
|
-
if user_input.startswith("http"):
|
|
86
|
-
return user_input
|
|
87
|
-
|
|
88
|
-
response_otp = session.post(
|
|
89
|
-
f"{BASE_URL}/api/auth/otp-redirect-link",
|
|
90
|
-
json={
|
|
91
|
-
"email": email,
|
|
92
|
-
"otp": user_input,
|
|
93
|
-
"redirectUrl": f"{BASE_URL}/?login-source=floatingSignup",
|
|
94
|
-
"emailLoginMethod": "web-otp",
|
|
95
|
-
},
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
if response_otp.status_code != 200:
|
|
99
|
-
raise ValueError("Invalid verification code.")
|
|
100
|
-
|
|
101
|
-
redirect_path = loads(response_otp.content).get("redirect")
|
|
102
|
-
|
|
103
|
-
if not redirect_path:
|
|
104
|
-
raise ValueError("No redirect URL received.")
|
|
105
|
-
|
|
106
|
-
return f"{BASE_URL}{redirect_path}" if redirect_path.startswith("/") else redirect_path
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def _extract_session_token(session: Session, redirect_url: str) -> str:
|
|
110
|
-
"""Extract the session token from cookies after completing authentication.
|
|
111
|
-
|
|
112
|
-
Args:
|
|
113
|
-
session: The active curl-cffi session.
|
|
114
|
-
redirect_url: The full redirect URL returned after OTP/link validation.
|
|
115
|
-
|
|
116
|
-
Returns:
|
|
117
|
-
The raw ``__Secure-next-auth.session-token`` cookie value.
|
|
118
|
-
|
|
119
|
-
Raises:
|
|
120
|
-
ValueError: If the token cookie is not found after the redirect.
|
|
121
|
-
"""
|
|
122
|
-
session.get(redirect_url)
|
|
123
|
-
token = session.cookies.get("__Secure-next-auth.session-token")
|
|
124
|
-
|
|
125
|
-
if not token:
|
|
126
|
-
raise ValueError("Authentication successful, but token not found.")
|
|
127
|
-
|
|
128
|
-
return token
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def _display_and_copy_token(token: str) -> None:
|
|
132
|
-
"""Display the token and optionally copy it to the system clipboard.
|
|
133
|
-
|
|
134
|
-
Prompts the user with a yes/no question (default: yes). If confirmed,
|
|
135
|
-
copies the token to the clipboard using ``pyperclip``.
|
|
136
|
-
|
|
137
|
-
Args:
|
|
138
|
-
token: The raw session token string to display and copy.
|
|
139
|
-
"""
|
|
140
|
-
console.print("\n[bold green]✅ Token generated successfully![/bold green]")
|
|
141
|
-
console.print(f"\n[bold white]Your session token:[/bold white]\n[green]{token}[/green]\n")
|
|
142
|
-
|
|
143
|
-
if Confirm.ask("Copy token to clipboard?", default=True, console=console):
|
|
144
|
-
try:
|
|
145
|
-
copy(token)
|
|
146
|
-
console.print("[dim]Token copied to clipboard.[/dim]")
|
|
147
|
-
except PyperclipException as error:
|
|
148
|
-
console.print(f"[red]Could not copy to clipboard: {error}[/red]")
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def _show_header() -> None:
|
|
152
|
-
"""Display the welcome header panel."""
|
|
153
|
-
console.print(
|
|
154
|
-
Panel(
|
|
155
|
-
"[bold white]Perplexity WebUI Scraper[/bold white]\n\n"
|
|
156
|
-
"Automatic session token generator via email authentication.\n"
|
|
157
|
-
"[dim]All session data will be cleared on exit.[/dim]",
|
|
158
|
-
title="🔐 Token Generator",
|
|
159
|
-
border_style="cyan",
|
|
160
|
-
)
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def _show_exit_message() -> None:
|
|
165
|
-
"""Display the security note and wait for the user to press ENTER before clearing the screen."""
|
|
166
|
-
console.print("\n[bold yellow]⚠️ Security Note:[/bold yellow]")
|
|
167
|
-
console.print("Press [bold white]ENTER[/bold white] to clear screen and exit.")
|
|
168
|
-
console.input()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def get_token() -> NoReturn:
|
|
172
|
-
"""Run the full authentication flow inside an ephemeral terminal screen.
|
|
173
|
-
|
|
174
|
-
Guides the user through email-based sign-in (OTP or magic link),
|
|
175
|
-
displays the extracted session token, and offers to copy it to the
|
|
176
|
-
clipboard. The screen is cleared on exit for security.
|
|
177
|
-
"""
|
|
178
|
-
with console.screen():
|
|
179
|
-
try:
|
|
180
|
-
_show_header()
|
|
181
|
-
|
|
182
|
-
session, csrf = _initialize_session()
|
|
183
|
-
|
|
184
|
-
console.print("\n[bold cyan]Step 1: Email Verification[/bold cyan]")
|
|
185
|
-
email = Prompt.ask(" Enter your Perplexity email", console=console)
|
|
186
|
-
_request_verification_code(session, csrf, email)
|
|
187
|
-
|
|
188
|
-
console.print("\n[bold cyan]Step 2: Verification[/bold cyan]")
|
|
189
|
-
console.print(" Check your email for a [bold]6-digit code[/bold] or [bold]magic link[/bold].")
|
|
190
|
-
user_input = Prompt.ask(" Enter code or paste link", console=console).strip()
|
|
191
|
-
redirect_url = _validate_and_get_redirect_url(session, email, user_input)
|
|
192
|
-
|
|
193
|
-
token = _extract_session_token(session, redirect_url)
|
|
194
|
-
|
|
195
|
-
_display_and_copy_token(token)
|
|
196
|
-
|
|
197
|
-
_show_exit_message()
|
|
198
|
-
|
|
199
|
-
exit(0)
|
|
200
|
-
except KeyboardInterrupt:
|
|
201
|
-
exit(0)
|
|
202
|
-
except Exception as error:
|
|
203
|
-
console.print(f"\n[bold red]⛔ Error:[/bold red] {error}")
|
|
204
|
-
console.input("[dim]Press ENTER to exit...[/dim]")
|
|
205
|
-
exit(1)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if __name__ == "__main__":
|
|
209
|
-
get_token()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|