
Python y SEO funcionan muy bien juntos: con unas pocas líneas puedes reunir en minutos los artículos mejor posicionados, extraer su contenido y detectar patrones de temas y palabras clave que están ayudando a la competencia. A continuación tienes dos rutas prácticas: con API (estable y sencilla) y scraping directo (solo para pruebas y con cautelas).
1. Esto es lo que vamos a hacer con Python
- Obtener enlaces de las SERP – Lo más estable es usar una API (p. ej., SerpAPI o Google Custom Search JSON API); scrapeo directo de Google es frágil y puede violar sus términos.
- Visitar cada URL – Con
requestsy reintentos controlados; o descarga en paralelo conconcurrent.futures. - Parsear el HTML –
BeautifulSoup(parserlxml) para títulos y subtítulos; y, si quieres texto “limpio”, librerías como trafilatura o readability-lxml. - Persistir y analizar el corpus – Guardamos JSON y aplicamos TF‑IDF, embeddings o revisión manual para localizar vacíos semánticos y “ganancias rápidas”.
- Escalar y robustecer – Con Scrapy/Playwright y rotación de IP/UA cuando el volumen crece; colas, auto‑throttling y pipelines de limpieza.
Importante: enviar consultas automatizadas a Google puede vulnerar sus políticas. Si necesitas datos de SERP de forma fiable y conforme, usa APIs oficiales o especializadas. Técnicamente, Google puede devolver 403/CAPTCHAs si detecta tráfico no humano: usar User‑Agent realista, pausar peticiones y, si procede, proxies, reduce bloqueos.
2. Prompt 1 – Generar la app con SerpAPI
Este prompt crea una utilidad CLI que busca con SerpAPI, descarga páginas y extrae estructura básica.
Rol deseado de la IA
====================
Eres un/a **Ingeniero/a de Datos SEO sénior** que aplica buenas prácticas de desarrollo
(Python ≥ 3.10, PEP 8, PEP 257, tipado estático) y optimización de rendimiento.
Objetivo del script (un solo fichero .py)
-----------------------------------------
Crear un programa de línea de comandos que:
1. **Recupere** con SerpAPI los *n* primeros resultados orgánicos (por defecto 10) de
una consulta proporcionada por el usuario.
2. **Descargue** cada URL con `requests` empleando:
- *timeout*, cabecera *User-Agent* personalizada y reintentos exponenciales.
- Opción de descarga concurrente (`concurrent.futures`).
3. **Extraiga** con `BeautifulSoup` el primer `<h1>`, todos los `<h2>` y párrafos
de texto “principales” (heurística: longitud > 40 caracteres).
4. **Guarde** un único fichero JSON UTF-8 con la estructura:
```json
[
{
"url": "...",
"title": "...", // <h1>
"h2": ["...", "..."],
"text": ["párrafo1", "párrafo2", …]
},
…
]
```
Nombre del archivo y carpeta de salida deben ser configurables.
## Menú / UI (CLI)
Implementa un menú con **`argparse`** que permita:
* `--query` (posicional): cadena de búsqueda obligatoria.
* `--results`, `-n`: número de resultados (1-50, por defecto 10).
* `--outfile`, `-o`: ruta del JSON de salida (por defecto `results.json`).
* `--concurrent` / `--no-concurrent`: activa o desactiva descarga paralela (BooleanOptionalAction).
* `--verbose`, `-v`: nivel de *logging* (INFO/DEBUG).
* `--version`: muestra la versión del script.
## Robustez y buenas prácticas
* **Type hints** con comprobación opcional (`mypy`).
* **Docstrings** estilo *Google* o *NumPy*.
* **Logging** en vez de `print`.
* Manejo fino de excepciones (HTTP 4xx/5xx, SSL, SerpAPIError…).
* Separación en funciones y, si procede, clases; evita bloques monolíticos.
* Usa `if __name__ == "__main__":` para el *entry-point*.
## Variables de entorno
* `SERPAPI_KEY` → API Key de SerpAPI (obligatoria).
* `REQUESTS_UA` → *User-Agent* HTTP (opcional, valor por defecto razonable).
* `HTTP_TIMEOUT` → timeout global en segundos (opcional, defecto 10).
## Instalación y ejecución (añadir al *docstring* principal)
```bash
# 1. Crear entorno virtual
python -m venv .venv && source .venv/bin/activate # Linux/macOS
# o bien .venv\Scripts\activate # Windows
# 2. Instalar dependencias mínimas (versiones fijadas)
pip install -r requirements.txt
# 3. Exportar la API-Key
export SERPAPI_KEY="tu_clave_real_aquí"
# 4. Ejecutar
python seo_scraper.py --query "mejores auriculares inalámbricos" -n 10 -o auriculares.json
```
## Fichero *requirements.txt* sugerido
```
requests>=2.32
beautifulsoup4>=4.12
lxml>=5
google-search-results>=2.4.2 # cliente oficial SerpAPI (módulo `serpapi`)
tqdm>=4.68 # barra de progreso opcional
```
## Calidad y estilo
* Formatea con `ruff` o `black`.
* Comentarios solo cuando aporten contexto real.
* Incluye tests mínimos (doctest/pytest) **en el mismo fichero** bajo `if __name__ == "__main__":`.
## Criterio de aceptación
El script debe ejecutarse sin errores con la orden de ejemplo y producir
un JSON válido con la estructura y parámetros especificados.Este es un posible código para esta aplicación (actualizado y funcional)
#!/usr/bin/env python3
# seo_scraper.py
"""
SEO Scraper CLI (SerpAPI)
Busca en Google usando SerpAPI, descarga las páginas resultantes,
extrae el primer <h1>, todos los <h2> y párrafos (>40 caracteres),
y guarda todo en un único JSON UTF-8.
Instalación y ejecución:
# 1. Crear entorno virtual
python -m venv .venv && source .venv/bin/activate # Linux/macOS
# o bien .venv\\Scripts\\activate # Windows
# 2. Instalar dependencias mínimas
pip install -r requirements.txt
# 3. Exportar la API-Key
export SERPAPI_KEY="tu_clave_real_aquí"
# Opcional: definir User-Agent y timeout
export REQUESTS_UA="MiScraper/1.0 (+https://miweb.example)"
export HTTP_TIMEOUT="15"
# 4. Ejecutar
python seo_scraper.py --query "mejores auriculares inalámbricos" -n 10 -o auriculares.json
requirements.txt sugerido:
requests>=2.32
beautifulsoup4>=4.12
lxml>=5
google-search-results>=2.4.2
tqdm>=4.68
"""
import os
import sys
import json
import logging
import argparse
from pathlib import Path
from typing import List, Dict, Any, Tuple
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from bs4 import BeautifulSoup
from serpapi import GoogleSearch
__version__ = "1.1.0"
def create_session(user_agent: str,
retries: int = 3,
backoff_factor: float = 1.0) -> requests.Session:
"""Crea una sesión de requests con reintentos exponenciales."""
session = requests.Session()
retry = Retry(
total=retries,
backoff_factor=backoff_factor,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods={"GET"},
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("http://", adapter)
session.mount("https://", adapter)
session.headers.update({"User-Agent": user_agent})
return session
def fetch_url(url: str,
session: requests.Session,
timeout: float,
logger: logging.Logger
) -> Tuple[str, str]:
"""Descarga el contenido HTML de una URL."""
try:
resp = session.get(url, timeout=timeout)
resp.raise_for_status()
return url, resp.text
except requests.RequestException as e:
logger.error("Error descargando %s: %s", url, e)
return url, ""
def extract_content(html: str) -> Dict[str, Any]:
"""Extrae title, h2 y párrafos principales de un HTML.
>>> sample = "<h1>Título</h1><h2>Sub</h2><p>Corto</p><p>Este párrafo supera los cuarenta caracteres para la prueba.</p>"
>>> result = extract_content(sample)
>>> result["title"]
'Título'
>>> result["h2"]
['Sub']
>>> len(result["text"]) == 1
True
"""
soup = BeautifulSoup(html, "lxml")
title_tag = soup.find("h1")
title = title_tag.get_text(strip=True) if title_tag else ""
h2_tags = [h.get_text(strip=True) for h in soup.find_all("h2")]
paras = []
for p in soup.find_all("p"):
text = p.get_text(strip=True)
if len(text) > 40:
paras.append(text)
return {"title": title, "h2": h2_tags, "text": paras}
def run_scraper(query: str,
num_results: int,
outfile: Path,
concurrent: bool,
user_agent: str,
timeout: float,
logger: logging.Logger) -> None:
"""Orquesta la búsqueda, descarga y extracción."""
api_key = os.getenv("SERPAPI_KEY")
if not api_key:
logger.error("La variable de entorno SERPAPI_KEY no está definida.")
sys.exit(1)
params = {"q": query, "api_key": api_key, "num": num_results}
logger.info("Buscando '%s' en SerpAPI (n=%d)...", query, num_results)
try:
data = GoogleSearch(params).get_dict()
except Exception as e:
logger.error("Error con SerpAPI: %s", e)
sys.exit(1)
organic = data.get("organic_results", []) or []
urls = [r.get("link") or r.get("url") for r in organic if r.get("link") or r.get("url")]
urls = urls[:num_results]
logger.debug("URLs obtenidas: %s", urls)
session = create_session(user_agent)
results: List[Dict[str, Any]] = []
if concurrent:
from concurrent.futures import ThreadPoolExecutor, as_completed
logger.info("Descarga concurrente activada.")
with ThreadPoolExecutor(max_workers=min(32, max(1, len(urls)))) as executor:
futures = {executor.submit(fetch_url, url, session, timeout, logger): url for url in urls}
for future in as_completed(futures):
url, html = future.result()
content = extract_content(html) if html else {"title": "", "h2": [], "text": []}
results.append({"url": url, **content})
else:
logger.info("Descarga secuencial.")
for url in urls:
_, html = fetch_url(url, session, timeout, logger)
content = extract_content(html) if html else {"title": "", "h2": [], "text": []}
results.append({"url": url, **content})
# Guardar JSON
outfile.parent.mkdir(parents=True, exist_ok=True)
with outfile.open("w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
logger.info("Resultados guardados en %s", outfile)
def parse_args() -> argparse.Namespace:
"""Parsea los argumentos de línea de comandos."""
parser = argparse.ArgumentParser(description="SEO Scraper con SerpAPI")
parser.add_argument("query", help="Cadena de búsqueda a consultar")
parser.add_argument("-n", "--results", type=int, default=10,
choices=range(1, 51), metavar="[1-50]",
help="Número de resultados (1-50)")
parser.add_argument("-o", "--outfile", type=Path, default=Path("results.json"),
help="Ruta del JSON de salida")
parser.add_argument("--concurrent", action=argparse.BooleanOptionalAction, default=False,
help="Activar/Desactivar descarga paralela")
parser.add_argument("-v", "--verbose", action="count", default=0,
help="Nivel de verbosidad: -v INFO, -vv DEBUG")
parser.add_argument("--version", action="version",
version=f"%(prog)s {__version__}")
return parser.parse_args()
def configure_logging(verbosity: int) -> None:
"""Configura el logging según el nivel de verbosidad."""
level = logging.WARNING
if verbosity >= 2:
level = logging.DEBUG
elif verbosity == 1:
level = logging.INFO
logging.basicConfig(
level=level,
format="[%(asctime)s] %(levelname)s:%(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
if __name__ == "__main__":
args = parse_args()
configure_logging(args.verbose)
log = logging.getLogger("seo_scraper")
ua = os.getenv("REQUESTS_UA", f"seo-scraper/{__version__} (+https://example.com)")
try:
timeout_val = float(os.getenv("HTTP_TIMEOUT", "10"))
except ValueError:
log.warning("HTTP_TIMEOUT no es numérico; usando 10s por defecto.")
timeout_val = 10.0
run_scraper(
query=args.query,
num_results=args.results,
outfile=args.outfile,
concurrent=bool(args.concurrent),
user_agent=ua,
timeout=timeout_val,
logger=log,
)
# Tests de doctest mínimos
import doctest
doctest.testmod()Funcionamiento y uso
- Dependencias:
pip install google-search-results beautifulsoup4 lxml requests tqdm. - Clave: SerpAPI ofrece plan gratuito con ~250 búsquedas/mes y un plan “Developer” de 5 000 búsquedas por 75 USD/mes; revisa su pricing actual antes de arrancar.
- Ejecución: define
SERPAPI_KEYy corre el script; obtendrás un JSON por URL con títulos, subtítulos y párrafos listos para análisis. El tamaño varía según la página.
3. Prompt 2 – Scraping directo con requests + BeautifulSoup
Alternativa sin APIs: hacer scraping de las SERP directamente. Úsala solo para pruebas: es frágil, cambia a menudo y puede contravenir políticas de Google. Para uso en producción o a escala, recurre a APIs (oficiales o de terceros).
### Rol asignado a la IA
Eres un/a **Ingeniero/a de Datos SEO sénior** con experiencia en Python (≥ 3.10), siguiendo PEP 8 + PEP 257, tipado estático y foco en robustez y rendimiento.
---
### Objetivo general del script (un solo `google_scraper.py`)
1. **Buscar en Google** sin APIs externas (peticiones `requests` a `https://www.google.com/search`).
2. **Paginar** hasta obtener un máximo de *n* (1-30, por defecto 10) resultados orgánicos.
3. **Extraer** la URL de cada resultado orgánico (ignorar anuncios, vídeos, mapas, etc.).
4. **Visitar** cada URL respetando:
* **Rotación básica de *User-Agent*** (lista interna o leída de un `.txt`).
* **Delay pseudo-aleatorio 2–5 s** entre peticiones.
* **Timeout** configurable y reintentos exponenciales.
5. **Parsear** con `BeautifulSoup` cada página destino y obtener:
```
- Primer <h1>
- Todos los <h2>
- Párrafos de “texto principal” (longitud > 40 caracteres)
```
6. **Guardar** un único fichero JSON UTF-8 con la forma:
```json
[
{
"url": "https://ejemplo.com",
"title": "Título H1",
"h2": ["Subtítulo 1", "Subtítulo 2"],
"text": ["Párrafo 1", "Párrafo 2"]
}
]
```
7. **Mostrar un menú/CLI** claro (argparse) para controlar la ejecución.
---
### Interfaz de línea de comandos
| Opción | Descripción |
| -------------------------------- | -------------------------------------------------------------- |
| `query` *(posicional)* | Cadena de búsqueda obligatoria. |
| `-n, --results` | Número de resultados (1-30), **defecto 10**. |
| `-o, --outfile` | Ruta del JSON de salida, **defecto results.json**. |
| `--ua-file` | Ruta opcional a fichero con *user-agents* (uno por línea). |
| `--concurrent / --no-concurrent` | Descarga paralela con `concurrent.futures`. |
| `-v, --verbose` | Nivel de *logging* (INFO/DEBUG). |
| `--version` | Muestra la versión del script. |
---
### Buenas prácticas exigidas
* **Type hints** y verificación opcional `mypy`.
* **Docstrings** estilo *Google* o *NumPy* para **todas** las funciones.
* **Logging** (no `print`) y barra de progreso opcional con `tqdm`.
* **Estructura lógica** en funciones/módulos; sin bloques monolíticos.
* Manejo de errores: HTTP 4xx/5xx, conexión/SSL, CAPTCHAs/bloqueos.
* **Tests unitarios** ligeros (pytest o doctest) al final del archivo.
---
### Variables de entorno aceptadas
| Variable | Propósito | Valor por defecto |
| -------------- | --------------------------------------------------- | --------------------------------------- |
| `REQUESTS_UA` | *User-Agent* por defecto si no se pasa `--ua-file`. | Uno genérico de navegador moderno. |
| `HTTP_TIMEOUT` | Timeout global en segundos. | `10` |
---
### Instalación y ejecución (añadir en el docstring principal)
```bash
# 1. Crear entorno virtual
python -m venv .venv && source .venv/bin/activate # Linux/macOS
REM o bien .venv\Scripts\activate # Windows
# 2. Instalar dependencias
pip install -r requirements.txt
# 3. Ejecutar
python google_scraper.py "mejores auriculares inalámbricos" -n 20 -o auriculares.json --concurrent -v
```
---
### Requisitos mínimos (`requirements.txt`)
```
requests>=2.32
beautifulsoup4>=4.12
lxml>=5.0
tqdm>=4.68
```
*(Para extraer texto de artículo de forma más fiable, considera `trafilatura` o `readability-lxml`.)*
---
### Criterios de aceptación
1. **Autocontenida**: todo en un único `.py`.
2. **Funcional**: devuelve resultados orgánicos reales, no anuncios.
3. **Robusta**: maneja timeouts, bloqueos y códigos de error sin abortar.
4. **Eficiente**: opción de descarga concurrente.
5. **Documentada**: docstring inicial con instrucciones de uso.
6. **Testable**: incluye al menos 3 pruebas sencillas.
7. **Estilo**: cumple normas de formato y tipado.
Código resultante
Base funcional para scraping directo (educativo; propenso a romper si cambia el HTML de Google):
#!/usr/bin/env python3
"""
google_scraper.py
Scraper educativo de resultados orgánicos de Google (sin APIs).
Instalación:
python -m venv .venv && source .venv/bin/activate # Linux/macOS
REM o .venv\\Scripts\\activate # Windows
pip install -r requirements.txt
Uso:
python google_scraper.py "mejores auriculares inalámbricos" \\
-n 20 -o auriculares.json --concurrent -v
Aviso:
En producción usa APIs (p. ej., SerpAPI o Google Custom Search JSON API).
El HTML de Google cambia a menudo; este ejemplo puede dejar de funcionar.
"""
import os
import sys
import time
import json
import random
import argparse
import logging
from typing import List, Dict, Any, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from bs4 import BeautifulSoup
from tqdm import tqdm
from requests.exceptions import RequestException
__version__ = "1.1.0"
DEFAULT_UAS: List[str] = [
# UAs genéricos (mantener actualizados según navegadores modernos)
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36",
]
def get_user_agents(ua_file: Optional[str] = None) -> List[str]:
"""Carga *User-Agents* desde fichero o devuelve lista por defecto."""
if ua_file and os.path.isfile(ua_file):
try:
with open(ua_file, "r", encoding="utf-8") as f:
uas = [line.strip() for line in f if line.strip()]
if uas:
return uas
except OSError:
logging.warning("No se pudo leer %s, usando UAs por defecto", ua_file)
return DEFAULT_UAS.copy()
def fetch_search_results(query: str,
num_results: int,
ua_list: List[str],
timeout: int) -> List[str]:
"""Obtiene URLs orgánicas de Google Search (puede romper con cambios de HTML)."""
urls: List[str] = []
session = requests.Session()
per_page = 10
for start in range(0, num_results, per_page):
params = {"q": query, "start": start, "hl": "es"}
headers = {"User-Agent": random.choice(ua_list)}
try:
resp = session.get("https://www.google.com/search",
params=params, headers=headers, timeout=timeout)
resp.raise_for_status()
except RequestException as e:
logging.error("Error al pedir página de resultados: %s", e)
break
soup = BeautifulSoup(resp.text, "lxml")
# Heurística: enlaces con <h3> dentro del contenedor #search
for a in soup.select("#search a[href] h3"):
link_tag = a.parent
href = link_tag.get("href")
if href and href.startswith("http"):
if href not in urls:
urls.append(href)
if len(urls) >= num_results:
break
if len(urls) >= num_results:
break
time.sleep(random.uniform(1.0, 2.0)) # delay entre páginas
return urls[:num_results]
def fetch_url(url: str,
ua_list: List[str],
timeout: int,
max_retries: int = 3,
backoff_factor: float = 1.0) -> Optional[str]:
"""Descarga una URL con reintentos, delay aleatorio y rotación de UA."""
session = requests.Session()
for attempt in range(1, max_retries + 1):
headers = {"User-Agent": random.choice(ua_list)}
time.sleep(random.uniform(2.0, 5.0))
try:
resp = session.get(url, headers=headers, timeout=timeout)
resp.raise_for_status()
return resp.text
except RequestException as e:
wait = backoff_factor * (2 ** (attempt - 1))
logging.warning("Error %s (intento %d/%d). Reintentando en %.1fs",
url, attempt, max_retries, wait)
time.sleep(wait)
logging.error("Fallo definitivo descargando %s", url)
return None
def parse_page(url: str, html: str) -> Dict[str, Any]:
"""Extrae <h1>, todos los <h2> y párrafos (> 40 chars)."""
soup = BeautifulSoup(html, "lxml")
h1 = soup.find("h1")
title = h1.get_text(strip=True) if h1 else ""
h2_list = [h.get_text(strip=True) for h in soup.find_all("h2")]
paragraphs = [p.get_text(strip=True) for p in soup.find_all("p")
if len(p.get_text(strip=True)) > 40]
return {"url": url, "title": title, "h2": h2_list, "text": paragraphs}
def scrape(query: str,
num_results: int,
ua_file: Optional[str],
concurrent: bool,
timeout: int) -> List[Dict[str, Any]]:
"""Orquesta la búsqueda y el scraping de páginas destino."""
ua_list = get_user_agents(ua_file)
urls = fetch_search_results(query, num_results, ua_list, timeout)
logging.info("Se encontraron %d URLs para extraer", len(urls))
results: List[Dict[str, Any]] = []
if concurrent:
with ThreadPoolExecutor(max_workers=min(32, max(1, len(urls)))) as executor:
futures = {executor.submit(fetch_url, url, ua_list, timeout): url for url in urls}
for future in tqdm(as_completed(futures), total=len(futures), desc="Descargando"):
html = future.result()
if html:
results.append(parse_page(futures[future], html))
else:
for url in tqdm(urls, desc="Descargando"):
html = fetch_url(url, ua_list, timeout)
if html:
results.append(parse_page(url, html))
return results
def main() -> None:
parser = argparse.ArgumentParser(description="Google organic scraper (educativo)")
parser.add_argument("query", help="Cadena de búsqueda")
parser.add_argument("-n", "--results", type=int, default=10, help="Número de resultados (1-30)")
parser.add_argument("-o", "--outfile", default="results.json", help="Ruta al JSON de salida")
parser.add_argument("--ua-file", help="Fichero con User-Agent por línea")
parser.add_argument("--concurrent", action=argparse.BooleanOptionalAction, default=False,
help="Descarga en paralelo")
parser.add_argument("-v", "--verbose", action="store_const", dest="loglevel",
const=logging.DEBUG, default=logging.INFO, help="Nivel de logging DEBUG")
parser.add_argument("--version", action="version", version=__version__)
args = parser.parse_args()
if not (1 <= args.results <= 30):
parser.error("El número de resultados debe estar entre 1 y 30.")
logging.basicConfig(level=args.loglevel,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S")
timeout = int(os.getenv("HTTP_TIMEOUT", "10"))
data = scrape(args.query, args.results, args.ua_file, bool(args.concurrent), timeout)
try:
with open(args.outfile, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logging.info("Guardados %d resultados en %s", len(data), args.outfile)
except OSError as e:
logging.error("No se pudo escribir el archivo %s: %s", args.outfile, e)
sys.exit(1)
if __name__ == "__main__":
main()
# -------------------------
# pytest unit tests
# -------------------------
def test_get_user_agents_default() -> None:
"""Devuelve lista por defecto cuando no hay fichero."""
uas = get_user_agents("no-existe.txt")
assert isinstance(uas, list) and len(uas) >= 1
def test_parse_page_basic() -> None:
"""Parseo básico de HTML."""
html = """
<h1>Main Title</h1>
<h2>Subtitle</h2>
<p>This paragraph is definitely longer than forty characters long.</p>
<p>Short one.</p>
"""
result = parse_page("http://test", html)
assert result["title"] == "Main Title"
assert result["h2"] == ["Subtitle"]
assert len(result["text"]) == 1
def test_fetch_search_results_mock(monkeypatch) -> None:
"""Extrae URLs orgánicas de HTML simulado."""
sample_html = '''
<div id="search">
<a href="http://a.com"><h3>Title A</h3></a>
<a href="http://b.com"><h3>Title B</h3></a>
<a><h3>No Link</h3></a>
</div>
'''
class DummyResp:
status_code = 200
text = sample_html
def raise_for_status(self): pass
def fake_get(*args, **kwargs):
return DummyResp()
monkeypatch.setattr(requests.Session, "get", lambda self, *a, **k: fake_get())
urls = fetch_search_results("test", 2, DEFAULT_UAS, timeout=5)
assert urls == ["http://a.com", "http://b.com"]4. Tabla comparativa de alternativas
| Enfoque / API | Tipo | Complejidad | Coste aprox.* | Ventajas | Desventajas |
|---|---|---|---|---|---|
| SerpAPI | SaaS SERP API | Baja | Gratis (≈250 búsq./mes); 75 USD/5 000 búsq./mes | Resuelve captchas, múltiples motores, JSON limpio | Precio crece con volumen |
| Google Custom Search JSON API | API oficial | Media | 100 consultas/día gratis; 5 USD/1 000 extra (hasta 10k/día) | Estable, conforme a políticas | Límites diarios; requiere configuración de PSE |
| Apify Google Search Scraper | SaaS (pago por evento) | Baja | $5 de crédito/mes en plan gratis (~>1 000 resultados); descuentos por plan | Pago granular; cientos de *actors* prehechos | Rendimiento sujeto a cola; coste por volumen |
| Scrapy + proxies | Framework Python | Alta | Variable (por GB o IP, según proveedor) | Gran control, *auto‑throttle*, pipelines | Curva de aprendizaje; gestión de desbloqueo |
| Playwright headless | Navegador real | Media‑Alta | Software libre; coste de infraestructura/proxy | Renderiza JS; útil para páginas dinámicas | Más consumo de recursos; tuning fino |
| Bright Data SERP API | SaaS | Baja | Pago por solicitud/éxito; planes mensuales (según volumen) | Amplia cobertura geográfica y dispositivos | Estructura de precios compleja |
*Precios consultados en noviembre de 2025; pueden variar según plan y región. Verifica siempre la página oficial antes de decidir.
5. Notas legales y de cumplimiento
Automatizar consultas a Google (p. ej., scrapeo de SERP) puede infringir sus políticas y provocar bloqueos de IP o captchas. Para proyectos estables y conformes, prioriza Google Custom Search JSON API o proveedores especializados de SERP API. Respeta siempre robots.txt de los sitios destino y los términos de uso de cada servicio.
6. Conclusión rápida
Si necesitas resultados ya y con estabilidad, usa SerpAPI o la API oficial de Google y automatiza tu pipeline con el primer script. Para aprendizaje o POCs, el scraper directo te servirá, pero migra a APIs antes de escalar. Añade luego TF‑IDF o embeddings a tu JSON para descubrir gaps temáticos y oportunidades de contenido.












