patchtroy 0.4.4__tar.gz → 0.4.6__tar.gz
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-0.4.4 → patchtroy-0.4.6}/Dockerfile +1 -1
- {patchtroy-0.4.4 → patchtroy-0.4.6}/PKG-INFO +1 -1
- {patchtroy-0.4.4 → patchtroy-0.4.6}/PRD.md +1 -1
- patchtroy-0.4.6/VERSION +1 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/benchmarks/BENCHMARKS.md +1 -1
- {patchtroy-0.4.4 → patchtroy-0.4.6}/docs/benchmarks.md +1 -1
- {patchtroy-0.4.4 → patchtroy-0.4.6}/docs/rest-api.md +1 -1
- {patchtroy-0.4.4 → patchtroy-0.4.6}/pyproject.toml +1 -1
- {patchtroy-0.4.4 → patchtroy-0.4.6}/src/patchtroy/__init__.py +1 -1
- {patchtroy-0.4.4 → patchtroy-0.4.6}/src/patchtroy/cli.py +51 -48
- {patchtroy-0.4.4 → patchtroy-0.4.6}/src/patchtroy/crawler.py +46 -14
- {patchtroy-0.4.4 → patchtroy-0.4.6}/src/patchtroy/pool.py +3 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/src/patchtroy/server.py +3 -3
- patchtroy-0.4.6/src/patchtroy/utils.py +105 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/tests/test_crawler.py +15 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/tests/test_server.py +1 -1
- {patchtroy-0.4.4 → patchtroy-0.4.6}/uv.lock +1 -1
- patchtroy-0.4.4/VERSION +0 -1
- patchtroy-0.4.4/src/patchtroy/utils.py +0 -54
- {patchtroy-0.4.4 → patchtroy-0.4.6}/.dockerignore +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/.gitattributes +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/.githooks/pre-commit +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/.githooks/prepare-commit-msg +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/.githooks/versionlib.sh +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/.github/workflows/ci.yml +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/.github/workflows/docs.yml +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/.github/workflows/release.yml +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/.gitignore +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/LICENSE +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/README.md +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/benchmarks/benchmark_results.json +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/benchmarks/run_benchmark.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/docker-compose.yml +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/docs/context-pool.md +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/docs/getting-started.md +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/docs/index.md +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/docs/llm-chunking.md +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/docs/media-capture.md +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/docs/proxy-rotation.md +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/docs/stealth-architecture.md +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/mkdocs.yml +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/src/patchtroy/chunker.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/src/patchtroy/extractors.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/src/patchtroy/models.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/src/patchtroy/proxy.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/tests/test_chunker.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/tests/test_cli.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/tests/test_extractors.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/tests/test_media_capture.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/tests/test_models.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/tests/test_pool.py +0 -0
- {patchtroy-0.4.4 → patchtroy-0.4.6}/tests/test_proxy.py +0 -0
|
@@ -8,7 +8,7 @@ FROM python:3.11-slim
|
|
|
8
8
|
LABEL maintainer="Marcus Zou <marcus.zou@icloud.com>"
|
|
9
9
|
LABEL org.opencontainers.image.title="Patchtroy"
|
|
10
10
|
LABEL org.opencontainers.image.description="Undetected stealth web scraper & markdown extractor microservice"
|
|
11
|
-
LABEL org.opencontainers.image.version="0.4.
|
|
11
|
+
LABEL org.opencontainers.image.version="0.4.6"
|
|
12
12
|
|
|
13
13
|
# Python and environment settings
|
|
14
14
|
ENV PYTHONUNBUFFERED=1 \
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: patchtroy
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.6
|
|
4
4
|
Summary: Undetected stealth web scraper & markdown extractor for LLMs, pairing Patchright with Trafilatura.
|
|
5
5
|
Project-URL: Homepage, https://github.com/marcuz-apl/patchtroy
|
|
6
6
|
Project-URL: Repository, https://github.com/marcuz-apl/patchtroy
|
|
@@ -152,7 +152,7 @@ Patchtroy occupies the unoccupied **"Lightweight Stealth Sweet Spot"**:
|
|
|
152
152
|
- [x] Token counter & LLM chunking utility (`result.chunk(max_tokens=2048)`).
|
|
153
153
|
- [x] Full test suite (32 tests passing).
|
|
154
154
|
|
|
155
|
-
### Phase 4 (v0.4.
|
|
155
|
+
### Phase 4 (v0.4.6 — Current)
|
|
156
156
|
- [x] PyPI public release workflows & multi-OS matrix CI (`.github/workflows/`).
|
|
157
157
|
- [x] Comprehensive benchmark publication vs Crawl4AI and Firecrawl (`benchmarks/BENCHMARKS.md`).
|
|
158
158
|
- [x] Official documentation portal (Material for MkDocs).
|
patchtroy-0.4.6/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
v0.4.7-260902d
|
|
@@ -19,7 +19,7 @@ Existing open-source solutions force developers to choose between:
|
|
|
19
19
|
|
|
20
20
|
## 2. Feature & Architecture Matrix
|
|
21
21
|
|
|
22
|
-
| Dimension | **Patchtroy** (v0.4.
|
|
22
|
+
| Dimension | **Patchtroy** (v0.4.6) | **Crawl4AI** (v0.9.3) | **Firecrawl** (Self-Hosted) |
|
|
23
23
|
| :--- | :---: | :---: | :---: |
|
|
24
24
|
| **Stealth Engine** | **✅ Native Patchright (C++ CDP masked)** | ❌ Standard Playwright (CDP leaked) | ⚠️ Standard Playwright / Puppeteer |
|
|
25
25
|
| **Dynamic Challenge Resilience** | **✅ Native driver protection** | ❌ Frequently flagged on automated checks | ❌ Blocked unless using paid SaaS proxy |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 📊 Benchmarks vs Crawl4AI and Firecrawl
|
|
2
2
|
|
|
3
|
-
A quantitative, reproducible benchmark comparing **Patchtroy (v0.4.
|
|
3
|
+
A quantitative, reproducible benchmark comparing **Patchtroy (v0.4.6)** against **Crawl4AI (v0.9.3)** and **Firecrawl (Self-Hosted)**.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "patchtroy"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.6"
|
|
8
8
|
description = "Undetected stealth web scraper & markdown extractor for LLMs, pairing Patchright with Trafilatura."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -6,7 +6,7 @@ from patchtroy.models import LinkItem, PatchtroyConfig, ScrapeResult
|
|
|
6
6
|
from patchtroy.pool import BrowserContextPool
|
|
7
7
|
from patchtroy.proxy import ProxyItem, ProxyManager
|
|
8
8
|
|
|
9
|
-
__version__ = "0.4.
|
|
9
|
+
__version__ = "0.4.6"
|
|
10
10
|
__author__ = "Marcus Zou"
|
|
11
11
|
__license__ = "Apache-2.0"
|
|
12
12
|
__copyright__ = "Copyright 2026 Alfazen Inc."
|
|
@@ -9,8 +9,9 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
from patchtroy.crawler import Patchtroy
|
|
11
11
|
from patchtroy.models import PatchtroyConfig
|
|
12
|
+
from patchtroy.utils import silence_windows_proactor_bug
|
|
12
13
|
|
|
13
|
-
__version__ = "0.4.
|
|
14
|
+
__version__ = "0.4.6"
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -129,68 +130,70 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
129
130
|
pdf_path=args.pdf,
|
|
130
131
|
)
|
|
131
132
|
|
|
133
|
+
silence_windows_proactor_bug()
|
|
132
134
|
crawler = Patchtroy(config)
|
|
133
135
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
with crawler:
|
|
137
|
+
# Single URL execution
|
|
138
|
+
if len(args.urls) == 1:
|
|
139
|
+
target_url = args.urls[0]
|
|
140
|
+
result = crawler.scrape(target_url)
|
|
141
|
+
|
|
142
|
+
if not result.success and not result.markdown:
|
|
143
|
+
sys.stderr.write(f"Error scraping {target_url}: {result.error}\n")
|
|
144
|
+
return 1
|
|
145
|
+
|
|
146
|
+
if args.format == "json":
|
|
147
|
+
# Avoid serializing raw media bytes directly into standard JSON
|
|
148
|
+
dump_data = result.model_dump(exclude={"screenshot_bytes", "pdf_bytes"})
|
|
149
|
+
content = json.dumps(dump_data, indent=2, ensure_ascii=False)
|
|
150
|
+
elif args.format == "html":
|
|
151
|
+
content = result.html
|
|
152
|
+
else:
|
|
153
|
+
header = f"# {result.title}\n\nSource: {result.url}\n\n" if result.title else ""
|
|
154
|
+
content = header + result.markdown
|
|
155
|
+
|
|
156
|
+
if args.output:
|
|
157
|
+
Path(args.output).write_text(content, encoding="utf-8")
|
|
158
|
+
sys.stderr.write(f"[Patchtroy] Extracted content saved to {args.output} ({len(content)} chars)\n")
|
|
159
|
+
else:
|
|
160
|
+
print(content)
|
|
161
|
+
|
|
162
|
+
if args.screenshot and result.screenshot_bytes:
|
|
163
|
+
sys.stderr.write(f"[Patchtroy] Screenshot saved to {args.screenshot}\n")
|
|
164
|
+
if args.pdf and result.pdf_bytes:
|
|
165
|
+
sys.stderr.write(f"[Patchtroy] PDF saved to {args.pdf}\n")
|
|
166
|
+
|
|
167
|
+
return 0
|
|
138
168
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
169
|
+
# Batch URLs execution
|
|
170
|
+
sys.stderr.write(f"[Patchtroy] Batch scraping {len(args.urls)} URLs (concurrency: {args.concurrency})...\n")
|
|
171
|
+
results = crawler.scrape_many(args.urls)
|
|
142
172
|
|
|
143
173
|
if args.format == "json":
|
|
144
|
-
|
|
145
|
-
|
|
174
|
+
dump_data = [
|
|
175
|
+
r.model_dump(exclude={"screenshot_bytes", "pdf_bytes"})
|
|
176
|
+
for r in results
|
|
177
|
+
]
|
|
146
178
|
content = json.dumps(dump_data, indent=2, ensure_ascii=False)
|
|
147
|
-
elif args.format == "html":
|
|
148
|
-
content = result.html
|
|
149
179
|
else:
|
|
150
|
-
|
|
151
|
-
|
|
180
|
+
combined = []
|
|
181
|
+
for r in results:
|
|
182
|
+
if r.success:
|
|
183
|
+
header = f"# {r.title}\n\nSource: {r.url}\n\n" if r.title else f"Source: {r.url}\n\n"
|
|
184
|
+
combined.append(header + r.markdown)
|
|
185
|
+
else:
|
|
186
|
+
combined.append(f"<!-- Failed: {r.url} ({r.error}) -->")
|
|
187
|
+
content = "\n\n---\n\n".join(combined)
|
|
152
188
|
|
|
153
189
|
if args.output:
|
|
154
190
|
Path(args.output).write_text(content, encoding="utf-8")
|
|
155
|
-
sys.stderr.write(f"[Patchtroy]
|
|
191
|
+
sys.stderr.write(f"[Patchtroy] Batch results saved to {args.output}\n")
|
|
156
192
|
else:
|
|
157
193
|
print(content)
|
|
158
194
|
|
|
159
|
-
if args.screenshot and result.screenshot_bytes:
|
|
160
|
-
sys.stderr.write(f"[Patchtroy] Screenshot saved to {args.screenshot}\n")
|
|
161
|
-
if args.pdf and result.pdf_bytes:
|
|
162
|
-
sys.stderr.write(f"[Patchtroy] PDF saved to {args.pdf}\n")
|
|
163
|
-
|
|
164
195
|
return 0
|
|
165
196
|
|
|
166
|
-
# Batch URLs execution
|
|
167
|
-
sys.stderr.write(f"[Patchtroy] Batch scraping {len(args.urls)} URLs (concurrency: {args.concurrency})...\n")
|
|
168
|
-
results = crawler.scrape_many(args.urls)
|
|
169
|
-
|
|
170
|
-
if args.format == "json":
|
|
171
|
-
dump_data = [
|
|
172
|
-
r.model_dump(exclude={"screenshot_bytes", "pdf_bytes"})
|
|
173
|
-
for r in results
|
|
174
|
-
]
|
|
175
|
-
content = json.dumps(dump_data, indent=2, ensure_ascii=False)
|
|
176
|
-
else:
|
|
177
|
-
combined = []
|
|
178
|
-
for r in results:
|
|
179
|
-
if r.success:
|
|
180
|
-
header = f"# {r.title}\n\nSource: {r.url}\n\n" if r.title else f"Source: {r.url}\n\n"
|
|
181
|
-
combined.append(header + r.markdown)
|
|
182
|
-
else:
|
|
183
|
-
combined.append(f"<!-- Failed: {r.url} ({r.error}) -->")
|
|
184
|
-
content = "\n\n---\n\n".join(combined)
|
|
185
|
-
|
|
186
|
-
if args.output:
|
|
187
|
-
Path(args.output).write_text(content, encoding="utf-8")
|
|
188
|
-
sys.stderr.write(f"[Patchtroy] Batch results saved to {args.output}\n")
|
|
189
|
-
else:
|
|
190
|
-
print(content)
|
|
191
|
-
|
|
192
|
-
return 0
|
|
193
|
-
|
|
194
197
|
|
|
195
198
|
if __name__ == "__main__":
|
|
196
199
|
sys.exit(main())
|
|
@@ -19,7 +19,12 @@ from patchtroy.extractors import (
|
|
|
19
19
|
from patchtroy.models import LinkItem, PatchtroyConfig, ScrapeResult
|
|
20
20
|
from patchtroy.pool import BrowserContextPool
|
|
21
21
|
from patchtroy.proxy import ProxyManager
|
|
22
|
-
from patchtroy.utils import
|
|
22
|
+
from patchtroy.utils import (
|
|
23
|
+
STEALTH_INJECTION_SCRIPT,
|
|
24
|
+
get_random_user_agent,
|
|
25
|
+
is_valid_url,
|
|
26
|
+
silence_windows_proactor_bug,
|
|
27
|
+
)
|
|
23
28
|
|
|
24
29
|
logger = logging.getLogger("patchtroy.crawler")
|
|
25
30
|
|
|
@@ -312,11 +317,30 @@ class AsyncPatchtroy:
|
|
|
312
317
|
return await client.scrape_many(urls, wait_for=wait_for, custom_schema=custom_schema)
|
|
313
318
|
|
|
314
319
|
|
|
320
|
+
def _run_sync(coro: Any) -> Any:
|
|
321
|
+
"""Execute coroutine synchronously with Windows proactor event loop protections."""
|
|
322
|
+
silence_windows_proactor_bug()
|
|
323
|
+
if sys.platform == "win32":
|
|
324
|
+
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
325
|
+
return asyncio.run(coro)
|
|
326
|
+
|
|
327
|
+
|
|
315
328
|
class Patchtroy:
|
|
316
329
|
"""Synchronous wrapper for Patchtroy crawler execution."""
|
|
317
330
|
|
|
318
331
|
def __init__(self, config: PatchtroyConfig | dict[str, Any] | None = None) -> None:
|
|
319
|
-
self.
|
|
332
|
+
self.config = (
|
|
333
|
+
PatchtroyConfig(**config)
|
|
334
|
+
if isinstance(config, dict)
|
|
335
|
+
else (config or PatchtroyConfig())
|
|
336
|
+
)
|
|
337
|
+
silence_windows_proactor_bug()
|
|
338
|
+
self._async_crawler: AsyncPatchtroy | None = None
|
|
339
|
+
|
|
340
|
+
def _get_crawler(self) -> AsyncPatchtroy:
|
|
341
|
+
if self._async_crawler is None:
|
|
342
|
+
self._async_crawler = AsyncPatchtroy(self.config)
|
|
343
|
+
return self._async_crawler
|
|
320
344
|
|
|
321
345
|
def scrape(
|
|
322
346
|
self,
|
|
@@ -325,9 +349,8 @@ class Patchtroy:
|
|
|
325
349
|
custom_schema: dict[str, Any] | None = None,
|
|
326
350
|
) -> ScrapeResult:
|
|
327
351
|
"""Execute scrape synchronously in an event loop."""
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
return asyncio.run(self._async_crawler.scrape(url, wait_for=wait_for, custom_schema=custom_schema))
|
|
352
|
+
crawler = self._get_crawler()
|
|
353
|
+
return _run_sync(crawler.scrape(url, wait_for=wait_for, custom_schema=custom_schema))
|
|
331
354
|
|
|
332
355
|
def scrape_many(
|
|
333
356
|
self,
|
|
@@ -336,9 +359,22 @@ class Patchtroy:
|
|
|
336
359
|
custom_schema: dict[str, Any] | None = None,
|
|
337
360
|
) -> list[ScrapeResult]:
|
|
338
361
|
"""Execute concurrent batch scraping synchronously."""
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
362
|
+
crawler = self._get_crawler()
|
|
363
|
+
return _run_sync(crawler.scrape_many(urls, wait_for=wait_for, custom_schema=custom_schema))
|
|
364
|
+
|
|
365
|
+
def close(self) -> None:
|
|
366
|
+
"""Close browser pool and cleanup resources."""
|
|
367
|
+
if self._async_crawler is not None:
|
|
368
|
+
try:
|
|
369
|
+
_run_sync(self._async_crawler.close())
|
|
370
|
+
finally:
|
|
371
|
+
self._async_crawler = None
|
|
372
|
+
|
|
373
|
+
def __enter__(self) -> Patchtroy:
|
|
374
|
+
return self
|
|
375
|
+
|
|
376
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
377
|
+
self.close()
|
|
342
378
|
|
|
343
379
|
@classmethod
|
|
344
380
|
def crawl(
|
|
@@ -351,9 +387,7 @@ class Patchtroy:
|
|
|
351
387
|
pdf: bool = False,
|
|
352
388
|
) -> ScrapeResult:
|
|
353
389
|
"""Synchronous one-shot convenience function for scraping."""
|
|
354
|
-
|
|
355
|
-
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
356
|
-
return asyncio.run(
|
|
390
|
+
return _run_sync(
|
|
357
391
|
AsyncPatchtroy.crawl(
|
|
358
392
|
url,
|
|
359
393
|
headless=headless,
|
|
@@ -374,9 +408,7 @@ class Patchtroy:
|
|
|
374
408
|
custom_schema: dict[str, Any] | None = None,
|
|
375
409
|
) -> list[ScrapeResult]:
|
|
376
410
|
"""Synchronous one-shot convenience function for concurrent batch crawling."""
|
|
377
|
-
|
|
378
|
-
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
379
|
-
return asyncio.run(
|
|
411
|
+
return _run_sync(
|
|
380
412
|
AsyncPatchtroy.crawl_many(
|
|
381
413
|
urls,
|
|
382
414
|
headless=headless,
|
|
@@ -90,16 +90,16 @@ def create_app() -> FastAPI:
|
|
|
90
90
|
app = FastAPI(
|
|
91
91
|
title="Patchtroy REST Microservice",
|
|
92
92
|
description="Undetected stealth web scraper & clean Markdown extractor for LLMs.",
|
|
93
|
-
version="0.4.
|
|
93
|
+
version="0.4.6",
|
|
94
94
|
lifespan=lifespan,
|
|
95
95
|
)
|
|
96
96
|
|
|
97
|
-
@app.get("/health")
|
|
97
|
+
@app.get("/health", tags=["Health"])
|
|
98
98
|
async def health_check() -> dict[str, Any]:
|
|
99
99
|
"""Microservice health and status check."""
|
|
100
100
|
return {
|
|
101
101
|
"status": "healthy",
|
|
102
|
-
"version": "0.4.
|
|
102
|
+
"version": "0.4.6",
|
|
103
103
|
"engine": "patchright",
|
|
104
104
|
"active": crawler_instance is not None and crawler_instance._pool.is_running,
|
|
105
105
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Utility functions, stealth injection scripts, and platform helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
DEFAULT_USER_AGENTS = [
|
|
10
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
11
|
+
"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",
|
|
12
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
STEALTH_INJECTION_SCRIPT = """
|
|
16
|
+
// Configure natural browser environment signatures
|
|
17
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
18
|
+
window.chrome = {
|
|
19
|
+
runtime: {},
|
|
20
|
+
loadTimes: function() {},
|
|
21
|
+
csi: function() {},
|
|
22
|
+
app: {}
|
|
23
|
+
};
|
|
24
|
+
// Emulate realistic plugins
|
|
25
|
+
Object.defineProperty(navigator, 'plugins', {
|
|
26
|
+
get: () => [1, 2, 3, 4, 5],
|
|
27
|
+
});
|
|
28
|
+
// Emulate standard languages
|
|
29
|
+
Object.defineProperty(navigator, 'languages', {
|
|
30
|
+
get: () => ['en-US', 'en'],
|
|
31
|
+
});
|
|
32
|
+
// Emulate permissions query
|
|
33
|
+
if (window.navigator && window.navigator.permissions) {
|
|
34
|
+
const originalQuery = window.navigator.permissions.query;
|
|
35
|
+
window.navigator.permissions.query = (parameters) => (
|
|
36
|
+
parameters.name === 'notifications' ?
|
|
37
|
+
Promise.resolve({ state: 'default' }) :
|
|
38
|
+
originalQuery(parameters)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_random_user_agent() -> str:
|
|
45
|
+
"""Return a realistic modern desktop browser User-Agent."""
|
|
46
|
+
return random.choice(DEFAULT_USER_AGENTS)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_valid_url(url: str) -> bool:
|
|
50
|
+
"""Validate URL scheme is http or https."""
|
|
51
|
+
try:
|
|
52
|
+
parsed = urlparse(url)
|
|
53
|
+
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
54
|
+
except Exception:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def silence_subprocess_transport_bug() -> None:
|
|
59
|
+
"""Silence Python asyncio BaseSubprocessTransport and pipe transport destructor errors.
|
|
60
|
+
|
|
61
|
+
When an event loop finishes and closes (via asyncio.run), underlying child process
|
|
62
|
+
transports may remain in the garbage collector until interpreter exit. During GC,
|
|
63
|
+
their __del__ destructors attempt post-loop cleanup:
|
|
64
|
+
- On Unix/Linux: calls self.close() -> loop.call_soon() -> RuntimeError('Event loop is closed')
|
|
65
|
+
- On Windows: calls _warn() -> __repr__() -> fileno() -> ValueError('I/O operation on closed pipe')
|
|
66
|
+
Wrapping both destructors safely catches and suppresses these post-loop cleanup errors across
|
|
67
|
+
all platforms (Linux, macOS, Windows).
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
from asyncio.base_subprocess import BaseSubprocessTransport
|
|
71
|
+
|
|
72
|
+
orig_sub_del = getattr(BaseSubprocessTransport, "__del__", None)
|
|
73
|
+
if orig_sub_del and not getattr(BaseSubprocessTransport, "_patchtroy_safe", False):
|
|
74
|
+
|
|
75
|
+
def _safe_sub_del(self: Any, *args: Any, **kwargs: Any) -> None:
|
|
76
|
+
try:
|
|
77
|
+
orig_sub_del(self, *args, **kwargs)
|
|
78
|
+
except (RuntimeError, ValueError, OSError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
BaseSubprocessTransport.__del__ = _safe_sub_del # type: ignore[method-assign]
|
|
82
|
+
BaseSubprocessTransport._patchtroy_safe = True # type: ignore[attr-defined]
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
from asyncio.proactor_events import _ProactorBasePipeTransport
|
|
88
|
+
|
|
89
|
+
orig_pipe_del = getattr(_ProactorBasePipeTransport, "__del__", None)
|
|
90
|
+
if orig_pipe_del and not getattr(_ProactorBasePipeTransport, "_patchtroy_safe", False):
|
|
91
|
+
|
|
92
|
+
def _safe_pipe_del(self: Any, *args: Any, **kwargs: Any) -> None:
|
|
93
|
+
try:
|
|
94
|
+
orig_pipe_del(self, *args, **kwargs)
|
|
95
|
+
except (RuntimeError, ValueError, OSError):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
_ProactorBasePipeTransport.__del__ = _safe_pipe_del # type: ignore[method-assign]
|
|
99
|
+
_ProactorBasePipeTransport._patchtroy_safe = True # type: ignore[attr-defined]
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Backwards-compatible alias
|
|
105
|
+
silence_windows_proactor_bug = silence_subprocess_transport_bug
|
|
@@ -40,3 +40,18 @@ def test_sync_scrape_many():
|
|
|
40
40
|
results = crawler.scrape_many(urls)
|
|
41
41
|
assert len(results) == 2
|
|
42
42
|
assert all(not r.success for r in results)
|
|
43
|
+
crawler.close()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_sync_context_manager():
|
|
47
|
+
with Patchtroy() as crawler:
|
|
48
|
+
res = crawler.scrape("ftp://bad-url")
|
|
49
|
+
assert res.success is False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_silence_windows_proactor_bug():
|
|
53
|
+
from patchtroy.utils import silence_windows_proactor_bug
|
|
54
|
+
|
|
55
|
+
# Should execute cleanly across all platforms
|
|
56
|
+
silence_windows_proactor_bug()
|
|
57
|
+
|
patchtroy-0.4.4/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
v0.4.4-260902a
|
|
@@ -1,54 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|