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 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()
@@ -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
+ [![CI](https://github.com/abrahamperz/ByteCrawl/actions/workflows/ci.yml/badge.svg)](https://github.com/abrahamperz/ByteCrawl/actions/workflows/ci.yml)
44
+ [![PyPI](https://img.shields.io/pypi/v/bytecrawl)](https://pypi.org/project/bytecrawl/)
45
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)](https://pypi.org/project/bytecrawl/)
46
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ bytecrawl-mcp = bytecrawl.mcp_server:main
3
+ bytecrawl-mcp-http = bytecrawl.mcp_http:main
@@ -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