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/__init__.py +26 -0
- patchtroy/chunker.py +178 -0
- patchtroy/cli.py +196 -0
- patchtroy/crawler.py +387 -0
- patchtroy/extractors.py +207 -0
- patchtroy/models.py +154 -0
- patchtroy/pool.py +135 -0
- patchtroy/proxy.py +140 -0
- patchtroy/server.py +170 -0
- patchtroy/utils.py +54 -0
- patchtroy-0.4.0.dist-info/METADATA +153 -0
- patchtroy-0.4.0.dist-info/RECORD +15 -0
- patchtroy-0.4.0.dist-info/WHEEL +4 -0
- patchtroy-0.4.0.dist-info/entry_points.txt +2 -0
- patchtroy-0.4.0.dist-info/licenses/LICENSE +201 -0
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
|
+
)
|
patchtroy/extractors.py
ADDED
|
@@ -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
|