返回提交历史
Modified
g4f/Provider/hf_space/__init__.py
+2
-2
Modified
g4f/Provider/local/Ollama.py
+1
-0
Modified
g4f/Provider/template/OpenaiTemplate.py
+2
-1
Modified
g4f/api/__init__.py
+1
-1
Added
g4f/gui/server/providers.html
+328
-0
Modified
g4f/gui/server/website.py
+233
-0
Modified
g4f/requests/cdp.py
+107
-15
XFEstudio/gpt4free
Add provider listing
3a006aaa
代码差异
7 个文件
+674
-19
@@ -68,10 +68,10 @@ class HuggingSpace(AsyncGeneratorProvider, ProviderModelMixin):
68
68
is_started = False
69
69
random.shuffle(cls.providers)
70
70
for provider in cls.providers:
71
if model in getattr(provider, "model_aliases", {}) or model in provider.get_models():
71
if model in (getattr(provider, "model_aliases", {}) or {}) or model in provider.get_models():
72
72
alias = (
73
73
provider.model_aliases[model]
74
if model in provider.model_aliases
74
if model in (getattr(provider, "model_aliases", {}) or {})
75
75
else model
76
76
)
77
77
async for chunk in provider.create_async_generator(
@@ -24,6 +24,7 @@ class Ollama(OpenaiTemplate):
24
24
active_by_default = True
25
25
local_models: list[str] = []
26
26
model_aliases = {"gpt-oss-120b": "gpt-oss:120b", "gpt-oss-20b": "gpt-oss:20b"}
27
supports_reasoning_effort_none = False
27
28
28
29
@classmethod
29
30
async def get_quota(cls, api_key: Optional[str] = None) -> Optional[dict]:
@@ -34,6 +34,7 @@ class OpenaiTemplate(AsyncGeneratorProvider, ProviderModelMixin, RaiseErrorMixin
34
34
supports_native_tools: bool = True
35
35
_checked_api_keys: dict = {}
36
36
add_thought_signature = None
37
supports_reasoning_effort_none = True
37
38
38
39
@classmethod
39
40
async def get_quota(cls, api_key: Optional[str] = None, **kwargs) -> dict:
@@ -65,7 +66,7 @@ class OpenaiTemplate(AsyncGeneratorProvider, ProviderModelMixin, RaiseErrorMixin
65
66
**({"model": cls.default_model} if cls.default_model else {}),
66
67
"messages": [{"role": "user", "content": "Hi"}],
67
68
"max_tokens": 1,
68
"reasoning_effort": "none",
69
"reasoning_effort": ("none" if cls.supports_reasoning_effort_none else "low"),
69
70
}
70
71
async with StreamSession() as session:
71
72
async with session.post(url, headers=headers, json=json_data) as response:
@@ -1320,7 +1320,7 @@ class Api:
1320
1320
session = CDPSession(headless=True)
1321
1321
await session.start()
1322
1322
try:
1323
image_bytes = await session.capture_screenshot(f"{url}&noads={int(time.time())}" if "?" in url else f"{url}?_={int(time.time())}")
1323
image_bytes = await session.capture_screenshot(f"{url}&noads={int(time.time())}" if "?" in url else f"{url}?noads={int(time.time())}")
1324
1324
# You might want to save this image or return it directly
1325
1325
# For now, let's return it as a FileResponse
1326
1326
# Create a temporary file to store the image
@@ -0,0 +1,328 @@
1
<!DOCTYPE html>
2
<html lang="en">
3
<head>
4
<meta charset="UTF-8">
5
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6
<title>Providers</title>
7
<style>
8
:root {
9
--primary: #6e48aa;
10
--secondary: #9d50bb;
11
--accent: #4776E6;
12
--dark: #1a1a2e;
13
--light: #f8f9fa;
14
--success: #28a745;
15
--warning: #ffc107;
16
--danger: #dc3545;
17
--card-bg: rgba(255,255,255,0.05);
18
--card-border: rgba(255,255,255,0.1);
19
}
20
21
* {
22
box-sizing: border-box;
23
}
24
25
body {
26
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
27
margin: 0;
28
padding: 0;
29
background: linear-gradient(135deg, var(--dark), #16213e);
30
color: var(--light);
31
min-height: 100vh;
32
}
33
34
.navbar {
35
background: rgba(26, 26, 46, 0.95);
36
backdrop-filter: blur(10px);
37
padding: 1rem 2rem;
38
display: flex;
39
justify-content: space-between;
40
align-items: center;
41
position: fixed;
42
width: 100%;
43
top: 0;
44
z-index: 1000;
45
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3);
46
}
47
48
.logo {
49
font-size: 1.8rem;
50
font-weight: 700;
51
background: linear-gradient(to right, var(--primary), var(--secondary));
52
-webkit-background-clip: text;
53
background-clip: text;
54
color: transparent;
55
text-decoration: none;
56
}
57
58
.nav-links {
59
display: flex;
60
gap: 2rem;
61
align-items: center;
62
}
63
64
.nav-links a {
65
color: var(--light);
66
text-decoration: none;
67
font-weight: 500;
68
transition: all 0.3s ease;
69
}
70
71
.nav-links a:hover {
72
color: var(--secondary);
73
}
74
75
.main-container {
76
padding: 6rem 2rem 2rem;
77
max-width: 1400px;
78
margin: 0 auto;
79
}
80
81
.page-header {
82
margin-bottom: 2rem;
83
}
84
85
.page-header h1 {
86
font-size: 2.2rem;
87
margin: 0 0 0.5rem;
88
background: linear-gradient(to right, var(--primary), var(--secondary), var(--accent));
89
-webkit-background-clip: text;
90
background-clip: text;
91
color: transparent;
92
}
93
94
.page-header p {
95
color: rgba(255,255,255,0.6);
96
margin: 0;
97
font-size: 1.05rem;
98
}
99
100
.providers-list {
101
display: grid;
102
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
103
gap: 1.5rem;
104
}
105
106
.provider-card {
107
background: var(--card-bg);
108
border: 1px solid var(--card-border);
109
border-radius: 12px;
110
padding: 1.5rem;
111
transition: transform 0.2s ease, box-shadow 0.2s ease;
112
cursor: pointer;
113
}
114
115
.provider-card:hover {
116
transform: translateY(-4px);
117
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
118
}
119
120
.provider-name {
121
font-size: 1.25rem;
122
font-weight: 600;
123
margin-bottom: 0.75rem;
124
}
125
126
.provider-url {
127
color: var(--accent);
128
font-size: 0.9rem;
129
margin-bottom: 0.5rem;
130
}
131
132
.provider-details {
133
font-size: 0.85rem;
134
line-height: 1.6;
135
color: #a0aec0;
136
}
137
138
.provider-details strong {
139
color: var(--accent);
140
}
141
142
.provider-actions {
143
display: flex;
144
gap: 0.5rem;
145
margin-top: 1rem;
146
padding-top: 1rem;
147
border-top: 1px solid var(--card-border);
148
}
149
150
.btn {
151
padding: 0.5rem 1rem;
152
border: none;
153
border-radius: 6px;
154
cursor: pointer;
155
font-weight: 500;
156
transition: all 0.2s ease;
157
}
158
159
.btn-primary {
160
background: var(--accent);
161
color: white;
162
}
163
164
.btn-primary:hover {
165
background: #5a45d6;
166
}
167
168
.btn-secondary {
169
background: var(--secondary);
170
color: white;
171
}
172
173
.btn-secondary:hover {
174
background: #8a82b8;
175
}
176
177
.no-providers {
178
text-align: center;
179
padding: 4rem;
180
color: var(--warning);
181
}
182
183
/* ---- Provider detail page ---- */
184
.detail-header h1 {
185
font-size: 2.2rem;
186
margin: 0 0 0.25rem;
187
background: linear-gradient(to right, var(--primary), var(--secondary), var(--accent));
188
-webkit-background-clip: text;
189
background-clip: text;
190
color: transparent;
191
}
192
.detail-subtitle {
193
color: rgba(255,255,255,0.5);
194
margin: 0 0 1.5rem;
195
font-size: 0.95rem;
196
}
197
198
.nav-prev-next {
199
display: flex;
200
justify-content: space-between;
201
align-items: center;
202
gap: 1rem;
203
margin-bottom: 2rem;
204
flex-wrap: wrap;
205
}
206
.nav-btn {
207
padding: 0.6rem 1.2rem;
208
border-radius: 8px;
209
text-decoration: none;
210
font-weight: 500;
211
font-size: 0.9rem;
212
transition: all 0.2s ease;
213
border: 1px solid var(--card-border);
214
background: var(--card-bg);
215
color: var(--light);
216
}
217
.nav-btn:hover {
218
background: rgba(255,255,255,0.1);
219
border-color: var(--accent);
220
}
221
.nav-prev { color: var(--accent); }
222
.nav-next { color: var(--accent); }
223
.nav-back { color: rgba(255,255,255,0.6); }
224
225
.detail-grid {
226
display: grid;
227
grid-template-columns: 1fr 1fr;
228
gap: 1.5rem;
229
margin-bottom: 1.5rem;
230
}
231
@media (max-width: 768px) {
232
.detail-grid { grid-template-columns: 1fr; }
233
}
234
235
.detail-card {
236
background: var(--card-bg);
237
border: 1px solid var(--card-border);
238
border-radius: 12px;
239
padding: 1.5rem;
240
}
241
.detail-card h2 {
242
font-size: 1.1rem;
243
margin: 0 0 1rem;
244
color: var(--accent);
245
}
246
247
.attr-table {
248
width: 100%;
249
border-collapse: collapse;
250
font-size: 0.9rem;
251
}
252
.attr-table th {
253
text-align: left;
254
padding: 0.5rem 0.75rem;
255
color: rgba(255,255,255,0.5);
256
font-weight: 500;
257
border-bottom: 1px solid var(--card-border);
258
white-space: nowrap;
259
}
260
.attr-table td {
261
padding: 0.5rem 0.75rem;
262
border-bottom: 1px solid rgba(255,255,255,0.05);
263
color: #c9d1d9;
264
}
265
.attr-table a { color: var(--accent); text-decoration: none; }
266
.attr-table a:hover { text-decoration: underline; }
267
268
.screenshot-section { text-align: center; }
269
.screenshot-caption {
270
font-size: 0.8rem;
271
color: rgba(255,255,255,0.4);
272
margin-top: 0.5rem;
273
}
274
275
.model-list, .param-list {
276
list-style: none;
277
padding: 0;
278
margin: 0;
279
display: flex;
280
flex-wrap: wrap;
281
gap: 0.5rem;
282
}
283
.model-list li, .param-list li {
284
background: rgba(71,118,230,0.15);
285
border: 1px solid rgba(71,118,230,0.3);
286
color: #79c0ff;
287
padding: 0.3rem 0.7rem;
288
border-radius: 6px;
289
font-size: 0.85rem;
290
font-family: ui-monospace, monospace;
291
}
292
.param-list li {
293
background: rgba(157,80,187,0.15);
294
border-color: rgba(157,80,187,0.3);
295
color: #d2a8ff;
296
}
297
298
.detail-models { margin-bottom: 1.5rem; }
299
</style>
300
</head>
301
<body>
302
<nav class="navbar">
303
<a href="/" class="logo">G4F</a>
304
<div class="nav-links">
305
<a href="/">Home</a>
306
<a href="/chat/">Chat</a>
307
<a href="/providers/">Providers</a>
308
<a href="/stats/">Stats</a>
309
</div>
310
</nav>
311
312
<main class="main-container">
313
<!-- CONTENT_START -->
314
<div class="page-header">
315
<h1>Available Providers</h1>
316
<p>Browse the list of AI providers supported by G4F</p>
317
</div>
318
319
<div class="providers-list">
320
<!-- Providers will be populated dynamically -->
321
<div class="no-providers">
322
<p>Loading providers...</p>
323
</div>
324
</div>
325
<!-- CONTENT_END -->
326
</main>
327
</body>
328
</html>
@@ -1,6 +1,8 @@
1
1
from __future__ import annotations
2
2
3
import asyncio
3
4
import os
5
import inspect
4
6
import requests
5
7
from datetime import datetime
6
8
from urllib.parse import quote, unquote
@@ -142,6 +144,8 @@ class Website:
142
144
"/apps/": {"function": self._apps, "methods": ["GET"]},
143
145
"/apps/<path:filename>": {"function": self._apps, "methods": ["GET"]},
144
146
"/stats/": {"function": self._stats, "methods": ["GET"]},
147
"/providers/": {"function": self._providers, "methods": ["GET"]},
148
"/providers/<name>": {"function": self._provider_detail, "methods": ["GET"]},
145
149
}
146
150
147
151
@app.route("/lib.js", methods=["GET"])
@@ -166,6 +170,235 @@ class Website:
166
170
def _stats(self):
167
171
return render("stats")
168
172
173
def _get_providers(self):
174
"""Load all providers and return a list of dicts with their attributes."""
175
from g4f.Provider import ProviderLoader
176
177
providers = []
178
for name in ProviderLoader.names:
179
try:
180
provider = ProviderLoader.from_name(name)
181
url = getattr(provider, "url", None)
182
models = getattr(provider, "models", []) or getattr(provider, "get_models", [])
183
needs_auth = getattr(provider, "needs_auth", False)
184
working = getattr(provider, "working", False)
185
supports_stream = getattr(provider, "supports_stream", False)
186
supports_message_history = getattr(provider, "supports_message_history", False)
187
supports_system_message = getattr(provider, "supports_system_message", False)
188
params = getattr(provider, "params", [])
189
if callable(params):
190
try:
191
params = params()
192
except Exception:
193
params = []
194
providers.append({
195
"name": name,
196
"url": url,
197
"models": models if isinstance(models, list) else list(models) if models else [],
198
"needs_auth": needs_auth,
199
"working": working,
200
"supports_stream": supports_stream,
201
"supports_message_history": supports_message_history,
202
"supports_system_message": supports_system_message,
203
"params": params if isinstance(params, list) else list(params) if params else [],
204
})
205
except Exception:
206
pass
207
return providers
208
209
def _providers(self):
210
providers = self._get_providers()
211
212
# Build HTML cards
213
cards_html = """
214
<div class="page-header">
215
<h1>Available Providers</h1>
216
<p>Browse the list of AI providers supported by G4F</p>
217
</div>
218
219
<div class="providers-list">
220
"""
221
for p in providers:
222
models_html = ""
223
if p["models"]:
224
models_list = ", ".join(p["models"][:5]) if isinstance(p["models"], list) else ""
225
if len(p["models"]) > 5:
226
models_list += f" (+{len(p['models']) - 5} more)"
227
models_html = f"<div class='provider-details'><strong>Models:</strong> {models_list}</div>"
228
else:
229
models_html = "<div class='provider-details'><em>No specific models</em></div>"
230
231
url_html = f"<div class='provider-url'>{p['url']}</div>" if p["url"] else ""
232
auth_html = "<div class='provider-details'><strong>Auth:</strong> Required</div>" if p["needs_auth"] else ""
233
working_html = "<div class='provider-details'><strong>Status:</strong> Working</div>" if p["working"] else ""
234
235
cards_html += f"""
236
<div class="provider-card" onclick="window.location.href='/providers/{p['name']}'">
237
<div class="provider-name">{p['name']}</div>
238
{url_html}
239
{models_html}
240
{auth_html}
241
{working_html}
242
<div class="provider-actions">
243
<a href="/providers/{p['name']}" class="btn btn-primary">Details</a>
244
<a href="{p['url']}" target="_blank" class="btn btn-secondary">Website</a>
245
</div>
246
</div>
247
"""
248
cards_html += "\n </div>"
249
250
# Read the template
251
template_path = os.path.join(os.path.dirname(__file__), "providers.html")
252
if os.path.exists(template_path):
253
with open(template_path, "r", encoding="utf-8") as f:
254
html = f.read()
255
# Replace content between markers
256
import re
257
html = re.sub(
258
r"<!-- CONTENT_START -->.*?<!-- CONTENT_END -->",
259
f"<!-- CONTENT_START -->{cards_html}<!-- CONTENT_END -->",
260
html,
261
flags=re.DOTALL,
262
)
263
return html
264
else:
265
return "Providers template not found"
266
267
def _provider_detail(self, name: str = ""):
268
from html import escape
269
270
providers = self._get_providers()
271
names = [p["name"] for p in providers]
272
273
# Find the current provider (case-insensitive)
274
idx = None
275
for i, n in enumerate(names):
276
if n.lower() == name.lower():
277
idx = i
278
break
279
280
if idx is None:
281
return self._providers()
282
283
p = providers[idx]
284
prev_p = providers[idx - 1] if idx > 0 else providers[-1]
285
next_p = providers[idx + 1] if idx < len(providers) - 1 else providers[0]
286
287
# Build models list HTML
288
if p["models"]:
289
if callable(p["models"]):
290
try:
291
p["models"] = p["models"]()
292
except Exception:
293
p["models"] = []
294
if inspect.isawaitable(p["models"]):
295
p["models"] = asyncio.run(p["models"])
296
models_html = "<ul class='model-list'>" + "".join(
297
f"<li>{escape(str(m))}</li>" for m in p["models"]
298
) + "</ul>"
299
else:
300
models_html = "<p><em>No specific models listed</em></p>"
301
302
# Build params list HTML
303
if p["params"]:
304
params_html = "<ul class='param-list'>" + "".join(
305
f"<li>{escape(str(param))}</li>" for param in p["params"]
306
) + "</ul>"
307
else:
308
params_html = "<p><em>None</em></p>"
309
310
# Build attributes table
311
attrs_html = f"""
312
<table class="attr-table">
313
<tr><th>URL</th><td><a href="{escape(p['url'] or '')}" target="_blank">{escape(p['url'] or 'N/A')}</a></td></tr>
314
<tr><th>Working</th><td>{'✅ Yes' if p['working'] else '❌ No'}</td></tr>
315
<tr><th>Needs Auth</th><td>{'🔒 Yes' if p['needs_auth'] else '🔓 No'}</td></tr>
316
<tr><th>Supports Stream</th><td>{'✅ Yes' if p['supports_stream'] else '❌ No'}</td></tr>
317
<tr><th>Supports Message History</th><td>{'✅ Yes' if p['supports_message_history'] else '❌ No'}</td></tr>
318
<tr><th>Supports System Message</th><td>{'✅ Yes' if p['supports_system_message'] else '❌ No'}</td></tr>
319
</table>
320
"""
321
322
# Screenshot / logo section
323
logo_url = f"/screenshot?url={p.get('url', (p.get('base_url', p.get('baseUrl', ''))).replace('https://', '').replace('http://', '').replace('playground.ai.', '').replace('api.', '').replace('router.', '').split('/')[0])}"
324
screenshot_html = f"""
325
<div class="screenshot-section">
326
<img src="{logo_url}" alt="{escape(p['name'])} logo" class="provider-logo"
327
onerror="this.onerror=null;this.src='https://image.thum.io/get/width/600/{escape(p['url'] or '')}'"
328
style="max-width:100%;border-radius:8px;border:1px solid var(--card-border)" />
329
<p class="screenshot-caption">Logo from g4f.space / screenshot from {escape(p['url'] or 'N/A')}</p>
330
</div>
331
"""
332
333
detail_html = f"""
334
<div class="detail-header">
335
<h1>{escape(p['name'])}</h1>
336
<p class="detail-subtitle">Provider #{idx + 1} of {len(providers)}</p>
337
</div>
338
339
<div class="nav-prev-next">
340
<a href="/providers/{escape(prev_p['name'])}" class="nav-btn nav-prev">
341
← {escape(prev_p['name'])}
342
</a>
343
<a href="/providers/" class="nav-btn nav-back">All Providers</a>
344
<a href="/providers/{escape(next_p['name'])}" class="nav-btn nav-next">
345
{escape(next_p['name'])} →
346
</a>
347
</div>
348
349
<div class="detail-grid">
350
<div class="detail-card">
351
<h2>Attributes</h2>
352
{attrs_html}
353
</div>
354
<div class="detail-card">
355
<h2>Logo / Screenshot</h2>
356
{screenshot_html}
357
</div>
358
</div>
359
360
<div class="detail-card detail-models">
361
<h2>Models ({len(p['models'])})</h2>
362
{models_html}
363
</div>
364
365
<div class="detail-card">
366
<h2>Parameters</h2>
367
{params_html}
368
</div>
369
370
<div class="nav-prev-next" style="margin-top:2rem">
371
<a href="/providers/{escape(prev_p['name'])}" class="nav-btn nav-prev">
372
← {escape(prev_p['name'])}
373
</a>
374
<a href="/providers/" class="nav-btn nav-back">All Providers</a>
375
<a href="/providers/{escape(next_p['name'])}" class="nav-btn nav-next">
376
{escape(next_p['name'])} →
377
</a>
378
</div>
379
"""
380
381
# Read the template and inject detail content
382
template_path = os.path.join(os.path.dirname(__file__), "providers.html")
383
if os.path.exists(template_path):
384
with open(template_path, "r", encoding="utf-8") as f:
385
html = f.read()
386
# Replace content between markers
387
import re
388
html = re.sub(
389
r"<!-- CONTENT_START -->.*?<!-- CONTENT_END -->",
390
f"<!-- CONTENT_START -->{detail_html}<!-- CONTENT_END -->",
391
html,
392
flags=re.DOTALL,
393
)
394
html = html.replace(
395
"<title>Providers</title>",
396
f"<title>{escape(p['name'])} – Provider Details</title>"
397
)
398
return html
399
else:
400
return "Providers template not found"
401
169
402
def _chat(self, filename=""):
170
403
filename = f"chat/{filename}" if filename else "chat/index"
171
404
return render(filename)
@@ -58,7 +58,6 @@ import subprocess
58
58
import time
59
59
import urllib.request
60
60
from typing import Optional, Dict, Any, List
61
import re
62
61
import hashlib
63
62
from urllib.parse import urlparse
64
63
import datetime
@@ -69,6 +68,8 @@ except ImportError:
69
68
pass
70
69
71
70
from ..cookies import BrowserConfig
71
from ..files import secure_filename
72
from .. import debug
72
73
73
74
try:
74
75
from PIL import Image
@@ -80,16 +81,6 @@ logger = logging.getLogger(__name__)
80
81
81
82
from pathlib import Path
82
83
83
def secure_filename(url: str) -> str:
84
"""Create a secure filename from a URL."""
85
parsed = urlparse(url)
86
path = parsed.path
87
if path:
88
filename = os.path.basename(path)
89
if filename:
90
return filename
91
return hashlib.sha256(url.encode()).hexdigest() + ".png"
92
93
84
def get_screenshot_dir(datekey: str = None) -> str:
94
85
"""Get the screenshot directory, creating it if necessary."""
95
86
try:
@@ -459,7 +450,7 @@ class CDPSession:
459
450
460
451
if method in self._event_queues:
461
452
for q in self._event_queues[method]:
462
q.put_nowait(params)
453
q.put_nowait({"_method": method, **params})
463
454
except Exception as e:
464
455
if not self._closing:
465
456
logger.error(f"CDP receiver loop error: {e}")
@@ -569,6 +560,53 @@ class CDPSession:
569
560
f"Timeout waiting for Page.loadEventFired when navigating to {url}"
570
561
)
571
562
563
async def wait_for_network_idle(
564
self, idle_time: float = 0.5, timeout: float = 15.0
565
) -> bool:
566
"""Wait until network activity settles (no requests for *idle_time* seconds).
567
568
Uses Network.requestWillBeSent / Network.loadingFinished events to track
569
in-flight requests. Returns True if the network went idle, False on timeout.
570
"""
571
queue: asyncio.Queue = asyncio.Queue()
572
self.add_event_handler("Network.requestWillBeSent", queue)
573
self.add_event_handler("Network.loadingFinished", queue)
574
self.add_event_handler("Network.loadingFailed", queue)
575
576
# Count currently in-flight requests via JS-free CDP approach:
577
# Every requestWillBeSent increments, every loadingFinished/loadingFailed decrements.
578
pending = 0
579
deadline = time.monotonic() + timeout
580
last_activity = time.monotonic()
581
582
try:
583
while True:
584
remaining = deadline - time.monotonic()
585
if remaining <= 0:
586
return False
587
588
idle_remaining = idle_time - (time.monotonic() - last_activity)
589
wait_for = min(remaining, max(0.05, idle_remaining))
590
591
try:
592
event = await asyncio.wait_for(queue.get(), timeout=wait_for)
593
method = event.get("_method", "")
594
if method == "Network.requestWillBeSent":
595
pending += 1
596
last_activity = time.monotonic()
597
elif method in ("Network.loadingFinished", "Network.loadingFailed"):
598
pending = max(0, pending - 1)
599
last_activity = time.monotonic()
600
except asyncio.TimeoutError:
601
pass
602
603
if pending == 0 and (time.monotonic() - last_activity) >= idle_time:
604
return True
605
finally:
606
self.remove_event_handler("Network.requestWillBeSent", queue)
607
self.remove_event_handler("Network.loadingFinished", queue)
608
self.remove_event_handler("Network.loadingFailed", queue)
609
572
610
async def mouse_move(self, x: int, y: int):
573
611
"""Simulate a mouse movement to the given coordinates."""
574
612
await self.call("Input.dispatchMouseEvent", type="mouseMoved", x=x, y=y)
@@ -708,13 +746,20 @@ class CDPSession:
708
746
"""Navigate to a URL and capture a screenshot, caching the result."""
709
747
datekey = datetime.date.today().isoformat()
710
748
screenshot_dir = get_screenshot_dir(datekey)
711
filename = secure_filename(url)
749
filename = f"{secure_filename(url.replace('https://', '').replace('http://', ''))}.png"
712
750
filepath = os.path.join(screenshot_dir, filename)
751
debug.log(f"Screenshot path: {filepath}")
713
752
714
753
if os.path.exists(filepath):
715
754
return Path(filepath).read_bytes()
716
755
756
debug.log(f"Capturing screenshot for {url} ...")
717
757
await self.navigate(url)
758
# Wait for network activity to settle before capturing
759
await self.wait_for_network_idle(idle_time=5, timeout=15.0)
760
if await self.evaluate_js('!document.doctype'):
761
raise RuntimeError(f"Failed to load page {url} for screenshot, doctype={await self.evaluate_js('String(document.doctype)')}")
762
await asyncio.sleep(3)
718
763
# Try to click any "Accept" or "Einwilligen" cookie consent buttons
719
764
await self.click_accept_button()
720
765
await asyncio.sleep(0.5)
@@ -725,9 +770,9 @@ class CDPSession:
725
770
if has_pillow:
726
771
from io import BytesIO
727
772
image = Image.open(BytesIO(image_bytes))
728
image = image.resize((1200, 675), Image.Resampling.LANCZOS)
773
image = image.resize((1200, 630), Image.Resampling.LANCZOS)
729
774
width, height = image.size
730
image = image.crop((0, 0, max(0, width - 16), height))
775
image = image.crop((0, 0, max(0, width - 14), height))
731
776
output = BytesIO()
732
777
image.save(output, format="PNG")
733
778
image_bytes = output.getvalue()
@@ -937,6 +982,50 @@ class SyncCDPSession:
937
982
self.call("Page.navigate", url=url)
938
983
time.sleep(2.0)
939
984
985
def wait_for_network_idle(self, idle_time: float = 0.5, timeout: float = 15.0) -> bool:
986
"""Wait until network activity settles (no in-flight requests for *idle_time* seconds).
987
988
Polls document.readyState and the Performance Resource Timing API to detect
989
when resource loading has stabilised. Returns True when idle, False on timeout.
990
"""
991
deadline = time.monotonic() + timeout
992
last_count = -1
993
stable_since = time.monotonic()
994
995
while time.monotonic() < deadline:
996
try:
997
ready = self.evaluate_js("document.readyState")
998
if ready == "complete":
999
# Count resources that are still loading (responseStart > 0 but no responseEnd)
1000
count = self.evaluate_js(
1001
"""(() => {
1002
const entries = performance.getEntriesByType('resource');
1003
let pending = 0;
1004
for (const e of entries) {
1005
if (e.responseStart > 0 && e.responseEnd === 0) {
1006
pending++;
1007
}
1008
}
1009
return pending;
1010
})()"""
1011
)
1012
count = count or 0
1013
if count == last_count:
1014
if (time.monotonic() - stable_since) >= idle_time:
1015
return True
1016
else:
1017
last_count = count
1018
stable_since = time.monotonic()
1019
else:
1020
# Page not fully loaded yet — reset stability timer
1021
last_count = -1
1022
stable_since = time.monotonic()
1023
except Exception:
1024
pass
1025
time.sleep(0.2)
1026
1027
return False
1028
940
1029
def click(self, x: int = 200, y: int = 400):
941
1030
"""
942
1031
Simulate a real mouse click at (x, y) on the page.
@@ -1001,8 +1090,11 @@ class SyncCDPSession:
1001
1090
return Path(filepath).read_bytes()
1002
1091
1003
1092
self.navigate(url)
1093
# Wait for network activity to settle before capturing
1094
self.wait_for_network_idle()
1004
1095
# Try to click any "Accept" or "Einwilligen" cookie consent buttons
1005
1096
self.click_accept_button()
1097
time.sleep(0.5)
1006
1098
result = self.call("Page.captureScreenshot")
1007
1099
image_bytes = base64.b64decode(result["data"])
1008
1100