@@ -121,74 +121,109 @@ async def _render_chat_share(url: str) -> tuple[str, str]:
121121 return await asyncio .to_thread (_render_chat_share_sync , url )
122122
123123
124- def _render_chat_share_sync (url : str ) -> tuple [str , str ]:
125- html = ""
126- final_url = url
124+ # ── Warm browser pool ──────────────────────────────────────────────────────
125+ # Launching Chromium from cold takes 3-5s. We keep a singleton alive and
126+ # reuse it across scrape requests. The browser is thread-safe when each
127+ # request uses its own BrowserContext.
128+
129+ import threading
130+
131+ _browser_lock = threading .Lock ()
132+ _pw_instance = None
133+ _browser_instance = None
134+
135+
136+ def _get_or_create_browser ():
137+ """Return a long-lived Playwright browser, launching one if needed."""
138+ global _pw_instance , _browser_instance
139+
140+ with _browser_lock :
141+ # If the browser is still alive, reuse it
142+ if _browser_instance is not None and _browser_instance .is_connected ():
143+ return _browser_instance
144+
145+ # Tear down stale Playwright context if any
146+ if _pw_instance is not None :
147+ try :
148+ _pw_instance .stop ()
149+ except Exception :
150+ pass
151+
152+ _pw_instance = sync_playwright ().start ()
127153
128- with sync_playwright () as p :
129- browser = None
130154 launch_errors = []
131155 for channel in (None , "msedge" , "chrome" ):
132156 try :
133157 kwargs = {"headless" : True }
134158 if channel :
135159 kwargs ["channel" ] = channel
136- browser = p .chromium .launch (** kwargs )
137- break
160+ _browser_instance = _pw_instance .chromium .launch (** kwargs )
161+ logger .info ("[scrape] Playwright browser launched (channel=%s)" , channel or "bundled" )
162+ return _browser_instance
138163 except Exception as exc :
139164 launch_errors .append (f"{ channel or 'bundled chromium' } : { exc } " )
140165
141- if browser is None :
142- raise RuntimeError (
143- "Could not launch Playwright browser. Tried bundled Chromium, "
144- f"Edge, and Chrome. Errors: { ' | ' .join (launch_errors )} "
145- )
146-
147- context = browser .new_context (
148- user_agent = (
149- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
150- "AppleWebKit/537.36 (KHTML, like Gecko) "
151- "Chrome/123.0.0.0 Safari/537.36"
152- ),
153- viewport = {"width" : 1280 , "height" : 800 },
154- ignore_https_errors = True ,
166+ raise RuntimeError (
167+ "Could not launch Playwright browser. Tried bundled Chromium, "
168+ f"Edge, and Chrome. Errors: { ' | ' .join (launch_errors )} "
155169 )
156170
157- try :
158- page = context .new_page ()
159171
160- def _block_heavy_assets (route ):
161- if route .request .resource_type in {"image" , "media" , "font" }:
162- route .abort ()
163- return
164- route .continue_ ()
172+ def _render_chat_share_sync (url : str ) -> tuple [str , str ]:
173+ html = ""
174+ final_url = url
165175
166- page . route ( "**/*" , _block_heavy_assets )
176+ browser = _get_or_create_browser ( )
167177
178+ context = browser .new_context (
179+ user_agent = (
180+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
181+ "AppleWebKit/537.36 (KHTML, like Gecko) "
182+ "Chrome/123.0.0.0 Safari/537.36"
183+ ),
184+ viewport = {"width" : 1280 , "height" : 800 },
185+ ignore_https_errors = True ,
186+ )
187+
188+ try :
189+ page = context .new_page ()
190+
191+ def _block_heavy_assets (route ):
192+ rtype = route .request .resource_type
193+ if rtype in {"image" , "media" , "font" , "stylesheet" }:
194+ route .abort ()
195+ return
196+ route .continue_ ()
197+
198+ page .route ("**/*" , _block_heavy_assets )
199+
200+ try :
201+ # domcontentloaded is much faster than networkidle — we don't
202+ # need to wait for analytics / tracking pixels to finish.
203+ page .goto (url , wait_until = "domcontentloaded" , timeout = 15000 )
204+ except Exception as exc :
205+ logger .warning ("Timeout or error during navigation: %s" , exc )
206+
207+ provider = _detect_chat_provider (page .url or url )
208+ selector = {
209+ "chatgpt" : "div[data-message-author-role]" ,
210+ "claude" : "script" ,
211+ "gemini" : "message-content, div.user-query, div.model-response" ,
212+ }.get (provider )
213+ if selector :
168214 try :
169- # Navigate and wait for network to become idle so JS can load messages.
170- page .goto (url , wait_until = "networkidle" , timeout = 20000 )
215+ page .wait_for_selector (selector , timeout = 8000 )
171216 except Exception as exc :
172- logger .warning ("Timeout or error waiting for networkidle: %s" , exc )
173-
174- provider = _detect_chat_provider (page .url or url )
175- selector = {
176- "chatgpt" : "div[data-message-author-role]" ,
177- "claude" : "script" ,
178- "gemini" : "message-content, div.user-query, div.model-response" ,
179- }.get (provider )
180- if selector :
181- try :
182- page .wait_for_selector (selector , timeout = 12000 )
183- except Exception as exc :
184- logger .warning ("Timed out waiting for %s content: %s" , provider , exc )
185-
186- page .wait_for_timeout (2000 )
187- final_url = page .url
188- html = page .content ()
189- finally :
190- context .close ()
191- browser .close ()
217+ logger .warning ("Timed out waiting for %s content: %s" , provider , exc )
218+
219+ # No hardcoded sleep — the selector wait above already guarantees
220+ # the chat content DOM nodes are present.
221+
222+ final_url = page .url
223+ html = page .content ()
224+ finally :
225+ context .close ()
226+ # NOTE: we intentionally do NOT close the browser — it's pooled.
192227
193228 return html , final_url
194229
0 commit comments