bytecrawl 1.0.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.
- bytecrawl/__init__.py +32 -0
- bytecrawl/core.py +386 -0
- bytecrawl/crawler.py +355 -0
- bytecrawl/mcp_http.py +194 -0
- bytecrawl/mcp_server.py +112 -0
- bytecrawl-1.0.0.dist-info/METADATA +168 -0
- bytecrawl-1.0.0.dist-info/RECORD +11 -0
- bytecrawl-1.0.0.dist-info/WHEEL +5 -0
- bytecrawl-1.0.0.dist-info/entry_points.txt +3 -0
- bytecrawl-1.0.0.dist-info/licenses/LICENSE +21 -0
- bytecrawl-1.0.0.dist-info/top_level.txt +1 -0
bytecrawl/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ByteCrawl — Universal scraping layer
|
|
3
|
+
===================================
|
|
4
|
+
A single API to scrape any site: static HTML, dynamic JS, hidden APIs,
|
|
5
|
+
session login, crawling at scale and Markdown conversion for LLMs.
|
|
6
|
+
|
|
7
|
+
Quickstart:
|
|
8
|
+
|
|
9
|
+
from bytecrawl import Scraper
|
|
10
|
+
|
|
11
|
+
bot = Scraper()
|
|
12
|
+
page = bot.fetch("https://books.toscrape.com")
|
|
13
|
+
books = page.extract("article.product_pod", {
|
|
14
|
+
"title": "h3 a::attr(title)",
|
|
15
|
+
"price": "p.price_color::text",
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
Explicit strategies: bot.static(url) · bot.api(url) · bot.browser(url)
|
|
19
|
+
Crawling: bot.crawl(url, item=..., fields=..., next_page=...)
|
|
20
|
+
Graph crawling: SharkSearch(query=...).crawl(url) · BFS · OPIC
|
|
21
|
+
LLM: page.markdown() · page.tokens()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .core import Page, Scraper, Session
|
|
25
|
+
from .crawler import BFS, OPIC, Crawler, CrawlResult, SharkSearch, cosine, pagerank
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Scraper", "Page", "Session",
|
|
29
|
+
"Crawler", "BFS", "SharkSearch", "OPIC", "CrawlResult",
|
|
30
|
+
"pagerank", "cosine",
|
|
31
|
+
]
|
|
32
|
+
__version__ = "1.0.0"
|
bytecrawl/core.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ByteCrawl library core.
|
|
3
|
+
|
|
4
|
+
Design: a Scraper object that knows how to fetch a page with the strategy you
|
|
5
|
+
want (or the best one automatically) and gives you back a Page object from
|
|
6
|
+
which you extract data with simple CSS selectors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
from bs4 import BeautifulSoup
|
|
18
|
+
|
|
19
|
+
DEFAULT_UA = (
|
|
20
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
21
|
+
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def _decoded_html(r: requests.Response) -> str:
|
|
25
|
+
"""Response text with the correct encoding.
|
|
26
|
+
|
|
27
|
+
requests falls back to ISO-8859-1 when the header has no charset
|
|
28
|
+
(RFC 2616), which breaks UTF-8 (£ -> £). When that happens, use
|
|
29
|
+
content-based detection instead.
|
|
30
|
+
"""
|
|
31
|
+
if r.encoding is None or r.encoding.lower() == "iso-8859-1":
|
|
32
|
+
r.encoding = r.apparent_encoding
|
|
33
|
+
return r.text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _split_selector(spec: str) -> tuple[str, str, str | None]:
|
|
37
|
+
"""Returns (css_selector, operation, argument).
|
|
38
|
+
|
|
39
|
+
operation: "text" (default) or "attr". argument: the attribute name.
|
|
40
|
+
Examples:
|
|
41
|
+
"p.price_color::text" -> ("p.price_color", "text", None)
|
|
42
|
+
"h3 a::attr(title)" -> ("h3 a", "attr", "title")
|
|
43
|
+
"div.quote" -> ("div.quote", "text", None)
|
|
44
|
+
"""
|
|
45
|
+
spec = spec.strip()
|
|
46
|
+
if "::attr(" in spec:
|
|
47
|
+
sel, rest = spec.split("::attr(", 1)
|
|
48
|
+
return sel.strip(), "attr", rest.rstrip(")").strip()
|
|
49
|
+
if spec.endswith("::text"):
|
|
50
|
+
return spec[: -len("::text")].strip(), "text", None
|
|
51
|
+
return spec, "text", None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _value_from(node, op: str, arg: str | None) -> str | None:
|
|
55
|
+
if node is None:
|
|
56
|
+
return None
|
|
57
|
+
if op == "attr":
|
|
58
|
+
return node.get(arg)
|
|
59
|
+
return node.get_text(strip=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Page:
|
|
64
|
+
"""An already-downloaded page. Extract data from it."""
|
|
65
|
+
|
|
66
|
+
url: str
|
|
67
|
+
html: str = ""
|
|
68
|
+
data: Any = None # JSON if it came from an API
|
|
69
|
+
method: str = "static" # static | api | browser
|
|
70
|
+
elapsed: float = 0.0
|
|
71
|
+
status: int = 200
|
|
72
|
+
_soup: BeautifulSoup | None = field(default=None, repr=False)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def soup(self) -> BeautifulSoup:
|
|
76
|
+
if self._soup is None:
|
|
77
|
+
self._soup = BeautifulSoup(self.html or "", "lxml")
|
|
78
|
+
return self._soup
|
|
79
|
+
|
|
80
|
+
# --- extraction ---------------------------------------------------------
|
|
81
|
+
def css(self, spec: str) -> str | None:
|
|
82
|
+
"""First match of a selector. Supports ::text and ::attr(name)."""
|
|
83
|
+
sel, op, arg = _split_selector(spec)
|
|
84
|
+
return _value_from(self.soup.select_one(sel), op, arg)
|
|
85
|
+
|
|
86
|
+
def css_all(self, spec: str) -> list[str]:
|
|
87
|
+
"""All matches of a selector."""
|
|
88
|
+
sel, op, arg = _split_selector(spec)
|
|
89
|
+
return [v for n in self.soup.select(sel) if (v := _value_from(n, op, arg)) is not None]
|
|
90
|
+
|
|
91
|
+
def extract(self, item: str, fields: dict[str, str]) -> list[dict]:
|
|
92
|
+
"""Extracts a list of records.
|
|
93
|
+
|
|
94
|
+
item: CSS selector delimiting each record (e.g. "article.product_pod").
|
|
95
|
+
fields: dict {name: relative_selector}. The selector is applied INSIDE
|
|
96
|
+
each item. If a field name ends in "[]" it returns a list of values.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
page.extract("div.quote", {
|
|
100
|
+
"quote": "span.text::text",
|
|
101
|
+
"author": "small.author::text",
|
|
102
|
+
"tags[]": "a.tag::text",
|
|
103
|
+
})
|
|
104
|
+
"""
|
|
105
|
+
records = []
|
|
106
|
+
for node in self.soup.select(item):
|
|
107
|
+
row = {}
|
|
108
|
+
for name, spec in fields.items():
|
|
109
|
+
sel, op, arg = _split_selector(spec)
|
|
110
|
+
if name.endswith("[]"):
|
|
111
|
+
row[name[:-2]] = [
|
|
112
|
+
v for n in node.select(sel) if (v := _value_from(n, op, arg)) is not None
|
|
113
|
+
]
|
|
114
|
+
else:
|
|
115
|
+
row[name] = _value_from(node.select_one(sel), op, arg)
|
|
116
|
+
records.append(row)
|
|
117
|
+
return records
|
|
118
|
+
|
|
119
|
+
def links(self) -> list[str]:
|
|
120
|
+
return [a["href"] for a in self.soup.select("a[href]")]
|
|
121
|
+
|
|
122
|
+
def json(self) -> Any:
|
|
123
|
+
return self.data
|
|
124
|
+
|
|
125
|
+
# --- LLM ----------------------------------------------------------------
|
|
126
|
+
def markdown(self, main_only: bool = True) -> str:
|
|
127
|
+
"""Converts the page to clean Markdown (saves tokens for LLMs)."""
|
|
128
|
+
if main_only:
|
|
129
|
+
try:
|
|
130
|
+
import trafilatura
|
|
131
|
+
|
|
132
|
+
# include_links keeps link boundaries; without it adjacent
|
|
133
|
+
# nodes glue together ("Visit siteSierra")
|
|
134
|
+
md = trafilatura.extract(self.html, output_format="markdown", include_links=True)
|
|
135
|
+
if md:
|
|
136
|
+
return _clean_markdown(md)
|
|
137
|
+
except ImportError:
|
|
138
|
+
pass
|
|
139
|
+
try:
|
|
140
|
+
from markdownify import markdownify
|
|
141
|
+
except ImportError as e:
|
|
142
|
+
raise ImportError(
|
|
143
|
+
"Page.markdown() needs the 'llm' extra: pip install bytecrawl[llm]"
|
|
144
|
+
) from e
|
|
145
|
+
|
|
146
|
+
return _clean_markdown(markdownify(self.html, strip=["script", "style"]))
|
|
147
|
+
|
|
148
|
+
def tokens(self, of: str | None = None) -> int:
|
|
149
|
+
"""Quick token estimate (~4 chars/token)."""
|
|
150
|
+
text = of if of is not None else (self.html or "")
|
|
151
|
+
return len(text) // 4
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _clean_markdown(md: str) -> str:
|
|
155
|
+
"""Removes conversion noise from marketing/SPA pages.
|
|
156
|
+
|
|
157
|
+
Carousels and marquees duplicate their content in the DOM to loop, so the
|
|
158
|
+
same block appears 2-3 times; animated counters leave their initial state.
|
|
159
|
+
Dedupe repeated blocks and merge headings split across lines.
|
|
160
|
+
"""
|
|
161
|
+
blocks = re.split(r"\n{2,}", md)
|
|
162
|
+
seen: set[str] = set()
|
|
163
|
+
cleaned: list[str] = []
|
|
164
|
+
prev_key = ""
|
|
165
|
+
for block in blocks:
|
|
166
|
+
# Headings broken inside the block: "## Start scraping\n today"
|
|
167
|
+
if block.lstrip().startswith("#") and "\n" in block:
|
|
168
|
+
lines = block.split("\n")
|
|
169
|
+
joined = [lines[0].rstrip()]
|
|
170
|
+
for ln in lines[1:]:
|
|
171
|
+
s = ln.strip()
|
|
172
|
+
continues = s[:1].islower() or joined[-1].rstrip().endswith(("'", ","))
|
|
173
|
+
if s and len(s) < 60 and continues:
|
|
174
|
+
joined[-1] = joined[-1] + " " + s
|
|
175
|
+
else:
|
|
176
|
+
joined.append(ln)
|
|
177
|
+
block = "\n".join(joined)
|
|
178
|
+
key = " ".join(block.split())
|
|
179
|
+
if not key:
|
|
180
|
+
continue
|
|
181
|
+
# Consecutive duplicates (looping carousels repeat short labels too)
|
|
182
|
+
if key == prev_key:
|
|
183
|
+
continue
|
|
184
|
+
# Carousel/marquee duplicates: same long block seen before
|
|
185
|
+
if len(key) >= 40 and key in seen:
|
|
186
|
+
continue
|
|
187
|
+
seen.add(key)
|
|
188
|
+
prev_key = key
|
|
189
|
+
cleaned.append(block.rstrip())
|
|
190
|
+
|
|
191
|
+
# Text split by animated spans: "# Power AI agents with" + "clean web data"
|
|
192
|
+
merged: list[str] = []
|
|
193
|
+
for block in cleaned:
|
|
194
|
+
prev = merged[-1] if merged else ""
|
|
195
|
+
frag = block.strip()
|
|
196
|
+
joinable = (
|
|
197
|
+
prev
|
|
198
|
+
and not prev.startswith(("```", "-", "*", ">", "|"))
|
|
199
|
+
and "\n" not in prev
|
|
200
|
+
and "\n" not in frag
|
|
201
|
+
and not frag.startswith(("#", "```", "-", "*", ">", "|", "["))
|
|
202
|
+
)
|
|
203
|
+
visible_len = len(re.sub(r"\]\([^)]*\)", "]", frag)) # ignore link URLs
|
|
204
|
+
lower_continuation = (
|
|
205
|
+
visible_len < 70
|
|
206
|
+
and frag[:1].islower()
|
|
207
|
+
and not prev.rstrip().endswith((".", "!", "?", ":", "`"))
|
|
208
|
+
)
|
|
209
|
+
# Headings cut mid-phrase keep capitalization: "## Easily connect with your" + "AI agents"
|
|
210
|
+
heading_continuation = (
|
|
211
|
+
re.match(r"#{1,6} ", prev) is not None
|
|
212
|
+
and len(frag) < 30
|
|
213
|
+
and not frag.rstrip().endswith((".", "!", "?", ":"))
|
|
214
|
+
and not prev.rstrip().endswith((".", "!", "?", ":", "`"))
|
|
215
|
+
)
|
|
216
|
+
if joinable and (lower_continuation or heading_continuation):
|
|
217
|
+
merged[-1] = prev.rstrip() + " " + frag
|
|
218
|
+
else:
|
|
219
|
+
merged.append(block)
|
|
220
|
+
|
|
221
|
+
# Inline-span joins lose the space: "at scale.It's also open source"
|
|
222
|
+
out = []
|
|
223
|
+
for block in merged:
|
|
224
|
+
if "```" not in block:
|
|
225
|
+
block = re.sub(r"(?<=[a-z])([.!?])(?=[A-Z\[])", r"\1 ", block)
|
|
226
|
+
out.append(block)
|
|
227
|
+
return "\n\n".join(out).strip()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Session:
|
|
231
|
+
"""Reusable authenticated session (cookies + headers persist)."""
|
|
232
|
+
|
|
233
|
+
def __init__(self, scraper: Scraper):
|
|
234
|
+
self._s = requests.Session()
|
|
235
|
+
self._s.headers.update({"User-Agent": scraper.user_agent})
|
|
236
|
+
self._scraper = scraper
|
|
237
|
+
|
|
238
|
+
def login(self, url: str, data: dict, csrf_field: str | None = None) -> Session:
|
|
239
|
+
"""Logs in. If csrf_field is given, reads it from the form first."""
|
|
240
|
+
if csrf_field:
|
|
241
|
+
r = self._s.get(url, timeout=self._scraper.timeout)
|
|
242
|
+
r.raise_for_status()
|
|
243
|
+
form = BeautifulSoup(_decoded_html(r), "lxml")
|
|
244
|
+
token = form.select_one(f'input[name="{csrf_field}"]')
|
|
245
|
+
if token:
|
|
246
|
+
data = {**data, csrf_field: token.get("value", "")}
|
|
247
|
+
r = self._s.post(url, data=data, timeout=self._scraper.timeout)
|
|
248
|
+
r.raise_for_status()
|
|
249
|
+
return self
|
|
250
|
+
|
|
251
|
+
def bearer(self, token: str) -> Session:
|
|
252
|
+
self._s.headers["Authorization"] = f"Bearer {token}"
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
def fetch(self, url: str) -> Page:
|
|
256
|
+
t0 = time.perf_counter()
|
|
257
|
+
r = self._s.get(url, timeout=self._scraper.timeout)
|
|
258
|
+
r.raise_for_status()
|
|
259
|
+
return Page(url=url, html=_decoded_html(r), method="static",
|
|
260
|
+
elapsed=round(time.perf_counter() - t0, 3), status=r.status_code)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class Scraper:
|
|
264
|
+
"""Entry point. Pick a strategy or let it decide on its own."""
|
|
265
|
+
|
|
266
|
+
def __init__(self, user_agent: str = DEFAULT_UA, delay: float = 0.0, timeout: int = 15):
|
|
267
|
+
self.user_agent = user_agent
|
|
268
|
+
self.delay = delay
|
|
269
|
+
self.timeout = timeout
|
|
270
|
+
self._session = requests.Session()
|
|
271
|
+
self._session.headers.update({"User-Agent": user_agent})
|
|
272
|
+
|
|
273
|
+
# --- strategies -----------------------------------------------------------
|
|
274
|
+
def static(self, url: str) -> Page:
|
|
275
|
+
"""Technique 1: static HTML with requests."""
|
|
276
|
+
t0 = time.perf_counter()
|
|
277
|
+
r = self._session.get(url, timeout=self.timeout)
|
|
278
|
+
r.raise_for_status()
|
|
279
|
+
self._wait()
|
|
280
|
+
return Page(url=url, html=_decoded_html(r), method="static",
|
|
281
|
+
elapsed=round(time.perf_counter() - t0, 3), status=r.status_code)
|
|
282
|
+
|
|
283
|
+
def api(self, url: str, params: dict | None = None) -> Page:
|
|
284
|
+
"""Technique 3: requests an API and stores the JSON."""
|
|
285
|
+
t0 = time.perf_counter()
|
|
286
|
+
r = self._session.get(url, params=params, timeout=self.timeout)
|
|
287
|
+
r.raise_for_status()
|
|
288
|
+
self._wait()
|
|
289
|
+
try:
|
|
290
|
+
data = r.json()
|
|
291
|
+
except ValueError as e:
|
|
292
|
+
ctype = r.headers.get("content-type", "unknown")
|
|
293
|
+
raise ValueError(f"{url} did not return JSON (content-type: {ctype})") from e
|
|
294
|
+
return Page(url=url, data=data, method="api",
|
|
295
|
+
elapsed=round(time.perf_counter() - t0, 3), status=r.status_code)
|
|
296
|
+
|
|
297
|
+
def browser(self, url: str, wait: str | None = None, scroll: bool = False) -> Page:
|
|
298
|
+
"""Technique 2: real browser (Playwright) for JS-rendered sites."""
|
|
299
|
+
try:
|
|
300
|
+
from playwright.sync_api import sync_playwright
|
|
301
|
+
except ImportError as e:
|
|
302
|
+
raise ImportError(
|
|
303
|
+
"Scraper.browser() needs the 'browser' extra: "
|
|
304
|
+
"pip install bytecrawl[browser] && playwright install chromium"
|
|
305
|
+
) from e
|
|
306
|
+
|
|
307
|
+
t0 = time.perf_counter()
|
|
308
|
+
with sync_playwright() as p:
|
|
309
|
+
nav = p.chromium.launch(headless=True)
|
|
310
|
+
pg = nav.new_page()
|
|
311
|
+
resp = pg.goto(url, wait_until="networkidle")
|
|
312
|
+
if wait:
|
|
313
|
+
pg.wait_for_selector(wait)
|
|
314
|
+
if scroll:
|
|
315
|
+
pg.mouse.wheel(0, 100000)
|
|
316
|
+
pg.wait_for_timeout(500)
|
|
317
|
+
html = pg.content()
|
|
318
|
+
status = resp.status if resp else 0
|
|
319
|
+
nav.close()
|
|
320
|
+
self._wait()
|
|
321
|
+
return Page(url=url, html=html, method="browser",
|
|
322
|
+
elapsed=round(time.perf_counter() - t0, 3), status=status)
|
|
323
|
+
|
|
324
|
+
def fetch(self, url: str, strategy: str = "auto") -> Page:
|
|
325
|
+
"""Fetches the page. strategy: auto | static | browser.
|
|
326
|
+
|
|
327
|
+
'auto' downloads statically and, if the page looks empty (typical of
|
|
328
|
+
SPAs that render with JS), retries with a browser.
|
|
329
|
+
"""
|
|
330
|
+
if strategy == "static":
|
|
331
|
+
return self.static(url)
|
|
332
|
+
if strategy == "browser":
|
|
333
|
+
return self.browser(url)
|
|
334
|
+
# auto
|
|
335
|
+
page = self.static(url)
|
|
336
|
+
text = page.soup.get_text(strip=True)
|
|
337
|
+
if len(text) < 200: # heuristic: almost no content -> probably JS
|
|
338
|
+
return self.browser(url)
|
|
339
|
+
return page
|
|
340
|
+
|
|
341
|
+
# --- crawling -----------------------------------------------------------
|
|
342
|
+
def crawl(
|
|
343
|
+
self,
|
|
344
|
+
start: str,
|
|
345
|
+
item: str,
|
|
346
|
+
fields: dict[str, str],
|
|
347
|
+
next_page: str | None = None,
|
|
348
|
+
pages: int | None = None,
|
|
349
|
+
base: str | None = None,
|
|
350
|
+
) -> list[dict]:
|
|
351
|
+
"""Walks multiple pages following the next-page link and extracts 'fields'.
|
|
352
|
+
|
|
353
|
+
start: initial URL.
|
|
354
|
+
item: selector for each record.
|
|
355
|
+
fields: fields to extract (see Page.extract).
|
|
356
|
+
next_page: selector for the next-page link (e.g. "li.next a::attr(href)").
|
|
357
|
+
pages: optional page limit.
|
|
358
|
+
base: prefix for relative URLs (otherwise inferred from the host).
|
|
359
|
+
"""
|
|
360
|
+
from urllib.parse import urljoin
|
|
361
|
+
|
|
362
|
+
url = start
|
|
363
|
+
results: list[dict] = []
|
|
364
|
+
n = 0
|
|
365
|
+
while url:
|
|
366
|
+
page = self.static(url)
|
|
367
|
+
results.extend(page.extract(item, fields))
|
|
368
|
+
n += 1
|
|
369
|
+
if pages and n >= pages:
|
|
370
|
+
break
|
|
371
|
+
if not next_page:
|
|
372
|
+
break
|
|
373
|
+
sel, op, arg = _split_selector(next_page)
|
|
374
|
+
node = page.soup.select_one(sel)
|
|
375
|
+
op = op if op != "text" else "attr"
|
|
376
|
+
href = _value_from(node, op, arg or "href") if node else None
|
|
377
|
+
url = urljoin(base or url, href) if href else None
|
|
378
|
+
return results
|
|
379
|
+
|
|
380
|
+
# --- session --------------------------------------------------------------
|
|
381
|
+
def session(self) -> Session:
|
|
382
|
+
return Session(self)
|
|
383
|
+
|
|
384
|
+
def _wait(self):
|
|
385
|
+
if self.delay:
|
|
386
|
+
time.sleep(self.delay)
|
bytecrawl/crawler.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Graph crawlers for ByteCrawl: BFS, Shark-Search and OPIC.
|
|
3
|
+
|
|
4
|
+
The web is a graph (pages = nodes, links = edges). A crawler decides in
|
|
5
|
+
what ORDER to visit URLs with a limited request budget. Each strategy is
|
|
6
|
+
a different answer to that question:
|
|
7
|
+
|
|
8
|
+
BFS — level by level: closest to the seed first.
|
|
9
|
+
Shark-Search — topical best-first: chases pages relevant to a query
|
|
10
|
+
(Hersovici et al., 1998). Links inherit score from their
|
|
11
|
+
parent with decay; bad branches die out on their own.
|
|
12
|
+
OPIC — On-line Page Importance Computation (Abiteboul et al., 2003):
|
|
13
|
+
each page holds "cash" that it distributes to its links when
|
|
14
|
+
visited. It is PageRank computed live, without the full graph.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
|
|
18
|
+
from bytecrawl.crawler import BFS, SharkSearch, OPIC, pagerank
|
|
19
|
+
|
|
20
|
+
result = SharkSearch(query="machine learning").crawl(
|
|
21
|
+
"https://example.com", max_pages=100)
|
|
22
|
+
result.pages # visited, in order
|
|
23
|
+
result.graph # {url: [links]} for offline analysis
|
|
24
|
+
pagerank(result.graph)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import heapq
|
|
30
|
+
import math
|
|
31
|
+
import re
|
|
32
|
+
import time
|
|
33
|
+
from collections import Counter
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from urllib.parse import urldefrag, urljoin, urlparse
|
|
36
|
+
|
|
37
|
+
from .core import Scraper
|
|
38
|
+
|
|
39
|
+
# Non-HTML extensions: not worth spending a request on them.
|
|
40
|
+
_SKIP_EXT = re.compile(
|
|
41
|
+
r"\.(png|jpe?g|gif|svg|webp|ico|css|js|pdf|zip|gz|tar|mp[34]|avi|mov|woff2?|ttf|xml|rss)$",
|
|
42
|
+
re.IGNORECASE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_WORD = re.compile(r"[a-záéíóúüñ0-9]+", re.IGNORECASE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _tokens(text: str) -> list[str]:
|
|
49
|
+
return [w.lower() for w in _WORD.findall(text or "")]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cosine(text_a: str, text_b: str) -> float:
|
|
53
|
+
"""Cosine similarity between two texts using term-frequency (TF) vectors.
|
|
54
|
+
|
|
55
|
+
The classic information-retrieval metric: 1.0 = same vocabulary in the
|
|
56
|
+
same proportions, 0.0 = not a single word in common.
|
|
57
|
+
"""
|
|
58
|
+
a, b = Counter(_tokens(text_a)), Counter(_tokens(text_b))
|
|
59
|
+
if not a or not b:
|
|
60
|
+
return 0.0
|
|
61
|
+
common = set(a) & set(b)
|
|
62
|
+
dot = sum(a[w] * b[w] for w in common)
|
|
63
|
+
norm = math.sqrt(sum(v * v for v in a.values())) * math.sqrt(sum(v * v for v in b.values()))
|
|
64
|
+
return dot / norm if norm else 0.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Common second-level suffixes (bbc.co.uk → "bbc.co.uk", not "co.uk").
|
|
68
|
+
# A frozen shortlist instead of the full public-suffix list keeps us dependency-free.
|
|
69
|
+
_SECOND_LEVEL = frozenset({"co", "com", "org", "net", "ac", "gov", "edu"})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _root_domain(netloc: str) -> str:
|
|
73
|
+
"""firecrawl.dev, www.firecrawl.dev and docs.firecrawl.dev are the same site."""
|
|
74
|
+
labels = netloc.lower().split(":")[0].split(".")
|
|
75
|
+
take = 3 if len(labels) >= 3 and labels[-2] in _SECOND_LEVEL else 2
|
|
76
|
+
return ".".join(labels[-take:])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalize(url: str, base: str) -> str | None:
|
|
80
|
+
"""Resolve relative URLs, strip #fragments and filter out non-web-page URLs."""
|
|
81
|
+
absolute, _ = urldefrag(urljoin(base, url))
|
|
82
|
+
parsed = urlparse(absolute)
|
|
83
|
+
if parsed.scheme not in ("http", "https"):
|
|
84
|
+
return None
|
|
85
|
+
if _SKIP_EXT.search(parsed.path):
|
|
86
|
+
return None
|
|
87
|
+
return absolute
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Frontier:
|
|
91
|
+
"""Priority queue of pending URLs with dedup.
|
|
92
|
+
|
|
93
|
+
heapq is a min-heap, so we store -score to always pop the URL with the
|
|
94
|
+
HIGHEST score. If a URL gets re-pushed with a new score (happens in OPIC,
|
|
95
|
+
where cash accumulates), we use lazy deletion: stale entries are discarded
|
|
96
|
+
on pop by comparing against the current score.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self):
|
|
100
|
+
self._heap: list[tuple[float, int, str]] = []
|
|
101
|
+
self._score: dict[str, float] = {}
|
|
102
|
+
self._counter = 0 # FIFO tie-break between equal scores
|
|
103
|
+
|
|
104
|
+
def push(self, url: str, score: float):
|
|
105
|
+
current = self._score.get(url)
|
|
106
|
+
if current is not None and score <= current:
|
|
107
|
+
return
|
|
108
|
+
self._score[url] = score
|
|
109
|
+
self._counter += 1
|
|
110
|
+
heapq.heappush(self._heap, (-score, self._counter, url))
|
|
111
|
+
|
|
112
|
+
def pop(self) -> tuple[str, float] | None:
|
|
113
|
+
while self._heap:
|
|
114
|
+
neg, _, url = heapq.heappop(self._heap)
|
|
115
|
+
if url in self._score and -neg == self._score[url]:
|
|
116
|
+
del self._score[url]
|
|
117
|
+
return url, -neg
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def __contains__(self, url: str) -> bool:
|
|
121
|
+
return url in self._score
|
|
122
|
+
|
|
123
|
+
def __len__(self) -> int:
|
|
124
|
+
return len(self._score)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class CrawlResult:
|
|
129
|
+
"""What a crawl returns: pages, graph and numbers for comparison."""
|
|
130
|
+
|
|
131
|
+
strategy: str
|
|
132
|
+
pages: list[dict] = field(default_factory=list) # url, title, score, relevance, depth, order
|
|
133
|
+
graph: dict[str, list[str]] = field(default_factory=dict)
|
|
134
|
+
stats: dict = field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
def relevant(self, threshold: float = 0.1) -> list[dict]:
|
|
137
|
+
return [p for p in self.pages if p["relevance"] >= threshold]
|
|
138
|
+
|
|
139
|
+
def top(self, n: int = 10) -> list[dict]:
|
|
140
|
+
return sorted(self.pages, key=lambda p: p["relevance"], reverse=True)[:n]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Crawler:
|
|
144
|
+
"""BFS crawler. Subclasses only change HOW links are scored.
|
|
145
|
+
|
|
146
|
+
The loop is identical for every strategy (pop → fetch → extract links →
|
|
147
|
+
score → push); that keeps the comparison between strategies fair:
|
|
148
|
+
same code, different ordering function.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
name = "bfs"
|
|
152
|
+
|
|
153
|
+
def __init__(self, query: str = "", delay: float = 0.2, timeout: int = 10,
|
|
154
|
+
same_domain: bool = True):
|
|
155
|
+
self.query = query
|
|
156
|
+
self.same_domain = same_domain
|
|
157
|
+
self.scraper = Scraper(delay=delay, timeout=timeout)
|
|
158
|
+
|
|
159
|
+
# --- extension point -------------------------------------------------------
|
|
160
|
+
def score_links(self, url: str, links: list[dict], relevance: float,
|
|
161
|
+
depth: int) -> list[tuple[str, float]]:
|
|
162
|
+
"""BFS: the score only encodes depth (shallower = visited sooner).
|
|
163
|
+
|
|
164
|
+
links: [{url, anchor}]. Returns [(url, score)] for the frontier.
|
|
165
|
+
"""
|
|
166
|
+
return [(link["url"], -(depth + 1)) for link in links]
|
|
167
|
+
|
|
168
|
+
def on_visit(self, url: str, links: list[dict]):
|
|
169
|
+
"""Hook for strategy-specific state (OPIC distributes cash here)."""
|
|
170
|
+
|
|
171
|
+
def initial_score(self, url: str) -> float:
|
|
172
|
+
return 0.0
|
|
173
|
+
|
|
174
|
+
# --- shared loop -------------------------------------------------------------
|
|
175
|
+
def crawl(self, start: str, max_pages: int = 50, max_depth: int = 10) -> CrawlResult:
|
|
176
|
+
start_norm = normalize(start, start)
|
|
177
|
+
if not start_norm:
|
|
178
|
+
raise ValueError(f"Invalid URL: {start}")
|
|
179
|
+
domain = _root_domain(urlparse(start_norm).netloc)
|
|
180
|
+
|
|
181
|
+
frontier = Frontier()
|
|
182
|
+
frontier.push(start_norm, self.initial_score(start_norm))
|
|
183
|
+
visited: set[str] = set()
|
|
184
|
+
depth_of = {start_norm: 0}
|
|
185
|
+
result = CrawlResult(strategy=self.name)
|
|
186
|
+
errors = 0
|
|
187
|
+
t0 = time.perf_counter()
|
|
188
|
+
|
|
189
|
+
while len(visited) < max_pages:
|
|
190
|
+
item = frontier.pop()
|
|
191
|
+
if item is None:
|
|
192
|
+
break
|
|
193
|
+
url, score = item
|
|
194
|
+
if url in visited:
|
|
195
|
+
continue
|
|
196
|
+
visited.add(url)
|
|
197
|
+
depth = depth_of.get(url, 0)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
page = self.scraper.static(url)
|
|
201
|
+
except Exception:
|
|
202
|
+
errors += 1
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
text = page.soup.get_text(" ", strip=True)
|
|
206
|
+
relevance = cosine(text, self.query) if self.query else 0.0
|
|
207
|
+
title = page.css("title") or url
|
|
208
|
+
|
|
209
|
+
links = []
|
|
210
|
+
for a in page.soup.select("a[href]"):
|
|
211
|
+
child = normalize(a["href"], url)
|
|
212
|
+
if not child or child == url:
|
|
213
|
+
continue
|
|
214
|
+
if self.same_domain and _root_domain(urlparse(child).netloc) != domain:
|
|
215
|
+
continue
|
|
216
|
+
links.append({"url": child, "anchor": a.get_text(" ", strip=True)})
|
|
217
|
+
|
|
218
|
+
result.graph[url] = [link["url"] for link in links]
|
|
219
|
+
result.pages.append({
|
|
220
|
+
"url": url, "title": title[:120], "score": round(score, 4),
|
|
221
|
+
"relevance": round(relevance, 4), "depth": depth,
|
|
222
|
+
"order": len(result.pages) + 1,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
self.on_visit(url, links)
|
|
226
|
+
|
|
227
|
+
if depth < max_depth:
|
|
228
|
+
for child_url, child_score in self.score_links(url, links, relevance, depth):
|
|
229
|
+
if child_url not in visited:
|
|
230
|
+
depth_of.setdefault(child_url, depth + 1)
|
|
231
|
+
frontier.push(child_url, child_score)
|
|
232
|
+
|
|
233
|
+
result.stats = {
|
|
234
|
+
"requests": len(visited),
|
|
235
|
+
"errors": errors,
|
|
236
|
+
"elapsed": round(time.perf_counter() - t0, 2),
|
|
237
|
+
"frontier_left": len(frontier),
|
|
238
|
+
"relevant_found": len(result.relevant()) if self.query else None,
|
|
239
|
+
"avg_relevance": round(
|
|
240
|
+
sum(p["relevance"] for p in result.pages) / len(result.pages), 4
|
|
241
|
+
) if result.pages else 0.0,
|
|
242
|
+
}
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class BFS(Crawler):
|
|
247
|
+
"""Explicit alias of the base behavior."""
|
|
248
|
+
|
|
249
|
+
name = "bfs"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class SharkSearch(Crawler):
|
|
253
|
+
"""Topical best-first with score inheritance (Hersovici et al., 1998).
|
|
254
|
+
|
|
255
|
+
score(link) = γ·inherited + (1−γ)·local_signal
|
|
256
|
+
inherited = δ·relevance(parent) if the parent was relevant,
|
|
257
|
+
otherwise δ·inherited(parent) → bad branches decay as δ^n.
|
|
258
|
+
local_signal = cosine(anchor + URL words, query).
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
name = "shark"
|
|
262
|
+
|
|
263
|
+
def __init__(self, query: str, delta: float = 0.5, gamma: float = 0.8, **kw):
|
|
264
|
+
super().__init__(query=query, **kw)
|
|
265
|
+
self.delta = delta
|
|
266
|
+
self.gamma = gamma
|
|
267
|
+
self._inherited: dict[str, float] = {}
|
|
268
|
+
|
|
269
|
+
def initial_score(self, url: str) -> float:
|
|
270
|
+
self._inherited[url] = 1.0
|
|
271
|
+
return 1.0
|
|
272
|
+
|
|
273
|
+
def score_links(self, url, links, relevance, depth):
|
|
274
|
+
# Inheritance: if the parent was relevant, children inherit its
|
|
275
|
+
# relevance; otherwise they inherit what the parent had inherited.
|
|
276
|
+
# Either way with delta decay: a branch with no signal fades as delta^n.
|
|
277
|
+
parent_inherited = self._inherited.get(url, 0.0)
|
|
278
|
+
inherited = self.delta * (relevance if relevance > 0.05 else parent_inherited)
|
|
279
|
+
scored = []
|
|
280
|
+
for link in links:
|
|
281
|
+
url_words = " ".join(_tokens(urlparse(link["url"]).path))
|
|
282
|
+
local = cosine(f'{link["anchor"]} {url_words}', self.query)
|
|
283
|
+
score = self.gamma * inherited + (1 - self.gamma) * local
|
|
284
|
+
self._inherited[link["url"]] = inherited
|
|
285
|
+
scored.append((link["url"], score))
|
|
286
|
+
return scored
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class OPIC(Crawler):
|
|
290
|
+
"""Online structural importance (Abiteboul et al., 2003).
|
|
291
|
+
|
|
292
|
+
Each page holds cash. On visit: its cash is added to its history
|
|
293
|
+
(accumulated importance), reset to 0 and split evenly among its outgoing
|
|
294
|
+
links. The URL with the most pending cash is always visited next.
|
|
295
|
+
Total system cash is conserved (the algorithm's invariant).
|
|
296
|
+
Pages with no outgoing links (sinks) return their cash to the known
|
|
297
|
+
unvisited URLs: the paper's "virtual node", immediate version.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
name = "opic"
|
|
301
|
+
|
|
302
|
+
def __init__(self, query: str = "", **kw):
|
|
303
|
+
super().__init__(query=query, **kw)
|
|
304
|
+
self.cash: dict[str, float] = {}
|
|
305
|
+
self.history: dict[str, float] = {}
|
|
306
|
+
self._known_unvisited: set[str] = set()
|
|
307
|
+
self._frontier_ref: Frontier | None = None
|
|
308
|
+
|
|
309
|
+
def initial_score(self, url: str) -> float:
|
|
310
|
+
self.cash[url] = 1.0
|
|
311
|
+
return 1.0
|
|
312
|
+
|
|
313
|
+
def on_visit(self, url: str, links: list[dict]):
|
|
314
|
+
amount = self.cash.pop(url, 0.0)
|
|
315
|
+
self.history[url] = self.history.get(url, 0.0) + amount
|
|
316
|
+
self._known_unvisited.discard(url)
|
|
317
|
+
targets = [link["url"] for link in links] or list(self._known_unvisited)
|
|
318
|
+
if not targets:
|
|
319
|
+
return
|
|
320
|
+
share = amount / len(targets)
|
|
321
|
+
for t in targets:
|
|
322
|
+
self.cash[t] = self.cash.get(t, 0.0) + share
|
|
323
|
+
self._known_unvisited.add(t)
|
|
324
|
+
|
|
325
|
+
def score_links(self, url, links, relevance, depth):
|
|
326
|
+
# Cash was already distributed in on_visit; the score IS the accumulated cash.
|
|
327
|
+
return [(link["url"], self.cash.get(link["url"], 0.0)) for link in links]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def pagerank(graph: dict[str, list[str]], damping: float = 0.85,
|
|
331
|
+
iterations: int = 30) -> dict[str, float]:
|
|
332
|
+
"""PageRank via power iteration over the crawled graph.
|
|
333
|
+
|
|
334
|
+
Offline version of the same concept OPIC approximates online: comparing
|
|
335
|
+
both rankings over the same graph is the interesting experiment.
|
|
336
|
+
"""
|
|
337
|
+
nodes = set(graph) | {v for vs in graph.values() for v in vs}
|
|
338
|
+
if not nodes:
|
|
339
|
+
return {}
|
|
340
|
+
n = len(nodes)
|
|
341
|
+
rank = {u: 1.0 / n for u in nodes}
|
|
342
|
+
for _ in range(iterations):
|
|
343
|
+
new = {u: (1 - damping) / n for u in nodes}
|
|
344
|
+
for u in nodes:
|
|
345
|
+
out = [v for v in graph.get(u, []) if v in nodes]
|
|
346
|
+
if out:
|
|
347
|
+
share = damping * rank[u] / len(out)
|
|
348
|
+
for v in out:
|
|
349
|
+
new[v] += share
|
|
350
|
+
else: # sink: distribute to all (virtual node)
|
|
351
|
+
share = damping * rank[u] / n
|
|
352
|
+
for v in nodes:
|
|
353
|
+
new[v] += share
|
|
354
|
+
rank = new
|
|
355
|
+
return dict(sorted(rank.items(), key=lambda kv: kv[1], reverse=True))
|
bytecrawl/mcp_http.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Hosted (HTTP) MCP server: ByteCrawl tools behind a public URL.
|
|
2
|
+
|
|
3
|
+
Same four tools as the local stdio server (bytecrawl.mcp_server), hardened
|
|
4
|
+
for the open internet:
|
|
5
|
+
|
|
6
|
+
1. SSRF guard — every URL is resolved and rejected if any of its IPs is
|
|
7
|
+
private/loopback/link-local (a hosted scraper must not be
|
|
8
|
+
an open proxy into the host's network).
|
|
9
|
+
2. Hard caps — focused_crawl is clamped to MAX_PAGES pages; the shared
|
|
10
|
+
scraper keeps a polite delay. The cost of one request is
|
|
11
|
+
bounded no matter what the client asks for.
|
|
12
|
+
3. Rate limit — sliding-window per client IP, in memory. Best-effort on
|
|
13
|
+
serverless (each warm instance counts separately); good
|
|
14
|
+
enough to keep one abuser from exhausting the free tier.
|
|
15
|
+
|
|
16
|
+
Serverless notes: runs stateless (no session affinity) and static-only —
|
|
17
|
+
Playwright is not available on Vercel, so JS-heavy sites should use the local
|
|
18
|
+
`bytecrawl-mcp` instead. Redirect chains are not re-validated hop by hop; the
|
|
19
|
+
guard checks the URL each tool receives (and the crawler stays on the seed's
|
|
20
|
+
registered domain).
|
|
21
|
+
|
|
22
|
+
Self-host: bytecrawl-mcp-http (serves http://0.0.0.0:8000/mcp)
|
|
23
|
+
Connect: claude mcp add --transport http bytecrawl https://bytecrawl.vercel.app/mcp
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ipaddress
|
|
29
|
+
import socket
|
|
30
|
+
import time
|
|
31
|
+
from collections import defaultdict, deque
|
|
32
|
+
from urllib.parse import urlparse
|
|
33
|
+
|
|
34
|
+
from mcp.server.mcpserver import MCPServer
|
|
35
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
36
|
+
|
|
37
|
+
from . import mcp_server as local
|
|
38
|
+
|
|
39
|
+
MAX_PAGES = 10 # hard cap per focused_crawl call (local allows 50)
|
|
40
|
+
RATE_LIMIT = 20 # requests per window per client IP
|
|
41
|
+
RATE_WINDOW = 60.0 # seconds
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- 1. SSRF guard ----------------------------------------------------------
|
|
45
|
+
def assert_public_url(url: str) -> None:
|
|
46
|
+
"""Rejects URLs that could reach private infrastructure.
|
|
47
|
+
|
|
48
|
+
Resolves the hostname and requires every returned address to be globally
|
|
49
|
+
routable. Blocks localhost, RFC1918 ranges, link-local (cloud metadata
|
|
50
|
+
endpoints like 169.254.169.254) and other reserved space.
|
|
51
|
+
"""
|
|
52
|
+
parsed = urlparse(url)
|
|
53
|
+
if parsed.scheme not in ("http", "https"):
|
|
54
|
+
raise ValueError(f"Only http(s) URLs are allowed, got: {parsed.scheme or 'none'}")
|
|
55
|
+
host = parsed.hostname
|
|
56
|
+
if not host:
|
|
57
|
+
raise ValueError("URL has no hostname")
|
|
58
|
+
try:
|
|
59
|
+
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
|
|
60
|
+
except socket.gaierror as e:
|
|
61
|
+
raise ValueError(f"Cannot resolve host: {host}") from e
|
|
62
|
+
for info in infos:
|
|
63
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
64
|
+
if not ip.is_global or ip.is_multicast:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"URL resolves to a non-public address ({ip}) — refusing to fetch it"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _guarded(url: str) -> None:
|
|
71
|
+
"""SSRF check, re-raised as ToolError so MCP clients see the reason
|
|
72
|
+
(plain exceptions are masked as a generic 'error executing tool')."""
|
|
73
|
+
try:
|
|
74
|
+
assert_public_url(url)
|
|
75
|
+
except ValueError as e:
|
|
76
|
+
raise ToolError(str(e)) from e
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- 3. rate limit ----------------------------------------------------------
|
|
80
|
+
class RateLimiter:
|
|
81
|
+
"""Sliding-window counter per key. In-memory: per-instance on serverless."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, limit: int = RATE_LIMIT, window: float = RATE_WINDOW):
|
|
84
|
+
self.limit = limit
|
|
85
|
+
self.window = window
|
|
86
|
+
self._hits: dict[str, deque] = defaultdict(deque)
|
|
87
|
+
|
|
88
|
+
def allow(self, key: str) -> bool:
|
|
89
|
+
now = time.monotonic()
|
|
90
|
+
hits = self._hits[key]
|
|
91
|
+
while hits and now - hits[0] > self.window:
|
|
92
|
+
hits.popleft()
|
|
93
|
+
if len(hits) >= self.limit:
|
|
94
|
+
return False
|
|
95
|
+
hits.append(now)
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RateLimitMiddleware:
|
|
100
|
+
"""ASGI wrapper: answers 429 before the MCP app sees the request."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, app, limiter: RateLimiter | None = None):
|
|
103
|
+
self.app = app
|
|
104
|
+
self.limiter = limiter or RateLimiter()
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _client_ip(scope) -> str:
|
|
108
|
+
for name, value in scope.get("headers", []):
|
|
109
|
+
if name == b"x-forwarded-for":
|
|
110
|
+
return value.decode().split(",")[0].strip()
|
|
111
|
+
client = scope.get("client")
|
|
112
|
+
return client[0] if client else "unknown"
|
|
113
|
+
|
|
114
|
+
async def __call__(self, scope, receive, send):
|
|
115
|
+
if scope["type"] == "http" and not self.limiter.allow(self._client_ip(scope)):
|
|
116
|
+
await send({
|
|
117
|
+
"type": "http.response.start",
|
|
118
|
+
"status": 429,
|
|
119
|
+
"headers": [(b"content-type", b"application/json"),
|
|
120
|
+
(b"retry-after", b"60")],
|
|
121
|
+
})
|
|
122
|
+
await send({
|
|
123
|
+
"type": "http.response.body",
|
|
124
|
+
"body": b'{"error": "rate limit exceeded, try again in a minute"}',
|
|
125
|
+
})
|
|
126
|
+
return
|
|
127
|
+
await self.app(scope, receive, send)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- the hardened server ----------------------------------------------------
|
|
131
|
+
server = MCPServer(
|
|
132
|
+
"bytecrawl",
|
|
133
|
+
instructions=(
|
|
134
|
+
"Hosted ByteCrawl: web scraping and focused crawling. Static HTML only "
|
|
135
|
+
"(no JS rendering here — run bytecrawl-mcp locally for that). "
|
|
136
|
+
f"Crawls are capped at {MAX_PAGES} pages per call."
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@server.tool(description=(
|
|
142
|
+
"Fetch a public web page and return it as clean Markdown for LLM "
|
|
143
|
+
"consumption. Static HTML only on the hosted server."
|
|
144
|
+
))
|
|
145
|
+
def fetch_markdown(url: str) -> dict:
|
|
146
|
+
_guarded(url)
|
|
147
|
+
return local.fetch_markdown(url)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@server.tool(description=(
|
|
151
|
+
"Extract structured records from a public page with CSS selectors "
|
|
152
|
+
"(::text / ::attr(name); a field name ending in [] collects a list)."
|
|
153
|
+
))
|
|
154
|
+
def extract(url: str, item: str, fields: dict[str, str]) -> dict:
|
|
155
|
+
_guarded(url)
|
|
156
|
+
return local.extract(url, item, fields)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@server.tool(description=(
|
|
160
|
+
"Focused crawl of a public site: visit the pages most relevant to 'query' "
|
|
161
|
+
"first. strategy: shark (default) | opic | bfs. Capped at "
|
|
162
|
+
f"{MAX_PAGES} pages per call on the hosted server."
|
|
163
|
+
))
|
|
164
|
+
def focused_crawl(url: str, query: str = "", strategy: str = "shark",
|
|
165
|
+
max_pages: int = MAX_PAGES) -> dict:
|
|
166
|
+
_guarded(url)
|
|
167
|
+
return local.focused_crawl(url, query=query, strategy=strategy,
|
|
168
|
+
max_pages=min(max_pages, MAX_PAGES))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@server.tool(description="Fetch a public JSON API endpoint.")
|
|
172
|
+
def fetch_json_api(url: str, params: dict | None = None) -> dict:
|
|
173
|
+
_guarded(url)
|
|
174
|
+
return local.fetch_json_api(url, params=params)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def create_app(limiter: RateLimiter | None = None):
|
|
178
|
+
"""ASGI app: streamable-HTTP MCP at /mcp, rate-limited. Stateless for
|
|
179
|
+
serverless (json_response avoids long-lived SSE streams on Vercel)."""
|
|
180
|
+
app = server.streamable_http_app(
|
|
181
|
+
streamable_http_path="/mcp", stateless_http=True, json_response=True,
|
|
182
|
+
)
|
|
183
|
+
return RateLimitMiddleware(app, limiter)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main() -> None:
|
|
187
|
+
"""Entry point for bytecrawl-mcp-http (self-hosted)."""
|
|
188
|
+
import uvicorn
|
|
189
|
+
|
|
190
|
+
uvicorn.run(create_app(), host="0.0.0.0", port=8000)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
main()
|
bytecrawl/mcp_server.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""MCP server: exposes ByteCrawl to AI agents (Claude Code, Claude Desktop, Cursor...).
|
|
2
|
+
|
|
3
|
+
Any MCP-capable agent gets four tools that map to what ByteCrawl does well:
|
|
4
|
+
clean Markdown for LLM ingestion, structured extraction with CSS selectors,
|
|
5
|
+
focused crawling (the differentiator) and hidden JSON APIs.
|
|
6
|
+
|
|
7
|
+
Run it: bytecrawl-mcp (stdio transport)
|
|
8
|
+
Claude Code: claude mcp add bytecrawl -- bytecrawl-mcp
|
|
9
|
+
Requires: pip install bytecrawl[mcp]
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from mcp.server.mcpserver import MCPServer
|
|
16
|
+
except ImportError as e: # pragma: no cover - exercised only without the extra
|
|
17
|
+
raise ImportError(
|
|
18
|
+
"The MCP server needs the 'mcp' extra: pip install bytecrawl[mcp]"
|
|
19
|
+
) from e
|
|
20
|
+
|
|
21
|
+
from .core import Scraper
|
|
22
|
+
from .crawler import BFS, OPIC, SharkSearch, pagerank
|
|
23
|
+
|
|
24
|
+
# One polite scraper shared by every tool call.
|
|
25
|
+
_scraper = Scraper(delay=0.2)
|
|
26
|
+
|
|
27
|
+
_STRATEGIES = {"bfs": BFS, "shark": SharkSearch, "opic": OPIC}
|
|
28
|
+
|
|
29
|
+
server = MCPServer(
|
|
30
|
+
"bytecrawl",
|
|
31
|
+
instructions=(
|
|
32
|
+
"Web scraping and focused crawling. Use fetch_markdown to read a page, "
|
|
33
|
+
"extract for structured records, focused_crawl to find the pages most "
|
|
34
|
+
"relevant to a topic within a site, and fetch_json_api for JSON endpoints."
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@server.tool(
|
|
40
|
+
description=(
|
|
41
|
+
"Fetch a web page and return it as clean Markdown, ready for LLM "
|
|
42
|
+
"consumption (5-10x fewer tokens than raw HTML)."
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
def fetch_markdown(url: str) -> dict:
|
|
46
|
+
page = _scraper.fetch(url)
|
|
47
|
+
md = page.markdown()
|
|
48
|
+
return {
|
|
49
|
+
"url": url,
|
|
50
|
+
"markdown": md,
|
|
51
|
+
"tokens_estimate": page.tokens(md),
|
|
52
|
+
"method": page.method,
|
|
53
|
+
"status": page.status,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@server.tool(
|
|
58
|
+
description=(
|
|
59
|
+
"Extract structured records from a page with CSS selectors. "
|
|
60
|
+
"'item' delimits each record (e.g. 'article.product'); 'fields' maps "
|
|
61
|
+
"names to relative selectors supporting ::text and ::attr(name), e.g. "
|
|
62
|
+
'{"title": "h3 a::attr(title)", "price": "p.price::text"}. '
|
|
63
|
+
"A field name ending in [] collects a list."
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
def extract(url: str, item: str, fields: dict[str, str]) -> dict:
|
|
67
|
+
page = _scraper.fetch(url)
|
|
68
|
+
records = page.extract(item, fields)
|
|
69
|
+
return {"url": url, "count": len(records), "records": records}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@server.tool(
|
|
73
|
+
description=(
|
|
74
|
+
"Crawl a site starting from 'url', visiting the pages most relevant to "
|
|
75
|
+
"'query' first (focused crawling). strategy: 'shark' (topical "
|
|
76
|
+
"best-first, default), 'opic' (structural importance) or 'bfs' "
|
|
77
|
+
"(level by level). Returns pages ranked by relevance plus crawl stats."
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
def focused_crawl(url: str, query: str = "", strategy: str = "shark",
|
|
81
|
+
max_pages: int = 20) -> dict:
|
|
82
|
+
if strategy not in _STRATEGIES:
|
|
83
|
+
raise ValueError(f"strategy must be one of {sorted(_STRATEGIES)}")
|
|
84
|
+
max_pages = min(max_pages, 50) # keep agent calls bounded and polite
|
|
85
|
+
crawler = _STRATEGIES[strategy](query=query, delay=0.2)
|
|
86
|
+
result = crawler.crawl(url, max_pages=max_pages)
|
|
87
|
+
ranks = pagerank(result.graph)
|
|
88
|
+
return {
|
|
89
|
+
"strategy": strategy,
|
|
90
|
+
"stats": result.stats,
|
|
91
|
+
"pages": result.top(max_pages) if query else result.pages,
|
|
92
|
+
"pagerank_top": [
|
|
93
|
+
{"url": u, "rank": round(r, 4)} for u, r in list(ranks.items())[:10]
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@server.tool(
|
|
99
|
+
description="Fetch a JSON API endpoint (the 'hidden API' scraping technique)."
|
|
100
|
+
)
|
|
101
|
+
def fetch_json_api(url: str, params: dict | None = None) -> dict:
|
|
102
|
+
page = _scraper.api(url, params=params)
|
|
103
|
+
return {"url": url, "status": page.status, "data": page.json()}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main() -> None:
|
|
107
|
+
"""Entry point for the bytecrawl-mcp console script (stdio transport)."""
|
|
108
|
+
server.run()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bytecrawl
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Focused crawling for LLM data collection: Shark-Search and OPIC crawlers plus a single scraping API (static HTML, dynamic JS, APIs, session login, Markdown for LLMs) on a 3-dependency core.
|
|
5
|
+
Author-email: abrahamperz <aperez@adokmx.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/abrahamperz/ByteCrawl
|
|
8
|
+
Project-URL: Repository, https://github.com/abrahamperz/ByteCrawl
|
|
9
|
+
Keywords: scraping,crawler,focused-crawling,shark-search,opic,pagerank,beautifulsoup,playwright,markdown,llm
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: requests>=2.28
|
|
19
|
+
Requires-Dist: beautifulsoup4>=4.11
|
|
20
|
+
Requires-Dist: lxml>=4.9
|
|
21
|
+
Provides-Extra: browser
|
|
22
|
+
Requires-Dist: playwright>=1.40; extra == "browser"
|
|
23
|
+
Provides-Extra: llm
|
|
24
|
+
Requires-Dist: markdownify>=0.11; extra == "llm"
|
|
25
|
+
Requires-Dist: trafilatura>=1.6; extra == "llm"
|
|
26
|
+
Provides-Extra: mcp
|
|
27
|
+
Requires-Dist: mcp>=2; python_version >= "3.10" and extra == "mcp"
|
|
28
|
+
Requires-Dist: markdownify>=0.11; extra == "mcp"
|
|
29
|
+
Requires-Dist: trafilatura>=1.6; extra == "mcp"
|
|
30
|
+
Provides-Extra: all
|
|
31
|
+
Requires-Dist: playwright>=1.40; extra == "all"
|
|
32
|
+
Requires-Dist: markdownify>=0.11; extra == "all"
|
|
33
|
+
Requires-Dist: trafilatura>=1.6; extra == "all"
|
|
34
|
+
Requires-Dist: mcp>=2; python_version >= "3.10" and extra == "all"
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
38
|
+
Requires-Dist: ruff; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# ByteCrawl
|
|
42
|
+
|
|
43
|
+
[](https://github.com/abrahamperz/ByteCrawl/actions/workflows/ci.yml)
|
|
44
|
+
[](https://pypi.org/project/bytecrawl/)
|
|
45
|
+
[](https://pypi.org/project/bytecrawl/)
|
|
46
|
+
[](LICENSE)
|
|
47
|
+
|
|
48
|
+
**Give your AI agent focused web crawling.** ByteCrawl is an MCP server (and a
|
|
49
|
+
small Python library) that doesn't just scrape a page — it crawls a whole site
|
|
50
|
+
and returns the pages *most relevant* to your topic first, using Shark-Search
|
|
51
|
+
and OPIC in pure Python.
|
|
52
|
+
|
|
53
|
+
- **Webpage**: https://bytecrawl.vercel.app/
|
|
54
|
+
- **Hosted MCP endpoint**: https://bytecrawl.vercel.app/mcp
|
|
55
|
+
|
|
56
|
+
## Quick start (MCP — nothing to install)
|
|
57
|
+
|
|
58
|
+
Point any MCP-capable agent (Claude Code, Claude Desktop, Cursor...) at the
|
|
59
|
+
hosted endpoint:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
claude mcp add --transport http bytecrawl https://bytecrawl.vercel.app/mcp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Now the agent has four tools:
|
|
66
|
+
|
|
67
|
+
| Tool | What it does |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `focused_crawl` | Crawl a site, rank pages by relevance to a query (Shark-Search / OPIC / BFS) |
|
|
70
|
+
| `fetch_markdown` | One page → clean Markdown (5–10× fewer tokens than raw HTML) |
|
|
71
|
+
| `extract` | Structured records via CSS selectors |
|
|
72
|
+
| `fetch_json_api` | Hit a hidden JSON API |
|
|
73
|
+
|
|
74
|
+
The hosted server is static-only, rate-limited per IP, caps crawls at 10 pages,
|
|
75
|
+
and refuses non-public URLs (SSRF guard). For heavy use or JS-rendered sites,
|
|
76
|
+
run it locally:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install bytecrawl[mcp]
|
|
80
|
+
claude mcp add bytecrawl -- bytecrawl-mcp # full power, on your machine
|
|
81
|
+
pip install bytecrawl[browser] && playwright install chromium # + JS rendering
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Why focused crawling?
|
|
85
|
+
|
|
86
|
+
Most crawlers visit pages in whatever order they find them. With a limited
|
|
87
|
+
request budget, order is everything — Shark-Search chases the branches that
|
|
88
|
+
smell like your query and lets the rest decay, so 100 requests get you the 100
|
|
89
|
+
*most useful* pages, not the 100 closest to the seed.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from bytecrawl import SharkSearch
|
|
93
|
+
|
|
94
|
+
result = SharkSearch(query="vector databases").crawl(
|
|
95
|
+
"https://example.com", max_pages=100)
|
|
96
|
+
|
|
97
|
+
for page in result.top(10):
|
|
98
|
+
print(f'{page["relevance"]:.3f} {page["url"]}')
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- **BFS** — level by level, closest to the seed first.
|
|
102
|
+
- **Shark-Search** (Hersovici et al., 1998) — topical best-first; links inherit
|
|
103
|
+
their parent's relevance with decay.
|
|
104
|
+
- **OPIC** (Abiteboul et al., 2003) — live PageRank via "cash" flow, no full
|
|
105
|
+
graph needed (a `pagerank()` implementation is included to compare against).
|
|
106
|
+
|
|
107
|
+
Versus the alternatives: Scrapy is a framework you wire up yourself, Firecrawl
|
|
108
|
+
is a paid SaaS — ByteCrawl is a plain library with a 3-package core and these
|
|
109
|
+
frontier strategies built in.
|
|
110
|
+
|
|
111
|
+
## Library API
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from bytecrawl import Scraper
|
|
115
|
+
|
|
116
|
+
bot = Scraper()
|
|
117
|
+
page = bot.fetch("https://books.toscrape.com") # auto: static, browser fallback
|
|
118
|
+
books = page.extract("article.product_pod",
|
|
119
|
+
{"title": "h3 a::attr(title)", "price": "p.price_color::text"})
|
|
120
|
+
page.markdown() # clean Markdown for LLMs · page.tokens() # token estimate
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
bot.static(url) # plain HTML
|
|
125
|
+
bot.api(url, params={...}) # hidden JSON API
|
|
126
|
+
bot.browser(url, wait="div.results") # JS via Playwright
|
|
127
|
+
bot.crawl(url, item="article", fields={...},
|
|
128
|
+
next_page="li.next a::attr(href)") # pagination
|
|
129
|
+
bot.session().login(url, data, csrf_field="csrf_token") # authenticated
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Install
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pip install bytecrawl # slim core (requests + beautifulsoup4 + lxml)
|
|
136
|
+
pip install bytecrawl[llm] # + Markdown for LLMs
|
|
137
|
+
pip install bytecrawl[browser] # + Playwright
|
|
138
|
+
pip install bytecrawl[mcp] # + local MCP server
|
|
139
|
+
pip install bytecrawl[all]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Learn each scraping technique
|
|
143
|
+
|
|
144
|
+
A guided walkthrough with a runnable example against a practice site:
|
|
145
|
+
[static HTML](docs/01-html-estatico.md) ·
|
|
146
|
+
[dynamic JS](docs/02-js-dinamico.md) ·
|
|
147
|
+
[hidden APIs](docs/03-api-oculta.md) ·
|
|
148
|
+
[pagination](docs/04-crawling-paginacion.md) ·
|
|
149
|
+
[login](docs/05-login-sesion.md) ·
|
|
150
|
+
[graph crawling](docs/06-crawling-grafos.md) ·
|
|
151
|
+
[Markdown for LLMs](docs/markdown-llms.md) ·
|
|
152
|
+
[ethics](docs/nota-etica.md)
|
|
153
|
+
|
|
154
|
+
## Contributing
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pip install -e ".[llm,dev,mcp]"
|
|
158
|
+
pytest # 109 tests, no network required
|
|
159
|
+
pytest -m live # + live browser tests (needs the browser extra)
|
|
160
|
+
ruff check bytecrawl tests
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Scrape responsibly: respect `robots.txt`, terms of service and rate limits.
|
|
164
|
+
ByteCrawl ships with a configurable delay between requests.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
bytecrawl/__init__.py,sha256=8AGDzOjuwX8H0WbjmfVe-thBZzkq2VTEqsgLxjcnpAk,1034
|
|
2
|
+
bytecrawl/core.py,sha256=c72k5DRyBfYgpwDXzo3fTVvdrQs1GXuw3RYsKIsMVdU,14480
|
|
3
|
+
bytecrawl/crawler.py,sha256=Z0XQ1SRbN5ilZjTmo904lhHDQyAyem5Mo39k1GysGFw,13263
|
|
4
|
+
bytecrawl/mcp_http.py,sha256=jA9ieAlIWaA0chzYzutHk0IATJr6Q3I36htSVC8RCPI,7187
|
|
5
|
+
bytecrawl/mcp_server.py,sha256=aSWrYFLFxr_lGeqWpGW6stHnecpEhCL45cSCSwI7BwQ,3831
|
|
6
|
+
bytecrawl-1.0.0.dist-info/licenses/LICENSE,sha256=iBV--gE_YY2iDtqnc4mtYyq4BhFdoKIr0VpptvV3foY,1071
|
|
7
|
+
bytecrawl-1.0.0.dist-info/METADATA,sha256=Ibw2y_nCwJWlj_uhqsMXB7WlrkrQMBWNDBQ3I9wsbQE,6468
|
|
8
|
+
bytecrawl-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
bytecrawl-1.0.0.dist-info/entry_points.txt,sha256=mqISq8YZx19UzEdoyF_bUGm1t8AQ3dhjF2o0tmLbf8g,105
|
|
10
|
+
bytecrawl-1.0.0.dist-info/top_level.txt,sha256=XYFyykLgFrhCeCA3ZFwZm_4wJxZzmAw7T3kibTQKJHo,10
|
|
11
|
+
bytecrawl-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abraham Pérez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bytecrawl
|