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.
Files changed (59) hide show
  1. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/PKG-INFO +1 -1
  2. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/pyproject.toml +2 -1
  3. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_static/models.json +15 -15
  4. perplexity_webui_scraper-1.0.2/src/perplexity_webui_scraper/api/cli.py +65 -0
  5. perplexity_webui_scraper-1.0.2/src/perplexity_webui_scraper/api/launcher.py +65 -0
  6. perplexity_webui_scraper-1.0.2/src/perplexity_webui_scraper/cli/commands/get_session_token.py +162 -0
  7. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/mcp/server.py +2 -2
  8. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/mcp/tools/__init__.py +2 -5
  9. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/models/registry.py +2 -2
  10. perplexity_webui_scraper-1.0.0/src/perplexity_webui_scraper/api/cli.py +0 -73
  11. perplexity_webui_scraper-1.0.0/src/perplexity_webui_scraper/api/launcher.py +0 -73
  12. perplexity_webui_scraper-1.0.0/src/perplexity_webui_scraper/cli/commands/get_session_token.py +0 -137
  13. perplexity_webui_scraper-1.0.0/src/perplexity_webui_scraper/cli/get_perplexity_session_token.py +0 -209
  14. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/README.md +0 -0
  15. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/__init__.py +0 -0
  16. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_internal/__init__.py +0 -0
  17. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_internal/constants.py +0 -0
  18. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_internal/exceptions.py +0 -0
  19. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_internal/logging.py +0 -0
  20. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_internal/types.py +0 -0
  21. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/_static/__init__.py +0 -0
  22. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/__init__.py +0 -0
  23. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/__main__.py +0 -0
  24. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/app.py +0 -0
  25. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/auth.py +0 -0
  26. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/conversation_cache.py +0 -0
  27. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/helpers.py +0 -0
  28. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/models.py +0 -0
  29. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/routes/__init__.py +0 -0
  30. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/routes/completions.py +0 -0
  31. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/routes/models.py +0 -0
  32. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/schemas/__init__.py +0 -0
  33. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/schemas/errors.py +0 -0
  34. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/schemas/request.py +0 -0
  35. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/schemas/response.py +0 -0
  36. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/api/server.py +0 -0
  37. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/cli/__init__.py +0 -0
  38. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/cli/__main__.py +0 -0
  39. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/cli/commands/__init__.py +0 -0
  40. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/config/__init__.py +0 -0
  41. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/config/client.py +0 -0
  42. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/config/conversation.py +0 -0
  43. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/__init__.py +0 -0
  44. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/client.py +0 -0
  45. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/conversation.py +0 -0
  46. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/files.py +0 -0
  47. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/parser.py +0 -0
  48. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/payload.py +0 -0
  49. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/core/response.py +0 -0
  50. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/http/__init__.py +0 -0
  51. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/http/client.py +0 -0
  52. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/http/fingerprint.py +0 -0
  53. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/http/resilience.py +0 -0
  54. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/mcp/__init__.py +0 -0
  55. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/mcp/__main__.py +0 -0
  56. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/mcp/tools/ask.py +0 -0
  57. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/models/__init__.py +0 -0
  58. {perplexity_webui_scraper-1.0.0 → perplexity_webui_scraper-1.0.2}/src/perplexity_webui_scraper/models/types.py +0 -0
  59. {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.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.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": "Pro",
5
- "description": "Perplexity Pro (Auto-select).",
4
+ "name": "Best",
5
+ "description": "Perplexity Best (Auto-select).",
6
6
  "identifier": "default",
7
- "tool_name": "pplx_ask",
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-opus-4.6",
76
- "name": "Claude Opus 4.6",
77
- "description": "Anthropic Claude Opus 4.6.",
78
- "identifier": "claude46opus",
79
- "tool_name": "pplx_claude_o46",
80
- "min_tier": "max",
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-opus-4.6-thinking",
85
- "name": "Claude Opus 4.6 Thinking",
86
- "description": "Anthropic Claude Opus 4.6 (Thinking).",
87
- "identifier": "claude46opusthinking",
88
- "tool_name": "pplx_claude_o46_think",
89
- "min_tier": "max",
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 os
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 = os.environ.get("PERPLEXITY_SESSION_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 TYPE_CHECKING, Any
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 orjson
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]] = orjson.loads(raw)
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
- )
@@ -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
- )
@@ -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()