patchtroy 0.4.0__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.
patchtroy/crawler.py ADDED
@@ -0,0 +1,387 @@
1
+ """Core crawler implementations for Patchtroy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from patchtroy.extractors import (
15
+ extract_links,
16
+ extract_markdown_and_metadata,
17
+ extract_structured_data,
18
+ )
19
+ from patchtroy.models import LinkItem, PatchtroyConfig, ScrapeResult
20
+ from patchtroy.pool import BrowserContextPool
21
+ from patchtroy.proxy import ProxyManager
22
+ from patchtroy.utils import STEALTH_INJECTION_SCRIPT, get_random_user_agent, is_valid_url
23
+
24
+ logger = logging.getLogger("patchtroy.crawler")
25
+
26
+
27
+ class AsyncPatchtroy:
28
+ """Asynchronous stealth web crawler powered by Patchright, Trafilatura, and Context Pooling."""
29
+
30
+ def __init__(self, config: PatchtroyConfig | dict[str, Any] | None = None) -> None:
31
+ if isinstance(config, dict):
32
+ self.config = PatchtroyConfig(**config)
33
+ elif isinstance(config, PatchtroyConfig):
34
+ self.config = config
35
+ else:
36
+ self.config = PatchtroyConfig()
37
+
38
+ # Initialize ProxyManager if configured
39
+ self.proxy_manager: ProxyManager | None = None
40
+ if self.config.proxies:
41
+ self.proxy_manager = ProxyManager(
42
+ proxies=self.config.proxies,
43
+ strategy=self.config.proxy_strategy,
44
+ )
45
+
46
+ # Initialize BrowserContextPool
47
+ self._pool = BrowserContextPool(self.config, proxy_manager=self.proxy_manager)
48
+
49
+ @property
50
+ def _browser(self) -> Any:
51
+ """Compatibility accessor for underlying browser."""
52
+ return self._pool._browser
53
+
54
+ @property
55
+ def _playwright(self) -> Any:
56
+ """Compatibility accessor for underlying playwright."""
57
+ return self._pool._playwright
58
+
59
+ async def __aenter__(self) -> AsyncPatchtroy:
60
+ await self.start()
61
+ return self
62
+
63
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
64
+ await self.close()
65
+
66
+ async def start(self) -> None:
67
+ """Launch underlying stealth Patchright Chromium browser process via pool."""
68
+ await self._pool.start()
69
+
70
+ async def close(self) -> None:
71
+ """Gracefully terminate browser pool and all contexts."""
72
+ await self._pool.close()
73
+
74
+ async def scrape(
75
+ self,
76
+ url: str,
77
+ wait_for: str | None = None,
78
+ custom_schema: dict[str, Any] | None = None,
79
+ ) -> ScrapeResult:
80
+ """Scrape a target URL using stealth browser automation, with optional HTTP fallback."""
81
+ if not is_valid_url(url):
82
+ return ScrapeResult(
83
+ url=url,
84
+ status_code=400,
85
+ success=False,
86
+ error=f"Invalid HTTP/HTTPS URL: {url}",
87
+ )
88
+
89
+ start_time = time.perf_counter()
90
+ wait_for_selector = wait_for or self.config.wait_for
91
+ ua = self.config.user_agent or get_random_user_agent()
92
+
93
+ # 1. Attempt stealth browser navigation
94
+ try:
95
+ return await self._scrape_with_browser(url, ua, wait_for_selector, custom_schema, start_time)
96
+ except Exception as exc:
97
+ logger.info("Browser scrape encountered issue: %s", exc)
98
+ if self.config.http_fallback:
99
+ logger.info("Engaging HTTP fallback for %s", url)
100
+ return await self._scrape_with_http(url, ua, custom_schema, start_time, str(exc))
101
+ else:
102
+ elapsed = time.perf_counter() - start_time
103
+ return ScrapeResult(
104
+ url=url,
105
+ status_code=500,
106
+ success=False,
107
+ error=str(exc),
108
+ elapsed_s=round(elapsed, 3),
109
+ )
110
+
111
+ async def _scrape_with_browser(
112
+ self,
113
+ url: str,
114
+ user_agent: str,
115
+ wait_for_selector: str | None,
116
+ custom_schema: dict[str, Any] | None,
117
+ start_time: float,
118
+ ) -> ScrapeResult:
119
+ """Execute in-browser rendering with stealth injection, context pooling, and media capture."""
120
+ timeout_ms = int(self.config.browser_timeout_s * 1000)
121
+
122
+ async with self._pool.acquire_context(user_agent=user_agent) as (context, used_proxy):
123
+ page = await context.new_page()
124
+ try:
125
+ # Apply stealth browser configuration
126
+ await page.add_init_script(STEALTH_INJECTION_SCRIPT)
127
+
128
+ resp = await page.goto(
129
+ url,
130
+ timeout=timeout_ms,
131
+ wait_until=self.config.wait_until,
132
+ )
133
+
134
+ status_code = resp.status if resp else 200
135
+
136
+ if wait_for_selector:
137
+ try:
138
+ await page.wait_for_selector(wait_for_selector, timeout=7000)
139
+ except Exception as wait_exc:
140
+ logger.debug("wait_for selector '%s' timeout: %s", wait_for_selector, wait_exc)
141
+
142
+ # Media captures: Screenshot & PDF
143
+ screenshot_bytes: bytes | None = None
144
+ if self.config.screenshot or self.config.screenshot_path:
145
+ screenshot_bytes = await page.screenshot(full_page=self.config.full_page_screenshot)
146
+ if self.config.screenshot_path:
147
+ dest = Path(self.config.screenshot_path)
148
+ dest.parent.mkdir(parents=True, exist_ok=True)
149
+ dest.write_bytes(screenshot_bytes)
150
+
151
+ pdf_bytes: bytes | None = None
152
+ if self.config.pdf or self.config.pdf_path:
153
+ try:
154
+ pdf_bytes = await page.pdf()
155
+ if self.config.pdf_path:
156
+ dest = Path(self.config.pdf_path)
157
+ dest.parent.mkdir(parents=True, exist_ok=True)
158
+ dest.write_bytes(pdf_bytes)
159
+ except Exception as pdf_exc:
160
+ logger.warning("PDF export failed (requires headless Chromium): %s", pdf_exc)
161
+
162
+ html = await page.content()
163
+ final_url = page.url or url
164
+
165
+ # Record proxy success
166
+ if used_proxy and self.proxy_manager:
167
+ self.proxy_manager.report_success(used_proxy)
168
+
169
+ # Content extractions
170
+ markdown, title, metadata = extract_markdown_and_metadata(html, base_url=final_url)
171
+ structured_data = []
172
+ if self.config.extract_schema:
173
+ schema_to_use = custom_schema or self.config.custom_schema
174
+ structured_data = extract_structured_data(html, source_url=final_url, custom_schema=schema_to_use)
175
+
176
+ links = []
177
+ if self.config.extract_links:
178
+ raw_links = extract_links(html, base_url=final_url)
179
+ links = [LinkItem(**link_dict) for link_dict in raw_links]
180
+
181
+ elapsed = time.perf_counter() - start_time
182
+ return ScrapeResult(
183
+ url=final_url,
184
+ status_code=status_code,
185
+ title=title,
186
+ markdown=markdown,
187
+ html=html,
188
+ structured_data=structured_data,
189
+ links=links,
190
+ metadata=metadata,
191
+ screenshot_bytes=screenshot_bytes,
192
+ pdf_bytes=pdf_bytes,
193
+ success=True,
194
+ engine_used="patchright",
195
+ elapsed_s=round(elapsed, 3),
196
+ )
197
+ except Exception:
198
+ if used_proxy and self.proxy_manager:
199
+ self.proxy_manager.report_failure(used_proxy)
200
+ raise
201
+ finally:
202
+ await page.close()
203
+
204
+ async def _scrape_with_http(
205
+ self,
206
+ url: str,
207
+ user_agent: str,
208
+ custom_schema: dict[str, Any] | None,
209
+ start_time: float,
210
+ browser_error: str,
211
+ ) -> ScrapeResult:
212
+ """Fast direct HTTP fallback when browser engine is unavailable or timed out."""
213
+ headers = {"User-Agent": user_agent, "Accept-Language": "en-US,en;q=0.9"}
214
+ try:
215
+ async with httpx.AsyncClient(
216
+ follow_redirects=True,
217
+ timeout=self.config.browser_timeout_s,
218
+ verify=False,
219
+ ) as client:
220
+ resp = await client.get(url, headers=headers)
221
+ html = resp.text
222
+ final_url = str(resp.url)
223
+ status_code = resp.status_code
224
+
225
+ markdown, title, metadata = extract_markdown_and_metadata(html, base_url=final_url)
226
+ structured_data = []
227
+ if self.config.extract_schema:
228
+ schema_to_use = custom_schema or self.config.custom_schema
229
+ structured_data = extract_structured_data(html, source_url=final_url, custom_schema=schema_to_use)
230
+
231
+ links = []
232
+ if self.config.extract_links:
233
+ raw_links = extract_links(html, base_url=final_url)
234
+ links = [LinkItem(**link_dict) for link_dict in raw_links]
235
+
236
+ elapsed = time.perf_counter() - start_time
237
+ return ScrapeResult(
238
+ url=final_url,
239
+ status_code=status_code,
240
+ title=title,
241
+ markdown=markdown,
242
+ html=html,
243
+ structured_data=structured_data,
244
+ links=links,
245
+ metadata=metadata,
246
+ success=resp.is_success,
247
+ error=f"HTTP fallback (browser failed: {browser_error})",
248
+ engine_used="http_fallback",
249
+ elapsed_s=round(elapsed, 3),
250
+ )
251
+ except Exception as http_exc:
252
+ elapsed = time.perf_counter() - start_time
253
+ return ScrapeResult(
254
+ url=url,
255
+ status_code=500,
256
+ success=False,
257
+ error=f"Both browser and HTTP fallback failed. Browser: {browser_error} | HTTP: {http_exc}",
258
+ engine_used="failed",
259
+ elapsed_s=round(elapsed, 3),
260
+ )
261
+
262
+ async def scrape_many(
263
+ self,
264
+ urls: list[str],
265
+ wait_for: str | None = None,
266
+ custom_schema: dict[str, Any] | None = None,
267
+ ) -> list[ScrapeResult]:
268
+ """Scrape multiple URLs concurrently bounded by the BrowserContextPool."""
269
+ tasks = [
270
+ self.scrape(url, wait_for=wait_for, custom_schema=custom_schema)
271
+ for url in urls
272
+ ]
273
+ return await asyncio.gather(*tasks)
274
+
275
+ @classmethod
276
+ async def crawl(
277
+ cls,
278
+ url: str,
279
+ headless: bool = True,
280
+ wait_for: str | None = None,
281
+ custom_schema: dict[str, Any] | None = None,
282
+ screenshot: bool = False,
283
+ pdf: bool = False,
284
+ ) -> ScrapeResult:
285
+ """One-shot convenience coroutine for instant single-URL scraping."""
286
+ config = PatchtroyConfig(
287
+ headless=headless,
288
+ wait_for=wait_for,
289
+ screenshot=screenshot,
290
+ pdf=pdf,
291
+ )
292
+ async with cls(config) as client:
293
+ return await client.scrape(url, wait_for=wait_for, custom_schema=custom_schema)
294
+
295
+ @classmethod
296
+ async def crawl_many(
297
+ cls,
298
+ urls: list[str],
299
+ headless: bool = True,
300
+ max_concurrency: int = 5,
301
+ wait_for: str | None = None,
302
+ custom_schema: dict[str, Any] | None = None,
303
+ ) -> list[ScrapeResult]:
304
+ """One-shot convenience coroutine for instant concurrent batch scraping."""
305
+ config = PatchtroyConfig(
306
+ headless=headless,
307
+ max_concurrency=max_concurrency,
308
+ wait_for=wait_for,
309
+ custom_schema=custom_schema,
310
+ )
311
+ async with cls(config) as client:
312
+ return await client.scrape_many(urls, wait_for=wait_for, custom_schema=custom_schema)
313
+
314
+
315
+ class Patchtroy:
316
+ """Synchronous wrapper for Patchtroy crawler execution."""
317
+
318
+ def __init__(self, config: PatchtroyConfig | dict[str, Any] | None = None) -> None:
319
+ self._async_crawler = AsyncPatchtroy(config)
320
+
321
+ def scrape(
322
+ self,
323
+ url: str,
324
+ wait_for: str | None = None,
325
+ custom_schema: dict[str, Any] | None = None,
326
+ ) -> ScrapeResult:
327
+ """Execute scrape synchronously in an event loop."""
328
+ if sys.platform == "win32":
329
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
330
+ return asyncio.run(self._async_crawler.scrape(url, wait_for=wait_for, custom_schema=custom_schema))
331
+
332
+ def scrape_many(
333
+ self,
334
+ urls: list[str],
335
+ wait_for: str | None = None,
336
+ custom_schema: dict[str, Any] | None = None,
337
+ ) -> list[ScrapeResult]:
338
+ """Execute concurrent batch scraping synchronously."""
339
+ if sys.platform == "win32":
340
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
341
+ return asyncio.run(self._async_crawler.scrape_many(urls, wait_for=wait_for, custom_schema=custom_schema))
342
+
343
+ @classmethod
344
+ def crawl(
345
+ cls,
346
+ url: str,
347
+ headless: bool = True,
348
+ wait_for: str | None = None,
349
+ custom_schema: dict[str, Any] | None = None,
350
+ screenshot: bool = False,
351
+ pdf: bool = False,
352
+ ) -> ScrapeResult:
353
+ """Synchronous one-shot convenience function for scraping."""
354
+ if sys.platform == "win32":
355
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
356
+ return asyncio.run(
357
+ AsyncPatchtroy.crawl(
358
+ url,
359
+ headless=headless,
360
+ wait_for=wait_for,
361
+ custom_schema=custom_schema,
362
+ screenshot=screenshot,
363
+ pdf=pdf,
364
+ )
365
+ )
366
+
367
+ @classmethod
368
+ def crawl_many(
369
+ cls,
370
+ urls: list[str],
371
+ headless: bool = True,
372
+ max_concurrency: int = 5,
373
+ wait_for: str | None = None,
374
+ custom_schema: dict[str, Any] | None = None,
375
+ ) -> list[ScrapeResult]:
376
+ """Synchronous one-shot convenience function for concurrent batch crawling."""
377
+ if sys.platform == "win32":
378
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
379
+ return asyncio.run(
380
+ AsyncPatchtroy.crawl_many(
381
+ urls,
382
+ headless=headless,
383
+ max_concurrency=max_concurrency,
384
+ wait_for=wait_for,
385
+ custom_schema=custom_schema,
386
+ )
387
+ )
@@ -0,0 +1,207 @@
1
+ """Content and structured data extraction engine for Patchtroy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+ from urllib.parse import urljoin
9
+
10
+ import trafilatura
11
+ from bs4 import BeautifulSoup
12
+
13
+ logger = logging.getLogger('patchtroy.extractors')
14
+
15
+
16
+ def extract_markdown_and_metadata(
17
+ html: str,
18
+ base_url: str = ''
19
+ ) -> tuple[str, str, dict[str, Any]]:
20
+ if not html or not html.strip():
21
+ return '', '', {}
22
+
23
+ title = ''
24
+ metadata: dict[str, Any] = {}
25
+
26
+ try:
27
+ soup = BeautifulSoup(html, 'html.parser')
28
+ title_el = soup.find('title')
29
+ if title_el and title_el.string:
30
+ title = title_el.string.strip()
31
+ except Exception:
32
+ pass
33
+
34
+ try:
35
+ meta = trafilatura.extract_metadata(html, default_url=base_url)
36
+ if meta:
37
+ metadata = {
38
+ 'title': meta.title,
39
+ 'author': meta.author,
40
+ 'url': meta.url or base_url,
41
+ 'hostname': meta.hostname,
42
+ 'description': meta.description,
43
+ 'sitename': meta.sitename,
44
+ 'date': meta.date,
45
+ 'categories': meta.categories,
46
+ 'tags': meta.tags,
47
+ }
48
+ if meta.title:
49
+ title = meta.title
50
+ except Exception as exc:
51
+ logger.debug('Trafilatura metadata extraction error: %s', exc)
52
+
53
+ markdown = ''
54
+ try:
55
+ extracted = trafilatura.extract(
56
+ html,
57
+ url=base_url,
58
+ output_format='markdown',
59
+ include_links=True,
60
+ include_images=True,
61
+ include_formatting=True,
62
+ include_tables=True,
63
+ favor_recall=True,
64
+ )
65
+ if extracted:
66
+ markdown = extracted.strip()
67
+ except Exception as exc:
68
+ logger.debug('Trafilatura content extraction error: %s', exc)
69
+
70
+ if not markdown:
71
+ try:
72
+ soup = BeautifulSoup(html, 'html.parser')
73
+ for tag in soup(['script', 'style', 'noscript', 'svg']):
74
+ tag.decompose()
75
+ body = soup.find('body') or soup
76
+ lines = [line.strip() for line in body.get_text(separator=' ').splitlines()]
77
+ markdown = chr(10).join(line for line in lines if line)
78
+ except Exception:
79
+ markdown = ''
80
+
81
+ return markdown, title, metadata
82
+
83
+
84
+ def extract_structured_data(
85
+ html: str,
86
+ source_url: str = '',
87
+ custom_schema: dict[str, Any] | None = None,
88
+ ) -> list[dict[str, Any]]:
89
+ if not html or not html.strip():
90
+ return []
91
+
92
+ items: list[dict[str, Any]] = []
93
+
94
+ try:
95
+ soup = BeautifulSoup(html, 'html.parser')
96
+ except Exception as exc:
97
+ logger.warning('BeautifulSoup parsing failed: %s', exc)
98
+ return []
99
+
100
+ if custom_schema and isinstance(custom_schema, dict) and custom_schema.get('fields'):
101
+ custom_items = _extract_custom_schema(soup, source_url, custom_schema)
102
+ if custom_items:
103
+ return custom_items
104
+
105
+ next_data_el = soup.find('script', id='__NEXT_DATA__')
106
+ if next_data_el and next_data_el.string:
107
+ try:
108
+ nd = json.loads(next_data_el.string)
109
+ props = nd.get('props', {}).get('pageProps', {})
110
+ raw_listings = (
111
+ props.get('listings')
112
+ or props.get('products')
113
+ or props.get('items')
114
+ or props.get('data')
115
+ )
116
+ if isinstance(raw_listings, list) and raw_listings:
117
+ for item in raw_listings:
118
+ if isinstance(item, dict):
119
+ item['_source_url'] = source_url
120
+ items.append(item)
121
+ if items:
122
+ return items
123
+ except Exception:
124
+ pass
125
+
126
+ ld_items = _extract_json_ld(soup, source_url)
127
+ if ld_items:
128
+ items.extend(ld_items)
129
+
130
+ return items
131
+
132
+
133
+ def extract_links(html: str, base_url: str = '') -> list[dict[str, str]]:
134
+ if not html:
135
+ return []
136
+
137
+ links: list[dict[str, str]] = []
138
+ seen: set[str] = set()
139
+
140
+ try:
141
+ soup = BeautifulSoup(html, 'html.parser')
142
+ for a in soup.find_all('a', href=True):
143
+ href = a['href'].strip()
144
+ if not href or href.startswith(('javascript:', 'mailto:', 'tel:', '#')):
145
+ continue
146
+ full_url = urljoin(base_url, href) if base_url else href
147
+ if full_url not in seen:
148
+ seen.add(full_url)
149
+ text = a.get_text(strip=True)
150
+ links.append({'href': full_url, 'text': text})
151
+ except Exception:
152
+ pass
153
+
154
+ return links
155
+
156
+
157
+ def _extract_json_ld(soup: BeautifulSoup, source_url: str) -> list[dict[str, Any]]:
158
+ items: list[dict[str, Any]] = []
159
+
160
+ for tag in soup.find_all('script', type='application/ld+json'):
161
+ if not tag.string:
162
+ continue
163
+ try:
164
+ data = json.loads(tag.string)
165
+ elements = data if isinstance(data, list) else [data]
166
+ for el in elements:
167
+ if not isinstance(el, dict):
168
+ continue
169
+ if '@graph' in el and isinstance(el['@graph'], list):
170
+ for sub in el['@graph']:
171
+ if isinstance(sub, dict):
172
+ sub['_source_url'] = source_url
173
+ items.append(sub)
174
+ else:
175
+ el['_source_url'] = source_url
176
+ items.append(el)
177
+ except Exception:
178
+ continue
179
+
180
+ return items
181
+
182
+
183
+ def _extract_custom_schema(
184
+ soup: BeautifulSoup,
185
+ source_url: str,
186
+ schema: dict[str, Any]
187
+ ) -> list[dict[str, Any]]:
188
+ item_selector = schema.get('item_selector')
189
+ fields = schema.get('fields', {})
190
+ results: list[dict[str, Any]] = []
191
+
192
+ if item_selector:
193
+ containers = soup.select(item_selector)
194
+ for container in containers:
195
+ record: dict[str, Any] = {'_source_url': source_url}
196
+ for field_name, selector in fields.items():
197
+ el = container.select_one(selector)
198
+ record[field_name] = el.get_text(strip=True) if el else None
199
+ results.append(record)
200
+ else:
201
+ record = {'_source_url': source_url}
202
+ for field_name, selector in fields.items():
203
+ el = soup.select_one(selector)
204
+ record[field_name] = el.get_text(strip=True) if el else None
205
+ results.append(record)
206
+
207
+ return results