misukisu commited on
Commit
b25ab31
·
verified ·
1 Parent(s): 95d392f

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +503 -0
app.py ADDED
@@ -0,0 +1,503 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """High-Performance Stealth Web Scraper & Browser Automation MCP Server
2
+
3
+ All-in-one file designed for Hugging Face Spaces (Port 7860).
4
+ """
5
+
6
+ import asyncio
7
+ import base64
8
+ import json
9
+ import logging
10
+ import os
11
+ import sys
12
+ from contextlib import asynccontextmanager
13
+ from typing import Any, AsyncGenerator
14
+
15
+ from bs4 import BeautifulSoup
16
+ from duckduckgo_search import DDGS
17
+ import httpx
18
+ from lxml_html_clean import Cleaner
19
+ import markdownify
20
+ from mcp.server.fastmcp import FastMCP
21
+ from patchright.async_api import Browser, BrowserContext, Page, async_playwright
22
+ from starlette.applications import Starlette
23
+ from starlette.responses import JSONResponse
24
+ from starlette.routing import Mount, Route
25
+ import trafilatura
26
+ import uvicorn
27
+
28
+ # ==========================================
29
+ # 1. Logging & Configuration
30
+ # ==========================================
31
+ logging.basicConfig(
32
+ level=logging.INFO,
33
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
34
+ handlers=[logging.StreamHandler(sys.stdout)],
35
+ )
36
+ logger = logging.getLogger("mcp_server")
37
+
38
+ PORT = int(os.getenv("PORT", 7860))
39
+ HOST = "0.0.0.0"
40
+ MAX_CONCURRENT_BROWSERS = 4
41
+ MAX_RECYCLE_REQUESTS = 100
42
+ MAX_CONTENT_CHARS = 100_000
43
+ HTTP_TIMEOUT_SEC = 12.0
44
+ BROWSER_TIMEOUT_MS = 30000
45
+
46
+ # ==========================================
47
+ # 2. Browser Pool
48
+ # ==========================================
49
+
50
+
51
+ class StealthBrowserPool:
52
+
53
+ def __init__(self):
54
+ self._playwright = None
55
+ self._browser: Browser | None = None
56
+ self._semaphore = asyncio.Semaphore(MAX_CONCURRENT_BROWSERS)
57
+ self._request_counter = 0
58
+ self._lock = asyncio.Lock()
59
+
60
+ async def get_browser(self) -> Browser:
61
+ async with self._lock:
62
+ if not self._playwright:
63
+ self._playwright = await async_playwright().start()
64
+ if not self._browser or not self._browser.is_connected():
65
+ logger.info("Starting Chromium Stealth instance...")
66
+ self._browser = await self._playwright.chromium.launch(
67
+ headless=True,
68
+ args=[
69
+ "--no-sandbox",
70
+ "--disable-setuid-sandbox",
71
+ "--disable-dev-shm-usage",
72
+ "--disable-gpu",
73
+ "--disable-blink-features=AutomationControlled",
74
+ "--no-first-run",
75
+ "--window-size=1920,1080",
76
+ ],
77
+ )
78
+ self._request_counter = 0
79
+ return self._browser
80
+
81
+ async def _check_recycle(self):
82
+ async with self._lock:
83
+ self._request_counter += 1
84
+ if self._request_counter >= MAX_RECYCLE_REQUESTS:
85
+ if self._browser:
86
+ try:
87
+ await self._browser.close()
88
+ except Exception:
89
+ pass
90
+ self._browser = None
91
+
92
+ @asynccontextmanager
93
+ async def get_page(self) -> AsyncGenerator[Page, None]:
94
+ await self._semaphore.acquire()
95
+ context: BrowserContext | None = None
96
+ page: Page | None = None
97
+ try:
98
+ browser = await self.get_browser()
99
+ context = await browser.new_context(
100
+ viewport={"width": 1920, "height": 1080},
101
+ user_agent=(
102
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
103
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
104
+ "Chrome/131.0.0.0 Safari/537.36"
105
+ ),
106
+ locale="en-US",
107
+ timezone_id="America/New_York",
108
+ )
109
+ await context.add_init_script("""
110
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
111
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
112
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
113
+ window.chrome = { runtime: {} };
114
+ """)
115
+ page = await context.new_page()
116
+ page.set_default_navigation_timeout(BROWSER_TIMEOUT_MS)
117
+ page.set_default_timeout(BROWSER_TIMEOUT_MS)
118
+ yield page
119
+ finally:
120
+ if page:
121
+ try:
122
+ await page.close()
123
+ except Exception:
124
+ pass
125
+ if context:
126
+ try:
127
+ await context.close()
128
+ except Exception:
129
+ pass
130
+ self._semaphore.release()
131
+ await self._check_recycle()
132
+
133
+
134
+ browser_pool = StealthBrowserPool()
135
+
136
+ # ==========================================
137
+ # 3. Content Extraction Pipeline
138
+ # ==========================================
139
+
140
+
141
+ def clean_html(raw_html: str) -> str:
142
+ cleaner = Cleaner(
143
+ scripts=True,
144
+ javascript=True,
145
+ comments=True,
146
+ style=True,
147
+ links=False,
148
+ meta=False,
149
+ page_structure=False,
150
+ safe_attrs_only=False,
151
+ )
152
+ return cleaner.clean_html(raw_html)
153
+
154
+
155
+ def extract_content(html: str, url: str = "") -> dict[str, Any]:
156
+ extracted = trafilatura.extract(
157
+ html,
158
+ url=url,
159
+ include_links=True,
160
+ include_images=True,
161
+ output_format="markdown",
162
+ )
163
+ metadata = trafilatura.extract_metadata(html) or {}
164
+ meta_dict = {
165
+ "title": getattr(metadata, "title", "") or "",
166
+ "author": getattr(metadata, "author", "") or "",
167
+ "date": getattr(metadata, "date", "") or "",
168
+ "description": getattr(metadata, "description", "") or "",
169
+ }
170
+
171
+ if not extracted or len(extracted.strip()) < 100:
172
+ cleaned = clean_html(html)
173
+ soup = BeautifulSoup(cleaned, "html.parser")
174
+ for tag in soup(
175
+ ["nav", "footer", "aside", "header", "script", "style", "noscript"]
176
+ ):
177
+ tag.decompose()
178
+ if not meta_dict["title"] and soup.title:
179
+ meta_dict["title"] = soup.title.string or ""
180
+ extracted = markdownify.markdownify(
181
+ str(soup), heading_style="ATX", strip=["svg"]
182
+ )
183
+
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}
191
+
192
+
193
+ def extract_json_ld(html: str) -> list[dict]:
194
+ soup = BeautifulSoup(html, "html.parser")
195
+ schemas = []
196
+ for script in soup.find_all("script", type="application/ld+json"):
197
+ try:
198
+ if script.string:
199
+ data = json.loads(script.string.strip())
200
+ schemas.append(data)
201
+ except Exception:
202
+ continue
203
+ return schemas
204
+
205
+
206
+ # ==========================================
207
+ # 4. Hybrid Scraping Core
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
+
217
+ async def dismiss_popups(page: Page):
218
+ selectors = [
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")',
225
+ 'button:has-text("Got it")',
226
+ ]
227
+ for sel in selectors:
228
+ try:
229
+ elem = await page.query_selector(sel)
230
+ if elem and await elem.is_visible():
231
+ await elem.click(timeout=800)
232
+ await asyncio.sleep(0.2)
233
+ break
234
+ except Exception:
235
+ continue
236
+
237
+
238
+ async def scrape_pipeline(
239
+ url: str,
240
+ force_browser: bool = False,
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(
247
+ http2=True,
248
+ timeout=HTTP_TIMEOUT_SEC,
249
+ follow_redirects=True,
250
+ headers=FAST_HEADERS,
251
+ ) as client:
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 {
266
+ "url": str(resp.url),
267
+ "status": 200,
268
+ "engine": "fast-http",
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
+ try:
278
+ cf_frame = await page.query_selector(
279
+ "iframe[src*='challenges.cloudflare.com']"
280
+ )
281
+ if cf_frame:
282
+ box = await cf_frame.bounding_box()
283
+ if box:
284
+ await page.mouse.click(
285
+ box["x"] + box["width"] / 2,
286
+ box["y"] + box["height"] / 2,
287
+ )
288
+ await asyncio.sleep(2.0)
289
+ except Exception:
290
+ pass
291
+
292
+ await dismiss_popups(page)
293
+
294
+ if wait_for_selector:
295
+ try:
296
+ await page.wait_for_selector(
297
+ wait_for_selector, state="visible", timeout=8000
298
+ )
299
+ except Exception:
300
+ pass
301
+
302
+ if auto_scroll:
303
+ for _ in range(4):
304
+ await page.mouse.wheel(0, 1000)
305
+ await asyncio.sleep(0.3)
306
+
307
+ await asyncio.sleep(0.5)
308
+ html = await page.content()
309
+ final_url = page.url
310
+ title = await page.title()
311
+
312
+ parsed = extract_content(html, url=final_url)
313
+ if not parsed["metadata"].get("title"):
314
+ parsed["metadata"]["title"] = title
315
+
316
+ return {
317
+ "url": final_url,
318
+ "status": 200,
319
+ "engine": "patchright-stealth",
320
+ "content": parsed["content"],
321
+ "metadata": parsed["metadata"],
322
+ }
323
+
324
+
325
+ # ==========================================
326
+ # 5. MCP Server & Tool Definitions
327
+ # ==========================================
328
+ mcp = FastMCP("WebScraper")
329
+
330
+
331
+ @mcp.tool()
332
+ async def scrape_url(
333
+ url: str,
334
+ force_browser: bool = False,
335
+ auto_scroll: bool = True,
336
+ wait_for_selector: str | None = None,
337
+ ) -> dict:
338
+ """Scrapes cleaned Markdown content and metadata from any URL."""
339
+ return await scrape_pipeline(
340
+ url, force_browser, auto_scroll, wait_for_selector
341
+ )
342
+
343
+
344
+ @mcp.tool()
345
+ async def search_and_scrape(query: str, max_results: int = 3) -> dict:
346
+ """Searches DuckDuckGo and concurrently extracts content from top results."""
347
+ max_results = min(max(1, max_results), 5)
348
+ loop = asyncio.get_running_loop()
349
+
350
+ def _search():
351
+ with DDGS() as ddgs:
352
+ return list(ddgs.text(query, max_results=max_results))
353
+
354
+ results = await loop.run_in_executor(None, _search)
355
+ if not results:
356
+ return {"query": query, "results": []}
357
+
358
+ tasks = [
359
+ scrape_pipeline(res["href"], force_browser=False, auto_scroll=False)
360
+ for res in results
361
+ ]
362
+ scraped_payloads = await asyncio.gather(*tasks, return_exceptions=True)
363
+
364
+ enriched = []
365
+ for meta, payload in zip(results, scraped_payloads):
366
+ if isinstance(payload, dict):
367
+ enriched.append(
368
+ {
369
+ "title": meta.get("title", ""),
370
+ "url": meta.get("href", ""),
371
+ "snippet": meta.get("body", ""),
372
+ "content": payload.get("content", ""),
373
+ }
374
+ )
375
+ else:
376
+ enriched.append(
377
+ {
378
+ "title": meta.get("title", ""),
379
+ "url": meta.get("href", ""),
380
+ "snippet": meta.get("body", ""),
381
+ "content": f"[Error: {str(payload)}]",
382
+ }
383
+ )
384
+ return {"query": query, "results": enriched}
385
+
386
+
387
+ @mcp.tool()
388
+ async def take_screenshot(
389
+ url: str, full_page: bool = True, wait_seconds: float = 1.0
390
+ ) -> dict:
391
+ """Takes a full-page or viewport screenshot of a webpage."""
392
+ async with browser_pool.get_page() as page:
393
+ await page.goto(url, wait_until="networkidle")
394
+ if wait_seconds > 0:
395
+ await asyncio.sleep(min(wait_seconds, 10.0))
396
+ screenshot_bytes = await page.screenshot(
397
+ full_page=full_page, type="png"
398
+ )
399
+ b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
400
+ return {
401
+ "url": page.url,
402
+ "title": await page.title(),
403
+ "format": "image/png;base64",
404
+ "base64_image": b64,
405
+ }
406
+
407
+
408
+ @mcp.tool()
409
+ async def interact_page(
410
+ url: str, actions: list[dict[str, Any]], extract_markdown: bool = True
411
+ ) -> dict:
412
+ """Executes a list of browser actions (click, type, press, wait, evaluate)."""
413
+ async with browser_pool.get_page() as page:
414
+ await page.goto(url, wait_until="domcontentloaded")
415
+ logs = []
416
+
417
+ for idx, act in enumerate(actions):
418
+ act_type = act.get("type", "").lower()
419
+ try:
420
+ if act_type == "click":
421
+ sel = act["selector"]
422
+ await page.click(sel, timeout=6000)
423
+ logs.append(f"[{idx}] Clicked '{sel}'")
424
+ elif act_type == "type":
425
+ sel = act["selector"]
426
+ txt = act["text"]
427
+ await page.fill(sel, txt, timeout=6000)
428
+ logs.append(f"[{idx}] Typed into '{sel}'")
429
+ elif act_type == "press":
430
+ k = act["key"]
431
+ await page.keyboard.press(k)
432
+ logs.append(f"[{idx}] Pressed '{k}'")
433
+ elif act_type == "wait":
434
+ s = min(float(act.get("seconds", 1.0)), 15.0)
435
+ await asyncio.sleep(s)
436
+ logs.append(f"[{idx}] Waited {s}s")
437
+ elif act_type == "evaluate":
438
+ res = await page.evaluate(act["script"])
439
+ logs.append(f"[{idx}] Evaluated script -> {res}")
440
+ except Exception as e:
441
+ logs.append(f"[{idx}] Failed: {str(e)}")
442
+
443
+ html = await page.content()
444
+ final_url = page.url
445
+
446
+ res = {"final_url": final_url, "logs": logs}
447
+ if extract_markdown:
448
+ parsed = extract_content(html, url=final_url)
449
+ res["content"] = parsed["content"]
450
+ res["metadata"] = parsed["metadata"]
451
+ return res
452
+
453
+
454
+ @mcp.tool()
455
+ async def extract_structured_data(
456
+ url: str, css_selectors: dict[str, str] | None = None
457
+ ) -> dict:
458
+ """Parses structured JSON-LD schemas and arbitrary CSS selector targets."""
459
+ async with browser_pool.get_page() as page:
460
+ await page.goto(url, wait_until="domcontentloaded")
461
+ html = await page.content()
462
+
463
+ soup = BeautifulSoup(html, "html.parser")
464
+ custom_data = {}
465
+ if css_selectors:
466
+ for k, sel in css_selectors.items():
467
+ elements = soup.select(sel)
468
+ custom_data[k] = [el.get_text(strip=True) for el in elements]
469
+
470
+ return {
471
+ "url": url,
472
+ "custom_fields": custom_data,
473
+ "json_ld_schemas": extract_json_ld(html),
474
+ }
475
+
476
+
477
+ # ==========================================
478
+ # 6. Starlette SSE Router & Server Runner
479
+ # ==========================================
480
+
481
+
482
+ async def health_check(request):
483
+ return JSONResponse(
484
+ {
485
+ "status": "healthy",
486
+ "mcp_sse_endpoint": "/sse",
487
+ "transport": "Server-Sent Events (SSE)",
488
+ }
489
+ )
490
+
491
+
492
+ # Mount the MCP SSE application with health endpoints
493
+ starlette_app = Starlette(
494
+ routes=[
495
+ Route("/", endpoint=health_check),
496
+ Route("/healthz", endpoint=health_check),
497
+ Mount("/", app=mcp.sse_app()),
498
+ ]
499
+ )
500
+
501
+ if __name__ == "__main__":
502
+ logger.info(f"Starting MCP Server on http://{HOST}:{PORT} ...")
503
+ uvicorn.run(starlette_app, host=HOST, port=PORT, log_level="info")