entari-plugin-hyw 4.0.0rc7__py3-none-any.whl → 4.0.0rc8__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.

Files changed (114) hide show
  1. entari_plugin_hyw/Untitled-1 +1865 -0
  2. entari_plugin_hyw/__init__.py +726 -394
  3. entari_plugin_hyw/history.py +26 -13
  4. entari_plugin_hyw/misc.py +3 -0
  5. entari_plugin_hyw/search_cache.py +154 -0
  6. {entari_plugin_hyw-4.0.0rc7.dist-info → entari_plugin_hyw-4.0.0rc8.dist-info}/METADATA +3 -1
  7. entari_plugin_hyw-4.0.0rc8.dist-info/RECORD +68 -0
  8. {entari_plugin_hyw-4.0.0rc7.dist-info → entari_plugin_hyw-4.0.0rc8.dist-info}/WHEEL +1 -1
  9. {entari_plugin_hyw-4.0.0rc7.dist-info → entari_plugin_hyw-4.0.0rc8.dist-info}/top_level.txt +1 -0
  10. hyw_core/__init__.py +94 -0
  11. hyw_core/browser_control/__init__.py +65 -0
  12. hyw_core/browser_control/assets/card-dist/index.html +409 -0
  13. hyw_core/browser_control/assets/index.html +5691 -0
  14. hyw_core/browser_control/engines/__init__.py +17 -0
  15. {entari_plugin_hyw/browser → hyw_core/browser_control}/engines/duckduckgo.py +42 -8
  16. {entari_plugin_hyw/browser → hyw_core/browser_control}/engines/google.py +1 -1
  17. {entari_plugin_hyw/browser → hyw_core/browser_control}/manager.py +15 -8
  18. entari_plugin_hyw/render_vue.py → hyw_core/browser_control/renderer.py +29 -14
  19. {entari_plugin_hyw/browser → hyw_core/browser_control}/service.py +287 -112
  20. hyw_core/config.py +154 -0
  21. hyw_core/core.py +322 -0
  22. hyw_core/definitions.py +83 -0
  23. entari_plugin_hyw/modular_pipeline.py → hyw_core/pipeline.py +121 -97
  24. {entari_plugin_hyw → hyw_core}/search.py +19 -14
  25. hyw_core/stages/__init__.py +21 -0
  26. entari_plugin_hyw/stage_base.py → hyw_core/stages/base.py +2 -2
  27. entari_plugin_hyw/stage_summary.py → hyw_core/stages/summary.py +34 -11
  28. entari_plugin_hyw/assets/card-dist/index.html +0 -387
  29. entari_plugin_hyw/browser/__init__.py +0 -10
  30. entari_plugin_hyw/browser/engines/bing.py +0 -95
  31. entari_plugin_hyw/card-ui/.gitignore +0 -24
  32. entari_plugin_hyw/card-ui/README.md +0 -5
  33. entari_plugin_hyw/card-ui/index.html +0 -16
  34. entari_plugin_hyw/card-ui/package-lock.json +0 -2342
  35. entari_plugin_hyw/card-ui/package.json +0 -31
  36. entari_plugin_hyw/card-ui/public/logos/anthropic.svg +0 -1
  37. entari_plugin_hyw/card-ui/public/logos/cerebras.svg +0 -9
  38. entari_plugin_hyw/card-ui/public/logos/deepseek.png +0 -0
  39. entari_plugin_hyw/card-ui/public/logos/gemini.svg +0 -1
  40. entari_plugin_hyw/card-ui/public/logos/google.svg +0 -1
  41. entari_plugin_hyw/card-ui/public/logos/grok.png +0 -0
  42. entari_plugin_hyw/card-ui/public/logos/huggingface.png +0 -0
  43. entari_plugin_hyw/card-ui/public/logos/microsoft.svg +0 -15
  44. entari_plugin_hyw/card-ui/public/logos/minimax.png +0 -0
  45. entari_plugin_hyw/card-ui/public/logos/mistral.png +0 -0
  46. entari_plugin_hyw/card-ui/public/logos/nvida.png +0 -0
  47. entari_plugin_hyw/card-ui/public/logos/openai.svg +0 -1
  48. entari_plugin_hyw/card-ui/public/logos/openrouter.png +0 -0
  49. entari_plugin_hyw/card-ui/public/logos/perplexity.svg +0 -24
  50. entari_plugin_hyw/card-ui/public/logos/qwen.png +0 -0
  51. entari_plugin_hyw/card-ui/public/logos/xai.png +0 -0
  52. entari_plugin_hyw/card-ui/public/logos/xiaomi.png +0 -0
  53. entari_plugin_hyw/card-ui/public/logos/zai.png +0 -0
  54. entari_plugin_hyw/card-ui/public/vite.svg +0 -1
  55. entari_plugin_hyw/card-ui/src/App.vue +0 -787
  56. entari_plugin_hyw/card-ui/src/assets/vue.svg +0 -1
  57. entari_plugin_hyw/card-ui/src/components/HelloWorld.vue +0 -41
  58. entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +0 -382
  59. entari_plugin_hyw/card-ui/src/components/SectionCard.vue +0 -41
  60. entari_plugin_hyw/card-ui/src/components/StageCard.vue +0 -240
  61. entari_plugin_hyw/card-ui/src/main.ts +0 -5
  62. entari_plugin_hyw/card-ui/src/style.css +0 -29
  63. entari_plugin_hyw/card-ui/src/test_regex.js +0 -103
  64. entari_plugin_hyw/card-ui/src/types.ts +0 -61
  65. entari_plugin_hyw/card-ui/tsconfig.app.json +0 -16
  66. entari_plugin_hyw/card-ui/tsconfig.json +0 -7
  67. entari_plugin_hyw/card-ui/tsconfig.node.json +0 -26
  68. entari_plugin_hyw/card-ui/vite.config.ts +0 -16
  69. entari_plugin_hyw/definitions.py +0 -174
  70. entari_plugin_hyw/stage_instruct.py +0 -355
  71. entari_plugin_hyw/stage_instruct_deepsearch.py +0 -104
  72. entari_plugin_hyw/stage_vision.py +0 -113
  73. entari_plugin_hyw-4.0.0rc7.dist-info/RECORD +0 -102
  74. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/anthropic.svg +0 -0
  75. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/cerebras.svg +0 -0
  76. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/deepseek.png +0 -0
  77. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/gemini.svg +0 -0
  78. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/google.svg +0 -0
  79. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/grok.png +0 -0
  80. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/huggingface.png +0 -0
  81. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/microsoft.svg +0 -0
  82. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/minimax.png +0 -0
  83. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/mistral.png +0 -0
  84. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/nvida.png +0 -0
  85. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/openai.svg +0 -0
  86. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/openrouter.png +0 -0
  87. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/perplexity.svg +0 -0
  88. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/qwen.png +0 -0
  89. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/xai.png +0 -0
  90. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/xiaomi.png +0 -0
  91. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/zai.png +0 -0
  92. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/vite.svg +0 -0
  93. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/anthropic.svg +0 -0
  94. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/cerebras.svg +0 -0
  95. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/deepseek.png +0 -0
  96. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/gemini.svg +0 -0
  97. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/google.svg +0 -0
  98. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/grok.png +0 -0
  99. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/huggingface.png +0 -0
  100. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/microsoft.svg +0 -0
  101. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/minimax.png +0 -0
  102. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/mistral.png +0 -0
  103. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/nvida.png +0 -0
  104. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/openai.svg +0 -0
  105. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/openrouter.png +0 -0
  106. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/perplexity.svg +0 -0
  107. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/qwen.png +0 -0
  108. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/xai.png +0 -0
  109. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/xiaomi.png +0 -0
  110. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/zai.png +0 -0
  111. {entari_plugin_hyw/browser → hyw_core/browser_control}/engines/base.py +0 -0
  112. {entari_plugin_hyw/browser → hyw_core/browser_control}/engines/default.py +0 -0
  113. {entari_plugin_hyw/browser → hyw_core/browser_control}/landing.html +0 -0
  114. {entari_plugin_hyw → hyw_core}/image_cache.py +0 -0
@@ -22,65 +22,22 @@ class ScreenshotService:
22
22
  self.headless = headless
23
23
  self._manager = None
24
24
  self._executor = ThreadPoolExecutor(max_workers=10)
25
- self._search_tab_pool = [] # List of Tab objects
26
- self._pool_lock = threading.Lock()
27
25
 
28
26
  if auto_start:
29
27
  self._ensure_ready()
30
-
31
- def prepare_search_tabs_background(self, count: int, url: str = "https://www.google.com") -> None:
32
- """
33
- Pre-launch tabs for search (BACKGROUND - fire and forget).
34
- Tabs are created in background thread, may not be ready immediately.
35
- """
36
- self._executor.submit(self._prepare_search_tabs_sync, count, url)
37
28
 
38
- def _prepare_search_tabs_sync(self, count: int, url: str = "https://www.google.com"):
39
- """Sync implementation of tab preparation - creates tabs in PARALLEL."""
29
+ def _get_tab(self, url: str) -> Any:
30
+ """Create a new tab and navigate to URL."""
31
+ self._ensure_ready()
32
+ return self._manager.new_tab(url)
33
+
34
+ def _release_tab(self, tab: Any):
35
+ """Close tab after use."""
36
+ if not tab: return
40
37
  try:
41
- self._ensure_ready()
42
- page = self._manager.page
43
- if not page: return
44
-
45
- with self._pool_lock:
46
- current_count = len(self._search_tab_pool)
47
- needed = count - current_count
48
-
49
- if needed <= 0:
50
- return
51
-
52
- logger.info(f"ScreenshotService: Pre-launching {needed} search tabs for {url} (parallel)...")
53
-
54
- # Create tabs in parallel using threads
55
- created_tabs = [None] * needed
56
-
57
- def create_single_tab(index):
58
- try:
59
- tab = page.new_tab(url)
60
- created_tabs[index] = tab
61
- logger.debug(f"ScreenshotService: Tab {index} ready")
62
- except Exception as e:
63
- logger.error(f"ScreenshotService: Failed to create tab {index}: {e}")
64
-
65
- threads = []
66
- for i in range(needed):
67
- t = threading.Thread(target=create_single_tab, args=(i,))
68
- t.start()
69
- threads.append(t)
70
-
71
- # Wait for all threads to complete
72
- for t in threads:
73
- t.join()
74
-
75
- # Add successfully created tabs to pool
76
- with self._pool_lock:
77
- for tab in created_tabs:
78
- if tab:
79
- self._search_tab_pool.append(tab)
80
- logger.info(f"ScreenshotService: Tab pool ready ({len(self._search_tab_pool)} tabs)")
81
-
82
- except Exception as e:
83
- logger.error(f"ScreenshotService: Failed to prepare tabs: {e}")
38
+ tab.close()
39
+ except:
40
+ pass
84
41
 
85
42
  async def search_via_page_input_batch(self, queries: List[str], url: str, selector: str = "#input") -> List[Dict[str, Any]]:
86
43
  """
@@ -104,17 +61,13 @@ class ScreenshotService:
104
61
 
105
62
  for i in range(len(queries)):
106
63
  tab = None
107
- # Try to get from pool first
108
- with self._pool_lock:
109
- if self._search_tab_pool:
110
- tab = self._search_tab_pool.pop(0)
111
- logger.debug(f"ScreenshotService: Got tab {i} from pool")
64
+ # Try to get from pool first (using shared logic now)
65
+ try:
66
+ tab = self._get_tab(target_url)
67
+ except Exception as e:
68
+ logger.warning(f"ScreenshotService: Batch search tab creation failed: {e}")
112
69
 
113
- if not tab:
114
- # Create new
115
- self._ensure_ready()
116
- tab = self._manager.page.new_tab(target_url)
117
- logger.debug(f"ScreenshotService: Created tab {i} for {target_url}")
70
+
118
71
 
119
72
  tabs.append(tab)
120
73
 
@@ -155,7 +108,8 @@ class ScreenshotService:
155
108
 
156
109
  logger.debug(f"Search[{index}]: Waiting for search results...")
157
110
  tab.wait.doc_loaded(timeout=10)
158
- time.sleep(0.5)
111
+ # Reduced settle wait for extraction
112
+ time.sleep(0.1)
159
113
 
160
114
  logger.debug(f"Search[{index}]: Extracting content...")
161
115
  html = tab.html
@@ -178,8 +132,7 @@ class ScreenshotService:
178
132
  logger.error(f"ScreenshotService: Search error for '{query}': {e}")
179
133
  results[index] = {"content": f"Error: {e}", "title": "Error", "url": "", "html": ""}
180
134
  finally:
181
- try: tab.close()
182
- except: pass
135
+ self._release_tab(tab)
183
136
 
184
137
  threads = []
185
138
  for i, (tab, query) in enumerate(zip(tabs, queries)):
@@ -248,7 +201,7 @@ class ScreenshotService:
248
201
 
249
202
  # Small delay for address bar to focus
250
203
  import time as _time
251
- _time.sleep(0.1)
204
+ _time.sleep(0.05)
252
205
 
253
206
  # Type the query
254
207
  tab.actions.type(query)
@@ -259,8 +212,8 @@ class ScreenshotService:
259
212
  # Wait for page to load
260
213
  try:
261
214
  tab.wait.doc_loaded(timeout=timeout)
262
- # Additional wait for search results
263
- _time.sleep(1)
215
+ # Reduced wait for initial results
216
+ _time.sleep(0.2)
264
217
  except:
265
218
  pass
266
219
 
@@ -292,6 +245,89 @@ class ScreenshotService:
292
245
  try: tab.close()
293
246
  except: pass
294
247
 
248
+ def _scroll_to_bottom(self, tab, step: int = 800, delay: float = 2.0, timeout: float = 10.0):
249
+ """
250
+ Scroll down gradually to trigger lazy loading.
251
+
252
+ Args:
253
+ delay: Max wait time per scroll step (seconds) if images aren't loading.
254
+ """
255
+ import time
256
+ start = time.time()
257
+ current_pos = 0
258
+ try:
259
+ while time.time() - start < timeout:
260
+ # Scroll down
261
+ current_pos += step
262
+ tab.run_js(f"window.scrollTo(0, {current_pos});")
263
+
264
+ # Active Wait: Check if images in viewport are loaded
265
+ # Poll every 100ms, up to 'delay' seconds
266
+ wait_start = time.time()
267
+ while time.time() - wait_start < delay:
268
+ all_loaded = tab.run_js("""
269
+ return (async () => {
270
+ const imgs = Array.from(document.querySelectorAll('img'));
271
+ const viewportHeight = window.innerHeight;
272
+
273
+ // 1. Identify images currently in viewport
274
+ const visibleImgs = imgs.filter(img => {
275
+ const rect = img.getBoundingClientRect();
276
+ return (rect.top < viewportHeight && rect.bottom > 0) && (rect.width > 0 && rect.height > 0);
277
+ });
278
+
279
+ if (visibleImgs.length === 0) return true;
280
+
281
+ // 2. Check loading status using decode() AND heuristic for placeholders
282
+ // Some sites load a tiny blurred placeholder first.
283
+ const checks = visibleImgs.map(img => {
284
+ // HEURISTIC: content is likely not ready if:
285
+ // - img has 'data-src' but src is different (or src is empty)
286
+ // - img has 'loading="lazy"' and is not complete
287
+ // - naturalWidth is very small (placeholder) compared to display width
288
+
289
+ const isPlaceholder = (
290
+ (img.getAttribute('data-src') && img.src !== img.getAttribute('data-src')) ||
291
+ (img.naturalWidth < 50 && img.clientWidth > 100)
292
+ );
293
+
294
+ if (isPlaceholder) {
295
+ // If it looks like a placeholder, we return false (not loaded)
296
+ // unless it stays like this for too long (handled by outer timeout)
297
+ return Promise.resolve(false);
298
+ }
299
+
300
+ if (img.complete && img.naturalHeight > 0) return Promise.resolve(true);
301
+
302
+ return img.decode().then(() => true).catch(() => false);
303
+ });
304
+
305
+ // Race against a small timeout to avoid hanging on one broken image
306
+ const allDecoded = Promise.all(checks);
307
+ const timeout = new Promise(resolve => setTimeout(() => resolve(false), 500));
308
+
309
+ // If any check returned false (meaning placeholder or not decoded), result is false
310
+ return Promise.race([allDecoded, timeout]).then(results => {
311
+ if (!Array.isArray(results)) return results === true;
312
+ return results.every(res => res === true);
313
+ });
314
+ })();
315
+ """)
316
+ if all_loaded:
317
+ break
318
+ time.sleep(0.1)
319
+
320
+ # Check if reached bottom
321
+ height = tab.run_js("return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);")
322
+ if current_pos >= height:
323
+ break
324
+
325
+ # Ensure final layout settle
326
+ time.sleep(0.2)
327
+
328
+ except Exception as e:
329
+ logger.warning(f"ScreenshotService: Scroll failed: {e}")
330
+
295
331
  def _fetch_page_sync(self, url: str, timeout: float, include_screenshot: bool) -> Dict[str, Any]:
296
332
  """Synchronous fetch logic."""
297
333
  if not url:
@@ -304,27 +340,48 @@ class ScreenshotService:
304
340
  if not page:
305
341
  return {"content": "Error: Browser not available", "title": "Error", "url": url}
306
342
 
307
- # New Tab with URL directly
308
- tab = page.new_tab(url)
343
+ # Get from pool
344
+ tab = self._get_tab(url)
309
345
 
310
346
  # Wait logic - optimized for search pages
311
347
  is_search_page = any(s in url.lower() for s in ['search', 'bing.com', 'duckduckgo', 'google.com/search', 'searx'])
312
348
  if is_search_page:
313
- # Optimized waiting for search engine results
314
- try:
315
- # Google uses #search or #rso
316
- # DuckDuckGo uses #react-layout
317
- # Bing uses #b_results
318
- if 'google' in url.lower():
319
- # Wait for results container (fastest possible return)
320
- tab.ele('#search', timeout=timeout)
321
- elif 'bing' in url.lower():
322
- tab.ele('#b_results', timeout=timeout)
323
- else:
324
- # Generic search fallback
325
- tab.wait.doc_loaded(timeout=timeout)
326
- except:
327
- pass
349
+ # Optimized waiting: Rapidly poll for ACTUAL results > 0
350
+ start_time = time.time()
351
+
352
+ # Special fast-path for DDG Lite (HTML only, no JS rendering needed)
353
+ if 'lite.duckduckgo' in url:
354
+ # just wait for body, it's static HTML
355
+ try:
356
+ tab.wait.doc_loaded(timeout=timeout)
357
+ except: pass
358
+ # Sleep tiny bit to ensure render
359
+ time.sleep(0.5)
360
+ else:
361
+ while time.time() - start_time < timeout:
362
+ found_results = False
363
+ try:
364
+ if 'google' in url.lower():
365
+ # Check if we have any result items (.g, .MjjYud) or the main container (#search)
366
+ # Using checks with minimal timeout to allow fast looping
367
+ if tab.ele('.g', timeout=0.1) or tab.ele('.MjjYud', timeout=0.1) or tab.ele('#search', timeout=0.1):
368
+ found_results = True
369
+ elif 'bing' in url.lower():
370
+ if tab.ele('.b_algo', timeout=0.1) or tab.ele('#b_results', timeout=0.1):
371
+ found_results = True
372
+ elif 'duckduckgo' in url.lower():
373
+ if tab.ele('.result', timeout=0.1) or tab.ele('#react-layout', timeout=0.1):
374
+ found_results = True
375
+ else:
376
+ # Generic fallback: wait for body to be populated
377
+ if tab.ele('body', timeout=0.1):
378
+ found_results = True
379
+ except:
380
+ pass
381
+
382
+ if found_results:
383
+ break
384
+ time.sleep(0.05) # Faster polling (50ms) as requested
328
385
  else:
329
386
  # 1. Wait for document to settle (Fast Dynamic Wait)
330
387
  try:
@@ -451,8 +508,7 @@ class ScreenshotService:
451
508
  return {"content": f"Error: fetch failed ({e})", "title": "Error", "url": url}
452
509
  finally:
453
510
  if tab:
454
- try: tab.close()
455
- except: pass
511
+ self._release_tab(tab)
456
512
 
457
513
  async def fetch_pages_batch(self, urls: List[str], timeout: float = 20.0, include_screenshot: bool = True) -> List[Dict[str, Any]]:
458
514
  """Fetch multiple pages concurrently."""
@@ -461,6 +517,13 @@ class ScreenshotService:
461
517
  tasks = [self.fetch_page(url, timeout, include_screenshot) for url in urls]
462
518
  return await asyncio.gather(*tasks, return_exceptions=True)
463
519
 
520
+ async def screenshot_urls_batch(self, urls: List[str], timeout: float = 15.0, full_page: bool = True) -> List[Optional[str]]:
521
+ """Take screenshots of multiple URLs concurrently."""
522
+ if not urls: return []
523
+ logger.info(f"ScreenshotService: Batch screenshot {len(urls)} URLs")
524
+ tasks = [self.screenshot_url(url, timeout=timeout, full_page=full_page) for url in urls]
525
+ return await asyncio.gather(*tasks, return_exceptions=True)
526
+
464
527
  async def screenshot_url(self, url: str, wait_load: bool = True, timeout: float = 15.0, full_page: bool = False, quality: int = 80) -> Optional[str]:
465
528
  """Screenshot URL (Async wrapper for sync)."""
466
529
  loop = asyncio.get_running_loop()
@@ -481,35 +544,147 @@ class ScreenshotService:
481
544
 
482
545
  tab = page.new_tab(url)
483
546
  try:
484
- if wait_load:
485
- tab.wait.load_complete(timeout=timeout)
486
- else:
487
- tab.wait.doc_loaded(timeout=timeout)
488
- except: pass
547
+ # Wait for full page load (including JS execution)
548
+ tab.wait.load_complete(timeout=timeout)
549
+
550
+ # Wait for actual content to appear (for CDN verification pages)
551
+ # Smart Wait Logic (Final Robust):
552
+ # 1. FORCED WAIT: 1.5s to allow initial redirects/rendering to start.
553
+ # 2. Browser ReadyState Complete
554
+ # 3. Height Stable for 2.0 seconds (20 checks)
555
+ # 4. Text > 100 chars (Crucial: Distinguishes stable content from stable spinners)
556
+ # 5. No Blacklist phrases
557
+
558
+ time.sleep(1.5) # user request: force wait 1.5s before detection
559
+
560
+ last_h = 0
561
+ stable_count = 0
562
+
563
+ for i in range(200): # Max 200 iterations (~20s)
564
+ try:
565
+ state = tab.run_js('''
566
+ return {
567
+ ready: document.readyState === 'complete',
568
+ title: document.title,
569
+ height: Math.max(
570
+ document.body.scrollHeight || 0,
571
+ document.documentElement.scrollHeight || 0
572
+ ),
573
+ text: document.body.innerText.substring(0, 1000) || "",
574
+ html: document.body.innerHTML.substring(0, 500) // Debug intro
575
+ };
576
+ ''') or {'ready': False, 'title': "", 'height': 0, 'text': ""}
577
+
578
+ is_ready = state.get('ready', False)
579
+ title = state.get('title', "").lower()
580
+ current_h = int(state.get('height', 0))
581
+ text_content = state.get('text', "")
582
+ text_len = len(text_content)
583
+ text_lower = text_content.lower()
584
+
585
+ # Blacklist check
586
+ is_verification = "checking your browser" in text_lower or \
587
+ "just a moment" in text_lower or \
588
+ "please wait" in text_lower or \
589
+ "security check" in title or \
590
+ "just a moment" in title
591
+
592
+ # Stability check
593
+ if current_h == last_h:
594
+ stable_count += 1
595
+ else:
596
+ stable_count = 0
597
+
598
+ # Conditions
599
+ has_content = text_len > 100 # At least 100 real chars
600
+ is_stable = stable_count >= 20 # Always require 2s stability
601
+
602
+ # Pass if all conditions met
603
+ if is_ready and not is_verification and has_content and is_stable:
604
+ break
605
+
606
+ last_h = current_h
607
+
608
+ # Wait timing
609
+ try: tab.wait.eles_loaded(timeout=0.1)
610
+ except: pass
611
+
612
+ except Exception:
613
+ stable_count = 0
614
+ try: time.sleep(0.1)
615
+ except: pass
616
+ continue
617
+
618
+ # DEBUG: Save HTML to inspect what happened (in data dir)
619
+ try:
620
+ import os
621
+ log_path = os.path.join(os.getcwd(), "data", "browser.log.html")
622
+ with open(log_path, "w", encoding="utf-8") as f:
623
+ f.write(f"<!-- URL: {url} -->\n")
624
+ f.write(tab.html)
625
+ except: pass
626
+
627
+ # Use faster scroll step (800) to ensure lazy loaded images appear
628
+ self._scroll_to_bottom(tab, step=800, delay=2.0, timeout=min(timeout, 10))
629
+
630
+ except:
631
+ pass
489
632
 
490
- # Wait for main element
491
- if tab.ele("#main-container"):
492
- pass
633
+ # Refine calculation: Set viewport width to 1024
634
+ capture_width = 1024
635
+
636
+ # Calculate actual content height after lazy loading
637
+ try:
638
+ # Use a robust height calculation
639
+ content_height = tab.run_js('''
640
+ return Math.max(
641
+ document.body.scrollHeight || 0,
642
+ document.documentElement.scrollHeight || 0,
643
+ document.body.offsetHeight || 0,
644
+ document.documentElement.offsetHeight || 0,
645
+ document.documentElement.clientHeight || 0
646
+ );
647
+ ''')
648
+ # Add a small buffer and cap at 15000px to prevent memory issues
649
+ h = min(int(content_height) + 50, 15000)
650
+ except:
651
+ h = 1000 # Fallback
493
652
 
653
+ # Set viewport to full content size for single-shot capture
654
+ try:
655
+ tab.run_cdp('Emulation.setDeviceMetricsOverride',
656
+ width=capture_width, height=h, deviceScaleFactor=1, mobile=False)
657
+ except:
658
+ pass
659
+
494
660
  # Scrollbar Hiding
495
661
  from .manager import SharedBrowserManager
496
662
  SharedBrowserManager.hide_scrollbars(tab)
497
- tab.run_js("""
498
- const style = document.createElement('style');
499
- style.textContent = `
500
- ::-webkit-scrollbar { display: none !important; }
501
- html, body { -ms-overflow-style: none !important; scrollbar-width: none !important; }
502
- `;
503
- document.head.appendChild(style);
504
- document.documentElement.style.overflow = 'hidden';
505
- document.body.style.overflow = 'hidden';
506
- """)
507
663
 
508
- ele = tab.ele("#main-container")
509
- if ele:
510
- return ele.get_screenshot(as_base64='jpg', quality=quality)
511
- else:
512
- return tab.get_screenshot(as_base64='jpg', full_page=full_page, quality=quality)
664
+ # Scroll back to top before screenshot
665
+ tab.run_js("window.scrollTo(0, 0);")
666
+
667
+ # Content is already loaded by _scroll_to_bottom which waits for images
668
+ # Just recalculate final height (content may have grown during scrolling)
669
+ try:
670
+ final_height = tab.run_js('''
671
+ return Math.max(
672
+ document.body.scrollHeight || 0,
673
+ document.documentElement.scrollHeight || 0,
674
+ document.body.offsetHeight || 0,
675
+ document.documentElement.offsetHeight || 0
676
+ );
677
+ ''')
678
+ final_h = min(int(final_height) + 50, 15000)
679
+ if final_h != h:
680
+ tab.run_cdp('Emulation.setDeviceMetricsOverride',
681
+ width=capture_width, height=final_h, deviceScaleFactor=1, mobile=False)
682
+ except:
683
+ pass
684
+
685
+ # Use full_page=False because we manually set the viewport to the full height
686
+ # This avoids stitching artifacts and blank spaces
687
+ return tab.get_screenshot(as_base64='jpg', full_page=False)
513
688
 
514
689
  except Exception as e:
515
690
  logger.error(f"ScreenshotService: Screenshot URL failed: {e}")
hyw_core/config.py ADDED
@@ -0,0 +1,154 @@
1
+ """
2
+ hyw_core.config - Configuration Management
3
+
4
+ Provides standalone configuration for hyw-core with optional passthrough from parent packages.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Dict, List, Any, Optional
9
+
10
+
11
+ @dataclass
12
+ class ModelConfig:
13
+ """Configuration for a specific model."""
14
+ model_name: Optional[str] = None
15
+ api_key: Optional[str] = None
16
+ base_url: Optional[str] = None
17
+ extra_body: Optional[Dict[str, Any]] = None
18
+ model_provider: Optional[str] = None
19
+ input_price: Optional[float] = None
20
+ output_price: Optional[float] = None
21
+ image_input: bool = True
22
+
23
+
24
+ @dataclass
25
+ class HywCoreConfig:
26
+ """
27
+ Core configuration for hyw-core.
28
+
29
+ Can be used standalone or with passthrough from parent packages.
30
+
31
+ Usage:
32
+ # Standalone from YAML
33
+ config = HywCoreConfig.from_yaml("config.yaml")
34
+
35
+ # Passthrough from parent
36
+ config = HywCoreConfig.from_dict({
37
+ "model_name": parent_config.model_name,
38
+ "api_key": parent_config.api_key,
39
+ ...
40
+ })
41
+ """
42
+
43
+ # LLM Configuration
44
+ models: List[Dict[str, Any]] = field(default_factory=list)
45
+ model_name: str = ""
46
+ api_key: str = ""
47
+ base_url: str = ""
48
+ temperature: float = 0.4
49
+
50
+ # Stage-specific model overrides
51
+ instruct_model: Optional[str] = None
52
+ instruct_api_key: Optional[str] = None
53
+ instruct_base_url: Optional[str] = None
54
+ instruct_extra_body: Optional[Dict[str, Any]] = None
55
+
56
+ summary_model: Optional[str] = None
57
+ summary_api_key: Optional[str] = None
58
+ summary_base_url: Optional[str] = None
59
+ summary_extra_body: Optional[Dict[str, Any]] = None
60
+
61
+ # Search Configuration
62
+ search_engine: str = "duckduckgo"
63
+ search_limit: int = 10
64
+ blocked_domains: List[str] = field(default_factory=list)
65
+
66
+ # Browser Configuration
67
+ headless: bool = True
68
+ fetch_timeout: float = 20.0
69
+
70
+ # Output Configuration
71
+ language: str = "Simplified Chinese"
72
+ theme_color: str = "#ef4444"
73
+
74
+ # Pricing (for cost estimation)
75
+ input_price: float = 0.0
76
+ output_price: float = 0.0
77
+
78
+ @classmethod
79
+ def from_dict(cls, data: Dict[str, Any]) -> "HywCoreConfig":
80
+ """
81
+ Create config from dictionary.
82
+
83
+ Used for passthrough from parent packages.
84
+ Filters out unknown fields to allow flexible passthrough.
85
+ """
86
+ import dataclasses
87
+ field_names = {f.name for f in dataclasses.fields(cls)}
88
+ filtered_data = {k: v for k, v in data.items() if k in field_names}
89
+ return cls(**filtered_data)
90
+
91
+ @classmethod
92
+ def from_yaml(cls, path: str) -> "HywCoreConfig":
93
+ """
94
+ Load config from YAML file.
95
+
96
+ Used for standalone usage.
97
+ """
98
+ import yaml
99
+ with open(path, 'r', encoding='utf-8') as f:
100
+ data = yaml.safe_load(f) or {}
101
+ return cls.from_dict(data)
102
+
103
+ def get_model_config(self, stage: str) -> ModelConfig:
104
+ """
105
+ Get resolved model config for a stage.
106
+
107
+ Args:
108
+ stage: "instruct", "qa", or "main" (summary)
109
+
110
+ Returns:
111
+ ModelConfig with resolved settings
112
+ """
113
+ # Determine primary and secondary stage config keys
114
+ if stage == "instruct":
115
+ primary_prefix = "instruct_"
116
+ secondary_prefix = None
117
+ elif stage == "qa":
118
+ primary_prefix = "qa_"
119
+ secondary_prefix = "instruct_"
120
+ else: # "main" / summary
121
+ primary_prefix = "summary_"
122
+ secondary_prefix = None
123
+
124
+ def resolve(field_name: str, is_essential: bool = True):
125
+ """Resolve a field with fallback: Primary -> Secondary -> Root."""
126
+ # Try primary
127
+ if primary_prefix:
128
+ val = getattr(self, f"{primary_prefix}{field_name}", None)
129
+ if val:
130
+ return val
131
+
132
+ # Try secondary
133
+ if secondary_prefix:
134
+ val = getattr(self, f"{secondary_prefix}{field_name}", None)
135
+ if val:
136
+ return val
137
+
138
+ # Fallback to root
139
+ return getattr(self, field_name, None)
140
+
141
+ return ModelConfig(
142
+ model_name=resolve("model") or resolve("model_name") or self.model_name,
143
+ api_key=resolve("api_key") or self.api_key,
144
+ base_url=resolve("base_url") or self.base_url,
145
+ extra_body=resolve("extra_body"),
146
+ model_provider=resolve("model_provider"),
147
+ input_price=resolve("input_price") or self.input_price,
148
+ output_price=resolve("output_price") or self.output_price,
149
+ )
150
+
151
+ def to_dict(self) -> Dict[str, Any]:
152
+ """Convert config to dictionary."""
153
+ import dataclasses
154
+ return dataclasses.asdict(self)