Extract clean structured article data from URLs with tiered escalation strategies
Project description
articula
Give it a URL, get the article. articula extracts the clean, structured
content of a news story, column, or blog post from a single URL — the title,
body text, author, and published date — and escalates through
progressively stronger strategies so that bot detection and JavaScript-rendered
pages don't stop it.
from articula import scrape
article = scrape("https://example.com/some-news-article")
print(article.title)
print(article.author, article.published_date)
print(article.text)
It is not a general-purpose crawler or a structured-data scraper. It does one thing well: turn an article URL into a clean record.
Why another scraper?
Most "fetch the page and run readability" approaches fall over on exactly the pages you care about:
- Bot walls — Cloudflare / "Just a moment…" interstitials,
403/429responses, naive-client blocks. - JavaScript-rendered bodies — the article isn't in the initial HTML; it's injected by a client-side framework after load.
articula handles both by escalating through tiers and only stopping when a
tier produces a genuinely usable article body:
| Tier | Strategy | Cost |
|---|---|---|
| 1 | static — plain HTTP GET with sensible headers | cheapest |
| 2 | headers_rotation — full browser-like header set, User-Agent rotation, bot-challenge detection | cheap |
| 3 | browser — headless Chromium (Playwright) renders JS, then extracts | heaviest |
Crucially, a tier "wins" only when its HTML yields a meaningful body. If the
static HTML is just a JavaScript shell or a challenge page, extraction fails and
articula escalates — all the way to the browser tier, which renders the page
like a real user before extracting. The cheap path stays fast (real articles
return on tier 1 without ever launching a browser); the hard path still works.
Content extraction itself cascades too: trafilatura → readability-lxml → heuristic, taking the first that produces a substantive body. Dates are normalized to ISO 8601; language is auto-detected.
Installation
The base package is deliberately lightweight (no browser binaries):
pip install articula
To unlock the JavaScript-rendering / anti-bot browser tier, install the extra and the Chromium binary:
pip install "articula[browser]"
playwright install chromium
With the extra installed, the browser tier engages automatically — no
configuration required. Without it, articula still does static + readability
extraction and degrades gracefully with a clear, actionable error when a page
genuinely needs JS rendering.
Requires Python 3.10+.
Usage
Synchronous (scripts, CLIs)
from articula import scrape
article = scrape("https://example.com/article")
Asynchronous (FastAPI, async pipelines)
from articula import async_scrape
article = await async_scrape("https://example.com/article")
The sync scrape() raises a clear error if called from inside a running event
loop — use async_scrape() there instead.
Reusing a browser across many URLs (efficient batch)
from articula import Scraper
async with Scraper(timeout=45) as scraper:
a = await scraper.scrape("https://example.com/one")
b = await scraper.scrape("https://example.com/two")
# one headless browser is launched lazily and reused
Batch with bounded concurrency
from articula import scrape_many # sync
# from articula import async_scrape_many # async equivalent
# returns results in input order; per-URL errors are captured, not fatal
results = scrape_many(urls, concurrency_limit=4)
for url, result in zip(urls, results):
if isinstance(result, Exception):
print(url, "failed:", result)
else:
print(url, "->", result.title)
In async code use await async_scrape_many(urls, concurrency_limit=4) instead —
scrape_many() raises if called from inside a running event loop.
Options
Every entry point accepts the same power-user knobs (all optional):
scrape(
url,
timeout=30.0, # total wall-clock budget, seconds
max_retries=3, # transient-failure retries (429/503/network)
custom_headers={...}, # extra request headers
custom_user_agent="…", # override the User-Agent
proxy_url="http://…", # route through a proxy
force_strategy="browser",# pin to one tier (e.g. skip straight to browser)
respect_robots=False, # see "Responsible use" below
)
The Article model
scrape() returns a frozen Article:
| Field | Type | Notes |
|---|---|---|
title |
str |
core field |
text |
str |
cleaned body, core field |
author |
str | None |
best-effort |
published_date |
str | None |
ISO 8601, best-effort |
resolved_url |
str |
final URL after redirects |
strategy_tier |
str |
"static" / "headers_rotation" / "browser" |
extraction_method |
str |
"trafilatura" / "readability" / "heuristic" |
extraction_confidence |
float |
0.0–1.0 quality signal |
detected_language |
str |
BCP-47, e.g. "en", "ko" |
strategies_attempted |
tuple[str, ...] |
escalation trail |
Partial success is success. As long as a body is extracted, you get an
Article — author/published_date are simply None when not found. Use
extraction_confidence to decide how much to trust a result.
Errors
articula raises only on genuine failure (it never silently returns junk):
ScraperError
├── FetchError # page unreachable after all tiers (network, 4xx/5xx, unsolved challenge)
├── ExtractionError # page fetched, but no meaningful body could be extracted
├── BrowserNotInstalledError
├── RobotsDisallowedError
└── ConfigurationError
FetchError and ExtractionError carry .url and .strategies_attempted for
debugging.
Responsible use
articula defaults to ignoring robots.txt (respect_robots=False) because
its purpose is to retrieve content that friction sometimes hides. That power
comes with responsibility:
- Scrape only content you are allowed to access.
- Respect site terms of service and applicable law.
- Rate-limit your requests; don't hammer origins.
- Pass
respect_robots=Trueto honorrobots.txtwhen appropriate.
You are responsible for how you use this tool.
Logging
articula uses the standard logging module under the articula logger with a
NullHandler attached, so it's silent until you configure logging:
import logging
logging.getLogger("articula").setLevel(logging.DEBUG)
logging.basicConfig()
Tier escalation, status codes, and timings are logged at DEBUG.
License
MIT
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 articula-0.1.1.tar.gz.
File metadata
- Download URL: articula-0.1.1.tar.gz
- Upload date:
- Size: 137.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0fe7c0737eafb5943a2ec67536564182f0e1a0c90975d5d7a4113074152f84ea
|
|
| MD5 |
1c8323fff9d842617bf9d5b060237281
|
|
| BLAKE2b-256 |
44b8dc151137541a566def47823f07473b83636d51eb66a2e97ffc61a4252add
|
File details
Details for the file articula-0.1.1-py3-none-any.whl.
File metadata
- Download URL: articula-0.1.1-py3-none-any.whl
- Upload date:
- Size: 28.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2274589e634f35d69f62588742fbc641dffbd098016f73c59f0cfc41cc5d6bc0
|
|
| MD5 |
d49ce4b73cdc79b0deb1f689a473df7a
|
|
| BLAKE2b-256 |
2d0c4a15be4e0ce505a8af7bc79f59c251ea75c315e46803fec23bfac860f011
|