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) 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) except (json.JSONDecodeError, KeyError): continue elif content.startswith("event: end_of_stream\r\n"): return chunks[-1] if chunks else {}