返回提交历史
Modified
etc/unittest/test_cdp_parallel.py
+1
-59
Modified
g4f/Provider/DeepInfra.py
+15
-1
Modified
g4f/Provider/needs_auth/BingCreateImages.py
+1
-0
Modified
g4f/Provider/needs_auth/Cerebras.py
+1
-1
Modified
g4f/Provider/needs_auth/OpenaiChat.py
+208
-27
Modified
g4f/api/__init__.py
+5
-2
Modified
g4f/gui/server/website.py
+1
-1
Modified
g4f/requests/__init__.py
+16
-5
Modified
g4f/requests/cdp.py
+69
-24
Modified
g4f/requests/cdp_browser.py
+39
-17
XFEstudio/gpt4free
Add provider improvments
0c72e0da
代码差异
10 个文件
+356
-137
@@ -1,7 +1,7 @@
1
1
"""
2
2
Tests for parallel CDP tab support.
3
3
4
Verifies that multiple CDPSession / SyncCDPSession instances can share a
4
Verifies that multiple CDPSession instances can share a
5
5
single browser process concurrently without killing each other when one
6
6
tab closes.
7
7
"""
@@ -14,7 +14,6 @@ import unittest
14
14
15
15
from g4f.requests.cdp import (
16
16
CDPSession,
17
SyncCDPSession,
18
17
acquire_shared_browser_ref,
19
18
release_shared_browser_ref,
20
19
get_shared_browser,
@@ -206,62 +205,5 @@ class TestCDPSessionParallel(unittest.TestCase):
206
205
207
206
asyncio.run(run())
208
207
209
210
@unittest.skipUnless(
211
(__import__("shutil").which("google-chrome")
212
or __import__("shutil").which("chromium")
213
or __import__("shutil").which("chromium-browser")
214
or __import__("os").path.exists("/usr/bin/google-chrome")
215
or __import__("os").path.exists("/usr/bin/chromium-browser"))
216
and __import__("importlib").util.find_spec("websocket"),
217
"No Chrome/Chromium executable or websocket-client not installed",
218
)
219
class TestSyncCDPSessionParallel(unittest.TestCase):
220
"""Integration tests for SyncCDPSession parallel tab support."""
221
222
def setUp(self):
223
_force_terminate_shared_browser()
224
225
def tearDown(self):
226
_force_terminate_shared_browser()
227
228
def test_two_sync_sessions_same_browser(self):
229
"""Two SyncCDPSession instances should share the same browser port."""
230
s1 = SyncCDPSession(headless=True)
231
s2 = SyncCDPSession(headless=True)
232
s1.start_chrome()
233
s2.start_chrome()
234
try:
235
self.assertEqual(s1.port, s2.port)
236
self.assertNotEqual(s1.target_id, s2.target_id)
237
s1.navigate("about:blank")
238
s2.navigate("about:blank")
239
self.assertTrue(_browser_alive(s1.host, s1.port))
240
finally:
241
s2.close()
242
# Browser should still be alive
243
self.assertTrue(_browser_alive(s1.host, s1.port))
244
s1.close()
245
# Browser stays alive (idle timer keeps it for reuse)
246
self.assertTrue(_browser_alive("127.0.0.1", s1.port))
247
248
def test_sync_close_one_keeps_browser(self):
249
"""Closing one SyncCDPSession must not kill the browser while another is active."""
250
s1 = SyncCDPSession(headless=True)
251
s2 = SyncCDPSession(headless=True)
252
s1.start_chrome()
253
s2.start_chrome()
254
port = s1.port
255
host = s1.host
256
try:
257
s2.close()
258
self.assertTrue(_browser_alive(host, port))
259
s1.navigate("about:blank")
260
self.assertTrue(_browser_alive(host, port))
261
finally:
262
s1.close()
263
self.assertTrue(_browser_alive(host, port))
264
265
266
208
if __name__ == "__main__":
267
209
unittest.main()
@@ -54,6 +54,9 @@ async def _get_turnstile_token_async(model: str) -> str:
54
54
debug.log("[DeepInfra] Waiting for active textarea...")
55
55
text_entered = False
56
56
for _ in range(80): # Up to 40 seconds
57
if not session.is_alive:
58
debug.log("[DeepInfra] Browser session lost, aborting textarea wait.")
59
break
57
60
try:
58
61
ready = await session.evaluate_js(
59
62
"""
@@ -126,6 +129,10 @@ async def _get_turnstile_token_async(model: str) -> str:
126
129
127
130
text_entered = True
128
131
break
132
except (ConnectionError, RuntimeError) as e:
133
if not session.is_alive:
134
debug.log(f"[DeepInfra] Browser session lost during textarea wait: {e}")
135
break
129
136
except Exception:
130
137
pass
131
138
await asyncio.sleep(0.5)
@@ -142,11 +149,18 @@ async def _get_turnstile_token_async(model: str) -> str:
142
149
token_js = "document.querySelector('[name=cf-turnstile-response]') ? document.querySelector('[name=cf-turnstile-response]').value : ''"
143
150
token = ""
144
151
for i in range(240): # Up to 120 seconds per attempt
152
if not session.is_alive:
153
debug.log("[DeepInfra] Browser session lost, aborting Turnstile token poll.")
154
break
145
155
try:
146
156
token = await session.evaluate_js(token_js)
147
157
if token:
148
158
debug.log(f"[DeepInfra] Token generated on check {i+1}!")
149
159
return token
160
except (ConnectionError, RuntimeError) as e:
161
if not session.is_alive:
162
debug.log(f"[DeepInfra] Browser session lost during token poll: {e}")
163
break
150
164
except Exception:
151
165
pass
152
166
await asyncio.sleep(0.5)
@@ -171,7 +185,7 @@ class DeepInfra(OpenaiTemplate):
171
185
login_url = "https://deepinfra.com/dash/api_keys"
172
186
base_url = "https://api.deepinfra.com/v1/openai"
173
187
174
working = True
188
working = False
175
189
active_by_default = True
176
190
177
191
default_model = "zai-org/GLM-5.2"
@@ -12,6 +12,7 @@ from ..helper import format_media_prompt
12
12
class BingCreateImages(AsyncGeneratorProvider, ProviderModelMixin):
13
13
label = "Microsoft Designer in Bing"
14
14
url = "https://www.bing.com/images/create"
15
screenshot_url = "https://www.bing.com"
15
16
working = True
16
17
needs_auth = True
17
18
image_models = ["dall-e-3"]
@@ -10,7 +10,7 @@ from ...cookies import get_cookies, get_cookies_async
10
10
11
11
class Cerebras(OpenaiAPI):
12
12
label = "Cerebras Inference"
13
url = "https://chat.cerebras.ai/"
13
url = "https://chat.cerebras.ai"
14
14
login_url = "https://cloud.cerebras.ai"
15
15
base_url = "https://api.cerebras.ai/v1"
16
16
working = True
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
import asyncio
4
4
import base64
5
5
import hashlib
6
import html
6
7
import json
7
8
import os
8
9
import random
@@ -91,6 +92,21 @@ _RE_IMAGES = re.compile(r"^/message/metadata/content_references/(\d+)/images$")
91
92
_RE_ACCESS_TOKEN = re.compile(r'"accessToken":"(.+?)"')
92
93
_RE_UTM_SOURCE = re.compile(r"[&?]utm_source=.+")
93
94
95
# New anonymous/guest chat surface ("web-mobile") — used when no access token
96
# is available; the classic backend-anon/f/conversation API no longer serves
97
# unauthenticated requests.
98
mweb_chat_requirements_prepare_url = "https://chatgpt.com/unauth-mweb/sentinel/chat-requirements/prepare"
99
mweb_chat_requirements_finalize_url = "https://chatgpt.com/unauth-mweb/sentinel/chat-requirements/finalize"
100
mweb_conversation_prepare_url = "https://chatgpt.com/unauth-mweb/conversation/prepare"
101
mweb_conversation_updates_url = "https://chatgpt.com/unauth-mweb/conversation/updates"
102
_RE_MWEB_CONVERSATION_ID = re.compile(r'data-conversation-id="([\w-]+)"')
103
_RE_MWEB_MESSAGE_ID = re.compile(r'data-message-id="([\w-]+)"')
104
_RE_MWEB_ASSISTANT_BLOCK = re.compile(
105
r'<p data-assistant-stream-block="" data-assistant-stream-block-index="(\d+)">(.*?)</p>',
106
re.DOTALL,
107
)
108
_RE_MWEB_MARKER = re.compile(r'<\?[^>]*>')
109
94
110
DEFAULT_HEADERS = {
95
111
"accept": "*/*",
96
112
"accept-encoding": "gzip, deflate, br, zstd",
@@ -167,6 +183,7 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
167
183
model_aliases = model_aliases
168
184
synthesize_content_type = "audio/aac"
169
185
request_config = RequestConfig()
186
supports_native_tools = True
170
187
quota_url = "https://chatgpt.com/backend-api/me"
171
188
172
189
_api_key: str = None
@@ -467,6 +484,108 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
467
484
{"status": status, "headers": auth_result.headers},
468
485
)
469
486
487
@classmethod
488
async def create_anonymous_mweb(
489
cls,
490
session: StreamSession,
491
auth_result: AuthResult,
492
prompt: str,
493
conversation: Conversation,
494
) -> str:
495
"""Send a single guest message via ChatGPT's "web-mobile" surface.
496
497
Replaces the retired ``backend-anon/f/conversation`` JSON API, which
498
no longer serves unauthenticated requests. This surface returns the
499
full (non-streamed) reply as an HTML partial-update document.
500
"""
501
user_agent = getattr(auth_result, "headers", {}).get("user-agent")
502
proof_token = getattr(auth_result, "proof_token", None)
503
if proof_token is None:
504
proof_token = auth_result.proof_token = get_config(user_agent)
505
json_headers = {**cls._headers, "accept": "application/json", "content-type": "application/json"}
506
async with session.post(
507
mweb_chat_requirements_prepare_url,
508
json={"p": get_requirements_token(proof_token)},
509
headers=json_headers,
510
) as response:
511
await raise_for_status(response)
512
prepare_token = (await response.json())["prepare_token"]
513
async with session.post(
514
mweb_chat_requirements_finalize_url,
515
json={"prepare_token": prepare_token},
516
headers=json_headers,
517
) as response:
518
await raise_for_status(response)
519
chat_requirements_token = (await response.json())["token"]
520
session_id = str(uuid.uuid4())
521
operation_id = str(uuid.uuid4())
522
conversation_state = {
523
"messages": [],
524
"parentMessageId": conversation.parent_message_id or "client-created-root",
525
"userMessageCount": 0,
526
}
527
form_headers = {
528
**cls._headers,
529
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
530
"oai-session-id": session_id,
531
}
532
# Registers a "document worker" for this session — without this the
533
# updates call below responds with conversation-document-upgrade-required.
534
async with session.post(
535
f"{mweb_conversation_prepare_url}?lightweight_authenticated=0",
536
data={
537
"conversationRetryOwner": json.dumps({"mode": "anonymous", "sessionEpoch": None}),
538
"conversationState": json.dumps(conversation_state),
539
"clientContextualInfo": json.dumps({
540
"app_name": "chatgpt.com",
541
"has_web_push_capabilities": True,
542
"is_dark_mode": False,
543
"web_push_notification_permission": "default",
544
"page_height": 800,
545
"page_width": 1280,
546
"pixel_ratio": 1,
547
"screen_height": 1080,
548
"screen_width": 1920,
549
"time_since_loaded": random.randint(2, 10),
550
}),
551
"timezone": "Europe/Berlin",
552
"timezoneOffsetMinutes": -120,
553
},
554
headers={**form_headers, "accept": "*/*"},
555
) as response:
556
await raise_for_status(response)
557
form_data = {
558
"conversationState": json.dumps(conversation_state),
559
"messageMetadata": "{}",
560
"oai-session-id": session_id,
561
"imageAttachments": "[]",
562
"pendingImageUploads": "[]",
563
"prompt": prompt,
564
"chatRequirementsToken": chat_requirements_token,
565
}
566
async with session.post(
567
f"{mweb_conversation_updates_url}?lightweight_authenticated=0&operationId={operation_id}",
568
data=form_data,
569
headers={**form_headers, "accept": "text/vnd.openai.web-mobile-partial+html"},
570
) as response:
571
await raise_for_status(response)
572
text = await response.text()
573
conversation_id_match = _RE_MWEB_CONVERSATION_ID.search(text)
574
if conversation_id_match:
575
conversation.conversation_id = conversation_id_match.group(1)
576
message_id_match = _RE_MWEB_MESSAGE_ID.search(text)
577
if message_id_match:
578
conversation.parent_message_id = conversation.message_id = message_id_match.group(1)
579
conversation.finish_reason = "stop"
580
# Later blocks with the same index are streaming updates that
581
# supersede earlier (partial) ones — keep only the last per index.
582
blocks = {}
583
for index, block in _RE_MWEB_ASSISTANT_BLOCK.findall(text):
584
blocks[int(index)] = _RE_MWEB_MARKER.sub("", block)
585
if not blocks:
586
debug.log(f"OpenaiChat: MWEB response had no assistant block: {text[:500]!r}")
587
return html.unescape("".join(blocks[index] for index in sorted(blocks)))
588
470
589
@classmethod
471
590
async def create_authed(
472
591
cls,
@@ -518,6 +637,20 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
518
637
) as session:
519
638
image_requests = None
520
639
media = merge_media(media, messages)
640
# A previously captured token can outlive its own expiry across
641
# calls (cls._api_key is a class-level attribute) — drop it here
642
# so a stale/expired token doesn't get treated as authenticated.
643
if (
644
cls._api_key is not None
645
and cls._expires is not None
646
and time.time() > cls._expires
647
):
648
cls._api_key = None
649
if cls._api_key is None and media:
650
# Anonymous chat doesn't support image uploads yet (the
651
# retired backend-anon endpoints used for that no longer work).
652
debug.log("OpenaiChat: Dropping media for anonymous chat (not supported)")
653
media = []
521
654
if not cls.needs_auth and not media:
522
655
if cls._headers is None:
523
656
cls._create_request_args(cls._cookies)
@@ -572,6 +705,20 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
572
705
if cls._api_key is None:
573
706
auto_continue = False
574
707
conversation.finish_reason = None
708
if cls._api_key is None:
709
# Guest chat now goes through the "web-mobile" surface —
710
# the legacy backend-anon JSON API no longer accepts requests.
711
prompt = conversation.prompt = format_media_prompt(messages, prompt)
712
print(f"OpenaiChat: Guest prompt: {prompt}")
713
reply = await cls.create_anonymous_mweb(session, auth_result, prompt, conversation)
714
print(f"OpenaiChat: Guest reply: {reply}")
715
if reply:
716
yield reply
717
conversation.prompt = None
718
if return_conversation:
719
yield conversation
720
yield FinishReason(conversation.finish_reason)
721
return
575
722
sources = OpenAISources([])
576
723
references = ContentReferences()
577
724
system_hints = ["picture_v2"] if image_model else []
@@ -727,10 +874,10 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
727
874
headers=headers,
728
875
) as response:
729
876
cls._update_request_args(auth_result, session)
730
if response.status in (401, 403, 429, 500):
731
raise MissingAuthError("Access token is not valid")
732
elif response.status == 422:
733
raise RuntimeError((await response.json()), data)
877
# if response.status in (401, 403, 429, 500):
878
# raise MissingAuthError("Access token is not valid")
879
# elif response.status == 422:
880
# raise RuntimeError((await response.json()), data)
734
881
await raise_for_status(response)
735
882
buffer = ""
736
883
matches = []
@@ -1353,19 +1500,25 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
1353
1500
f"Access token is not valid: {cls.request_config.access_token}"
1354
1501
)
1355
1502
except NoValidHarFileError:
1356
if cls.request_config.access_token is None:
1357
yield RequestLogin(
1358
cls.label, os.environ.get("G4F_LOGIN_URL", "")
1359
)
1360
await cls.nodriver_auth(proxy)
1361
else:
1362
raise
1503
# An expired cached token needs the same browser re-login as a
1504
# missing one — re-raising here would surface a stale
1505
# MissingAuthError instead of actually refreshing the token.
1506
yield RequestLogin(
1507
cls.label, os.environ.get("G4F_LOGIN_URL", "")
1508
)
1509
await cls.nodriver_auth(proxy)
1363
1510
1364
1511
@classmethod
1365
1512
async def nodriver_auth(cls, proxy: str = None):
1366
1513
async with get_nodriver_session(proxy=proxy) as browser:
1367
1514
page = await browser.get(cls.url)
1515
try:
1516
await cls._nodriver_auth_page(page)
1517
finally:
1518
await page.close()
1368
1519
1520
@classmethod
1521
async def _nodriver_auth_page(cls, page):
1369
1522
def on_request(event, page=None):
1370
1523
if not hasattr(event, "request"):
1371
1524
return
@@ -1410,31 +1563,58 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
1410
1563
"window.navigator.userAgent", return_by_value=True
1411
1564
)
1412
1565
debug.log(f"OpenaiChat: User-Agent: {user_agent}")
1413
for _ in range(3):
1566
logged_in = not cls.needs_auth
1567
for attempt in range(3):
1414
1568
try:
1415
1569
if cls.needs_auth:
1416
try:
1417
await page.select(
1418
'[data-testid="accounts-profile-button"]', 300
1570
debug.log(
1571
f"OpenaiChat: Waiting for login (attempt {attempt + 1}/3, up to 300s)..."
1572
)
1573
profile_button = await page.select(
1574
'[data-testid="accounts-profile-button"]', 300
1575
)
1576
if profile_button is None:
1577
title = await page.evaluate("document.title", return_by_value=True)
1578
url = await page.evaluate("window.location.href", return_by_value=True)
1579
debug.log(
1580
f"OpenaiChat: Not logged in yet (title={title!r}, url={url!r})"
1419
1581
)
1420
except TimeoutError:
1421
1582
continue
1422
try:
1423
textarea = await page.select("#prompt-textarea", 300)
1424
await textarea.send_keys("Hello")
1425
await asyncio.sleep(1)
1426
except TimeoutError:
1583
logged_in = True
1584
debug.log(
1585
f"OpenaiChat: Waiting for #prompt-textarea (attempt {attempt + 1}/3, up to 300s)..."
1586
)
1587
textarea = await page.select("#prompt-textarea, #mobile-composer-prompt", 300)
1588
if textarea is None:
1589
title = await page.evaluate("document.title", return_by_value=True)
1590
url = await page.evaluate("window.location.href", return_by_value=True)
1591
debug.log(
1592
f"OpenaiChat: #prompt-textarea not found (title={title!r}, url={url!r})"
1593
)
1427
1594
continue
1595
await textarea.send_keys("Hello")
1596
await asyncio.sleep(1)
1428
1597
except cdp.runtime.ProtocolException:
1429
1598
continue
1430
1599
break
1431
try:
1432
button = await page.select('[data-testid="send-button"]')
1600
if not logged_in:
1601
# Without a confirmed login the page falls back to ChatGPT's
1602
# anonymous "unauth-mweb" guest UI, which never yields a real
1603
# access token — fail clearly instead of hanging or silently
1604
# continuing as a guest.
1605
raise MissingAuthError(
1606
"Login was not completed in the browser window in time"
1607
)
1608
# Mobile layout uses [data-composer-submit] instead of data-testid.
1609
button = await page.select(
1610
'[data-testid="send-button"], [data-composer-submit]'
1611
)
1612
if button is not None:
1433
1613
await button.click()
1434
1614
debug.log("OpenaiChat: 'Hello' sended")
1435
except TimeoutError:
1436
pass
1437
while True:
1615
else:
1616
debug.log("OpenaiChat: send-button not found, 'Hello' not sent")
1617
for _ in range(120):
1438
1618
body = await page.evaluate(
1439
1619
"JSON.stringify(window.__remixContext)", return_by_value=True
1440
1620
)
@@ -1452,6 +1632,8 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
1452
1632
debug.log(
1453
1633
f"OpenaiChat: Access token: {'False' if cls._api_key is None else cls._api_key[:12] + '...'}"
1454
1634
)
1635
if cls.needs_auth and cls._api_key is None:
1636
raise MissingAuthError("Could not obtain an access token after login")
1455
1637
# while True:
1456
1638
# if cls.request_config.proof_token:
1457
1639
# break
@@ -1462,7 +1644,6 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
1462
1644
"document.documentElement.getAttribute('data-build')"
1463
1645
)
1464
1646
cls.request_config.cookies = await page.send(get_cookies([cls.url]))
1465
await page.close()
1466
1647
cls._create_request_args(
1467
1648
cls.request_config.cookies,
1468
1649
cls.request_config.headers,
@@ -16,7 +16,7 @@ import os.path
16
16
import hashlib
17
17
import base64
18
18
from contextlib import asynccontextmanager
19
from urllib.parse import quote_plus
19
from urllib.parse import quote_plus, unquote_plus
20
20
from fastapi import FastAPI, Response, Request, UploadFile, Form, Depends, Header
21
21
from fastapi.responses import (
22
22
StreamingResponse,
@@ -158,7 +158,7 @@ section{padding:28px 0 92px}.section-head{display:flex;justify-content:space-bet
158
158
<a class="card" href="/docs"><h3>Live schema</h3><p>Try requests in your browser and inspect the generated OpenAPI contract.</p><span class="endpoint">GET /docs</span></a>
159
159
</div></section><section><div class="section-head"><h2>Start here</h2><span>curl · JSON · streamable</span></div><div class="code-box"><button class="copy" onclick="copyExample(this)">copy</button><pre id="example">curl -X POST /v1/chat/completions \\
160
160
-H 'Content-Type: application/json' \\
161
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}'</pre></div></section></main>
161
-d '{"model":"auto","messages":[{"role":"user","content":"Hello"}]}'</pre></div></section></main>
162
162
<footer>g4f API · <a href="/openapi.json">openapi.json</a> · Compatible clients welcome.</footer></div>
163
163
<script>function copyExample(button){navigator.clipboard?.writeText(document.getElementById("example").textContent).then(()=>{button.textContent="copied";setTimeout(()=>button.textContent="copy",1400)})}</script></body></html>"""
164
164
@@ -2473,6 +2473,9 @@ def run_api(
2473
2473
uvicorn_options = {
2474
2474
"timeout_keep_alive": 65,
2475
2475
"backlog": 2048,
2476
# Avoid hanging forever on shutdown when a long-running request (e.g.
2477
# a browser-based provider login) is still in-flight.
2478
"timeout_graceful_shutdown": 10,
2476
2479
}
2477
2480
uvicorn_options.update(filter_none(**kwargs))
2478
2481
@@ -370,7 +370,7 @@ class Website:
370
370
# Screenshot / logo section
371
371
screenshot_url = p.get("screenshot_url") or p.get("url") or ""
372
372
create_url = f"/screenshot?url={quote_plus(str(screenshot_url))}"
373
screenshot_url = screenshot_url.replace("https://", "").replace("http://", "")
373
screenshot_url = screenshot_url.replace("https://", "").replace("http://", "").replace("www.", "")
374
374
logo_url = "https://g4f.space/logo/" + p.get("name", "").replace(
375
375
'MetaAIAccount', 'Facebook AI').replace(
376
376
'MetaAI', 'Facebook AI').replace(
@@ -9,7 +9,7 @@ from collections.abc import Callable
9
9
from contextlib import asynccontextmanager
10
10
from http.cookies import Morsel
11
11
from pathlib import Path
12
from typing import Iterator, AsyncIterator
12
from typing import Iterator, AsyncIterator, Optional
13
13
from urllib.parse import urlparse
14
14
15
15
try:
@@ -50,6 +50,8 @@ from .cdp_browser import (
50
50
get_cookie_params_from_dict as _get_cookie_params_from_dict_cdp,
51
51
)
52
52
53
Browser = CDPBrowser
54
53
55
from .. import debug
54
56
from .raise_for_status import raise_for_status
55
57
from ..errors import MissingRequirementsError
@@ -280,13 +282,14 @@ def _make_cdp_on_stop(user_data_dir: str):
280
282
return on_stop
281
283
282
284
def set_browser_executable_path(browser_executable_path: str):
283
BrowserConfig.browser_executable_path = browser_executable_path
285
BrowserConfig.executable_path = browser_executable_path
284
286
285
287
async def get_nodriver(
286
288
proxy: str = None,
287
289
user_data_dir="nodriver",
288
290
timeout: int = 300,
289
291
browser_executable_path: str = None,
292
browser_args: list = None,
290
293
**kwargs,
291
294
) -> tuple:
292
295
"""Return a CDPBrowser wrapper that emulates the nodriver Browser API.
@@ -300,6 +303,9 @@ async def get_nodriver(
300
303
'Chrome/Chromium/Edge executable not found. Install Google Chrome.'
301
304
)
302
305
306
if browser_executable_path:
307
set_browser_executable_path(browser_executable_path)
308
303
309
ud_key = str(user_data_dir) if user_data_dir else "default"
304
310
305
311
async with _shared_cdp_lock:
@@ -312,7 +318,10 @@ async def get_nodriver(
312
318
313
319
# No shared browser yet — create a new CDPBrowser
314
320
headless = BrowserConfig.headless if BrowserConfig.headless is not None else True
315
browser = CDPBrowser(headless=headless, proxy=proxy, user_data_dir=user_data_dir)
321
browser = CDPBrowser(
322
headless=headless, proxy=proxy, user_data_dir=user_data_dir,
323
browser_args=browser_args,
324
)
316
325
317
326
async with _shared_cdp_lock:
318
327
_shared_cdp_browsers[ud_key] = (browser, 1)
@@ -325,8 +334,10 @@ async def get_nodriver(
325
334
@asynccontextmanager
326
335
async def get_nodriver_session(**kwargs):
327
336
browser, stop_browser = await get_nodriver(**kwargs)
328
yield browser
329
await stop_browser()
337
try:
338
yield browser
339
finally:
340
await stop_browser()
330
341
331
342
332
343
@@ -19,27 +19,6 @@ CDPSession (Async) — for high-throughput providers like Cloudflare.
19
19
finally:
20
20
await session.close()
21
21
22
──────────────────────────────────────────────────────────────────────
23
SyncCDPSession (Sync) — for Turnstile-solving providers like DeepInfra.
24
──────────────────────────────────────────────────────────────────────
25
• Synchronous blocking recv() loop — waits as long as the browser needs.
26
• No async timeouts — more reliable for slow/interactive pages.
27
• Run from an async context via run_in_executor().
28
• Requires: pip install websocket-client
29
30
Example:
31
def run_sync():
32
session = SyncCDPSession(port=12345, headless=False)
33
session.start_chrome()
34
try:
35
session.navigate("https://example.com")
36
title = session.evaluate_js("document.title")
37
return title
38
finally:
39
session.close()
40
41
title = await asyncio.get_event_loop().run_in_executor(None, run_sync)
42
43
22
──────────────────────────────────────────────────────────────────────
44
23
Common features:
45
24
• Auto-detects Chrome/Chromium/Edge path via BrowserConfig or system PATH.
@@ -263,10 +242,20 @@ def find_running_cdp_port(host: str) -> Optional[int]:
263
242
return None
264
243
265
244
266
def get_shared_browser(host: str, preferred_port: int, headless: bool = True) -> int:
245
def get_shared_browser(
246
host: str,
247
preferred_port: int,
248
headless: bool = True,
249
proxy: Optional[str] = None,
250
browser_args: Optional[List[str]] = None,
251
) -> int:
267
252
"""
268
253
Ensure a single shared browser instance is running and return its port.
269
254
If a browser is already running anywhere on the system, we use it directly.
255
256
``proxy``/``browser_args`` only take effect when the shared browser is
257
first launched — later callers reusing the shared process are ignored,
258
since Chrome does not support changing its proxy at runtime.
270
259
"""
271
260
global _shared_browser_process, _shared_browser_port
272
261
@@ -359,6 +348,10 @@ def get_shared_browser(host: str, preferred_port: int, headless: bool = True) ->
359
348
]
360
349
if headless:
361
350
cmd.append("--headless=new")
351
if proxy:
352
cmd.append(f"--proxy-server={proxy}")
353
if browser_args:
354
cmd.extend(browser_args)
362
355
363
356
debug.log(f"CDP: Launching Chrome: {' '.join(cmd)}")
364
357
_shared_browser_process = subprocess.Popen(
@@ -416,6 +409,8 @@ class CDPSession:
416
409
host: Optional[str] = None,
417
410
user_data_dir: Optional[str] = None,
418
411
headless: Optional[bool] = None,
412
proxy: Optional[str] = None,
413
browser_args: Optional[List[str]] = None,
419
414
):
420
415
if port is None:
421
416
port = BrowserConfig.port
@@ -428,6 +423,8 @@ class CDPSession:
428
423
if headless is None:
429
424
headless = BrowserConfig.headless
430
425
self.headless = headless
426
self.proxy = proxy
427
self.browser_args = browser_args
431
428
self.user_data_dir = (
432
429
user_data_dir # Ignored if using shared pool, but kept for compatibility
433
430
)
@@ -441,15 +438,23 @@ class CDPSession:
441
438
self._event_handlers: Dict[str, List[asyncio.Future]] = {}
442
439
self._event_queues: Dict[str, List[asyncio.Queue]] = {}
443
440
self._closing = False
441
self._connection_lost = False
444
442
445
443
# Network event loggers
446
444
self.network_requests: List[dict] = []
447
445
self.network_responses: List[dict] = []
448
446
447
@property
448
def is_alive(self) -> bool:
449
"""Return True if the WebSocket is still connected and not closing."""
450
return not self._closing and not self._connection_lost and self.ws is not None and not self.ws.closed
451
449
452
async def start(self):
450
453
"""Launch/get shared Chrome and connect via CDP targeting a new tab."""
451
454
if self.port is None:
452
self.port = get_shared_browser(self.host, self.port, self.headless)
455
self.port = get_shared_browser(
456
self.host, self.port, self.headless, self.proxy, self.browser_args
457
)
453
458
454
459
# Acquire a reference so the shared browser stays alive for this tab
455
460
acquire_shared_browser_ref()
@@ -492,6 +497,20 @@ class CDPSession:
492
497
await self.call("Network.enable")
493
498
await self.call("Emulation.setFocusEmulationEnabled", enabled=True)
494
499
500
# Force a desktop-sized viewport — the OS window size hint
501
# (--window-size) is not always honored by the window manager, which
502
# can leave the page narrow enough to trigger a site's mobile layout.
503
try:
504
await self.call(
505
"Emulation.setDeviceMetricsOverride",
506
width=1280,
507
height=800,
508
deviceScaleFactor=1,
509
mobile=False,
510
)
511
except Exception:
512
pass
513
495
514
# Anti-detect: Override User-Agent to remove "HeadlessChrome"
496
515
user_agent = await self.evaluate_js("navigator.userAgent")
497
516
if user_agent and "HeadlessChrome" in user_agent:
@@ -552,11 +571,15 @@ class CDPSession:
552
571
except Exception as e:
553
572
if not self._closing:
554
573
logger.error(f"CDP receiver loop error: {e}")
574
finally:
575
self._connection_lost = True
555
576
556
577
async def call(self, method: str, **params) -> dict:
557
578
"""Call a CDP method and wait for its result."""
558
579
if not self.ws:
559
580
raise RuntimeError("CDPSession is not connected")
581
if self._connection_lost or self.ws.closed:
582
raise ConnectionError("CDPSession connection lost (browser closed?)")
560
583
561
584
self.id_counter += 1
562
585
req_id = self.id_counter
@@ -565,7 +588,12 @@ class CDPSession:
565
588
self._pending_requests[req_id] = fut
566
589
567
590
payload = {"id": req_id, "method": method, "params": params}
568
await self.ws.send_json(payload)
591
try:
592
await self.ws.send_json(payload)
593
except Exception as e:
594
self._connection_lost = True
595
self._pending_requests.pop(req_id, None)
596
raise ConnectionError(f"CDPSession connection lost during send: {e}")
569
597
570
598
try:
571
599
return await asyncio.wait_for(fut, timeout=30.0)
@@ -658,6 +686,22 @@ class CDPSession:
658
686
f"Timeout waiting for Page.loadEventFired when navigating to {url}"
659
687
)
660
688
689
async def reload(self):
690
"""Reload the current page and wait for it to load."""
691
fut = asyncio.get_running_loop().create_future()
692
if "Page.loadEventFired" not in self._event_handlers:
693
self._event_handlers["Page.loadEventFired"] = []
694
self._event_handlers["Page.loadEventFired"].append(fut)
695
696
await self.call("Page.reload")
697
698
try:
699
await asyncio.wait_for(fut, timeout=30.0)
700
except asyncio.TimeoutError:
701
if fut in self._event_handlers.get("Page.loadEventFired", []):
702
self._event_handlers["Page.loadEventFired"].remove(fut)
703
logger.warning("Timeout waiting for Page.loadEventFired when reloading")
704
661
705
async def wait_for_network_idle(
662
706
self, idle_time: float = 0.5, timeout: float = 15.0
663
707
) -> bool:
@@ -1005,6 +1049,7 @@ if (deepseekSendButton) {
1005
1049
"""Navigate to a URL and capture a screenshot, caching the result."""
1006
1050
url_without_suffix = url[:-7] if url.endswith("_2.webp") or url.endswith("_3.webp") else url
1007
1051
url_with_noads = f"{url_without_suffix}&noads={int(time.time())}" if "?" in url_without_suffix else f"{url_without_suffix}?noads={int(time.time())}"
1052
debug.log(f"Navigating to URL: {url_with_noads}")
1008
1053
await self.navigate(url_with_noads)
1009
1054
1010
1055
if await self.evaluate_js('!document.doctype'):
@@ -229,12 +229,20 @@ class CDPElement:
229
229
self._node_id = node_id
230
230
231
231
async def click(self):
232
"""Scroll into view and click the element via JS."""
233
await self._tab.evaluate_js(
234
f"(function(){{var el=document.querySelector('[data-cdp-oid=\"{self._object_id}\"]');"
235
f"if(!el)return;el.scrollIntoView({{block:'center'}});el.click();}})()"
236
)
237
# Fallback: use CDP DOM.requestNode + Input dispatch
232
"""Scroll into view and click the element."""
233
try:
234
await self._tab._session.call(
235
"Runtime.callFunctionOn",
236
objectId=self._object_id,
237
functionDeclaration=(
238
"function(){this.scrollIntoView({block:'center'});"
239
"this.click();}"
240
),
241
)
242
return
243
except Exception:
244
pass
245
# Fallback: dispatch a real mouse click at the element's on-screen position.
238
246
try:
239
247
res = await self._tab._session.call(
240
248
"DOM.requestNode", objectId=self._object_id
@@ -258,20 +266,31 @@ class CDPElement:
258
266
259
267
async def send_keys(self, text: str):
260
268
"""Type text into the element."""
261
# Focus the element first
262
await self._tab._session.call(
263
"DOM.focus", objectId=self._object_id
264
)
265
# Dispatch each character as a key event
269
# Focus via JS first — more reliable than DOM.focus for contenteditable
270
# editors (e.g. ChatGPT's ProseMirror-based prompt box).
271
try:
272
await self._tab._session.call(
273
"Runtime.callFunctionOn",
274
objectId=self._object_id,
275
functionDeclaration="function(){this.focus();}",
276
)
277
except Exception:
278
pass
279
try:
280
await self._tab._session.call("DOM.focus", objectId=self._object_id)
281
except Exception:
282
pass
283
# keyDown/keyUp alone don't insert text into contenteditable elements —
284
# Input.insertText is required to actually mutate the editor content.
266
285
for char in text:
267
286
await self._tab._session.call(
268
287
"Input.dispatchKeyEvent",
269
type="keyDown",
270
text=char,
288
type="rawKeyDown",
271
289
key=char,
272
290
code="",
273
291
windowsVirtualKeyCode=ord(char) if char.isascii() else 0,
274
292
)
293
await self._tab._session.call("Input.insertText", text=char)
275
294
await self._tab._session.call(
276
295
"Input.dispatchKeyEvent",
277
296
type="keyUp",
@@ -306,8 +325,8 @@ class CDPTab:
306
325
return self
307
326
308
327
async def reload(self):
309
"""Reload the current page."""
310
await self._session.call("Page.reload")
328
"""Reload the current page and wait for it to finish loading."""
329
await self._session.reload()
311
330
312
331
async def close(self):
313
332
"""Close this tab."""
@@ -567,10 +586,11 @@ class CDPBrowser:
567
586
"""
568
587
569
588
def __init__(self, headless: Optional[bool] = None, proxy: str = None,
570
user_data_dir: str = None):
589
user_data_dir: str = None, browser_args: Optional[List[str]] = None):
571
590
self.headless = headless
572
591
self.proxy = proxy
573
592
self.user_data_dir = user_data_dir
593
self.browser_args = browser_args
574
594
self._tabs: List[CDPTab] = []
575
595
self.cdp = _CdpShim
576
596
self.cookies = _BrowserCookies(self)
@@ -585,7 +605,9 @@ class CDPBrowser:
585
605
586
606
Emulates ``browser.get(url)`` from nodriver.
587
607
"""
588
session = CDPSession(headless=self.headless)
608
session = CDPSession(
609
headless=self.headless, proxy=self.proxy, browser_args=self.browser_args
610
)
589
611
await session.start()
590
612
tab = CDPTab(session)
591
613
self._tabs.append(tab)