patchtroy 0.4.4__tar.gz → 0.4.5__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.
Files changed (52) hide show
  1. {patchtroy-0.4.4 → patchtroy-0.4.5}/Dockerfile +1 -1
  2. {patchtroy-0.4.4 → patchtroy-0.4.5}/PKG-INFO +1 -1
  3. {patchtroy-0.4.4 → patchtroy-0.4.5}/PRD.md +1 -1
  4. patchtroy-0.4.5/VERSION +1 -0
  5. {patchtroy-0.4.4 → patchtroy-0.4.5}/benchmarks/BENCHMARKS.md +1 -1
  6. {patchtroy-0.4.4 → patchtroy-0.4.5}/docs/benchmarks.md +1 -1
  7. {patchtroy-0.4.4 → patchtroy-0.4.5}/docs/rest-api.md +1 -1
  8. {patchtroy-0.4.4 → patchtroy-0.4.5}/pyproject.toml +1 -1
  9. {patchtroy-0.4.4 → patchtroy-0.4.5}/src/patchtroy/__init__.py +1 -1
  10. {patchtroy-0.4.4 → patchtroy-0.4.5}/src/patchtroy/cli.py +51 -48
  11. {patchtroy-0.4.4 → patchtroy-0.4.5}/src/patchtroy/crawler.py +46 -14
  12. {patchtroy-0.4.4 → patchtroy-0.4.5}/src/patchtroy/pool.py +4 -0
  13. {patchtroy-0.4.4 → patchtroy-0.4.5}/src/patchtroy/server.py +3 -3
  14. patchtroy-0.4.5/src/patchtroy/utils.py +103 -0
  15. {patchtroy-0.4.4 → patchtroy-0.4.5}/tests/test_crawler.py +15 -0
  16. {patchtroy-0.4.4 → patchtroy-0.4.5}/tests/test_server.py +1 -1
  17. {patchtroy-0.4.4 → patchtroy-0.4.5}/uv.lock +1 -1
  18. patchtroy-0.4.4/VERSION +0 -1
  19. patchtroy-0.4.4/src/patchtroy/utils.py +0 -54
  20. {patchtroy-0.4.4 → patchtroy-0.4.5}/.dockerignore +0 -0
  21. {patchtroy-0.4.4 → patchtroy-0.4.5}/.gitattributes +0 -0
  22. {patchtroy-0.4.4 → patchtroy-0.4.5}/.githooks/pre-commit +0 -0
  23. {patchtroy-0.4.4 → patchtroy-0.4.5}/.githooks/prepare-commit-msg +0 -0
  24. {patchtroy-0.4.4 → patchtroy-0.4.5}/.githooks/versionlib.sh +0 -0
  25. {patchtroy-0.4.4 → patchtroy-0.4.5}/.github/workflows/ci.yml +0 -0
  26. {patchtroy-0.4.4 → patchtroy-0.4.5}/.github/workflows/docs.yml +0 -0
  27. {patchtroy-0.4.4 → patchtroy-0.4.5}/.github/workflows/release.yml +0 -0
  28. {patchtroy-0.4.4 → patchtroy-0.4.5}/.gitignore +0 -0
  29. {patchtroy-0.4.4 → patchtroy-0.4.5}/LICENSE +0 -0
  30. {patchtroy-0.4.4 → patchtroy-0.4.5}/README.md +0 -0
  31. {patchtroy-0.4.4 → patchtroy-0.4.5}/benchmarks/benchmark_results.json +0 -0
  32. {patchtroy-0.4.4 → patchtroy-0.4.5}/benchmarks/run_benchmark.py +0 -0
  33. {patchtroy-0.4.4 → patchtroy-0.4.5}/docker-compose.yml +0 -0
  34. {patchtroy-0.4.4 → patchtroy-0.4.5}/docs/context-pool.md +0 -0
  35. {patchtroy-0.4.4 → patchtroy-0.4.5}/docs/getting-started.md +0 -0
  36. {patchtroy-0.4.4 → patchtroy-0.4.5}/docs/index.md +0 -0
  37. {patchtroy-0.4.4 → patchtroy-0.4.5}/docs/llm-chunking.md +0 -0
  38. {patchtroy-0.4.4 → patchtroy-0.4.5}/docs/media-capture.md +0 -0
  39. {patchtroy-0.4.4 → patchtroy-0.4.5}/docs/proxy-rotation.md +0 -0
  40. {patchtroy-0.4.4 → patchtroy-0.4.5}/docs/stealth-architecture.md +0 -0
  41. {patchtroy-0.4.4 → patchtroy-0.4.5}/mkdocs.yml +0 -0
  42. {patchtroy-0.4.4 → patchtroy-0.4.5}/src/patchtroy/chunker.py +0 -0
  43. {patchtroy-0.4.4 → patchtroy-0.4.5}/src/patchtroy/extractors.py +0 -0
  44. {patchtroy-0.4.4 → patchtroy-0.4.5}/src/patchtroy/models.py +0 -0
  45. {patchtroy-0.4.4 → patchtroy-0.4.5}/src/patchtroy/proxy.py +0 -0
  46. {patchtroy-0.4.4 → patchtroy-0.4.5}/tests/test_chunker.py +0 -0
  47. {patchtroy-0.4.4 → patchtroy-0.4.5}/tests/test_cli.py +0 -0
  48. {patchtroy-0.4.4 → patchtroy-0.4.5}/tests/test_extractors.py +0 -0
  49. {patchtroy-0.4.4 → patchtroy-0.4.5}/tests/test_media_capture.py +0 -0
  50. {patchtroy-0.4.4 → patchtroy-0.4.5}/tests/test_models.py +0 -0
  51. {patchtroy-0.4.4 → patchtroy-0.4.5}/tests/test_pool.py +0 -0
  52. {patchtroy-0.4.4 → patchtroy-0.4.5}/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.4"
11
+ LABEL org.opencontainers.image.version="0.4.5"
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.4
3
+ Version: 0.4.5
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.4 — Current)
155
+ ### Phase 4 (v0.4.5 — 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).
@@ -0,0 +1 @@
1
+ v0.4.5-260902b
@@ -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.4) | **Crawl4AI** (v0.9.3) | **Firecrawl** (Self-Hosted) |
22
+ | Dimension | **Patchtroy** (v0.4.5) | **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.4)** against **Crawl4AI (v0.9.3)** and **Firecrawl (Self-Hosted)**.
3
+ A quantitative, reproducible benchmark comparing **Patchtroy (v0.4.5)** against **Crawl4AI (v0.9.3)** and **Firecrawl (Self-Hosted)**.
4
4
 
5
5
  ---
6
6
 
@@ -53,7 +53,7 @@ curl http://localhost:4013/health
53
53
  ```json
54
54
  {
55
55
  "status": "healthy",
56
- "version": "0.4.4",
56
+ "version": "0.4.5",
57
57
  "engine": "patchright",
58
58
  "active": true
59
59
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "patchtroy"
7
- version = "0.4.4"
7
+ version = "0.4.5"
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.4"
9
+ __version__ = "0.4.5"
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.4"
14
+ __version__ = "0.4.5"
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
- # Single URL execution
135
- if len(args.urls) == 1:
136
- target_url = args.urls[0]
137
- result = crawler.scrape(target_url)
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
- if not result.success and not result.markdown:
140
- sys.stderr.write(f"Error scraping {target_url}: {result.error}\n")
141
- return 1
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
- # Avoid serializing raw media bytes directly into standard JSON
145
- dump_data = result.model_dump(exclude={"screenshot_bytes", "pdf_bytes"})
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
- header = f"# {result.title}\n\nSource: {result.url}\n\n" if result.title else ""
151
- content = header + result.markdown
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] Extracted content saved to {args.output} ({len(content)} chars)\n")
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 STEALTH_INJECTION_SCRIPT, get_random_user_agent, is_valid_url
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._async_crawler = AsyncPatchtroy(config)
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
- 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))
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
- 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))
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
- if sys.platform == "win32":
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
- if sys.platform == "win32":
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,
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ import sys
7
8
  from collections.abc import AsyncIterator
8
9
  from contextlib import asynccontextmanager
9
10
  from typing import Any
@@ -89,6 +90,9 @@ class BrowserContextPool:
89
90
  pass
90
91
  self._playwright = None
91
92
 
93
+ if sys.platform == "win32":
94
+ await asyncio.sleep(0.05)
95
+
92
96
  @asynccontextmanager
93
97
  async def acquire_context(
94
98
  self,
@@ -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.4",
93
+ version="0.4.5",
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.4",
102
+ "version": "0.4.5",
103
103
  "engine": "patchright",
104
104
  "active": crawler_instance is not None and crawler_instance._pool.is_running,
105
105
  }
@@ -0,0 +1,103 @@
1
+ """Utility functions, stealth injection scripts, and platform helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import sys
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ DEFAULT_USER_AGENTS = [
11
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
12
+ "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",
13
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
14
+ ]
15
+
16
+ STEALTH_INJECTION_SCRIPT = """
17
+ // Configure natural browser environment signatures
18
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
19
+ window.chrome = {
20
+ runtime: {},
21
+ loadTimes: function() {},
22
+ csi: function() {},
23
+ app: {}
24
+ };
25
+ // Emulate realistic plugins
26
+ Object.defineProperty(navigator, 'plugins', {
27
+ get: () => [1, 2, 3, 4, 5],
28
+ });
29
+ // Emulate standard languages
30
+ Object.defineProperty(navigator, 'languages', {
31
+ get: () => ['en-US', 'en'],
32
+ });
33
+ // Emulate permissions query
34
+ if (window.navigator && window.navigator.permissions) {
35
+ const originalQuery = window.navigator.permissions.query;
36
+ window.navigator.permissions.query = (parameters) => (
37
+ parameters.name === 'notifications' ?
38
+ Promise.resolve({ state: 'default' }) :
39
+ originalQuery(parameters)
40
+ );
41
+ }
42
+ """
43
+
44
+
45
+ def get_random_user_agent() -> str:
46
+ """Return a realistic modern desktop browser User-Agent."""
47
+ return random.choice(DEFAULT_USER_AGENTS)
48
+
49
+
50
+ def is_valid_url(url: str) -> bool:
51
+ """Validate URL scheme is http or https."""
52
+ try:
53
+ parsed = urlparse(url)
54
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
55
+ except Exception:
56
+ return False
57
+
58
+
59
+ def silence_windows_proactor_bug() -> None:
60
+ """Silence known Python Windows asyncio proactor pipe transport bug.
61
+
62
+ On Windows, when an asyncio subprocess transport is garbage collected after
63
+ loop shutdown, _ProactorBasePipeTransport.__del__ and BaseSubprocessTransport.__del__
64
+ call __repr__, which calls fileno() on a closed pipe handle and raises
65
+ ValueError: I/O operation on closed pipe. This wraps both destructors to cleanly
66
+ catch and ignore closed pipe errors during interpreter teardown.
67
+ """
68
+ if sys.platform != "win32":
69
+ return
70
+
71
+ try:
72
+ from asyncio.proactor_events import _ProactorBasePipeTransport
73
+
74
+ orig_pipe_del = getattr(_ProactorBasePipeTransport, "__del__", None)
75
+ if orig_pipe_del and not getattr(_ProactorBasePipeTransport, "_patchtroy_safe", False):
76
+
77
+ def _safe_pipe_del(self: Any, *args: Any, **kwargs: Any) -> None:
78
+ try:
79
+ orig_pipe_del(self, *args, **kwargs)
80
+ except (ValueError, OSError):
81
+ pass
82
+
83
+ _ProactorBasePipeTransport.__del__ = _safe_pipe_del # type: ignore[method-assign]
84
+ _ProactorBasePipeTransport._patchtroy_safe = True # type: ignore[attr-defined]
85
+ except Exception:
86
+ pass
87
+
88
+ try:
89
+ from asyncio.base_subprocess import BaseSubprocessTransport
90
+
91
+ orig_sub_del = getattr(BaseSubprocessTransport, "__del__", None)
92
+ if orig_sub_del and not getattr(BaseSubprocessTransport, "_patchtroy_safe", False):
93
+
94
+ def _safe_sub_del(self: Any, *args: Any, **kwargs: Any) -> None:
95
+ try:
96
+ orig_sub_del(self, *args, **kwargs)
97
+ except (ValueError, OSError):
98
+ pass
99
+
100
+ BaseSubprocessTransport.__del__ = _safe_sub_del # type: ignore[method-assign]
101
+ BaseSubprocessTransport._patchtroy_safe = True # type: ignore[attr-defined]
102
+ except Exception:
103
+ pass
@@ -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
+
@@ -17,7 +17,7 @@ async def test_health_check_endpoint():
17
17
  assert resp.status_code == 200
18
18
  data = resp.json()
19
19
  assert data["status"] == "healthy"
20
- assert data["version"] == "0.4.4"
20
+ assert data["version"] == "0.4.5"
21
21
  assert data["engine"] == "patchright"
22
22
 
23
23
 
@@ -900,7 +900,7 @@ wheels = [
900
900
 
901
901
  [[package]]
902
902
  name = "patchtroy"
903
- version = "0.4.4"
903
+ version = "0.4.5"
904
904
  source = { editable = "." }
905
905
  dependencies = [
906
906
  { name = "beautifulsoup4" },
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