XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
返回提交历史

XFEstudio/gpt4free

Add provider listing

3a006aaa
hlohaus <983577+hlohaus@users.noreply.github.com>
提交于

代码差异

7 个文件 +674 -19
Modified g4f/Provider/hf_space/__init__.py +2 -2
@@ -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(
Modified g4f/Provider/local/Ollama.py +1 -0
@@ -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]:
Modified g4f/Provider/template/OpenaiTemplate.py +2 -1
@@ -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:
Modified g4f/api/__init__.py +1 -1
@@ -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
Added g4f/gui/server/providers.html +328 -0
@@ -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>
Modified g4f/gui/server/website.py +233 -0
@@ -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)
Modified g4f/requests/cdp.py +107 -15
@@ -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