Skip to main content

Nettle

PyPI Python License: MIT

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: "&copy 2024" → "© 2024" (tabla HTML5
# completa, un solo pase, sin doble decode) y las URLs con ?a=1&copy=2
# quedan intactas — mejor que bs4, que corrompe las URLs.

Verificado contra bs4 real ejecutándose en paralelo: 82/82 selectores CSS con resultados idénticos, 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.

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).

## 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.

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()
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.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nettle-html 0.7.0
File Size Uploaded
nettle_html-0.7.0.tar.gz 129.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for nettle-html 0.7.0
File Interpreter ABI Platform
nettle_html-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size:216.6 kB

Release files / nettle_html-0.7.0.tar.gz

Download URL nettle_html-0.7.0.tar.gz
Size 129.3 kB
Tags Source
SHA-256 checksum
How to use checksums
a03c4b50f6dad809fcf1ae37c7823cb5d415eb5b768f08967e3fd01feddbb2c5
BLAKE2b-256 checksum
How to use checksums
ececa778659fb9c8359fcd103a5b4ec3c72092c09e2ecc9531677914d6a86dc5
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.7.0-py3-none-any.whl

Download URL nettle_html-0.7.0-py3-none-any.whl
Size 87.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5265ce2c09fbeb44f05da38efaf3f20ef5aced031a88cc81116efbd97853d295
BLAKE2b-256 checksum
How to use checksums
f826497ff052ee517461f31b24190c3e01b06bf46a6550a851770d86672554eb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

0.9.0

2 release files

0.8.0

2 release files

This release

0.7.0 This release

2 release files

0.6.0

2 release files

0.5.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page