Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit e53f3e7

Browse files
committed
optimize scrape browser call
1 parent 6873c68 commit e53f3e7

2 files changed

Lines changed: 95 additions & 51 deletions

File tree

‎src/api/app.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,15 @@ async def lifespan(app: FastAPI):
106106
_ingest_pipeline.close()
107107
if _retrieval_pipeline:
108108
_retrieval_pipeline.close()
109+
# Shut down the warm Playwright browser pool
110+
try:
111+
from src.api.routes.memory import _browser_instance, _pw_instance
112+
if _browser_instance:
113+
_browser_instance.close()
114+
if _pw_instance:
115+
_pw_instance.stop()
116+
except Exception:
117+
pass
109118
# Stop analytics flush thread
110119
if settings.enable_analytics:
111120
from src.config.analytics import analytics as _analytics

‎src/api/routes/memory.py‎

Lines changed: 86 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)