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