Minimal asynchronous Playwright-based web crawler
Project description
litecrawl – Minimal asynchronous Playwright-based web crawler
A single-interface, concurrency-safe crawler with SQLite frontier, Playwright rendering including scrolling and actions, adaptive scheduling, and pluggable content tranformation.
Public API
def litecrawl(
# Initialization
sqlite_path: str,
start_urls: list[str],
# URL gating
normalize_patterns: list[dict] | None = None, # [ { "pattern": str, "replace": str } ]
# all patterns applied to all URLs using re.sub(), may use regex groups
include_patterns: list[str] | None = None, # [ "pattern": str ]
# if any given, URLs MUST match minimum one using re.search()
exclude_patterns: list[str] | None = None, # [ "pattern": str ]
# if any given, URLs must NOT match ANY using re.search()
# Work distribution
n_claims: int = 100, # pages claimed for processing per run
n_concurrent: int = 10, # concurrent processings per run (connection pool)
# Playwright
pw_headers: dict[str, str] | None = None, # {"User-Agent": "litecrawl/0.1"} if None
pw_scroll_rounds: int = 1, # scroll-to-bottom for dynamic loading
pw_scroll_wait_ms: int = 800, # wait after scrolling
pw_timeout_ms: int = 15000, # per-page hard timeout (ms)
pw_viewport: dict | None = None, # {"width": 2160, "height": 3840} if None
pw_block_media: bool = True, # abort "image", "font", "media"
# Hooks
page_hook: Callable[[Page], Awaitable[None]] | None = None, # perform async actions on page object after scroll
transform_hook: Callable[[bytes, str, str], str | bytes] | None = None, # transform retrieved content
downstream_hook: Callable[[bytes, str, str, bool], None] | None = None, # act on transformed content
# Scheduling
new_interval_sec: int = 60*60*24, # interval in seconds used after first crawl for new pages
min_interval_sec: int = 60*60, # hard lower interval boundary in seconds, must be > 0
max_interval_sec: int = 60*60*24*30, # hard upper interval boundary in seconds, must be >= min_interval_sec
fresh_factor: float = 0.2, # interval multiplicator when fresh == True, must be <= 1.0
stale_factor: float = 2.0 # interval multiplicator when fresh == False, must be >= 1.0
# Safeguard
processing_timeout_sec: int = 60*10 # time in seconds before processing is cleared
) -> None:
...
SQLite Schema
-- pages: identity + scheduling
CREATE TABLE IF NOT EXISTS pages (
norm_url TEXT PRIMARY KEY, -- page identity; normalized url
last_crawl_time INTEGER NULL, -- logged last crawl time in unix epoch seconds
next_crawl_time INTEGER NULL, -- scheduled next crawl time in unix epoch seconds
processing_time INTEGER NULL, -- time of claiming in unix epoch seconds, reset to NULL when processed
content_hash TEXT NOT NULL, -- hash of latest content to measure change in content
);
-- indexes: for crawl maturity and safeguard filtering
CREATE INDEX IF NOT EXISTS idx_pages_next_crawl ON pages(next_crawl_time);
CREATE INDEX IF NOT EXISTS idx_pages_processing ON pages(processing_time);
Workflow
1) Bootstrap
- Create the SQLite schema if missing.
- Normalize and validate
start_urls(as described in section 7). - Any valid, normalized
start_urlsare inserted as pages idempotently.
2) Clean up old processing
- Any rows with
processing_timeolder thanprocessing_timeout_secare reset (processing_time = NULL) and theirnext_crawl_timeis calculated with thestale_factor(as described in section 9) to ensure faulty pages do not continously clog the pipeline.
3) Claim pages ready for processing
- Begin
IMMEDIATEtransaction. - Claim due rows not already being processed, i.e.
WHERE (next_crawl_time IS NULL OR next_crawl_time <= unixepoch()) AND processing_time IS NULL. - Prioritize rows never processed before, i.e.
ORDER BY next_crawl_time IS NULL DESC, next_crawl_time ASC. - Limit to maximum claims per run, i.e.
LIMIT n_claims. - Immediately mark claimed rows as being processed, i.e.
UPDATE ... SET processing_time = unixepoch(). - Commit transaction and release lock.
- Process all claimed rows concurrently using async Playwright (as described in section 4) with semaphore limit of
n_concurrent. - Once a row is completely processed, update its status (as described in section 9), including
SET processing_time = NULL. - Wait for all tasks to complete before exiting.
4) Process pages using Playwright pool
-
Initialize Playwright (single shared browser instance).
-
Then, for each page, first perform sanity check by normalizing the existing
norm_url(see section 7 for details). -
If the normalized
norm_urlis different from the originalnorm_url:DELETE FROM pagesfornorm_url.- Continue processing the page with the normalized
norm_urlas the newnorm_url.
-
If
norm_url(original or replaced with normalized) then does not validate:DELETE FROM pagesfornorm_urland end page processing (remove from processing pool).
-
Then, create a new page object and apply
pw_viewport,pw_timeout_ms, and globalpw_headers. -
If
pw_block_media == True, block heavy assets:await page.route('**/*', lambda route: route.abort() if route.request.resource_type in ('image','font','media') else route.continue_() )
-
Navigate to
norm_url(await page.goto(norm_url)). -
Perform
pw_scroll_roundsscrolls withpw_scroll_wait_msbetween; wait for network idle. -
If
page_hookis given, apply it for any custom interaction.
5) Handle HTTP status and redirects
- The final
page.urlis normalized, and if it is different fromnorm_url, handle it as a redirect:- First, content for
norm_urlis considered empty (i.e.b""). - Second, the normalized
page.urlis considered a (the only) link found atnorm_url. - Then, immediately finalize processing of the claimed row for
norm_url(as described in section 9). - Finally, continue processing the page with the normalized
page.urlas the newnorm_url.
- First, content for
- If HTTP status is not OK, content for the normalized
page.urlis considered empty. - If the normalized
page.urldoes not validate:DELETE FROM pages(if it exists) and end page processing (remove from processing pool).
6) Extract links and parse content
- If the content-type header
.startswith('text/html'), use lxml to extract URLs froma[href],form[action]andiframe[src]. - Normalize extracted URLs (see section 7).
- If URLs validate, insert idempotently into
pagesusing default values. - If
transform_hookgiven, apply with page content asbytesalong with content type and normalized URL. - After application, normalize page content to
bytes, if returned asstr.
7) Normalize and validate URLs
-
Apply in any case where an URL could potentially be non-normal or invalid, e.g. for claimed pages, redirects, and extracted links:
- Convert relative URLs to absolute using source normalized URL as base, if necessary.
- Convert protocol to lowercase, keep only
http/https. - Remove any port
:80or:443. - Remove any URL fragments (
#...). - Apply
normalize_patternsusingre.sub()in given order; may use capture groups. - Validate URL, keep only if valid:
- Must match at least one of
include_patterns, if any given. - Must not match any
exclude_patterns, if any given.
- Must match at least one of
8) Detect fresh content (strictly ordered)
- Initially assume that the processed page has
fresh = False. - If at least one new valid normalized URL (not already in
pages) was found (link or redirect), thenfresh = True. - Or if
content_hashfrom row inpagesis different from a sha256 hash of the crawled (and transformed) page content, thenfresh = True.
9) Schedule and finalize processing
- After processing, schedule
next_crawl_timebased onlast_crawl_timeandfresh. - If
last_crawl_time IS NULL, setnext_interval_sec = new_interval_sec, else:- Calculate
prev_interval_sec = next_crawl_time - last_crawl_time. - If
fresh == True, thennext_interval_sec = MAX(prev_interval_sec * fresh_factor, min_interval_sec). - Else if
fresh == False, thennext_interval_sec = MIN(prev_interval_sec * stale_factor, max_interval_sec).
- Calculate
- Then, if
downstream_hookgiven, apply. - Finally, when the page has been processed,
UPSERT:norm_urllast_crawl_time = unixepoch()next_crawl_time = unixepoch() + next_interval_secprocessing_time = NULLcontent_hash = sha256(transformed_content)
Contracts
def normalize_and_validate_url(
url: str,
base_url: str | None,
normalize_patterns: list[dict] | None,
include_patterns: list[str] | None,
exclude_patterns: list[str] | None,
) -> str | None:
...
def page_hook(page: Page) -> Awaitable[None]:
"""
Perform async actions on page after scrolling, before content extraction.
Example: Click cookie banner, expand collapsed content.
If triggering network activity, end with appropriate wait.
"""
...
def transform_hook(content: bytes, content_type: str, url: str) -> str | bytes:
"""
Transform content before freshness detection.
Example: Strip boilerplate from text/html, pass through PDFs, discard other types.
Must be deterministic (no timestamps or randomness).
Return "", or b"" to treat as empty (or discarded) content.
Any returned str is converted to bytes (UTF-8) in postprocessing.
"""
...
def downstream_hook(content: bytes, content_type: str, url: str, fresh: bool) -> None:
"""
Final hook called after processing with transformed content.
Example: Write to storage, update database, send notifications.
"""
...
Cron operation (recommended)
Import litecrawl module in example.py and call litecrawl() with desired parameters.
# example.py
from litecrawl import litecrawl
litecrawl(...)
Run example.py every minute with a hard wall-clock limit:
* * * * * /usr/bin/timeout 10m /usr/bin/python3 /opt/example.py >> /var/log/example-litecrawl.log 2>&1
Tips:
- Synchronize
/usr/bin/timeout [10m]andprocessing_timeout_sec=[60*10]for full safeguard against hangs, overload and overlaps. - Adjust
n_claimsandn_concurrentto optimize performance.
Philosophy
- One file, one public function.
- SQLite frontier, no external queues.
- Simple batch processing: Claim n_claims rows atomically, process with n_concurrent semaphore.
- Async Playwright rendering with bandwidth-friendly routing.
- Normalize, then validate (includes≥1 and excludes=0).
- Freshness detection = new valid link OR cleaned content hash changed.
- Scheduling = next interval equals previous interval multiplied with factor, then clamp.
- Logs provide diagnostics, no built-in status.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file litecrawl-0.1.0.tar.gz.
File metadata
- Download URL: litecrawl-0.1.0.tar.gz
- Upload date:
- Size: 12.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.20
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
daffa0be9d3870789f48cda38119c106dba0728d2368004bbde80e97da0cd5ac
|
|
| MD5 |
9d2931ae7d1f1528963ea7c393e53eee
|
|
| BLAKE2b-256 |
1847c20f951e4a4eb953c440ab24732abea67c6b0957ffac134a013fb49c4a6a
|
File details
Details for the file litecrawl-0.1.0-py3-none-any.whl.
File metadata
- Download URL: litecrawl-0.1.0-py3-none-any.whl
- Upload date:
- Size: 10.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.20
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
431cd6a877a87fb49f059b1e959e1c966aab43362405de1c8916adc06d7059b1
|
|
| MD5 |
803f8e5bb37c4b1cb1d065dd2a77f017
|
|
| BLAKE2b-256 |
307f8e53c5db51efb005148e5ea486a650f5b69b44f38790237bbd95c82b7cc6
|