Análisis SEO de la competencia con Python

analisis competencia seo python

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

  1. 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.
  2. Visitar cada URL – Con requests y reintentos controlados; o descarga en paralelo con concurrent.futures.
  3. Parsear el HTMLBeautifulSoup (parser lxml) para títulos y subtítulos; y, si quieres texto “limpio”, librerías como trafilatura o readability-lxml.
  4. 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”.
  5. 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

  1. Dependencias: pip install google-search-results beautifulsoup4 lxml requests tqdm.
  2. 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.
  3. Ejecución: define SERPAPI_KEY y 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 / APITipoComplejidadCoste aprox.*VentajasDesventajas
SerpAPISaaS SERP APIBajaGratis (≈250 búsq./mes); 75 USD/5 000 búsq./mesResuelve captchas, múltiples motores, JSON limpioPrecio crece con volumen
Google Custom Search JSON APIAPI oficialMedia100 consultas/día gratis; 5 USD/1 000 extra (hasta 10k/día)Estable, conforme a políticasLímites diarios; requiere configuración de PSE
Apify Google Search ScraperSaaS (pago por evento)Baja$5 de crédito/mes en plan gratis (~>1 000 resultados); descuentos por planPago granular; cientos de *actors* prehechosRendimiento sujeto a cola; coste por volumen
Scrapy + proxiesFramework PythonAltaVariable (por GB o IP, según proveedor)Gran control, *auto‑throttle*, pipelinesCurva de aprendizaje; gestión de desbloqueo
Playwright headlessNavegador realMedia‑AltaSoftware libre; coste de infraestructura/proxyRenderiza JS; útil para páginas dinámicasMás consumo de recursos; tuning fino
Bright Data SERP APISaaSBajaPago por solicitud/éxito; planes mensuales (según volumen)Amplia cobertura geográfica y dispositivosEstructura 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.

Aprende más sobre SEO Semántico

Daniel Pajuelo

Daniel Pajuelo es ingeniero informático y SEO Senior, actualmente trabajando en Guruwalk. En su blog personal escribe sobre Inteligencia Artificial, SEO, Programación...

Ver más

Continua leyendo

Leer más sobre: SEO, Programación