websearch / perplexity /client.py
hermesinho's picture
v3: MCP server with auto-account, updated models
7099c5c
Raw
History Blame Contribute Delete
7.83 kB
import re
import sys
import json
import random
import mimetypes
from uuid import uuid4
from curl_cffi import CurlMime, requests
from .config import (
DEFAULT_HEADERS,
ENDPOINT_AUTH_SESSION,
ENDPOINT_AUTH_SIGNIN,
ENDPOINT_SSE_ASK,
ENDPOINT_UPLOAD_URL,
MODEL_MAPPINGS,
)
from .emailnator import Emailnator
from .exceptions import (
AuthenticationError,
FileUploadError,
InvalidModeError,
InvalidModelError,
InvalidSourceError,
QueryLimitExceededError,
)
from .utils import parse_nested_json_response, validate_search_params, validate_query_limits
from .logger import get_logger
logger = get_logger("client")
class Client:
def __init__(self, cookies={}):
self.session = requests.Session(
headers=DEFAULT_HEADERS.copy(),
cookies=cookies,
impersonate="chrome",
)
self.own = bool(cookies)
self.copilot = 0 if not cookies else 5
self.file_upload = 0 if not cookies else 10
self.signin_regex = re.compile(
r'"(https://www\.perplexity\.ai/api/auth/callback/email\?callbackUrl=.*?)"'
)
self.timestamp = format(random.getrandbits(32), "08x")
self.session.get(ENDPOINT_AUTH_SESSION, timeout=15)
def _get_csrf(self):
csrf = self.session.cookies.get_dict().get("next-auth.csrf-token", "")
if csrf:
return csrf.split("%")[0]
try:
resp = self.session.get("https://www.perplexity.ai/api/auth/csrf", timeout=15)
return resp.json().get("csrfToken", "")
except Exception:
return ""
def create_account(self, cookies):
while True:
try:
emailnator_cli = Emailnator(cookies)
csrf = self._get_csrf()
resp = self.session.post(
ENDPOINT_AUTH_SIGNIN,
json={
"email": emailnator_cli.email,
"csrfToken": csrf,
"callbackUrl": "https://www.perplexity.ai/",
},
timeout=15,
)
if resp.ok:
new_msgs = emailnator_cli.reload(
wait_for=lambda x: x["subject"] == "Sign in to Perplexity",
timeout=20,
)
if new_msgs:
break
else:
logger.error(f"Account creation failed: {resp.status_code} {resp.text[:200]}")
except Exception as e:
logger.warning(f"Account creation attempt failed: {e}")
msg = emailnator_cli.get(func=lambda x: x["subject"] == "Sign in to Perplexity")
new_account_link = self.signin_regex.search(emailnator_cli.open(msg["messageID"])).group(1)
self.session.get(new_account_link, timeout=15)
self.copilot = 5
self.file_upload = 10
return True
def search(
self,
query,
mode="auto",
model=None,
sources=["web"],
files={},
stream=False,
language="en-US",
follow_up=None,
incognito=False,
):
try:
validate_search_params(mode, model, sources, self.own)
validate_query_limits(self.copilot, self.file_upload, mode, len(files))
except Exception as e:
raise
self.copilot = (
self.copilot - 1 if mode in ["pro", "reasoning", "deep research"] else self.copilot
)
self.file_upload = self.file_upload - len(files) if files else self.file_upload
uploaded_files = []
for filename, file in files.items():
file_type = mimetypes.guess_type(filename)[0]
file_upload_info = (
self.session.post(
ENDPOINT_UPLOAD_URL,
params={"version": "2.18", "source": "default"},
json={
"content_type": file_type,
"file_size": sys.getsizeof(file),
"filename": filename,
"force_image": False,
"source": "default",
},
timeout=30,
)
).json()
mp = CurlMime()
for key, value in file_upload_info["fields"].items():
mp.addpart(name=key, data=value)
mp.addpart(
name="file",
content_type=file_type,
filename=filename,
data=file,
)
upload_resp = self.session.post(file_upload_info["s3_bucket_url"], multipart=mp, timeout=30)
if not upload_resp.ok:
raise FileUploadError(f"File upload failed: {upload_resp.status_code}")
if "image/upload" in file_upload_info["s3_object_url"]:
uploaded_url = re.sub(
r"/private/s--.*?--/v\d+/user_uploads/",
"/private/user_uploads/",
upload_resp.json()["secure_url"],
)
else:
uploaded_url = file_upload_info["s3_object_url"]
uploaded_files.append(uploaded_url)
json_data = {
"query_str": query,
"params": {
"attachments": (
uploaded_files + follow_up.get("attachments", []) if follow_up else uploaded_files
),
"frontend_context_uuid": str(uuid4()),
"frontend_uuid": str(uuid4()),
"is_incognito": incognito,
"language": language,
"last_backend_uuid": (follow_up["backend_uuid"] if follow_up else None),
"mode": "concise" if mode == "auto" else "copilot",
"model_preference": MODEL_MAPPINGS[mode][model],
"source": "default",
"sources": sources,
"version": "2.18",
},
}
resp = self.session.post(ENDPOINT_SSE_ASK, json=json_data, stream=True, timeout=120)
chunks = []
def stream_response(resp):
for chunk in resp.iter_lines(delimiter=b"\r\n\r\n"):
content = chunk.decode("utf-8")
if content.startswith("event: message\r\n"):
try:
content_json = json.loads(content[len("event: message\r\ndata: "):])
content_json = parse_nested_json_response(content_json)
chunks.append(content_json)
yield chunks[-1]
except (json.JSONDecodeError, KeyError):
continue
elif content.startswith("event: end_of_stream\r\n"):
return
if stream:
return stream_response(resp)
last_complete = {}
for chunk in resp.iter_lines(delimiter=b"\r\n\r\n"):
content = chunk.decode("utf-8")
if content.startswith("event: message\r\n"):
try:
content_json = json.loads(content[len("event: message\r\ndata: "):])
content_json = parse_nested_json_response(content_json)
chunks.append(content_json)
# Keep track of chunks that have an answer field
if content_json.get("answer"):
last_complete = content_json
except (json.JSONDecodeError, KeyError):
continue
elif content.startswith("event: end_of_stream\r\n"):
# Return the last chunk that had an answer, or the last chunk
return last_complete if last_complete else (chunks[-1] if chunks else {})