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/models.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Data schemas and models for Patchtroy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PatchtroyConfig(BaseModel):
|
|
12
|
+
"""Configuration options for Patchtroy crawler execution."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
15
|
+
|
|
16
|
+
headless: bool = Field(
|
|
17
|
+
default=True,
|
|
18
|
+
description="Run Chromium in headless mode."
|
|
19
|
+
)
|
|
20
|
+
browser_timeout_s: float = Field(
|
|
21
|
+
default=25.0,
|
|
22
|
+
ge=2.0,
|
|
23
|
+
le=120.0,
|
|
24
|
+
description="Maximum seconds to wait for page load before fallback or error."
|
|
25
|
+
)
|
|
26
|
+
wait_for: str | None = Field(
|
|
27
|
+
default=None,
|
|
28
|
+
description="Optional CSS selector to wait for before extracting page content."
|
|
29
|
+
)
|
|
30
|
+
wait_until: str = Field(
|
|
31
|
+
default="domcontentloaded",
|
|
32
|
+
description="Navigation lifecycle event: 'domcontentloaded', 'load', or 'networkidle'."
|
|
33
|
+
)
|
|
34
|
+
user_agent: str | None = Field(
|
|
35
|
+
default=None,
|
|
36
|
+
description="Custom User-Agent string. If omitted, uses realistic browser UA."
|
|
37
|
+
)
|
|
38
|
+
http_fallback: bool = Field(
|
|
39
|
+
default=True,
|
|
40
|
+
description="Automatically fall back to direct HTTP request if browser crashes or times out."
|
|
41
|
+
)
|
|
42
|
+
extract_schema: bool = Field(
|
|
43
|
+
default=True,
|
|
44
|
+
description="Extract JSON-LD and Next.js __NEXT_DATA__ structured items."
|
|
45
|
+
)
|
|
46
|
+
extract_links: bool = Field(
|
|
47
|
+
default=True,
|
|
48
|
+
description="Extract all valid hyperlinks from the page."
|
|
49
|
+
)
|
|
50
|
+
viewport_width: int = Field(default=1280, ge=320, le=3840)
|
|
51
|
+
viewport_height: int = Field(default=800, ge=240, le=2160)
|
|
52
|
+
proxy: str | None = Field(
|
|
53
|
+
default=None,
|
|
54
|
+
description="Single proxy URL (e.g. 'http://user:pass@host:port')."
|
|
55
|
+
)
|
|
56
|
+
proxies: list[str] | str | None = Field(
|
|
57
|
+
default=None,
|
|
58
|
+
description="List of proxy URLs, comma-separated string, or path to proxy file for rotation."
|
|
59
|
+
)
|
|
60
|
+
proxy_strategy: str = Field(
|
|
61
|
+
default="round-robin",
|
|
62
|
+
description="Proxy rotation strategy: 'round-robin' or 'random'."
|
|
63
|
+
)
|
|
64
|
+
max_concurrency: int = Field(
|
|
65
|
+
default=5,
|
|
66
|
+
ge=1,
|
|
67
|
+
le=50,
|
|
68
|
+
description="Maximum concurrent browser contexts for batch scraping."
|
|
69
|
+
)
|
|
70
|
+
screenshot: bool = Field(
|
|
71
|
+
default=False,
|
|
72
|
+
description="Capture page screenshot."
|
|
73
|
+
)
|
|
74
|
+
full_page_screenshot: bool = Field(
|
|
75
|
+
default=False,
|
|
76
|
+
description="Capture full-page screenshot instead of viewport only."
|
|
77
|
+
)
|
|
78
|
+
screenshot_path: str | None = Field(
|
|
79
|
+
default=None,
|
|
80
|
+
description="Optional file path to automatically save screenshot to."
|
|
81
|
+
)
|
|
82
|
+
pdf: bool = Field(
|
|
83
|
+
default=False,
|
|
84
|
+
description="Generate page PDF (headless Chromium only)."
|
|
85
|
+
)
|
|
86
|
+
pdf_path: str | None = Field(
|
|
87
|
+
default=None,
|
|
88
|
+
description="Optional file path to automatically save PDF to."
|
|
89
|
+
)
|
|
90
|
+
custom_schema: dict[str, Any] | None = Field(
|
|
91
|
+
default=None,
|
|
92
|
+
description="Optional declarative custom CSS selector schema for structured item harvesting."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class LinkItem(BaseModel):
|
|
97
|
+
"""Hyperlink extracted from rendered page."""
|
|
98
|
+
href: str
|
|
99
|
+
text: str = ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ScrapeResult(BaseModel):
|
|
103
|
+
"""Result emitted from a Patchtroy scrape operation."""
|
|
104
|
+
|
|
105
|
+
url: str
|
|
106
|
+
status_code: int = 200
|
|
107
|
+
title: str = ""
|
|
108
|
+
markdown: str = ""
|
|
109
|
+
html: str = ""
|
|
110
|
+
structured_data: list[dict[str, Any]] = Field(default_factory=list)
|
|
111
|
+
links: list[LinkItem] = Field(default_factory=list)
|
|
112
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
113
|
+
screenshot_bytes: bytes | None = None
|
|
114
|
+
pdf_bytes: bytes | None = None
|
|
115
|
+
success: bool = True
|
|
116
|
+
error: str | None = None
|
|
117
|
+
engine_used: str = "patchright"
|
|
118
|
+
elapsed_s: float = 0.0
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def has_content(self) -> bool:
|
|
122
|
+
return bool(self.markdown.strip() or self.structured_data)
|
|
123
|
+
|
|
124
|
+
def save_screenshot(self, filepath: str | Path) -> Path:
|
|
125
|
+
"""Save screenshot bytes to disk."""
|
|
126
|
+
if not self.screenshot_bytes:
|
|
127
|
+
raise ValueError(f"No screenshot bytes available for {self.url}")
|
|
128
|
+
dest = Path(filepath)
|
|
129
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
dest.write_bytes(self.screenshot_bytes)
|
|
131
|
+
return dest
|
|
132
|
+
|
|
133
|
+
def save_pdf(self, filepath: str | Path) -> Path:
|
|
134
|
+
"""Save PDF bytes to disk."""
|
|
135
|
+
if not self.pdf_bytes:
|
|
136
|
+
raise ValueError(f"No PDF bytes available for {self.url}")
|
|
137
|
+
dest = Path(filepath)
|
|
138
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
dest.write_bytes(self.pdf_bytes)
|
|
140
|
+
return dest
|
|
141
|
+
|
|
142
|
+
def chunk(
|
|
143
|
+
self,
|
|
144
|
+
max_tokens: int = 2048,
|
|
145
|
+
overlap_tokens: int = 100,
|
|
146
|
+
) -> list[Any]:
|
|
147
|
+
"""Split extracted Markdown content into LLM-ready TextChunk records."""
|
|
148
|
+
from patchtroy.chunker import chunk_markdown
|
|
149
|
+
return chunk_markdown(
|
|
150
|
+
self.markdown,
|
|
151
|
+
max_tokens=max_tokens,
|
|
152
|
+
overlap_tokens=overlap_tokens,
|
|
153
|
+
metadata={"url": self.url, "title": self.title},
|
|
154
|
+
)
|
patchtroy/pool.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Browser context pool for high-throughput concurrent scraping in Patchtroy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from patchtroy.models import PatchtroyConfig
|
|
12
|
+
from patchtroy.proxy import ProxyManager
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("patchtroy.pool")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BrowserContextPool:
|
|
18
|
+
"""Manages a single browser process and leases isolated browser contexts concurrently."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
config: PatchtroyConfig,
|
|
23
|
+
proxy_manager: ProxyManager | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.config = config
|
|
26
|
+
self.proxy_manager = proxy_manager
|
|
27
|
+
self.max_concurrency = config.max_concurrency
|
|
28
|
+
self._semaphore: asyncio.Semaphore | None = None
|
|
29
|
+
self._playwright: Any = None
|
|
30
|
+
self._browser: Any = None
|
|
31
|
+
self._active_contexts: int = 0
|
|
32
|
+
self._lock = asyncio.Lock()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_running(self) -> bool:
|
|
36
|
+
"""Check if underlying browser is launched and active."""
|
|
37
|
+
return self._browser is not None
|
|
38
|
+
|
|
39
|
+
async def start(self) -> None:
|
|
40
|
+
"""Launch the shared browser instance."""
|
|
41
|
+
if self._browser is not None:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
async with self._lock:
|
|
45
|
+
if self._browser is not None:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if self._semaphore is None:
|
|
49
|
+
self._semaphore = asyncio.Semaphore(self.max_concurrency)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
try:
|
|
53
|
+
from patchright.async_api import async_playwright
|
|
54
|
+
except ImportError:
|
|
55
|
+
from playwright.async_api import async_playwright
|
|
56
|
+
|
|
57
|
+
self._playwright = await async_playwright().start()
|
|
58
|
+
launch_args = [
|
|
59
|
+
"--disable-blink-features=AutomationControlled",
|
|
60
|
+
"--no-sandbox",
|
|
61
|
+
"--disable-dev-shm-usage",
|
|
62
|
+
]
|
|
63
|
+
self._browser = await self._playwright.chromium.launch(
|
|
64
|
+
headless=self.config.headless,
|
|
65
|
+
args=launch_args,
|
|
66
|
+
)
|
|
67
|
+
logger.info(
|
|
68
|
+
"BrowserContextPool started with concurrency limit %d",
|
|
69
|
+
self.max_concurrency,
|
|
70
|
+
)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
logger.warning("Failed to initialize BrowserContextPool browser: %s", exc)
|
|
73
|
+
self._browser = None
|
|
74
|
+
|
|
75
|
+
async def close(self) -> None:
|
|
76
|
+
"""Terminate all pooled contexts and close the browser."""
|
|
77
|
+
async with self._lock:
|
|
78
|
+
if self._browser:
|
|
79
|
+
try:
|
|
80
|
+
await self._browser.close()
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
self._browser = None
|
|
84
|
+
|
|
85
|
+
if self._playwright:
|
|
86
|
+
try:
|
|
87
|
+
await self._playwright.stop()
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
self._playwright = None
|
|
91
|
+
|
|
92
|
+
@asynccontextmanager
|
|
93
|
+
async def acquire_context(
|
|
94
|
+
self,
|
|
95
|
+
user_agent: str,
|
|
96
|
+
proxy: str | None = None,
|
|
97
|
+
) -> AsyncIterator[tuple[Any, str | None]]:
|
|
98
|
+
"""Acquire a managed browser context bounded by the concurrency semaphore."""
|
|
99
|
+
if self._browser is None:
|
|
100
|
+
await self.start()
|
|
101
|
+
if self._browser is None:
|
|
102
|
+
raise RuntimeError("BrowserContextPool has no available browser instance.")
|
|
103
|
+
|
|
104
|
+
if self._semaphore is None:
|
|
105
|
+
self._semaphore = asyncio.Semaphore(self.max_concurrency)
|
|
106
|
+
|
|
107
|
+
async with self._semaphore:
|
|
108
|
+
chosen_proxy = proxy
|
|
109
|
+
if not chosen_proxy and self.proxy_manager and self.proxy_manager.has_proxies:
|
|
110
|
+
chosen_proxy = self.proxy_manager.get_proxy()
|
|
111
|
+
elif not chosen_proxy and self.config.proxy:
|
|
112
|
+
chosen_proxy = self.config.proxy
|
|
113
|
+
|
|
114
|
+
context_kwargs: dict[str, Any] = {
|
|
115
|
+
"user_agent": user_agent,
|
|
116
|
+
"viewport": {
|
|
117
|
+
"width": self.config.viewport_width,
|
|
118
|
+
"height": self.config.viewport_height,
|
|
119
|
+
},
|
|
120
|
+
"ignore_https_errors": True,
|
|
121
|
+
"locale": "en-US",
|
|
122
|
+
}
|
|
123
|
+
if chosen_proxy:
|
|
124
|
+
context_kwargs["proxy"] = {"server": chosen_proxy}
|
|
125
|
+
|
|
126
|
+
context = await self._browser.new_context(**context_kwargs)
|
|
127
|
+
self._active_contexts += 1
|
|
128
|
+
try:
|
|
129
|
+
yield context, chosen_proxy
|
|
130
|
+
finally:
|
|
131
|
+
self._active_contexts -= 1
|
|
132
|
+
try:
|
|
133
|
+
await context.close()
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
patchtroy/proxy.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Proxy rotation manager for Patchtroy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("patchtroy.proxy")
|
|
13
|
+
|
|
14
|
+
ProxyStrategy = Literal["round-robin", "random"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ProxyItem:
|
|
19
|
+
"""Represents a single proxy with health and failure tracking."""
|
|
20
|
+
|
|
21
|
+
server: str
|
|
22
|
+
failures: int = 0
|
|
23
|
+
quarantined_until: float = 0.0
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def is_healthy(self) -> bool:
|
|
27
|
+
"""Return True if proxy is not quarantined."""
|
|
28
|
+
return time.monotonic() >= self.quarantined_until
|
|
29
|
+
|
|
30
|
+
def mark_failure(self, max_failures: int = 3, quarantine_seconds: float = 60.0) -> None:
|
|
31
|
+
"""Increment failure counter and quarantine if threshold is reached."""
|
|
32
|
+
self.failures += 1
|
|
33
|
+
if self.failures >= max_failures:
|
|
34
|
+
self.quarantined_until = time.monotonic() + quarantine_seconds
|
|
35
|
+
logger.warning(
|
|
36
|
+
"Proxy %s quarantined for %.1fs after %d failures",
|
|
37
|
+
self.server,
|
|
38
|
+
quarantine_seconds,
|
|
39
|
+
self.failures,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def mark_success(self) -> None:
|
|
43
|
+
"""Reset failures counter upon a successful request."""
|
|
44
|
+
self.failures = 0
|
|
45
|
+
self.quarantined_until = 0.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProxyManager:
|
|
49
|
+
"""Manages a pool of proxies with rotation strategies and fault recovery."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
proxies: list[str] | str | Path | None = None,
|
|
54
|
+
strategy: ProxyStrategy = "round-robin",
|
|
55
|
+
max_failures: int = 3,
|
|
56
|
+
quarantine_seconds: float = 60.0,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.strategy: ProxyStrategy = strategy
|
|
59
|
+
self.max_failures = max_failures
|
|
60
|
+
self.quarantine_seconds = quarantine_seconds
|
|
61
|
+
self._index: int = 0
|
|
62
|
+
self._items: list[ProxyItem] = []
|
|
63
|
+
|
|
64
|
+
if proxies:
|
|
65
|
+
self.load_proxies(proxies)
|
|
66
|
+
|
|
67
|
+
def load_proxies(self, proxies: list[str] | str | Path) -> None:
|
|
68
|
+
"""Load proxies from a list of URLs, a comma-separated string, or a file path."""
|
|
69
|
+
proxy_list: list[str] = []
|
|
70
|
+
|
|
71
|
+
if isinstance(proxies, (str, Path)):
|
|
72
|
+
path = Path(proxies)
|
|
73
|
+
if path.is_file():
|
|
74
|
+
content = path.read_text(encoding="utf-8")
|
|
75
|
+
proxy_list = [
|
|
76
|
+
line.strip()
|
|
77
|
+
for line in content.splitlines()
|
|
78
|
+
if line.strip() and not line.strip().startswith("#")
|
|
79
|
+
]
|
|
80
|
+
elif isinstance(proxies, str):
|
|
81
|
+
proxy_list = [p.strip() for p in proxies.split(",") if p.strip()]
|
|
82
|
+
elif isinstance(proxies, list):
|
|
83
|
+
proxy_list = [p.strip() for p in proxies if p and p.strip()]
|
|
84
|
+
|
|
85
|
+
# Deduplicate while preserving order
|
|
86
|
+
seen = set()
|
|
87
|
+
unique = []
|
|
88
|
+
for p in proxy_list:
|
|
89
|
+
# Ensure protocol prefix exists if not provided
|
|
90
|
+
normalized = p if "://" in p else f"http://{p}"
|
|
91
|
+
if normalized not in seen:
|
|
92
|
+
seen.add(normalized)
|
|
93
|
+
unique.append(normalized)
|
|
94
|
+
|
|
95
|
+
self._items = [ProxyItem(server=p) for p in unique]
|
|
96
|
+
self._index = 0
|
|
97
|
+
logger.info("Loaded %d proxies into ProxyManager", len(self._items))
|
|
98
|
+
|
|
99
|
+
def __len__(self) -> int:
|
|
100
|
+
return len(self._items)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def has_proxies(self) -> bool:
|
|
104
|
+
"""Return True if at least one proxy is loaded."""
|
|
105
|
+
return len(self._items) > 0
|
|
106
|
+
|
|
107
|
+
def get_proxy(self) -> str | None:
|
|
108
|
+
"""Select next healthy proxy based on configured strategy."""
|
|
109
|
+
if not self._items:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
healthy = [item for item in self._items if item.is_healthy]
|
|
113
|
+
# If all proxies are currently quarantined, use least-recently quarantined as fallback
|
|
114
|
+
pool = healthy if healthy else self._items
|
|
115
|
+
|
|
116
|
+
if self.strategy == "random":
|
|
117
|
+
chosen = random.choice(pool)
|
|
118
|
+
return chosen.server
|
|
119
|
+
|
|
120
|
+
# Round-robin selection
|
|
121
|
+
chosen = pool[self._index % len(pool)]
|
|
122
|
+
self._index = (self._index + 1) % len(pool)
|
|
123
|
+
return chosen.server
|
|
124
|
+
|
|
125
|
+
def report_failure(self, proxy_server: str) -> None:
|
|
126
|
+
"""Record a failure for the specified proxy server."""
|
|
127
|
+
for item in self._items:
|
|
128
|
+
if item.server == proxy_server:
|
|
129
|
+
item.mark_failure(
|
|
130
|
+
max_failures=self.max_failures,
|
|
131
|
+
quarantine_seconds=self.quarantine_seconds,
|
|
132
|
+
)
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
def report_success(self, proxy_server: str) -> None:
|
|
136
|
+
"""Record a success for the specified proxy server."""
|
|
137
|
+
for item in self._items:
|
|
138
|
+
if item.server == proxy_server:
|
|
139
|
+
item.mark_success()
|
|
140
|
+
break
|
patchtroy/server.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""FastAPI REST API microservice for Patchtroy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import logging
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import Any, AsyncIterator
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from patchtroy.crawler import AsyncPatchtroy
|
|
13
|
+
from patchtroy.models import PatchtroyConfig, ScrapeResult
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("patchtroy.server")
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
from fastapi.responses import JSONResponse
|
|
20
|
+
except ImportError as exc:
|
|
21
|
+
raise ImportError(
|
|
22
|
+
"FastAPI is required to run the Patchtroy REST microservice. "
|
|
23
|
+
"Install it via: pip install 'patchtroy[server]'"
|
|
24
|
+
) from exc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ScrapeRequestBody(BaseModel):
|
|
28
|
+
"""Payload for /scrape endpoint."""
|
|
29
|
+
|
|
30
|
+
url: str = Field(description="Target URL to scrape")
|
|
31
|
+
wait_for: str | None = Field(default=None, description="CSS selector to wait for")
|
|
32
|
+
wait_until: str = Field(
|
|
33
|
+
default="domcontentloaded",
|
|
34
|
+
description="Navigation lifecycle: domcontentloaded, load, networkidle",
|
|
35
|
+
)
|
|
36
|
+
timeout: float = Field(default=25.0, description="Navigation timeout in seconds")
|
|
37
|
+
screenshot: bool = Field(default=False, description="Whether to capture screenshot")
|
|
38
|
+
full_page: bool = Field(default=False, description="Full-page screenshot mode")
|
|
39
|
+
pdf: bool = Field(default=False, description="Whether to generate PDF (headless only)")
|
|
40
|
+
proxy: str | None = Field(default=None, description="Optional proxy URL for request")
|
|
41
|
+
custom_schema: dict[str, Any] | None = Field(
|
|
42
|
+
default=None, description="Custom declarative CSS schema"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BatchScrapeRequestBody(BaseModel):
|
|
47
|
+
"""Payload for /scrape/batch endpoint."""
|
|
48
|
+
|
|
49
|
+
urls: list[str] = Field(description="List of target URLs to scrape")
|
|
50
|
+
wait_for: str | None = Field(default=None, description="CSS selector to wait for")
|
|
51
|
+
timeout: float = Field(default=25.0, description="Navigation timeout in seconds")
|
|
52
|
+
concurrency: int = Field(default=5, ge=1, le=50, description="Concurrent browser contexts")
|
|
53
|
+
custom_schema: dict[str, Any] | None = Field(
|
|
54
|
+
default=None, description="Custom declarative CSS schema"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _serialize_scrape_result(res: ScrapeResult) -> dict[str, Any]:
|
|
59
|
+
"""Serialize ScrapeResult into a JSON-friendly dict, encoding media bytes as base64."""
|
|
60
|
+
data = res.model_dump(exclude={"screenshot_bytes", "pdf_bytes"})
|
|
61
|
+
if res.screenshot_bytes:
|
|
62
|
+
data["screenshot_base64"] = base64.b64encode(res.screenshot_bytes).decode("ascii")
|
|
63
|
+
if res.pdf_bytes:
|
|
64
|
+
data["pdf_base64"] = base64.b64encode(res.pdf_bytes).decode("ascii")
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def create_app() -> FastAPI:
|
|
69
|
+
"""Factory creating configured FastAPI microservice instance."""
|
|
70
|
+
crawler_instance: AsyncPatchtroy | None = None
|
|
71
|
+
|
|
72
|
+
async def get_crawler() -> AsyncPatchtroy:
|
|
73
|
+
nonlocal crawler_instance
|
|
74
|
+
if crawler_instance is None:
|
|
75
|
+
crawler_instance = AsyncPatchtroy()
|
|
76
|
+
await crawler_instance.start()
|
|
77
|
+
return crawler_instance
|
|
78
|
+
|
|
79
|
+
@asynccontextmanager
|
|
80
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
81
|
+
nonlocal crawler_instance
|
|
82
|
+
logger.info("Initializing Patchtroy microservice browser engine...")
|
|
83
|
+
await get_crawler()
|
|
84
|
+
yield
|
|
85
|
+
logger.info("Terminating Patchtroy microservice browser engine...")
|
|
86
|
+
if crawler_instance:
|
|
87
|
+
await crawler_instance.close()
|
|
88
|
+
crawler_instance = None
|
|
89
|
+
|
|
90
|
+
app = FastAPI(
|
|
91
|
+
title="Patchtroy REST Microservice",
|
|
92
|
+
description="Undetected stealth web scraper & clean Markdown extractor for LLMs.",
|
|
93
|
+
version="0.4.0",
|
|
94
|
+
lifespan=lifespan,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@app.get("/health")
|
|
98
|
+
async def health_check() -> dict[str, Any]:
|
|
99
|
+
"""Microservice health and status check."""
|
|
100
|
+
return {
|
|
101
|
+
"status": "healthy",
|
|
102
|
+
"version": "0.4.0",
|
|
103
|
+
"engine": "patchright",
|
|
104
|
+
"active": crawler_instance is not None and crawler_instance._pool.is_running,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@app.post("/scrape")
|
|
108
|
+
async def scrape_single(body: ScrapeRequestBody) -> JSONResponse:
|
|
109
|
+
"""Scrape a single target web page into clean Markdown."""
|
|
110
|
+
crawler = await get_crawler()
|
|
111
|
+
|
|
112
|
+
config = PatchtroyConfig(
|
|
113
|
+
browser_timeout_s=body.timeout,
|
|
114
|
+
wait_for=body.wait_for,
|
|
115
|
+
wait_until=body.wait_until,
|
|
116
|
+
screenshot=body.screenshot,
|
|
117
|
+
full_page_screenshot=body.full_page,
|
|
118
|
+
pdf=body.pdf,
|
|
119
|
+
proxy=body.proxy,
|
|
120
|
+
custom_schema=body.custom_schema,
|
|
121
|
+
)
|
|
122
|
+
# Create lightweight client sharing the application's browser pool
|
|
123
|
+
client = AsyncPatchtroy(config)
|
|
124
|
+
client._pool = crawler._pool
|
|
125
|
+
|
|
126
|
+
result = await client.scrape(
|
|
127
|
+
body.url,
|
|
128
|
+
wait_for=body.wait_for,
|
|
129
|
+
custom_schema=body.custom_schema,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return JSONResponse(content=_serialize_scrape_result(result))
|
|
133
|
+
|
|
134
|
+
@app.post("/scrape/batch")
|
|
135
|
+
async def scrape_batch(body: BatchScrapeRequestBody) -> JSONResponse:
|
|
136
|
+
"""Scrape multiple target web pages concurrently."""
|
|
137
|
+
crawler = await get_crawler()
|
|
138
|
+
|
|
139
|
+
config = PatchtroyConfig(
|
|
140
|
+
browser_timeout_s=body.timeout,
|
|
141
|
+
wait_for=body.wait_for,
|
|
142
|
+
max_concurrency=body.concurrency,
|
|
143
|
+
custom_schema=body.custom_schema,
|
|
144
|
+
)
|
|
145
|
+
client = AsyncPatchtroy(config)
|
|
146
|
+
client._pool = crawler._pool
|
|
147
|
+
|
|
148
|
+
results = await client.scrape_many(
|
|
149
|
+
body.urls,
|
|
150
|
+
wait_for=body.wait_for,
|
|
151
|
+
custom_schema=body.custom_schema,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
serialized = [_serialize_scrape_result(r) for r in results]
|
|
155
|
+
return JSONResponse(content=serialized)
|
|
156
|
+
|
|
157
|
+
return app
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_server(host: str = "0.0.0.0", port: int = 4013, reload: bool = False) -> None:
|
|
161
|
+
"""Launch the Uvicorn ASGI server."""
|
|
162
|
+
try:
|
|
163
|
+
import uvicorn
|
|
164
|
+
except ImportError as exc:
|
|
165
|
+
raise ImportError(
|
|
166
|
+
"Uvicorn is required to run the server. "
|
|
167
|
+
"Install it via: pip install 'patchtroy[server]'"
|
|
168
|
+
) from exc
|
|
169
|
+
|
|
170
|
+
uvicorn.run("patchtroy.server:create_app", host=host, port=port, reload=reload, factory=True)
|
patchtroy/utils.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Utility functions, stealth injection scripts, and headers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
DEFAULT_USER_AGENTS = [
|
|
9
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
10
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
11
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
STEALTH_INJECTION_SCRIPT = """
|
|
15
|
+
// Evade common automated browser detection signatures
|
|
16
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
17
|
+
window.chrome = {
|
|
18
|
+
runtime: {},
|
|
19
|
+
loadTimes: function() {},
|
|
20
|
+
csi: function() {},
|
|
21
|
+
app: {}
|
|
22
|
+
};
|
|
23
|
+
// Emulate realistic plugins
|
|
24
|
+
Object.defineProperty(navigator, 'plugins', {
|
|
25
|
+
get: () => [1, 2, 3, 4, 5],
|
|
26
|
+
});
|
|
27
|
+
// Emulate standard languages
|
|
28
|
+
Object.defineProperty(navigator, 'languages', {
|
|
29
|
+
get: () => ['en-US', 'en'],
|
|
30
|
+
});
|
|
31
|
+
// Emulate permissions query
|
|
32
|
+
if (window.navigator && window.navigator.permissions) {
|
|
33
|
+
const originalQuery = window.navigator.permissions.query;
|
|
34
|
+
window.navigator.permissions.query = (parameters) => (
|
|
35
|
+
parameters.name === 'notifications' ?
|
|
36
|
+
Promise.resolve({ state: 'default' }) :
|
|
37
|
+
originalQuery(parameters)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_random_user_agent() -> str:
|
|
44
|
+
"""Return a realistic modern desktop browser User-Agent."""
|
|
45
|
+
return random.choice(DEFAULT_USER_AGENTS)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_valid_url(url: str) -> bool:
|
|
49
|
+
"""Validate URL scheme is http or https."""
|
|
50
|
+
try:
|
|
51
|
+
parsed = urlparse(url)
|
|
52
|
+
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
53
|
+
except Exception:
|
|
54
|
+
return False
|