misukisu commited on
Commit
96cb792
·
verified ·
1 Parent(s): c80c47f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +61 -116
app.py CHANGED
@@ -1,6 +1,6 @@
1
  """High-Performance Stealth Web Scraper & Browser Automation MCP Server
2
 
3
- All-in-one file designed for 2 vCPU / 16 GB RAM Hugging Face Spaces.
4
  """
5
 
6
  import asyncio
@@ -22,25 +22,25 @@ from patchright.async_api import Browser, BrowserContext, Page, async_playwright
22
  import trafilatura
23
 
24
  # ==========================================
25
- # 1. Logging & Configuration
26
  # ==========================================
27
  logging.basicConfig(
28
  level=logging.INFO,
29
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
30
  handlers=[logging.StreamHandler(sys.stdout)],
31
  )
32
- logger = logging.getLogger("mcp_scraper")
33
 
34
  PORT = int(os.getenv("PORT", 7860))
35
  HOST = "0.0.0.0"
36
- MAX_CONCURRENT_BROWSERS = 4 # Tuned for 2 vCPU
37
- MAX_RECYCLE_REQUESTS = 100 # Prevents long-term memory leaks
38
- MAX_CONTENT_CHARS = 100_000 # Context window safeguard
39
  HTTP_TIMEOUT_SEC = 12.0
40
  BROWSER_TIMEOUT_MS = 30000
41
 
42
  # ==========================================
43
- # 2. Patchright Stealth Browser Pool
44
  # ==========================================
45
 
46
 
@@ -53,36 +53,38 @@ class StealthBrowserPool:
53
  self._request_counter = 0
54
  self._lock = asyncio.Lock()
55
 
56
- async def initialize(self):
57
- if not self._playwright:
58
- self._playwright = await async_playwright().start()
59
- if not self._browser or not self._browser.is_connected():
60
- logger.info("Starting Patchright Chromium Stealth Engine...")
61
- self._browser = await self._playwright.chromium.launch(
62
- headless=True,
63
- args=[
64
- "--no-sandbox",
65
- "--disable-setuid-sandbox",
66
- "--disable-dev-shm-usage",
67
- "--disable-gpu",
68
- "--disable-blink-features=AutomationControlled",
69
- "--no-first-run",
70
- "--window-size=1920,1080",
71
- ],
72
- )
73
- self._request_counter = 0
 
 
74
 
75
  async def _check_recycle(self):
76
  async with self._lock:
77
  self._request_counter += 1
78
  if self._request_counter >= MAX_RECYCLE_REQUESTS:
79
- logger.info(
80
- "Recycling browser instance to free memory under 16GB limit..."
81
- )
82
  if self._browser:
83
- await self._browser.close()
 
 
 
84
  self._browser = None
85
- await self.initialize()
86
 
87
  @asynccontextmanager
88
  async def get_page(self) -> AsyncGenerator[Page, None]:
@@ -90,11 +92,8 @@ class StealthBrowserPool:
90
  context: BrowserContext | None = None
91
  page: Page | None = None
92
  try:
93
- if not self._browser or not self._browser.is_connected():
94
- await self.initialize()
95
-
96
- # High-entropy stealth fingerprint context
97
- context = await self._browser.new_context(
98
  viewport={"width": 1920, "height": 1080},
99
  user_agent=(
100
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
@@ -103,10 +102,8 @@ class StealthBrowserPool:
103
  ),
104
  locale="en-US",
105
  timezone_id="America/New_York",
106
- device_scale_factor=1,
107
  )
108
 
109
- # Injected anti-detect overrides
110
  await context.add_init_script("""
111
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
112
  Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
@@ -117,7 +114,6 @@ class StealthBrowserPool:
117
  page = await context.new_page()
118
  page.set_default_navigation_timeout(BROWSER_TIMEOUT_MS)
119
  page.set_default_timeout(BROWSER_TIMEOUT_MS)
120
-
121
  yield page
122
  finally:
123
  if page:
@@ -133,17 +129,11 @@ class StealthBrowserPool:
133
  self._semaphore.release()
134
  await self._check_recycle()
135
 
136
- async def shutdown(self):
137
- if self._browser:
138
- await self._browser.close()
139
- if self._playwright:
140
- await self._playwright.stop()
141
-
142
 
143
  browser_pool = StealthBrowserPool()
144
 
145
  # ==========================================
146
- # 3. Content Extraction Pipeline
147
  # ==========================================
148
 
149
 
@@ -162,7 +152,6 @@ def clean_html(raw_html: str) -> str:
162
 
163
 
164
  def extract_content(html: str, url: str = "") -> dict[str, Any]:
165
- """Primary extraction via Trafilatura with automatic Markdownify fallback."""
166
  extracted = trafilatura.extract(
167
  html,
168
  url=url,
@@ -195,7 +184,7 @@ def extract_content(html: str, url: str = "") -> dict[str, Any]:
195
  if len(extracted) > MAX_CONTENT_CHARS:
196
  extracted = (
197
  extracted[:MAX_CONTENT_CHARS]
198
- + f"\n\n... [Truncated: Reached {MAX_CONTENT_CHARS} characters limit]"
199
  )
200
 
201
  return {"content": extracted.strip(), "metadata": meta_dict}
@@ -215,14 +204,13 @@ def extract_json_ld(html: str) -> list[dict]:
215
 
216
 
217
  # ==========================================
218
- # 4. Hybrid Scraping & Automation Core
219
  # ==========================================
220
 
221
  FAST_HEADERS = {
222
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
223
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
224
  "Accept-Language": "en-US,en;q=0.9",
225
- "Upgrade-Insecure-Requests": "1",
226
  }
227
 
228
 
@@ -231,8 +219,6 @@ async def dismiss_popups(page: Page):
231
  'button[id*="cookie" i]',
232
  'button[class*="cookie" i]',
233
  'button[aria-label*="accept" i]',
234
- 'button[class*="accept" i]',
235
- 'button[id*="accept" i]',
236
  'button:has-text("Accept all")',
237
  'button:has-text("Accept")',
238
  'button:has-text("I agree")',
@@ -255,7 +241,6 @@ async def scrape_pipeline(
255
  auto_scroll: bool = True,
256
  wait_for_selector: str | None = None,
257
  ) -> dict:
258
- # Tier 1: Fast HTTP
259
  if not force_browser:
260
  try:
261
  async with httpx.AsyncClient(
@@ -267,14 +252,14 @@ async def scrape_pipeline(
267
  resp = await client.get(url)
268
  if resp.status_code == 200:
269
  html = resp.text
270
- signals = [
271
- "cf-challenge",
272
- "challenges.cloudflare.com",
273
- "ray-id",
274
- "enable javascript",
275
- "just a moment...",
276
- ]
277
- if not any(s in html.lower() for s in signals):
278
  parsed = extract_content(html, url=url)
279
  if len(parsed["content"]) >= 150:
280
  return {
@@ -284,15 +269,12 @@ async def scrape_pipeline(
284
  "content": parsed["content"],
285
  "metadata": parsed["metadata"],
286
  }
287
- except Exception as e:
288
- logger.debug(f"HTTP scrape failed for {url}: {e}")
289
 
290
- # Tier 2: Patchright Stealth Headless Browser
291
- logger.info(f"Escalating to Patchright Stealth Engine for: {url}")
292
  async with browser_pool.get_page() as page:
293
  await page.goto(url, wait_until="domcontentloaded")
294
 
295
- # Solve Cloudflare Turnstile box if visible
296
  try:
297
  cf_frame = await page.query_selector(
298
  "iframe[src*='challenges.cloudflare.com']"
@@ -324,7 +306,6 @@ async def scrape_pipeline(
324
  await asyncio.sleep(0.3)
325
 
326
  await asyncio.sleep(0.5)
327
-
328
  html = await page.content()
329
  final_url = page.url
330
  title = await page.title()
@@ -343,14 +324,14 @@ async def scrape_pipeline(
343
 
344
 
345
  # ==========================================
346
- # 5. MCP Server & Tool Definitions
347
  # ==========================================
348
  mcp = FastMCP("High-Performance Web Scraper", host=HOST, port=PORT)
349
 
350
 
351
  @mcp.tool(
352
  name="scrape_url",
353
- description="Scrapes cleaned Markdown content and metadata from any URL. Automatically handles Cloudflare, SPA rendering, and popups.",
354
  )
355
  async def scrape_url(
356
  url: str,
@@ -358,13 +339,6 @@ async def scrape_url(
358
  auto_scroll: bool = True,
359
  wait_for_selector: str | None = None,
360
  ) -> dict:
361
- """Parameters:
362
-
363
- - url: The target URL to scrape.
364
- - force_browser: Skip fast HTTP and directly use headless stealth browser.
365
- - auto_scroll: Scroll down to trigger lazy loaded elements.
366
- - wait_for_selector: CSS selector to wait for before extracting.
367
- """
368
  return await scrape_pipeline(
369
  url, force_browser, auto_scroll, wait_for_selector
370
  )
@@ -372,14 +346,9 @@ async def scrape_url(
372
 
373
  @mcp.tool(
374
  name="search_and_scrape",
375
- description="Searches DuckDuckGo and concurrently extracts cleaned markdown from the top ranking result pages.",
376
  )
377
  async def search_and_scrape(query: str, max_results: int = 3) -> dict:
378
- """Parameters:
379
-
380
- - query: Search terms.
381
- - max_results: Number of search results to scrape (default: 3, max: 5).
382
- """
383
  max_results = min(max(1, max_results), 5)
384
  loop = asyncio.get_running_loop()
385
 
@@ -423,17 +392,11 @@ async def search_and_scrape(query: str, max_results: int = 3) -> dict:
423
 
424
  @mcp.tool(
425
  name="take_screenshot",
426
- description="Takes a high-resolution full-page or viewport screenshot of a webpage and returns it as a Base64 PNG.",
427
  )
428
  async def take_screenshot(
429
  url: str, full_page: bool = True, wait_seconds: float = 1.0
430
  ) -> dict:
431
- """Parameters:
432
-
433
- - url: The page URL to render and capture.
434
- - full_page: Capture entire scrollable document height (True) or standard viewport (False).
435
- - wait_seconds: Additional time to wait after loading for animations.
436
- """
437
  async with browser_pool.get_page() as page:
438
  await page.goto(url, wait_until="networkidle")
439
  if wait_seconds > 0:
@@ -452,21 +415,11 @@ async def take_screenshot(
452
 
453
  @mcp.tool(
454
  name="interact_page",
455
- description="Executes a chain of browser actions: click, fill inputs, press keys, wait, and evaluate JS.",
456
  )
457
  async def interact_page(
458
  url: str, actions: list[dict[str, Any]], extract_markdown: bool = True
459
  ) -> dict:
460
- """Actions schema list example:
461
-
462
- [
463
- {"type": "click", "selector": "button#submit"},
464
- {"type": "type", "selector": "input#search", "text": "Hugging Face"},
465
- {"type": "press", "key": "Enter"},
466
- {"type": "wait", "seconds": 2.0},
467
- {"type": "evaluate", "script": "window.scrollTo(0, document.body.scrollHeight);"}
468
- ]
469
- """
470
  async with browser_pool.get_page() as page:
471
  await page.goto(url, wait_until="domcontentloaded")
472
  logs = []
@@ -492,19 +445,15 @@ async def interact_page(
492
  await asyncio.sleep(s)
493
  logs.append(f"[{idx}] Waited {s}s")
494
  elif act_type == "evaluate":
495
- script = act["script"]
496
- res = await page.evaluate(script)
497
  logs.append(f"[{idx}] Evaluated script -> {res}")
498
  except Exception as e:
499
- logs.append(f"[{idx}] Action failed: {str(e)}")
500
 
501
  html = await page.content()
502
  final_url = page.url
503
 
504
- res = {
505
- "final_url": final_url,
506
- "logs": logs,
507
- }
508
  if extract_markdown:
509
  parsed = extract_content(html, url=final_url)
510
  res["content"] = parsed["content"]
@@ -515,16 +464,11 @@ async def interact_page(
515
 
516
  @mcp.tool(
517
  name="extract_structured_data",
518
- description="Parses structured JSON-LD schemas and arbitrary CSS selector targets from a webpage.",
519
  )
520
  async def extract_structured_data(
521
  url: str, css_selectors: dict[str, str] | None = None
522
  ) -> dict:
523
- """Parameters:
524
-
525
- - url: The target URL.
526
- - css_selectors: Key-value map of names to CSS selectors, e.g. {"prices": ".price-tag", "headers": "h2"}
527
- """
528
  async with browser_pool.get_page() as page:
529
  await page.goto(url, wait_until="domcontentloaded")
530
  html = await page.content()
@@ -544,9 +488,10 @@ async def extract_structured_data(
544
 
545
 
546
  # ==========================================
547
- # 6. Main Entry Point (SSE Transport)
548
  # ==========================================
549
  if __name__ == "__main__":
550
- logger.info(f"Starting Web Scraper MCP Server on http://{HOST}:{PORT}/sse")
551
- # Runs the official FastMCP Server on port 7860 via Server-Sent Events (SSE)
 
552
  mcp.run(transport="sse")
 
1
  """High-Performance Stealth Web Scraper & Browser Automation MCP Server
2
 
3
+ Resilient, single-file server for Hugging Face Spaces (Port 7860).
4
  """
5
 
6
  import asyncio
 
22
  import trafilatura
23
 
24
  # ==========================================
25
+ # 1. Configuration & Settings
26
  # ==========================================
27
  logging.basicConfig(
28
  level=logging.INFO,
29
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
30
  handlers=[logging.StreamHandler(sys.stdout)],
31
  )
32
+ logger = logging.getLogger("mcp_server")
33
 
34
  PORT = int(os.getenv("PORT", 7860))
35
  HOST = "0.0.0.0"
36
+ MAX_CONCURRENT_BROWSERS = 4
37
+ MAX_RECYCLE_REQUESTS = 100
38
+ MAX_CONTENT_CHARS = 100_000
39
  HTTP_TIMEOUT_SEC = 12.0
40
  BROWSER_TIMEOUT_MS = 30000
41
 
42
  # ==========================================
43
+ # 2. Resilient Browser Pool (Lazy Initialized)
44
  # ==========================================
45
 
46
 
 
53
  self._request_counter = 0
54
  self._lock = asyncio.Lock()
55
 
56
+ async def get_browser(self) -> Browser:
57
+ async with self._lock:
58
+ if not self._playwright:
59
+ self._playwright = await async_playwright().start()
60
+ if not self._browser or not self._browser.is_connected():
61
+ logger.info("Initializing Chromium Stealth instance...")
62
+ self._browser = await self._playwright.chromium.launch(
63
+ headless=True,
64
+ args=[
65
+ "--no-sandbox",
66
+ "--disable-setuid-sandbox",
67
+ "--disable-dev-shm-usage",
68
+ "--disable-gpu",
69
+ "--disable-blink-features=AutomationControlled",
70
+ "--no-first-run",
71
+ "--window-size=1920,1080",
72
+ ],
73
+ )
74
+ self._request_counter = 0
75
+ return self._browser
76
 
77
  async def _check_recycle(self):
78
  async with self._lock:
79
  self._request_counter += 1
80
  if self._request_counter >= MAX_RECYCLE_REQUESTS:
81
+ logger.info("Recycling browser process to free RAM...")
 
 
82
  if self._browser:
83
+ try:
84
+ await self._browser.close()
85
+ except Exception:
86
+ pass
87
  self._browser = None
 
88
 
89
  @asynccontextmanager
90
  async def get_page(self) -> AsyncGenerator[Page, None]:
 
92
  context: BrowserContext | None = None
93
  page: Page | None = None
94
  try:
95
+ browser = await self.get_browser()
96
+ context = await browser.new_context(
 
 
 
97
  viewport={"width": 1920, "height": 1080},
98
  user_agent=(
99
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
 
102
  ),
103
  locale="en-US",
104
  timezone_id="America/New_York",
 
105
  )
106
 
 
107
  await context.add_init_script("""
108
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
109
  Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
 
114
  page = await context.new_page()
115
  page.set_default_navigation_timeout(BROWSER_TIMEOUT_MS)
116
  page.set_default_timeout(BROWSER_TIMEOUT_MS)
 
117
  yield page
118
  finally:
119
  if page:
 
129
  self._semaphore.release()
130
  await self._check_recycle()
131
 
 
 
 
 
 
 
132
 
133
  browser_pool = StealthBrowserPool()
134
 
135
  # ==========================================
136
+ # 3. HTML & Markdown Processing
137
  # ==========================================
138
 
139
 
 
152
 
153
 
154
  def extract_content(html: str, url: str = "") -> dict[str, Any]:
 
155
  extracted = trafilatura.extract(
156
  html,
157
  url=url,
 
184
  if len(extracted) > MAX_CONTENT_CHARS:
185
  extracted = (
186
  extracted[:MAX_CONTENT_CHARS]
187
+ + f"\n\n... [Truncated: reached {MAX_CONTENT_CHARS} limit]"
188
  )
189
 
190
  return {"content": extracted.strip(), "metadata": meta_dict}
 
204
 
205
 
206
  # ==========================================
207
+ # 4. Scrape & Automation Engine
208
  # ==========================================
209
 
210
  FAST_HEADERS = {
211
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
212
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
213
  "Accept-Language": "en-US,en;q=0.9",
 
214
  }
215
 
216
 
 
219
  'button[id*="cookie" i]',
220
  'button[class*="cookie" i]',
221
  'button[aria-label*="accept" i]',
 
 
222
  'button:has-text("Accept all")',
223
  'button:has-text("Accept")',
224
  'button:has-text("I agree")',
 
241
  auto_scroll: bool = True,
242
  wait_for_selector: str | None = None,
243
  ) -> dict:
 
244
  if not force_browser:
245
  try:
246
  async with httpx.AsyncClient(
 
252
  resp = await client.get(url)
253
  if resp.status_code == 200:
254
  html = resp.text
255
+ if not any(
256
+ s in html.lower()
257
+ for s in [
258
+ "cf-challenge",
259
+ "ray-id",
260
+ "just a moment...",
261
+ ]
262
+ ):
263
  parsed = extract_content(html, url=url)
264
  if len(parsed["content"]) >= 150:
265
  return {
 
269
  "content": parsed["content"],
270
  "metadata": parsed["metadata"],
271
  }
272
+ except Exception:
273
+ pass
274
 
 
 
275
  async with browser_pool.get_page() as page:
276
  await page.goto(url, wait_until="domcontentloaded")
277
 
 
278
  try:
279
  cf_frame = await page.query_selector(
280
  "iframe[src*='challenges.cloudflare.com']"
 
306
  await asyncio.sleep(0.3)
307
 
308
  await asyncio.sleep(0.5)
 
309
  html = await page.content()
310
  final_url = page.url
311
  title = await page.title()
 
324
 
325
 
326
  # ==========================================
327
+ # 5. MCP Tools Setup
328
  # ==========================================
329
  mcp = FastMCP("High-Performance Web Scraper", host=HOST, port=PORT)
330
 
331
 
332
  @mcp.tool(
333
  name="scrape_url",
334
+ description="Scrapes cleaned Markdown content and metadata from any URL.",
335
  )
336
  async def scrape_url(
337
  url: str,
 
339
  auto_scroll: bool = True,
340
  wait_for_selector: str | None = None,
341
  ) -> dict:
 
 
 
 
 
 
 
342
  return await scrape_pipeline(
343
  url, force_browser, auto_scroll, wait_for_selector
344
  )
 
346
 
347
  @mcp.tool(
348
  name="search_and_scrape",
349
+ description="Searches DuckDuckGo and concurrently extracts content from top results.",
350
  )
351
  async def search_and_scrape(query: str, max_results: int = 3) -> dict:
 
 
 
 
 
352
  max_results = min(max(1, max_results), 5)
353
  loop = asyncio.get_running_loop()
354
 
 
392
 
393
  @mcp.tool(
394
  name="take_screenshot",
395
+ description="Takes a high-resolution full-page or viewport screenshot of a webpage.",
396
  )
397
  async def take_screenshot(
398
  url: str, full_page: bool = True, wait_seconds: float = 1.0
399
  ) -> dict:
 
 
 
 
 
 
400
  async with browser_pool.get_page() as page:
401
  await page.goto(url, wait_until="networkidle")
402
  if wait_seconds > 0:
 
415
 
416
  @mcp.tool(
417
  name="interact_page",
418
+ description="Executes a list of browser actions (click, type, press, wait, evaluate).",
419
  )
420
  async def interact_page(
421
  url: str, actions: list[dict[str, Any]], extract_markdown: bool = True
422
  ) -> dict:
 
 
 
 
 
 
 
 
 
 
423
  async with browser_pool.get_page() as page:
424
  await page.goto(url, wait_until="domcontentloaded")
425
  logs = []
 
445
  await asyncio.sleep(s)
446
  logs.append(f"[{idx}] Waited {s}s")
447
  elif act_type == "evaluate":
448
+ res = await page.evaluate(act["script"])
 
449
  logs.append(f"[{idx}] Evaluated script -> {res}")
450
  except Exception as e:
451
+ logs.append(f"[{idx}] Failed: {str(e)}")
452
 
453
  html = await page.content()
454
  final_url = page.url
455
 
456
+ res = {"final_url": final_url, "logs": logs}
 
 
 
457
  if extract_markdown:
458
  parsed = extract_content(html, url=final_url)
459
  res["content"] = parsed["content"]
 
464
 
465
  @mcp.tool(
466
  name="extract_structured_data",
467
+ description="Parses structured JSON-LD schemas and arbitrary CSS selector targets.",
468
  )
469
  async def extract_structured_data(
470
  url: str, css_selectors: dict[str, str] | None = None
471
  ) -> dict:
 
 
 
 
 
472
  async with browser_pool.get_page() as page:
473
  await page.goto(url, wait_until="domcontentloaded")
474
  html = await page.content()
 
488
 
489
 
490
  # ==========================================
491
+ # 6. Main SSE Runner
492
  # ==========================================
493
  if __name__ == "__main__":
494
+ logger.info(f"Starting MCP Server on http://{HOST}:{PORT}/sse ...")
495
+ mcp.settings.host = HOST
496
+ mcp.settings.port = PORT
497
  mcp.run(transport="sse")