Nettle
El toolkit de scraping que Python esperaba. Una sola librería para parsear HTML, extraer datos limpios, llamar cualquier endpoint, descubrir APIs ocultas y sniffiear tráfico real de navegador — con cero dependencias externas.
Nettle existe porque el scraping real no termina en "seleccionar un nodo": termina peleando con \xa0, entidades crudas, JSON escondido en scripts, endpoints ocultos en JavaScript y sitios que te bloquean por parecer bot. Nettle resuelve todo el pipeline, no solo el primer paso.
- Corre en todas partes: Windows, Linux, macOS y Android (Termux). Python puro + stdlib, sin compilaciones ni binarios raros. El sniffing con navegador encuentra solo tu Chrome/Chromium/Edge/Brave en cualquier sistema.
- Multilingüe de verdad: verificado contra 53 sitios reales en 14 idiomas (español, inglés, japonés, chino, coreano, árabe RTL, ruso, hindi, alemán, francés, portugués, turco, tailandés, vietnamita) — URLs unicode, encodings CJK/árabe/cirílico y detección de charset automáticos.
- Gratis y libre: licencia MIT, uso comercial incluido.
from nettle import fetch
doc = fetch("https://quotes.toscrape.com/")
data = doc.extract({
"quotes": {
"select": "div.quote",
"each": {
"text": {"css": "span.text", "clean": "plain"},
"author": {"css": "small.author", "clean": "plain"},
"tags": {"css": "a.tag", "all": True, "clean": "plain"},
},
}
})
Eso es todo. Sin replace("\xa0", " "), sin html.unescape, sin re.sub(r"\s+", ...) por cada campo, sin armar dicts a mano. Texto sucio entra, datos limpios salen.
- Antes:
"Hello\xa0world's & friends" - Con Nettle:
"Hello world's & friends"
Instalación
pip install nettle-html
O desde el código fuente:
git clone https://github.com/ldikay99/nettle.git
pip install ./nettle
Requiere Python 3.9 o superior y nada más — pip install nettle-html no instala una sola dependencia. Opcional: un navegador basado en Chromium (Chrome, Edge, Brave) si quieres capturar tráfico de red real; Nettle lo detecta solo en tu sistema.
Por qué Nettle y no BeautifulSoup
| Dolor con BS4 + requests | Nettle |
|---|---|
Texto sucio (\xa0, entidades, whitespace loco) — limpias a mano por cada campo |
Limpieza integrada: clean_text() y clean: "plain" en cada extracción |
Soup no habla HTTP — necesitas requests aparte |
Cliente HTTP propio: fetch(), request(), sesiones con cookies y reintentos |
| Nada de endpoints — solo ves el HTML renderizado | discover_endpoints() los encuentra y verifica en una llamada |
| No ves el tráfico que genera la página | sniff_network() captura XHR/fetch con un Chrome real, como DevTools |
| Fingerprints de bot detectados | Rotación de perfiles de navegador reales con cabeceras Sec-Ch-Ua/Sec-Fetch coherentes |
| JSON embebido hay que sacarlo con regex frágiles | sniff_embedded_json() extrae cualquier variable = {...} que parsee como JSON |
| CSV/JSON los armas tú | to_json(), to_csv(), to_dicts() listos |
| Heurísticas fijas — si tu sitio no encaja, sufres | nettle.registry: enseñas tus convenciones en runtime, sin fork |
1. Scrape declarativo — describe el dato, no el proceso
doc.extract(esquema) mapea selectores CSS a diccionarios ya limpios. Anida, itera registros, saca atributos, absolutiza URLs:
from nettle import fetch
doc = fetch("https://books.toscrape.com/")
libros = doc.extract({
"libros": {
"select": "article.product_pod",
"each": {
"titulo": {"css": "h3 a", "attr": "title"},
"precio": {"css": ".price_color", "clean": "plain"},
"stock": {"css": ".instock.availability", "clean": "plain"},
"link": {"css": "h3 a", "attr": "href", "abs": True},
},
}
})["libros"]
Cada campo acepta attr (o lista de atributos fallback tipo ["data-src", "src"] para imágenes lazy), all=True para listas, abs=True para URLs absolutas, default= para valores por defecto y clean= con los modos plain, strict, keep_newlines o raw.
Atajos rápidos: doc.values("h1", ".precio") para varios textos de una, doc.record({...}) por elemento, doc.table("table") para tablas HTML → lista de dicts, doc.lists() para listas con items.
2. HTTP directo — cualquier método, cualquier endpoint
Si ya tienes la URL, la llamas. Nettle no asume rutas ni exige descubrir nada:
from nettle import request, call_endpoint
request("POST", "https://tienda.example/catalog/load", json={"q": "zapatos"})
request("PUT", "https://tienda.example/items/42", json={"precio": 10})
call_endpoint("https://tienda.example/items/42", "DELETE")
call_endpoint("https://tienda.example/search", "GET", params={"page": 2})
GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS — con JSON, form-data, bytes o texto. La respuesta trae .text, .json(), .status, .headers, .ok y .doc (el HTML ya parseado). Los errores HTTP (401, 404, 500…) devuelven la respuesta para inspeccionarla; los fallos de red lanzan FetchError con reintentos y backoff exponencial incluidos.
Para varias llamadas relacionadas, Session mantiene cookies entre requests y hereda tus defaults globales.
3. Descubre endpoints con una llamada
No sabes dónde está la API? Pásale la página y Nettle te devuelve los endpoints ordenados por confianza, cada uno con su evidencia:
from nettle import discover_endpoints
res = discover_endpoints("https://techcrunch.com/")
for e in res["endpoints"][:5]:
print(e["score"], e.get("status"), e["url"], e["evidence"])
# 22 200 https://techcrunch.com/wp-json/ ['classified-api', 'probe:json-ok', ...]
Qué consulta: llamadas fetch/axios/XHR/$.ajax literales en el JavaScript, asignaciones de config (baseURL = "..."), JSON embebido, atributos data-api/data-endpoint, link[rel=preload], descriptores estándar (/openapi.json, /graphql, /v3/api-docs...) y robots.txt. Luego verifica los mejores candidatos con un GET barato — un endpoint que responde JSON gana; uno que contesta 401/405 también puntúa, porque demuestra que existe. Con probe=False es análisis 100% estático: un solo request.
4. Tráfico real, como el panel Network de DevTools
Las URLs construidas en runtime y las SPAs no aparecen en el HTML. sniff_network() abre la página en un Chrome real vía CDP y captura todo lo que pasa por la red:
from nettle import sniff_network
tráfico = sniff_network("https://www.bbc.com/news")
for req in tráfico["xhr_fetch"]:
print(req["method"], req["url"], req["status"])
for j in tráfico["json"]:
print(j["url"], j.get("body", "")[:80])
El tráfico lo genera un navegador de verdad — no hay fingerprint de bot que detectar. Con scroll=True (por defecto) hace scroll automático para disparar el lazy-load antes de capturar. Nettle lanza su propio navegador si no encuentra uno corriendo (perfil aislado), cierra su pestaña al terminar y apaga el navegador que él mismo lanzó, árbol de procesos completo — cero huérfanos, verificado con contaje de procesos.
sniff_network(url, port=(9400, 9410)) # tú eliges: puerto, rango o lista
sniff_network(url, headless=False) # navegdor VISIBLE para ver qué pasa
registry.cdp["ports"] = range(9500, 9510) # o global desde el registry
shutdown_chrome() # cierre manual garantizado
Para varias capturas seguidas usa keep_chrome=True. Lo capturado viene agrupado en xhr_fetch, json y media.
5. JSON escondido en la página
Los datos que nunca llegan al DOM: estado de frameworks, JSON-LD, payloads precargados:
from nettle import fetch, sniff_embedded_json
doc = fetch("https://cualquier-sitio.com/")
for blob in sniff_embedded_json(doc):
print(blob["source"], "→", str(blob["data"])[:100])
Reconoce script[type*=json], JSON-LD y cualquier asignación nombre = {...} que parsee como JSON — conoce los globales típicos (__NEXT_DATA__, __NUXT__, initialState, ...) pero no depende de ellos: cualquier framework que metas, lo encuentra.
6. URLs: encuéntralas, clasifícalas, fíltralas
from nettle import fetch, find_urls, classify_url, filter_urls
doc = fetch("https://example.com/")
todas = find_urls(doc) # hrefs, srcs, srcset, data-*, JSON-LD, meta og:, refresh...
apis = find_urls(doc, kind="api") # solo las que parecen endpoints
media = filter_urls(todas, ext=[".png", ".webp"])
mias = find_urls(doc, same_host=True)
classify_url("https://cdn.example.com/img.webp") # "media"
classify_url("https://example.com/gql", use_hints=False) # heurísticas off
Descubre URLs de href, src, srcset, atributos lazy (data-src, data-original...), meta tags Open Graph, meta refresh y strings dentro de scripts — todo absolutizado y deduplicado.
7. Exporta sin fricción
from nettle import to_json, to_csv, write_json, write_csv
to_json(libros) # JSON string con unicode legible
write_csv(libros, "libros.csv") # columnas deducidas de los dicts, UTF-8 garantizado
Los archivos siempre se escriben en UTF-8 con finales de línea normales — sin sorpresas de encoding en Windows.
8. Adáptalo a tu sitio — NADA está quemado
Esta es la promesa central y es auditada: cada constante de la librería vive en nettle.registry y se lee en cada llamada — límites de scan, timeouts, puertos CDP, caps de preview, semillas de scroll, estados de retry, todo. Si tu sitio usa convenciones que Nettle no conoce, las enseñas tú en runtime, sin fork ni monkey-patching:
from nettle import registry
registry.add_api_hints("/tienda-service/", "/catalogo/") # tus rutas de API
registry.add_state_globals("MI_APP_STATE") # tu framework
registry.add_url_keywords("shopApi") # tus configs JS
registry.add_data_endpoint_attrs("data-x-endpoint") # tus atributos HTML
registry.add_media_exts(".weirdfmt") # tus formatos
registry.add_well_known("/api/swagger.json") # tus descriptores
registry.register_classifier(lambda u: "api" if "/loquesea" in u else None)
registry.add_discovery_skip_hosts("cdn.misitio.com") # nunca proponer ese host
registry.http.update(timeout=10, retries=1, verify=False) # defaults HTTP globales
registry.set_cdp_ports((9400, 9410)) # tu rango de puertos CDP
registry.cdp["headless"] = False # navegador visible global
registry.sniff["max_blobs"] = 200 # más JSON embebido por página
registry.parse["legacy_entities"] = True # entidades HTML5 sin ';'
registry.reset() # volver a fábrica
Dominios del registry: http, cdp (puertos, headless, timeouts, scroll), sniff (JSON embebido y probes), discover, parse, css, serialize, dns — cada clave con default estimado y modificable en runtime.
Cada heurística de la librería consulta el registry en cada llamada, así que tus reglas aplican en todas partes: descubrimiento, clasificación, sniffing, CDP. Tus clasificadores corren antes que los built-in.
9. Anti-detección integrada
fetch() y Session se presentan como navegador real por defecto: rotación de perfiles Chrome (Windows/Linux/macOS) con cabeceras User-Agent, Sec-Ch-Ua y Sec-Fetch-* coherentes entre sí. Si necesitas control total: Session(user_agent="...", headers={...}) o registry.http["user_agent"] para hacerlo global. Y cuando el sitio exige un navegador de verdad, sniff_network() lo ejecuta por ti.
10. Compatible con tu código de BeautifulSoup
Migrar desde bs4 no es reescribir: las llamadas típicas funcionan tal cual.
from nettle import fetch
doc = fetch("https://example.com/")
# find_all con todo lo que bs4 acepta
doc.find_all("a", {"href": regex}) # dict de attrs posicional
doc.find_all("a", href=regex) # kwargs con regex
doc.find_all(["a", "p"]) # lista de tags
doc.find_all("b", recursive=False) # solo hijos directos
doc.find_all(string="precio") # por texto directo
# get_text estilo bs4 (separador posicional) o estilo nettle
doc.get_text(" ") # bs4
doc.get_text(strip=True, sep=" ") # nettle
# Navegación y cirugía de árbol (nivel bs4, también desde nodos Text)
el.parent, el.parents, el.contents, el.string, el.stripped_strings
el.next_sibling, el.previous_sibling, el.next_element, el.previous_element
el.find_next("p"), el.find_all_next("a"), el.find_next_sibling("li")
el.decompose(), el.unwrap(), el.replace_with(n), el.wrap(w), el.clear()
copy.copy(el) # clona el subárbol sin mutar el original
doc.title, doc.head, doc.body
# Entidades como los navegadores: "© 2024" → "© 2024" (tabla HTML5
# completa, un solo pase, sin doble decode) y las URLs con ?a=1©=2
# quedan intactas — mejor que bs4, que corrompe las URLs.
Verificado contra bs4 real ejecutándose en paralelo: 129/129 selectores CSS con resultados idénticos (incluidos escapes \:, \., hex \3A y namespaces svg|circle — paridad soupsieve), 30/32 operaciones find/find_all, 49/49 navegaciones, 11/11 cirugías de árbol re-serializadas, 8/8 combinaciones de get_text — y nettle parsea un documento de 5.2MB en la mitad del tiempo de bs4.
¿Tienes golden tests históricos de bs4? prettify(bs4_compat=True) replica el output de bs4 byte a byte (41/41 documentos verificados) y get_text(bs4_compat=True) replica su colapso de whitespace — migra sin reescribir tus tests. Y si necesitas velocidad bruta en documentos enormes: parse(html, backend="lxml") usa lxml como tokenizador si está instalado (2.4× más rápido en 13MB, mismo árbol, misma API) con fallback automático al motor puro stdlib — nunca es dependencia.
from nettle import Session
s = Session(hooks={"response": lambda r: r}) # hooks estilo requests
Selectores estrictos: un selector mal escrito lanza SelectorError,
nunca devuelve "todos los elementos" en silencio.
Soporta :not(lista), :not(:has(...)), :is()/:where(), :nth-last-child,
:only-child y [attr="valor" i] case-insensitive.
## 11. HTTP de mundo real
- **URLs con unicode funcionan**: `fetch("https://ja.wikipedia.org/wiki/東京都")` — percent-encoding automático de rutas no-ASCII (IRI → URI).
- **gzip/deflate transparente**: menos ancho de banda, y si un CDN fuerza compresión la respuesta se decodifica sola (antes: basura binaria).
- **TLS flexible**: `Session(verify=False)` o `registry.http["verify"] = False` para certs internos/self-signed.
- **Proxies**: `Session(proxies={"https": "http://..."})` o vía registry.
- **Control de tiempo**: `timeout=` por intento, `total_timeout=` como presupuesto de toda la operación (reintentos incluidos).
- **Redirects visibles**: `response.history` — la cadena completa; `response.raise_for_status()` estilo requests; `doc.response.status` desde el propio documento de `fetch()`.
- **registry.http manda de verdad**: `registry.http["timeout"] = 5` aplica a `request()`, `fetch()` y toda la librería.
- **Session con base_url y auth**: `Session(base_url="https://api.example.com/v1")` hace que `s.get("items")` resuelva solo; `Session(auth=("user", "pass"))` autentica todo (o por-request con `s.get(url, auth=...)`).
- **Qué se reintenta es tuyo**: `registry.http["retry_statuses"]` controla exactamente qué códigos se reintentan con backoff.
- **Errores de URL inmediatos y claros**: esquema faltante o no-HTTP falla en 0.00s con mensaje que dice qué hacer (antes: 5s de retries y error críptico de urllib).
- **Brotli (br) nativo**: `Accept-Encoding` anuncia `br` y el body se decodifica con el decoder puro-Python RFC 7932 incluido (`nettle.brotli_decompress`) — sin dependencias. Si prefieres no recibir brotli: `registry.http["accept_encoding"] = "gzip, deflate"`. Encodings apilados (`gzip, br`) también.
- **Retry con jitter**: backoff exponencial aleatorizado (mitad fija + mitad `random`) para no martillear 429/503 en manada. Ajustable: `registry.http["retry_jitter"]`, `retry_max_delay`.
- **Charset BOM > meta > header**: un BOM UTF-8 o `<meta charset>` en el body ganan al `Content-Type` del servidor (los header mal etiquetados ya no producen mojibake).
- **Cookies de primera clase** (ver sección 13).
- **fetch(render=True)** para SPAs (ver sección 14).
## 12. DNS: la IP del servidor, en una llamada
```python
from nettle import resolve_ip, server_ip
resolve_ip("https://ja.wikipedia.org/") # → "208.80.154.224"
resolve_ip("www.google.com", all=True) # → todas las IPs (IPv4+IPv6)
server_ip("github.com") # alias
Timeout controlado (registry.dns["timeout"]) y FetchError claro si el host no resuelve.
Cross-platform de verdad
Nettle es Python 100% puro — el mismo código corre idéntico en:
| Sistema | Estado |
|---|---|
| Linux | Soportado (desarrollo principal) |
| Windows | Soportado — rutas de navegador, temp dir y procesos nativos |
| macOS | Soportado — detecta Chrome/Chromium/Edge/Brave en /Applications |
| Android (Termux) | Soportado — detecta binarios bajo $PREFIX |
El único componente que toca el sistema es el opcional sniff_network(): Nettle encuentra navegadores Chromium en las rutas estándar de cada OS, y si el tuyo vive en un lugar raro, apúntalo con la variable de entorno NETTLE_CHROME_BIN. Todo lo demás — parse, select, extract, HTTP, descubrimiento, formato — es stdlib puro y funciona en cualquier parte donde corra Python 3.9+.
Preguntas frecuentes
¿Necesito instalar Chrome? No. Solo para sniff_network() (captura de tráfico real). Todo lo demás funciona con Python solo.
¿Qué dependencias instala? Cero. Ni lxml, ni requests, ni bs4. Todo es stdlib — auditable, liviano y sin conflictos de versiones.
¿Sirve para SPAs (React/Vue/Svelte)? Sí: discover_endpoints() y sniff_embedded_json() encuentran los datos precargados, y sniff_network() captura el tráfico del navegador para lo que se carga dinámicamente.
¿Me van a bloquear como bot? El cliente HTTP imita navegadores reales por defecto (perfiles rotativos, cabeceras coherentes), y sniff_network() usa un navegador de verdad, así que no hay fingerprint de bot. Los sitios con protección extrema pueden seguir filtrando — para esos, el tráfico de navegador real es tu mejor arma.
¿Licencia? MIT — gratis para cualquier uso, comercial incluido. Ver LICENSE.
13. Cookies de primera clase — insertar, extraer, transferir
import nettle
s = nettle.Session()
# insertar
s.set_cookies({"session": "abc", "lang": "es"}, domain="example.com")
s.get(url, cookies={"once": "si"}) # por-request, persiste en el jar
# extraer lo que el servidor seteó
s.get("https://httpbin.org/cookies/set?k=v") # el jar captura Set-Cookie solito
s.get_cookie_dict("https://httpbin.org/") # → {"k": "v"}
s.cookie_report(url) # name/value/domain/path/expires/secure
# persistir / interoperar con curl y wget
s.save_cookies("jar.txt") # Netscape cookies.txt (expires=0 en session)
s.load_cookies("jar.txt") # roundtrip de vuelta
# puente CDP→HTTP: deja que Chrome resuelva el desafío JS y re-juega con HTTP puro
cookies = nettle.browser_cookies("https://xueqiu.com/") # lista de cookies del Chrome real
nettle.export_browser_cookies("https://xueqiu.com/", "xq.txt")
n = s.adopt_browser_cookies("https://xueqiu.com/") # → las carga en el jar
s.get("https://xueqiu.com/") # replay HTTP puro
Nota honesta anti-bot: WAFs que fingerprintean TLS (DataDome/Cloudflare) siguen
devolviendo 403 aunque lleves sus cookies — pero el desafío de cookies JS
queda cubierto. Un WAF que bloquea por IP (thepaper.cn desde esta máquina)
bloquea también al Chrome headless: browser_cookies() devuelve [] y no crashea.
14. SPAs sin <a> en el HTML crudo — fetch(render=True)
doc = nettle.fetch("https://www.daum.net/") # 0 <a> en el HTML crudo…
# UserWarning: ... looks like a JS app shell ... Re-run with nettle.fetch(url, render=True)
doc = nettle.fetch("https://www.daum.net/", render=True) # DOM renderizado vía Chrome CDP
len(doc.select("a[href]")) # → 371 (daum), 62 (twitch), 150 (gazeta)
doc.render_title # "Daum"
doc.cookies # cookies del navegador
# profile persistente envenenado por el sitio? fresh_profile=True
Umbral de detección configurable: registry.http["spa_shell"] = {"max_links": 3, "min_scripts": 5}.
API en una mirada
| Quiero... | Usa |
|---|---|
| Parsear HTML | parse(html) o fetch(url) |
| Extraer datos limpios | doc.extract(esquema), doc.record(), doc.values() |
| Tablas | doc.table(selector) |
| Llamar un endpoint | request(método, url, json=...), call_endpoint() |
| Descubrir endpoints | discover_endpoints(url) |
| Ver tráfico real | sniff_network(url) |
| JSON embebido | sniff_embedded_json(doc) |
| URLs | find_urls(), classify_url(), filter_urls() |
| Exportar | to_json(), to_csv(excel_safe=True), write_csv() |
| Navegar/cirugía estilo bs4 | el.parents, el.string, el.decompose(), el.unwrap(), doc.title |
| Apagar el navegador CDP | shutdown_chrome() |
| Cookies | s.set_cookies(...), s.get_cookie_dict(u), s.cookie_report(u), s.save_cookies(p) |
| Cookies del Chrome real | browser_cookies(u), s.adopt_browser_cookies(u), export_browser_cookies(u, p) |
| Captura como HAR | sniff_network(u, har_path="c.har"), to_har(capture) |
| DOM renderizado | fetch(u, render=True), render_page(u) |
| Brotli (decode) | nettle.brotli_decompress(bytes) |
| IP del servidor | resolve_ip(url), server_ip(host) |
| Sesión con base/auth | Session(base_url=..., auth=...) |
| Enseñar mis reglas | nettle.registry |
Release files for nettle-html 0.8.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| nettle_html-0.8.0.tar.gz | 262.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| nettle_html-0.8.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size:456.5 kB
Release files / nettle_html-0.8.0.tar.gz
| Download URL | nettle_html-0.8.0.tar.gz |
|---|---|
| Size | 262.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4b4d8c51f2943f7f816103ea9eea70db410786418d4b2575cdadef1b6e7357ed
|
|
BLAKE2b-256 checksum How to use checksums |
a113ecca4824a3d714a2b23d95377fc7188f3172e8dc1f7e35cbd7812e722cb7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / nettle_html-0.8.0-py3-none-any.whl
| Download URL | nettle_html-0.8.0-py3-none-any.whl |
|---|---|
| Size | 194.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
967a13ecdf39dbf50089a882820f2898851e94798f8d3ff20d6ef1613d99c30d
|
|
BLAKE2b-256 checksum How to use checksums |
7e1816e398d688e95e8f978de352a234af0041a74f6ed95a66b33b1ad04256ab
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|