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/__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())
|