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/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