entari-plugin-hyw 4.0.0rc4__py3-none-any.whl → 4.0.0rc6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of entari-plugin-hyw might be problematic. Click here for more details.
- entari_plugin_hyw/__init__.py +216 -75
- entari_plugin_hyw/assets/card-dist/index.html +70 -79
- entari_plugin_hyw/browser/__init__.py +10 -0
- entari_plugin_hyw/browser/engines/base.py +13 -0
- entari_plugin_hyw/browser/engines/bing.py +95 -0
- entari_plugin_hyw/browser/engines/duckduckgo.py +137 -0
- entari_plugin_hyw/browser/engines/google.py +155 -0
- entari_plugin_hyw/browser/landing.html +172 -0
- entari_plugin_hyw/browser/manager.py +153 -0
- entari_plugin_hyw/browser/service.py +304 -0
- entari_plugin_hyw/card-ui/src/App.vue +526 -182
- entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +7 -11
- entari_plugin_hyw/card-ui/src/components/StageCard.vue +33 -30
- entari_plugin_hyw/card-ui/src/types.ts +9 -0
- entari_plugin_hyw/definitions.py +155 -0
- entari_plugin_hyw/history.py +111 -33
- entari_plugin_hyw/misc.py +34 -0
- entari_plugin_hyw/modular_pipeline.py +384 -0
- entari_plugin_hyw/render_vue.py +326 -239
- entari_plugin_hyw/search.py +95 -708
- entari_plugin_hyw/stage_base.py +92 -0
- entari_plugin_hyw/stage_instruct.py +345 -0
- entari_plugin_hyw/stage_instruct_deepsearch.py +104 -0
- entari_plugin_hyw/stage_summary.py +164 -0
- {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/METADATA +4 -4
- {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/RECORD +28 -16
- entari_plugin_hyw/pipeline.py +0 -1219
- entari_plugin_hyw/prompts.py +0 -47
- {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/WHEEL +0 -0
- {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/top_level.txt +0 -0
entari_plugin_hyw/render_vue.py
CHANGED
|
@@ -1,27 +1,26 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Vue-based Card Renderer (
|
|
2
|
+
Vue-based Card Renderer (DrissionPage-based)
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
Renders content to image using the shared DrissionPage browser.
|
|
5
|
+
Wraps synchronous DrissionPage operations in a thread pool.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
-
import gc
|
|
10
|
-
import os
|
|
11
|
-
import threading
|
|
12
9
|
import asyncio
|
|
13
10
|
from pathlib import Path
|
|
14
|
-
from typing import List, Dict, Any
|
|
15
|
-
from concurrent.futures import
|
|
11
|
+
from typing import List, Dict, Any, Optional
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
13
|
|
|
17
14
|
from loguru import logger
|
|
18
|
-
from
|
|
15
|
+
from .browser.manager import SharedBrowserManager
|
|
19
16
|
|
|
20
17
|
|
|
21
18
|
class ContentRenderer:
|
|
22
|
-
"""
|
|
19
|
+
"""Renderer using DrissionPage with thread pool for async interface."""
|
|
23
20
|
|
|
24
|
-
def __init__(self, template_path: str = None, auto_start: bool = True):
|
|
21
|
+
def __init__(self, template_path: str = None, auto_start: bool = True, headless: bool = True):
|
|
22
|
+
self.headless = headless
|
|
23
|
+
|
|
25
24
|
if template_path is None:
|
|
26
25
|
current_dir = Path(__file__).parent
|
|
27
26
|
template_path = current_dir / "assets" / "card-dist" / "index.html"
|
|
@@ -33,282 +32,370 @@ class ContentRenderer:
|
|
|
33
32
|
self.template_content = self.template_path.read_text(encoding="utf-8")
|
|
34
33
|
logger.info(f"ContentRenderer: loaded Vue template ({len(self.template_content)} bytes)")
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
self.
|
|
38
|
-
self.
|
|
39
|
-
self._context = None
|
|
40
|
-
self._page = None
|
|
41
|
-
self._render_count = 0
|
|
42
|
-
self._max_renders_before_restart = 50
|
|
43
|
-
|
|
44
|
-
# Background event loop for playwright
|
|
45
|
-
self._loop: asyncio.AbstractEventLoop = None
|
|
46
|
-
self._thread: threading.Thread = None
|
|
47
|
-
self._ready = threading.Event()
|
|
48
|
-
self._lock = threading.Lock()
|
|
35
|
+
self._manager = None
|
|
36
|
+
self._executor = ThreadPoolExecutor(max_workers=10) # Enough for batch crawls
|
|
37
|
+
self._render_tab = None
|
|
49
38
|
|
|
50
39
|
if auto_start:
|
|
51
|
-
self.
|
|
40
|
+
self._ensure_manager()
|
|
52
41
|
|
|
53
|
-
def
|
|
54
|
-
"""
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# Start browser immediately
|
|
59
|
-
self._loop.run_until_complete(self._init_browser())
|
|
60
|
-
self._ready.set()
|
|
61
|
-
# Keep loop running for future tasks
|
|
62
|
-
self._loop.run_forever()
|
|
63
|
-
|
|
64
|
-
self._thread = threading.Thread(target=_run_loop, daemon=True, name="ContentRenderer-Browser")
|
|
65
|
-
self._thread.start()
|
|
66
|
-
logger.info("ContentRenderer: Background browser thread started")
|
|
42
|
+
def _ensure_manager(self):
|
|
43
|
+
"""Ensure shared browser manager exists."""
|
|
44
|
+
if not self._manager:
|
|
45
|
+
from .browser.manager import get_shared_browser_manager
|
|
46
|
+
self._manager = get_shared_browser_manager(headless=self.headless)
|
|
67
47
|
|
|
68
|
-
async def
|
|
69
|
-
"""Initialize
|
|
70
|
-
|
|
48
|
+
async def start(self, timeout: int = 6000):
|
|
49
|
+
"""Initialize renderer manager (async wrapper)."""
|
|
50
|
+
loop = asyncio.get_running_loop()
|
|
51
|
+
await loop.run_in_executor(self._executor, self._ensure_manager)
|
|
52
|
+
|
|
53
|
+
async def prepare_tab(self) -> str:
|
|
54
|
+
"""Async wrapper to prepare a new render tab."""
|
|
55
|
+
loop = asyncio.get_running_loop()
|
|
56
|
+
return await loop.run_in_executor(self._executor, self._prepare_tab_sync)
|
|
57
|
+
|
|
58
|
+
def _prepare_tab_sync(self) -> str:
|
|
59
|
+
"""Create and warm up a new tab, return its ID."""
|
|
60
|
+
import time as pytimeout
|
|
61
|
+
start = pytimeout.time()
|
|
62
|
+
self._ensure_manager()
|
|
71
63
|
try:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
self._context = await self._browser.new_context(
|
|
78
|
-
viewport={"width": 540, "height": 1400},
|
|
79
|
-
device_scale_factor=2.0,
|
|
80
|
-
)
|
|
81
|
-
self._page = await self._context.new_page()
|
|
82
|
-
await self._page.goto(self.template_path.as_uri(), wait_until="domcontentloaded", timeout=timeout)
|
|
64
|
+
tab = self._manager.new_tab(self.template_path.as_uri())
|
|
65
|
+
tab_id = tab.tab_id
|
|
66
|
+
|
|
67
|
+
# Basic wait
|
|
68
|
+
tab.wait(1)
|
|
83
69
|
|
|
84
|
-
# Pre-warm
|
|
70
|
+
# Pre-warm
|
|
85
71
|
warmup_data = {
|
|
86
72
|
"markdown": "# Ready",
|
|
87
73
|
"total_time": 0,
|
|
88
74
|
"stages": [],
|
|
89
75
|
"references": [],
|
|
90
|
-
"page_references": [],
|
|
91
|
-
"image_references": [],
|
|
92
76
|
"stats": {},
|
|
93
77
|
"theme_color": "#ef4444",
|
|
94
78
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
79
|
+
|
|
80
|
+
if tab.ele('#app', timeout=5):
|
|
81
|
+
tab.run_js(f"window.updateRenderData({json.dumps(warmup_data)})")
|
|
82
|
+
|
|
83
|
+
elapsed = pytimeout.time() - start
|
|
84
|
+
logger.info(f"ContentRenderer: Prepared tab {tab_id} in {elapsed:.2f}s")
|
|
85
|
+
return tab_id
|
|
98
86
|
except Exception as e:
|
|
99
|
-
logger.error(f"ContentRenderer: Failed to
|
|
87
|
+
logger.error(f"ContentRenderer: Failed to prepare tab: {e}")
|
|
100
88
|
raise
|
|
101
89
|
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
90
|
+
async def render_pages_batch(
|
|
91
|
+
self,
|
|
92
|
+
pages: List[Dict[str, Any]],
|
|
93
|
+
theme_color: str = "#ef4444"
|
|
94
|
+
) -> List[str]:
|
|
95
|
+
"""
|
|
96
|
+
Render multiple page markdown contents to images concurrently.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
pages: List of dicts with 'title', 'content', 'url' keys
|
|
100
|
+
theme_color: Theme color for rendering
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of base64-encoded JPG images
|
|
104
|
+
"""
|
|
105
|
+
if not pages:
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
loop = asyncio.get_running_loop()
|
|
109
|
+
|
|
110
|
+
# Prepare tabs concurrently
|
|
111
|
+
logger.info(f"ContentRenderer: Preparing {len(pages)} tabs for batch render")
|
|
112
|
+
tab_tasks = [
|
|
113
|
+
loop.run_in_executor(self._executor, self._prepare_tab_sync)
|
|
114
|
+
for _ in pages
|
|
115
|
+
]
|
|
116
|
+
tab_ids = await asyncio.gather(*tab_tasks, return_exceptions=True)
|
|
117
|
+
|
|
118
|
+
# Filter out failed tab preparations
|
|
119
|
+
valid_pairs = []
|
|
120
|
+
for i, (page, tab_id) in enumerate(zip(pages, tab_ids)):
|
|
121
|
+
if isinstance(tab_id, Exception):
|
|
122
|
+
logger.warning(f"ContentRenderer: Failed to prepare tab for page {i}: {tab_id}")
|
|
123
|
+
else:
|
|
124
|
+
valid_pairs.append((page, tab_id))
|
|
125
|
+
|
|
126
|
+
if not valid_pairs:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
# Render concurrently
|
|
130
|
+
render_tasks = [
|
|
131
|
+
loop.run_in_executor(
|
|
132
|
+
self._executor,
|
|
133
|
+
self._render_page_to_b64_sync,
|
|
134
|
+
page,
|
|
135
|
+
tab_id,
|
|
136
|
+
theme_color
|
|
137
|
+
)
|
|
138
|
+
for page, tab_id in valid_pairs
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
results = await asyncio.gather(*render_tasks, return_exceptions=True)
|
|
142
|
+
|
|
143
|
+
# Process results
|
|
144
|
+
screenshots = []
|
|
145
|
+
for i, res in enumerate(results):
|
|
146
|
+
if isinstance(res, Exception):
|
|
147
|
+
logger.warning(f"ContentRenderer: Batch render error for page {i}: {res}")
|
|
148
|
+
screenshots.append(None)
|
|
149
|
+
else:
|
|
150
|
+
screenshots.append(res)
|
|
151
|
+
|
|
152
|
+
logger.info(f"ContentRenderer: Batch rendered {len([s for s in screenshots if s])} pages")
|
|
153
|
+
return screenshots
|
|
107
154
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
155
|
+
def _render_page_to_b64_sync(
|
|
156
|
+
self,
|
|
157
|
+
page_data: Dict[str, Any],
|
|
158
|
+
tab_id: str,
|
|
159
|
+
theme_color: str
|
|
160
|
+
) -> Optional[str]:
|
|
161
|
+
"""Render a single page's markdown to base64 image."""
|
|
162
|
+
tab = None
|
|
163
|
+
try:
|
|
164
|
+
self._ensure_manager()
|
|
165
|
+
browser_page = self._manager.page
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
tab = browser_page.get_tab(tab_id)
|
|
169
|
+
except Exception:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
if not tab:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
# Build render data for this page
|
|
176
|
+
markdown = f"# {page_data.get('title', 'Page')}\n\n{page_data.get('content', '')}"
|
|
177
|
+
|
|
178
|
+
render_data = {
|
|
179
|
+
"markdown": markdown,
|
|
180
|
+
"total_time": 0,
|
|
181
|
+
"stages": [],
|
|
182
|
+
"references": [],
|
|
183
|
+
"page_references": [],
|
|
184
|
+
"image_references": [],
|
|
185
|
+
"stats": {},
|
|
186
|
+
"theme_color": theme_color,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# 1. Update Data & Settle
|
|
190
|
+
tab.run_js(f"window.updateRenderData({json.dumps(render_data)})")
|
|
191
|
+
tab.wait(0.5) # Since images are Base64, decoding is nearly instant once injected
|
|
113
192
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
193
|
+
# 2. Dynamic Resize
|
|
194
|
+
# Get actual content height to prevent clipping
|
|
195
|
+
scroll_height = tab.run_js('return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);')
|
|
196
|
+
viewport_height = int(scroll_height) + 200
|
|
197
|
+
|
|
198
|
+
tab.run_cdp('Emulation.setDeviceMetricsOverride',
|
|
199
|
+
width=1920, height=viewport_height, deviceScaleFactor=1, mobile=False
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# 3. Hide Scrollbars (Now that viewport is large enough, overflow:hidden won't clip)
|
|
203
|
+
tab.run_js('document.documentElement.style.overflow = "hidden"')
|
|
204
|
+
tab.run_js('document.body.style.overflow = "hidden"')
|
|
205
|
+
|
|
206
|
+
# Use element's actual position and size
|
|
207
|
+
main_ele = tab.ele('#main-container', timeout=3)
|
|
208
|
+
if main_ele:
|
|
209
|
+
# Robustly hide scrollbars via CDP and Style Injection
|
|
210
|
+
SharedBrowserManager.hide_scrollbars(tab)
|
|
211
|
+
|
|
212
|
+
# Force root styles to eliminate gutter and ensure full width
|
|
213
|
+
tab.run_js('document.documentElement.style.overflow = "hidden";')
|
|
214
|
+
tab.run_js('document.body.style.overflow = "hidden";')
|
|
215
|
+
tab.run_js('document.documentElement.style.scrollbarGutter = "unset";')
|
|
216
|
+
tab.run_js('document.documentElement.style.width = "100%";')
|
|
126
217
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
218
|
+
orig_overflow = "auto" # just a placeholder, we rely on full refresh usually or don't care about restoring for single-purpose tabs
|
|
219
|
+
|
|
220
|
+
b64_img = main_ele.get_screenshot(as_base64='jpg')
|
|
221
|
+
|
|
222
|
+
# Restore not strictly needed for throwaway render tabs, but good practice
|
|
223
|
+
# tab.run_js(f'document.documentElement.style.overflow = "{orig_overflow}";')
|
|
224
|
+
try:
|
|
225
|
+
tab.set.scroll_bars(True)
|
|
226
|
+
except:
|
|
227
|
+
pass
|
|
228
|
+
return b64_img
|
|
229
|
+
else:
|
|
230
|
+
return tab.get_screenshot(as_base64='jpg', full_page=False)
|
|
231
|
+
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.error(f"ContentRenderer: Failed to render page: {e}")
|
|
234
|
+
return None
|
|
235
|
+
finally:
|
|
236
|
+
if tab:
|
|
237
|
+
try:
|
|
238
|
+
tab.close()
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
141
241
|
|
|
142
|
-
async def _ensure_page(self):
|
|
143
|
-
"""Ensure page is ready, restart if needed (runs in background loop)."""
|
|
144
|
-
if self._render_count >= self._max_renders_before_restart:
|
|
145
|
-
logger.info(f"ContentRenderer: Restarting browser after {self._render_count} renders...")
|
|
146
|
-
await self._close_internal()
|
|
147
|
-
self._render_count = 0
|
|
148
|
-
|
|
149
|
-
if not self._page:
|
|
150
|
-
await self._init_browser()
|
|
151
242
|
|
|
152
243
|
async def render(
|
|
153
244
|
self,
|
|
154
245
|
markdown_content: str,
|
|
155
246
|
output_path: str,
|
|
247
|
+
tab_id: Optional[str] = None,
|
|
156
248
|
stats: Dict[str, Any] = None,
|
|
157
249
|
references: List[Dict[str, Any]] = None,
|
|
158
250
|
page_references: List[Dict[str, Any]] = None,
|
|
159
251
|
image_references: List[Dict[str, Any]] = None,
|
|
160
252
|
stages_used: List[Dict[str, Any]] = None,
|
|
161
|
-
image_timeout: int = 3000,
|
|
162
253
|
theme_color: str = "#ef4444",
|
|
163
254
|
**kwargs
|
|
164
255
|
) -> bool:
|
|
165
|
-
"""Render content to image."""
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
"total_time": stats_dict.get("total_time", 0) or 0,
|
|
181
|
-
"stages": [
|
|
182
|
-
{
|
|
183
|
-
"name": s.get("name", "Step"),
|
|
184
|
-
"model": s.get("model", ""),
|
|
185
|
-
"provider": s.get("provider", ""),
|
|
186
|
-
"time": s.get("time", 0),
|
|
187
|
-
"cost": s.get("cost", 0),
|
|
188
|
-
"references": s.get("references") or s.get("search_results"),
|
|
189
|
-
"image_references": s.get("image_references"),
|
|
190
|
-
"crawled_pages": s.get("crawled_pages"),
|
|
191
|
-
}
|
|
192
|
-
for s in (stages_used or [])
|
|
193
|
-
],
|
|
194
|
-
"references": references or [],
|
|
195
|
-
"page_references": page_references or [],
|
|
196
|
-
"image_references": image_references or [],
|
|
197
|
-
"stats": stats_dict,
|
|
198
|
-
"theme_color": theme_color,
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
# Reorder images in stages
|
|
202
|
-
self._reorder_images_in_stages(render_data["markdown"], render_data["stages"])
|
|
203
|
-
|
|
204
|
-
# Run render in background loop (non-blocking wait for result)
|
|
205
|
-
try:
|
|
206
|
-
future = self._run_in_background(
|
|
207
|
-
self._render_internal(render_data, str(resolved_output_path), image_timeout)
|
|
208
|
-
)
|
|
209
|
-
# Use asyncio.to_thread to wait for the future without blocking the event loop
|
|
210
|
-
return await asyncio.to_thread(future.result, 60)
|
|
211
|
-
except Exception as e:
|
|
212
|
-
logger.error(f"ContentRenderer: render failed ({e})")
|
|
213
|
-
return False
|
|
256
|
+
"""Render content to image using a specific (pre-warmed) tab or a temp one."""
|
|
257
|
+
loop = asyncio.get_running_loop()
|
|
258
|
+
return await loop.run_in_executor(
|
|
259
|
+
self._executor,
|
|
260
|
+
self._render_sync,
|
|
261
|
+
markdown_content,
|
|
262
|
+
output_path,
|
|
263
|
+
tab_id,
|
|
264
|
+
stats,
|
|
265
|
+
references,
|
|
266
|
+
page_references,
|
|
267
|
+
image_references,
|
|
268
|
+
stages_used,
|
|
269
|
+
theme_color
|
|
270
|
+
)
|
|
214
271
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
272
|
+
def _render_sync(
|
|
273
|
+
self,
|
|
274
|
+
markdown_content: str,
|
|
275
|
+
output_path: str,
|
|
276
|
+
tab_id: Optional[str],
|
|
277
|
+
stats: Dict[str, Any],
|
|
278
|
+
references: List[Dict[str, Any]],
|
|
279
|
+
page_references: List[Dict[str, Any]],
|
|
280
|
+
image_references: List[Dict[str, Any]],
|
|
281
|
+
stages_used: List[Dict[str, Any]],
|
|
282
|
+
theme_color: str
|
|
283
|
+
) -> bool:
|
|
284
|
+
"""Synchronous render implementation."""
|
|
285
|
+
tab = None
|
|
219
286
|
|
|
220
287
|
try:
|
|
221
|
-
|
|
288
|
+
self._ensure_manager()
|
|
289
|
+
page = self._manager.page
|
|
222
290
|
|
|
223
|
-
|
|
224
|
-
|
|
291
|
+
if tab_id:
|
|
292
|
+
try:
|
|
293
|
+
tab = page.get_tab(tab_id)
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
225
296
|
|
|
226
|
-
|
|
227
|
-
|
|
297
|
+
if not tab:
|
|
298
|
+
logger.warning("ContentRenderer: Pre-warmed tab not found, creating new.")
|
|
299
|
+
tab = page.new_tab(self.template_path.as_uri())
|
|
300
|
+
tab.wait(0.5)
|
|
228
301
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
timeout=image_timeout
|
|
234
|
-
)
|
|
235
|
-
except Exception:
|
|
236
|
-
logger.warning(f"ContentRenderer: Timeout waiting for images ({image_timeout}ms)")
|
|
302
|
+
resolved_output_path = Path(output_path).resolve()
|
|
303
|
+
resolved_output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
|
|
305
|
+
stats_dict = stats[0] if isinstance(stats, list) and stats else (stats or {})
|
|
237
306
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
307
|
+
render_data = {
|
|
308
|
+
"markdown": markdown_content,
|
|
309
|
+
"total_time": stats_dict.get("total_time", 0) or 0,
|
|
310
|
+
"stages": stages_used or [],
|
|
311
|
+
"references": references or [],
|
|
312
|
+
"page_references": page_references or [],
|
|
313
|
+
"image_references": image_references or [],
|
|
314
|
+
"stats": stats_dict,
|
|
315
|
+
"theme_color": theme_color,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
tab.run_js(f"window.updateRenderData({json.dumps(render_data)})")
|
|
319
|
+
|
|
320
|
+
# Brief settle wait for masonry/images
|
|
321
|
+
tab.wait(0.6)
|
|
322
|
+
|
|
323
|
+
# Dynamic Resize
|
|
324
|
+
scroll_height = tab.run_js('return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);')
|
|
325
|
+
viewport_height = int(scroll_height) + 200
|
|
326
|
+
|
|
327
|
+
tab.run_cdp('Emulation.setDeviceMetricsOverride',
|
|
328
|
+
width=1920, height=viewport_height, deviceScaleFactor=1, mobile=False
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Hide scrollbars
|
|
332
|
+
tab.run_js('document.documentElement.style.overflow = "hidden"')
|
|
333
|
+
tab.run_js('document.body.style.overflow = "hidden"')
|
|
334
|
+
|
|
335
|
+
# Use element's actual position and size
|
|
336
|
+
main_ele = tab.ele('#main-container', timeout=5)
|
|
337
|
+
if main_ele:
|
|
338
|
+
import base64
|
|
339
|
+
|
|
340
|
+
# Robustly hide scrollbars via CDP and Style Injection
|
|
341
|
+
SharedBrowserManager.hide_scrollbars(tab)
|
|
342
|
+
|
|
343
|
+
# Force root styles to eliminate gutter and ensure full width
|
|
344
|
+
tab.run_js('document.documentElement.style.overflow = "hidden";')
|
|
345
|
+
tab.run_js('document.body.style.overflow = "hidden";')
|
|
346
|
+
tab.run_js('document.documentElement.style.scrollbarGutter = "unset";')
|
|
347
|
+
tab.run_js('document.documentElement.style.width = "100%";')
|
|
348
|
+
|
|
349
|
+
b64_img = main_ele.get_screenshot(as_base64='jpg')
|
|
350
|
+
|
|
351
|
+
# Restore scrollbars (optional here since we often close or navigate away)
|
|
352
|
+
try:
|
|
353
|
+
tab.set.scroll_bars(True)
|
|
354
|
+
except:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
with open(str(resolved_output_path), 'wb') as f:
|
|
358
|
+
f.write(base64.b64decode(b64_img))
|
|
242
359
|
else:
|
|
243
|
-
|
|
360
|
+
logger.warning("ContentRenderer: #main-container not found, using fallback")
|
|
361
|
+
tab.get_screenshot(path=str(resolved_output_path.parent), name=resolved_output_path.name, full_page=True)
|
|
244
362
|
|
|
245
|
-
self._render_count += 1
|
|
246
|
-
duration = time.time() - start_time
|
|
247
|
-
logger.success(f"ContentRenderer: Rendered in {duration:.3f}s (No.{self._render_count})")
|
|
248
363
|
return True
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
logger.error(f"ContentRenderer: render failed ({exc})")
|
|
252
|
-
# Reset page to force restart next time
|
|
253
|
-
self._page = None
|
|
364
|
+
except Exception as e:
|
|
365
|
+
logger.error(f"ContentRenderer: Render failed: {e}")
|
|
254
366
|
return False
|
|
255
367
|
finally:
|
|
256
|
-
|
|
368
|
+
if tab:
|
|
369
|
+
try:
|
|
370
|
+
tab.close()
|
|
371
|
+
except Exception:
|
|
372
|
+
pass
|
|
257
373
|
|
|
258
|
-
async def
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
for idx, model in enumerate(models or [], start=1):
|
|
268
|
-
name = model.get("name", "unknown")
|
|
269
|
-
base_url = model.get("base_url") or default_base_url
|
|
270
|
-
provider = model.get("provider", "")
|
|
271
|
-
lines.append(f"{idx}. **{name}** \n - base_url: {base_url} \n - provider: {provider}")
|
|
374
|
+
async def close(self):
|
|
375
|
+
"""Close renderer."""
|
|
376
|
+
self._executor.shutdown(wait=False)
|
|
377
|
+
if self._render_tab:
|
|
378
|
+
try:
|
|
379
|
+
self._render_tab.close()
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
self._render_tab = None
|
|
272
383
|
|
|
273
|
-
markdown_content = "\n\n".join(lines) if len(lines) > 1 else "# 模型列表\n暂无模型"
|
|
274
384
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
output_path=output_path,
|
|
278
|
-
stats={},
|
|
279
|
-
references=[],
|
|
280
|
-
stages_used=[],
|
|
281
|
-
)
|
|
385
|
+
# Singleton
|
|
386
|
+
_content_renderer: Optional[ContentRenderer] = None
|
|
282
387
|
|
|
283
|
-
def _reorder_images_in_stages(self, markdown: str, stages: List[Dict[str, Any]]) -> None:
|
|
284
|
-
"""Reorder image references in stages based on appearance in markdown."""
|
|
285
|
-
import re
|
|
286
|
-
|
|
287
|
-
img_urls = []
|
|
288
|
-
for match in re.finditer(r'!\[.*?\]\((.*?)\)', markdown):
|
|
289
|
-
url_part = match.group(1).split()[0].strip()
|
|
290
|
-
if url_part and url_part not in img_urls:
|
|
291
|
-
img_urls.append(url_part)
|
|
292
|
-
|
|
293
|
-
if not img_urls:
|
|
294
|
-
return
|
|
295
388
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
for r in refs:
|
|
311
|
-
if r["url"] not in seen_urls:
|
|
312
|
-
new_refs.append(r)
|
|
313
|
-
|
|
314
|
-
stage["image_references"] = new_refs
|
|
389
|
+
|
|
390
|
+
async def get_content_renderer() -> ContentRenderer:
|
|
391
|
+
global _content_renderer
|
|
392
|
+
if _content_renderer is None:
|
|
393
|
+
_content_renderer = ContentRenderer()
|
|
394
|
+
await _content_renderer.start()
|
|
395
|
+
return _content_renderer
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def set_global_renderer(renderer: ContentRenderer):
|
|
399
|
+
"""Set the global renderer instance."""
|
|
400
|
+
global _content_renderer
|
|
401
|
+
_content_renderer = renderer
|