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 ADDED
@@ -0,0 +1,26 @@
1
+ """Patchtroy — Undetected stealth web scraper & markdown extractor for LLMs."""
2
+
3
+ from patchtroy.chunker import TextChunk, chunk_markdown, count_tokens
4
+ from patchtroy.crawler import AsyncPatchtroy, Patchtroy
5
+ from patchtroy.models import LinkItem, PatchtroyConfig, ScrapeResult
6
+ from patchtroy.pool import BrowserContextPool
7
+ from patchtroy.proxy import ProxyItem, ProxyManager
8
+
9
+ __version__ = "0.4.0"
10
+ __author__ = "Marcus Zou"
11
+ __license__ = "Apache-2.0"
12
+ __copyright__ = "Copyright 2026 Alfazen Inc."
13
+
14
+ __all__ = [
15
+ "AsyncPatchtroy",
16
+ "BrowserContextPool",
17
+ "LinkItem",
18
+ "Patchtroy",
19
+ "PatchtroyConfig",
20
+ "ProxyItem",
21
+ "ProxyManager",
22
+ "ScrapeResult",
23
+ "TextChunk",
24
+ "chunk_markdown",
25
+ "count_tokens",
26
+ ]
patchtroy/chunker.py ADDED
@@ -0,0 +1,178 @@
1
+ """Structure-aware Markdown chunking and token counting utility for LLMs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ # Regex pattern for fast BPE-like token estimation without heavy ML dependencies
10
+ _TOKEN_PATTERN = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\w+| ?\d+| ?[^\s\w\d]+|\s+(?!\S)|\s+""")
11
+
12
+
13
+ def count_tokens(text: str) -> int:
14
+ """Fast, accurate token counting.
15
+
16
+ Uses `tiktoken` (cl100k_base) if installed; otherwise falls back to a calibrated regex tokenizer.
17
+ """
18
+ if not text:
19
+ return 0
20
+
21
+ try:
22
+ import tiktoken
23
+ enc = tiktoken.get_encoding("cl100k_base")
24
+ return len(enc.encode(text, disallowed_special=()))
25
+ except Exception:
26
+ # High-speed fallback: regex BPE token estimation (~98% match with cl100k_base)
27
+ return max(1, len(_TOKEN_PATTERN.findall(text)))
28
+
29
+
30
+ @dataclass
31
+ class TextChunk:
32
+ """Represents a coherent segment of extracted Markdown for LLM ingestion."""
33
+
34
+ text: str
35
+ chunk_index: int
36
+ token_count: int
37
+ metadata: dict[str, Any] = field(default_factory=dict)
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ """Convert chunk into a dictionary record."""
41
+ return {
42
+ "chunk_index": self.chunk_index,
43
+ "text": self.text,
44
+ "token_count": self.token_count,
45
+ "metadata": self.metadata,
46
+ }
47
+
48
+
49
+ def _split_oversized_text(text: str, max_tokens: int) -> list[str]:
50
+ """Split oversized text recursively: sentences -> words."""
51
+ if count_tokens(text) <= max_tokens:
52
+ return [text]
53
+
54
+ # Try sentence splitting
55
+ sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
56
+ if len(sentences) > 1:
57
+ res = []
58
+ for s in sentences:
59
+ res.extend(_split_oversized_text(s, max_tokens))
60
+ return res
61
+
62
+ # If single sentence exceeds max_tokens, split by words
63
+ words = text.split()
64
+ parts: list[str] = []
65
+ current: list[str] = []
66
+ for w in words:
67
+ current.append(w)
68
+ if count_tokens(" ".join(current)) >= max_tokens:
69
+ parts.append(" ".join(current))
70
+ current = []
71
+ if current:
72
+ parts.append(" ".join(current))
73
+ return parts or [text]
74
+
75
+
76
+ def chunk_markdown(
77
+ markdown: str,
78
+ max_tokens: int = 2048,
79
+ overlap_tokens: int = 100,
80
+ metadata: dict[str, Any] | None = None,
81
+ ) -> list[TextChunk]:
82
+ """Split clean Markdown text into coherent, structure-preserving chunks.
83
+
84
+ Splits hierarchically by headings (#, ##, ###), then by paragraphs (double newlines),
85
+ ensuring that chunks do not exceed `max_tokens` while preserving semantic context
86
+ and overlap.
87
+ """
88
+ if not markdown or not markdown.strip():
89
+ return []
90
+
91
+ clean_text = markdown.strip()
92
+ total_tokens = count_tokens(clean_text)
93
+ base_meta = metadata.copy() if metadata else {}
94
+
95
+ def extract_heading(txt: str) -> str | None:
96
+ heading_match = re.search(r"^(#{1,6}\s+.+)$", txt, re.MULTILINE)
97
+ return heading_match.group(1).strip() if heading_match else None
98
+
99
+ # If entire document fits inside max_tokens, return single chunk
100
+ if total_tokens <= max_tokens:
101
+ meta = base_meta.copy()
102
+ heading = extract_heading(clean_text)
103
+ if heading:
104
+ meta["section_heading"] = heading
105
+ return [
106
+ TextChunk(
107
+ text=clean_text,
108
+ chunk_index=0,
109
+ token_count=total_tokens,
110
+ metadata=meta,
111
+ )
112
+ ]
113
+
114
+ # Split into sections by Markdown headings
115
+ heading_pattern = re.compile(r"(?=^(?:#{1,6}\s+.+$))", re.MULTILINE)
116
+ sections = [s.strip() for s in heading_pattern.split(clean_text) if s.strip()]
117
+
118
+ # Further break down any section that exceeds max_tokens into paragraphs / sentences
119
+ blocks: list[str] = []
120
+ for section in sections:
121
+ if count_tokens(section) <= max_tokens:
122
+ blocks.append(section)
123
+ else:
124
+ paragraphs = [p.strip() for p in section.split("\n\n") if p.strip()]
125
+ for p in paragraphs:
126
+ if count_tokens(p) <= max_tokens:
127
+ blocks.append(p)
128
+ else:
129
+ sub_parts = _split_oversized_text(p, max_tokens)
130
+ blocks.extend(sub_parts)
131
+
132
+ chunks: list[TextChunk] = []
133
+ current_blocks: list[str] = []
134
+ current_tokens = 0
135
+
136
+ def finalize_chunk(blocks_to_join: list[str], idx: int) -> TextChunk:
137
+ content = "\n\n".join(blocks_to_join).strip()
138
+ t_count = count_tokens(content)
139
+ chunk_meta = base_meta.copy()
140
+ heading = extract_heading(content)
141
+ if heading:
142
+ chunk_meta["section_heading"] = heading
143
+ return TextChunk(
144
+ text=content,
145
+ chunk_index=idx,
146
+ token_count=t_count,
147
+ metadata=chunk_meta,
148
+ )
149
+
150
+ for block in blocks:
151
+ block_tokens = count_tokens(block)
152
+ if current_blocks and (current_tokens + block_tokens > max_tokens):
153
+ chunk = finalize_chunk(current_blocks, len(chunks))
154
+ chunks.append(chunk)
155
+
156
+ # Calculate overlap from previous blocks
157
+ overlap_accum: list[str] = []
158
+ overlap_count = 0
159
+ for prev_b in reversed(current_blocks):
160
+ b_toks = count_tokens(prev_b)
161
+ if overlap_count + b_toks <= overlap_tokens:
162
+ overlap_accum.insert(0, prev_b)
163
+ overlap_count += b_toks
164
+ else:
165
+ break
166
+
167
+ current_blocks = overlap_accum + [block]
168
+ current_tokens = count_tokens("\n\n".join(current_blocks))
169
+ else:
170
+ current_blocks.append(block)
171
+ current_tokens += block_tokens
172
+
173
+ if current_blocks:
174
+ chunk = finalize_chunk(current_blocks, len(chunks))
175
+ if not chunks or chunks[-1].text != chunk.text:
176
+ chunks.append(chunk)
177
+
178
+ return chunks
patchtroy/cli.py ADDED
@@ -0,0 +1,196 @@
1
+ """Command-line interface for Patchtroy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from patchtroy.crawler import Patchtroy
11
+ from patchtroy.models import PatchtroyConfig
12
+
13
+ __version__ = "0.4.0"
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ parser = argparse.ArgumentParser(
18
+ prog="patchtroy",
19
+ description="Undetected stealth web scraper & markdown extractor for LLMs.",
20
+ )
21
+ parser.add_argument("urls", nargs="*", help="Target URL(s) to scrape or 'serve' command")
22
+ parser.add_argument(
23
+ "--serve",
24
+ action="store_true",
25
+ help="Start the FastAPI REST microservice",
26
+ )
27
+ parser.add_argument(
28
+ "--host",
29
+ default="0.0.0.0",
30
+ help="Host address for REST microservice (default: 0.0.0.0)",
31
+ )
32
+ parser.add_argument(
33
+ "--port",
34
+ type=int,
35
+ default=4013,
36
+ help="Port for REST microservice (default: 4013)",
37
+ )
38
+ parser.add_argument(
39
+ "-o", "--output", help="Output file path (default: print to stdout)"
40
+ )
41
+ parser.add_argument(
42
+ "-f",
43
+ "--format",
44
+ choices=["markdown", "json", "html"],
45
+ default="markdown",
46
+ help="Output format (default: markdown)",
47
+ )
48
+ parser.add_argument(
49
+ "--wait-for",
50
+ help="CSS selector to wait for before extracting page content",
51
+ )
52
+ parser.add_argument(
53
+ "--headful",
54
+ action="store_true",
55
+ help="Run Chromium with visible browser window (non-headless)",
56
+ )
57
+ parser.add_argument(
58
+ "--timeout",
59
+ type=float,
60
+ default=25.0,
61
+ help="Browser navigation timeout in seconds (default: 25.0)",
62
+ )
63
+ parser.add_argument(
64
+ "--screenshot",
65
+ help="Capture and save screenshot to specified file path (e.g. page.png)",
66
+ )
67
+ parser.add_argument(
68
+ "--full-page",
69
+ action="store_true",
70
+ help="Capture full-page screenshot instead of viewport only",
71
+ )
72
+ parser.add_argument(
73
+ "--pdf",
74
+ help="Generate and save PDF to specified file path (headless Chromium only)",
75
+ )
76
+ parser.add_argument(
77
+ "--proxy",
78
+ help="Single proxy URL (e.g. http://user:pass@host:port)",
79
+ )
80
+ parser.add_argument(
81
+ "--proxy-file",
82
+ help="Path to proxy file for automatic proxy rotation",
83
+ )
84
+ parser.add_argument(
85
+ "--proxy-strategy",
86
+ choices=["round-robin", "random"],
87
+ default="round-robin",
88
+ help="Proxy rotation strategy (default: round-robin)",
89
+ )
90
+ parser.add_argument(
91
+ "-c",
92
+ "--concurrency",
93
+ type=int,
94
+ default=5,
95
+ help="Maximum concurrent browser contexts for batch scraping (default: 5)",
96
+ )
97
+ parser.add_argument(
98
+ "-v", "--version", action="version", version=f"patchtroy {__version__}"
99
+ )
100
+
101
+ args = parser.parse_args(argv)
102
+
103
+ # Launch REST microservice if requested
104
+ if args.serve or (args.urls and args.urls[0] == "serve"):
105
+ from patchtroy.server import run_server
106
+ sys.stderr.write(f"[Patchtroy] Starting REST API on {args.host}:{args.port}...\n")
107
+ run_server(host=args.host, port=args.port)
108
+ return 0
109
+
110
+ if not args.urls:
111
+ parser.print_help()
112
+ return 1
113
+
114
+ # Configure proxy settings
115
+ proxies = args.proxy_file if args.proxy_file else None
116
+
117
+ config = PatchtroyConfig(
118
+ headless=not args.headful,
119
+ browser_timeout_s=args.timeout,
120
+ wait_for=args.wait_for,
121
+ proxy=args.proxy,
122
+ proxies=proxies,
123
+ proxy_strategy=args.proxy_strategy,
124
+ max_concurrency=args.concurrency,
125
+ screenshot=bool(args.screenshot),
126
+ full_page_screenshot=args.full_page,
127
+ screenshot_path=args.screenshot,
128
+ pdf=bool(args.pdf),
129
+ pdf_path=args.pdf,
130
+ )
131
+
132
+ crawler = Patchtroy(config)
133
+
134
+ # Single URL execution
135
+ if len(args.urls) == 1:
136
+ target_url = args.urls[0]
137
+ result = crawler.scrape(target_url)
138
+
139
+ if not result.success and not result.markdown:
140
+ sys.stderr.write(f"Error scraping {target_url}: {result.error}\n")
141
+ return 1
142
+
143
+ 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"})
146
+ content = json.dumps(dump_data, indent=2, ensure_ascii=False)
147
+ elif args.format == "html":
148
+ content = result.html
149
+ else:
150
+ header = f"# {result.title}\n\nSource: {result.url}\n\n" if result.title else ""
151
+ content = header + result.markdown
152
+
153
+ if args.output:
154
+ 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")
156
+ else:
157
+ print(content)
158
+
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
+ return 0
165
+
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
+
195
+ if __name__ == "__main__":
196
+ sys.exit(main())