github-pages-auditor 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ """github-pages-auditor: auditor CLI de repositorios, GitHub Pages y releases."""
2
+
3
+ __version__ = "1.2.0"
4
+ NOMBRE_PROGRAMA = "github-pages-auditor"
5
+ AUTOR = "Alberto Ortiz"
6
+ URL_GITHUB = "https://github.com/alberto2005-coder/"
7
+ URL_WEB = "https://albertoortiz.is-a.dev/"
8
+
9
+
10
+ def creditos(sep: str = " · ") -> str:
11
+ """Línea de créditos que se muestra en --help, --version, consola e informe."""
12
+ return f"Desarrollado por {AUTOR}{sep}GitHub: {URL_GITHUB}{sep}Web: {URL_WEB}"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,79 @@
1
+ from concurrent.futures import ThreadPoolExecutor, as_completed
2
+
3
+ import requests
4
+ from rich.console import Console
5
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeRemainingColumn
6
+
7
+ from .config import Config
8
+ from .errors import ErrorAuditoria
9
+ from .github_api import GitHubAPIProvider
10
+ from .github_web import GitHubWebProvider
11
+ from .http import crear_sesion_http
12
+ from .models import AuditReport, RepoInfo
13
+
14
+
15
+ def crear_proveedor(config: Config, session):
16
+ if config.usa_api:
17
+ return (GitHubAPIProvider(config.usuario, config.dominio_web, session, token=config.github_token),
18
+ "API REST de GitHub (Autenticado)")
19
+ return (GitHubWebProvider(config.usuario, config.dominio_web, session),
20
+ "Scraping Web Público (Sin API)")
21
+
22
+
23
+ def ejecutar_auditoria(config: Config, session=None, console: Console = None) -> AuditReport:
24
+ """Audita todos los repos. Un fallo de red en un repo se anota en ``RepoInfo.error``
25
+ sin tumbar el resto; los errores globales (usuario inexistente, token inválido,
26
+ límite de tasa) se propagan como ``ErrorAuditoria``.
27
+ """
28
+ session = session or crear_sesion_http(timeout=config.timeout)
29
+ console = console or Console(no_color=config.sin_color)
30
+ provider, nombre_proveedor = crear_proveedor(config, session)
31
+
32
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
33
+ transient=True, console=console) as progress:
34
+ task_list = progress.add_task(
35
+ description=f"🔍 Escaneando repositorios de [bold yellow]{config.usuario}[/bold yellow]...", total=None)
36
+
37
+ def actualizar_progreso(pagina: int, encontrados: int):
38
+ progress.update(task_list, description=(
39
+ f"🔍 Escaneando página [bold cyan]{pagina}[/bold cyan]... "
40
+ f"([bold green]{encontrados}[/bold green] repositorios encontrados)"))
41
+
42
+ try:
43
+ repos_base = provider.obtener_repositorios(on_progress=actualizar_progreso)
44
+ except requests.RequestException as e:
45
+ raise ErrorAuditoria(f"Error de red al listar los repositorios: {e}") from e
46
+
47
+ repos_base = [
48
+ r for r in repos_base
49
+ if not (config.excluir_forks and r.es_fork) and not (config.excluir_archivados and r.esta_archivado)
50
+ ]
51
+ informe = AuditReport(usuario=config.usuario, dominio_base=config.dominio_web, proveedor=nombre_proveedor,
52
+ modo="api" if config.usa_api else "web")
53
+ if not repos_base:
54
+ return informe
55
+
56
+ def trabajo(repo: RepoInfo) -> RepoInfo:
57
+ try:
58
+ return provider.enriquecer_repo(repo)
59
+ except requests.RequestException as e:
60
+ repo.error = str(e)
61
+ return repo
62
+
63
+ resultados = [None] * len(repos_base)
64
+ with Progress(SpinnerColumn(), TextColumn("[bold cyan]Auditando código:[/bold cyan]"), BarColumn(bar_width=35),
65
+ TaskProgressColumn(), TextColumn("[yellow]({task.completed}/{task.total})[/yellow]"),
66
+ TimeRemainingColumn(), console=console) as progress:
67
+ task_audit = progress.add_task("audit", total=len(repos_base))
68
+ with ThreadPoolExecutor(max_workers=config.max_workers) as executor:
69
+ futuros = {executor.submit(trabajo, repo): i for i, repo in enumerate(repos_base)}
70
+ try:
71
+ for futuro in as_completed(futuros):
72
+ resultados[futuros[futuro]] = futuro.result()
73
+ progress.advance(task_audit)
74
+ except BaseException:
75
+ executor.shutdown(wait=False, cancel_futures=True)
76
+ raise
77
+
78
+ informe.repos = resultados # mismo orden que el listado: informe determinista
79
+ return informe
@@ -0,0 +1,152 @@
1
+ """Configuración: parámetros de consola > variables de entorno (.env opcional) > defecto."""
2
+ import argparse
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Optional, Tuple
6
+
7
+ from dotenv import find_dotenv, load_dotenv
8
+
9
+ from . import NOMBRE_PROGRAMA, __version__, creditos
10
+ from .rules import TODAS, parsear_fail_on
11
+
12
+ SALIDA_BASE = "paginas_github"
13
+ WORKERS_DEFECTO = 15
14
+ TIMEOUT_DEFECTO = 10.0
15
+ FORMATOS = ("txt", "json", "md")
16
+
17
+
18
+ @dataclass
19
+ class Config:
20
+ usuario: str
21
+ dominio_web: str
22
+ github_token: Optional[str]
23
+ archivo_salida: Optional[str] # None = no escribir informe; "-" = stdout
24
+ max_workers: int
25
+ forzar_web: bool
26
+ sin_color: bool
27
+ timeout: float = TIMEOUT_DEFECTO
28
+ formato: str = "txt"
29
+ fail_on: Tuple[str, ...] = ()
30
+ excluir_forks: bool = False
31
+ excluir_archivados: bool = False
32
+ inject: Optional[str] = None
33
+ quiet: bool = False
34
+
35
+ @property
36
+ def usa_api(self) -> bool:
37
+ return bool(self.github_token) and not self.forzar_web
38
+
39
+ @property
40
+ def salida_estandar(self) -> bool:
41
+ return self.archivo_salida == "-"
42
+
43
+
44
+ def normalizar_dominio(dominio: Optional[str], usuario: str) -> str:
45
+ dominio = (dominio or "").strip()
46
+ if not dominio:
47
+ return f"https://{usuario}.github.io"
48
+ if not dominio.lower().startswith(("http://", "https://")):
49
+ dominio = f"https://{dominio}"
50
+ return dominio.rstrip("/")
51
+
52
+
53
+ def _fail_on(texto: str):
54
+ try:
55
+ return parsear_fail_on(texto)
56
+ except ValueError as e:
57
+ raise argparse.ArgumentTypeError(str(e))
58
+
59
+
60
+ def crear_parser() -> argparse.ArgumentParser:
61
+ env = lambda k, d=None: (os.getenv(k) or "").strip() or d # noqa: E731 (vacío == no definido)
62
+
63
+ parser = argparse.ArgumentParser(
64
+ prog=NOMBRE_PROGRAMA,
65
+ description="Auditor CLI para repositorios, GitHub Pages y releases de un usuario de GitHub.\n"
66
+ "Funciona solo con parámetros: el archivo .env y el token de la API son opcionales.",
67
+ epilog=(
68
+ "Prioridad de configuración: parámetros > variables de entorno/.env > valores por defecto.\n"
69
+ "Variables de entorno: USUARIO_GITHUB, DOMINIO_WEB, GITHUB_TOKEN, ARCHIVO_SALIDA.\n"
70
+ "Códigos de salida: 0 ok, 1 --fail-on disparado, 2 error, 3 sin repositorios, 130 Ctrl+C.\n\n"
71
+ + creditos(sep="\n")
72
+ ),
73
+ formatter_class=argparse.RawDescriptionHelpFormatter,
74
+ )
75
+ parser.add_argument("-u", "--usuario", "--user", dest="usuario", default=env("USUARIO_GITHUB"),
76
+ help="Nombre de usuario de GitHub (obligatorio).")
77
+ parser.add_argument("-w", "--web", "--dominio", dest="dominio_web", default=env("DOMINIO_WEB"),
78
+ help="Dominio base de tu sitio web o GitHub Pages (por defecto https://<usuario>.github.io).")
79
+ parser.add_argument("-k", "--key", "--token", dest="github_token", default=env("GITHUB_TOKEN"),
80
+ help="GitHub Personal Access Token (opcional). Con token se usa la API; sin él, el modo web. "
81
+ "Más seguro: variable GITHUB_TOKEN (un parámetro queda en el historial del shell).")
82
+ parser.add_argument("-o", "--output", dest="archivo_salida", default=env("ARCHIVO_SALIDA"),
83
+ help=f"Archivo del informe (por defecto {SALIDA_BASE}.<formato>). Usa '-' para escribirlo "
84
+ "en la salida estándar (la interfaz pasa a stderr).")
85
+ parser.add_argument("-f", "--format", dest="formato", choices=FORMATOS, default="txt",
86
+ help="Formato del informe: txt (por defecto), json (contrato versionado) o md "
87
+ "(tabla de demos en vivo para tu README).")
88
+ parser.add_argument("--fail-on", dest="fail_on", type=_fail_on, default=(), metavar="REGLAS",
89
+ help="Sale con código 1 si hay hallazgos en estas reglas (separadas por comas): "
90
+ f"{', '.join(TODAS)}, o 'any'.")
91
+ parser.add_argument("--exclude-forks", dest="excluir_forks", action="store_true",
92
+ help="Ignora los repositorios que son forks.")
93
+ parser.add_argument("--exclude-archived", dest="excluir_archivados", action="store_true",
94
+ help="Ignora los repositorios archivados.")
95
+ parser.add_argument("--inject", dest="inject", metavar="ARCHIVO",
96
+ help="Actualiza la tabla de demos dentro de ARCHIVO (p. ej. README.md), entre los marcadores "
97
+ "<!-- github-pages-auditor:start --> y <!-- github-pages-auditor:end -->.")
98
+ parser.add_argument("-q", "--quiet", dest="quiet", action="store_true",
99
+ help="No muestra tablas ni progreso (solo errores).")
100
+ parser.add_argument("--workers", dest="max_workers", type=int, default=WORKERS_DEFECTO,
101
+ help=f"Número máximo de hilos concurrentes (por defecto {WORKERS_DEFECTO}).")
102
+ parser.add_argument("--timeout", dest="timeout", type=float, default=TIMEOUT_DEFECTO,
103
+ help=f"Timeout por petición en segundos (por defecto {TIMEOUT_DEFECTO:g}).")
104
+ parser.add_argument("--no-api", dest="forzar_web", action="store_true",
105
+ help="Forzar scraping web omitiendo la API aunque haya token.")
106
+ parser.add_argument("--no-color", dest="sin_color", action="store_true",
107
+ help="Desactivar colores en la consola.")
108
+ parser.add_argument("--no-env", dest="no_env", action="store_true",
109
+ help="No leer el archivo .env.")
110
+ parser.add_argument("-V", "--version", action="version",
111
+ version=f"%(prog)s {__version__}\n" + creditos(sep="\n"))
112
+ return parser
113
+
114
+
115
+ def parse_args(args=None) -> Config:
116
+ # El .env se carga ANTES de construir los valores por defecto del parser.
117
+ pre = argparse.ArgumentParser(add_help=False)
118
+ pre.add_argument("--no-env", action="store_true")
119
+ if not pre.parse_known_args(args)[0].no_env:
120
+ load_dotenv(find_dotenv(usecwd=True)) # busca el .env en el directorio actual
121
+
122
+ parser = crear_parser()
123
+ parsed = parser.parse_args(args)
124
+
125
+ usuario = (parsed.usuario or "").strip()
126
+ if not usuario:
127
+ parser.error("El usuario de GitHub es obligatorio. Usa -u <usuario> o define USUARIO_GITHUB.")
128
+ if parsed.max_workers < 1:
129
+ parser.error("--workers debe ser >= 1.")
130
+ if parsed.timeout <= 0:
131
+ parser.error("--timeout debe ser > 0.")
132
+
133
+ salida = (parsed.archivo_salida or "").strip() or None
134
+ if salida is None and not parsed.inject:
135
+ salida = f"{SALIDA_BASE}.{parsed.formato}" # con --inject solo se escribe informe si se pide con -o
136
+
137
+ return Config(
138
+ usuario=usuario,
139
+ dominio_web=normalizar_dominio(parsed.dominio_web, usuario),
140
+ github_token=(parsed.github_token or "").strip() or None,
141
+ archivo_salida=salida,
142
+ max_workers=parsed.max_workers,
143
+ forzar_web=parsed.forzar_web,
144
+ sin_color=parsed.sin_color,
145
+ timeout=parsed.timeout,
146
+ formato=parsed.formato,
147
+ fail_on=tuple(parsed.fail_on),
148
+ excluir_forks=parsed.excluir_forks,
149
+ excluir_archivados=parsed.excluir_archivados,
150
+ inject=parsed.inject,
151
+ quiet=parsed.quiet,
152
+ )
@@ -0,0 +1,21 @@
1
+ """Excepciones propias: errores globales que detienen la auditoría."""
2
+
3
+
4
+ class ErrorAuditoria(Exception):
5
+ """Error base de la herramienta."""
6
+
7
+
8
+ class UsuarioNoEncontrado(ErrorAuditoria):
9
+ """El usuario de GitHub no existe."""
10
+
11
+
12
+ class LimiteDeTasa(ErrorAuditoria):
13
+ """GitHub ha limitado las peticiones."""
14
+
15
+
16
+ class TokenInvalido(ErrorAuditoria):
17
+ """GitHub rechazó el token (401)."""
18
+
19
+
20
+ class ErrorSalida(ErrorAuditoria):
21
+ """No se puede generar o escribir la salida pedida (p. ej. --inject sin marcadores)."""
@@ -0,0 +1,112 @@
1
+ """Serializadores de salida: JSON (contrato versionado) y Markdown (índice de demos)."""
2
+ import json
3
+ import re
4
+ from datetime import datetime, timezone
5
+ from typing import Dict, List, Optional, Sequence
6
+
7
+ from . import NOMBRE_PROGRAMA, URL_GITHUB, __version__
8
+ from .errors import ErrorSalida
9
+ from .models import AuditReport, RepoInfo
10
+
11
+ SCHEMA_VERSION = 1
12
+ MARCA_INICIO = "<!-- github-pages-auditor:start -->"
13
+ MARCA_FIN = "<!-- github-pages-auditor:end -->"
14
+
15
+
16
+ # ----------------------------------------------------------------- JSON
17
+ def _repo_a_dict(r: RepoInfo) -> dict:
18
+ s = r.pages_salud
19
+ return {
20
+ "name": r.nombre,
21
+ "description": r.descripcion,
22
+ "fork": r.es_fork,
23
+ "archived": r.esta_archivado,
24
+ "has_pages": r.tiene_pages,
25
+ "pages": None if s is None else {
26
+ "status": s.estado,
27
+ "url": r.url_pages,
28
+ "http_status": s.http_status,
29
+ "response_ms": s.ms,
30
+ "title": s.titulo,
31
+ "problems": s.problemas,
32
+ "warnings": s.avisos,
33
+ },
34
+ "pages_url": r.url_pages,
35
+ "has_releases": r.tiene_releases,
36
+ "has_readme": r.tiene_readme,
37
+ "has_license": r.tiene_licencia,
38
+ "has_gitignore": r.tiene_gitignore,
39
+ "has_description": r.tiene_descripcion,
40
+ "error": r.error,
41
+ }
42
+
43
+
44
+ def a_dict(report: AuditReport, hallazgos: Dict[str, List[str]], fail_on: Sequence[str],
45
+ disparadas: Sequence[str], ahora: Optional[datetime] = None) -> dict:
46
+ ahora = ahora or datetime.now(timezone.utc)
47
+ return {
48
+ "schema_version": SCHEMA_VERSION,
49
+ "tool": {"name": NOMBRE_PROGRAMA, "version": __version__, "url": URL_GITHUB},
50
+ "generated_at": ahora.strftime("%Y-%m-%dT%H:%M:%SZ"),
51
+ "user": report.usuario,
52
+ "mode": report.modo,
53
+ "domain": report.dominio_base,
54
+ "summary": {
55
+ "total": report.total_repos,
56
+ "analyzed": report.total_repos - len(report.con_error),
57
+ "pages_live": len(report.con_pages),
58
+ "pages_broken": len(report.pages_rotas),
59
+ "releases": len(report.con_releases),
60
+ "no_pages_no_releases": len(report.sin_nada),
61
+ "missing_readme": len(report.sin_readme),
62
+ "missing_license": len(report.sin_licencia),
63
+ "missing_gitignore": len(report.sin_gitignore),
64
+ "missing_description": len(report.sin_descripcion),
65
+ "forks": len(report.forks),
66
+ "archived": len(report.archivados),
67
+ "errors": len(report.con_error),
68
+ },
69
+ "findings": hallazgos,
70
+ "fail_on": {"rules": list(fail_on), "triggered": list(disparadas)},
71
+ "repos": [_repo_a_dict(r) for r in report.repos],
72
+ }
73
+
74
+
75
+ def a_json(report: AuditReport, hallazgos, fail_on, disparadas, ahora=None, solo_ascii=False) -> str:
76
+ return json.dumps(a_dict(report, hallazgos, fail_on, disparadas, ahora),
77
+ indent=2, ensure_ascii=solo_ascii) + "\n"
78
+
79
+
80
+ # ------------------------------------------------------------- Markdown
81
+ def _celda(texto: Optional[str]) -> str:
82
+ return " ".join((texto or "").replace("|", "\\|").split())
83
+
84
+
85
+ def bloque_markdown(report: AuditReport, incluir_forks: bool = False) -> str:
86
+ """Tabla de demos en vivo, entre marcadores, lista para un README de perfil."""
87
+ vivas = sorted(
88
+ (r for r in report.con_pages if incluir_forks or not r.es_fork),
89
+ key=lambda r: r.nombre.lower(),
90
+ )
91
+ lineas = [MARCA_INICIO, "## Demos en vivo", ""]
92
+ if vivas:
93
+ lineas += ["| Proyecto | Demo | Descripción |", "| --- | --- | --- |"]
94
+ for r in vivas:
95
+ desc = r.descripcion or (r.pages_salud.titulo if r.pages_salud else "")
96
+ lineas.append(f"| [{_celda(r.nombre)}](https://github.com/{report.usuario}/{r.nombre}) "
97
+ f"| [Abrir]({r.url_pages}) | {_celda(desc)} |")
98
+ else:
99
+ lineas.append("_Todavía no hay demos publicadas._")
100
+ lineas += ["", f"<sub>Generado con [{NOMBRE_PROGRAMA}]({URL_GITHUB}) v{__version__}</sub>", MARCA_FIN]
101
+ return "\n".join(lineas) + "\n"
102
+
103
+
104
+ def inyectar(texto: str, bloque: str) -> str:
105
+ """Sustituye lo que haya entre los marcadores; falla si no existen (no adivina dónde escribir)."""
106
+ patron = re.compile(re.escape(MARCA_INICIO) + r".*?" + re.escape(MARCA_FIN) + r"\n?", re.DOTALL)
107
+ if not patron.search(texto):
108
+ raise ErrorSalida(
109
+ f"El archivo no contiene los marcadores {MARCA_INICIO} ... {MARCA_FIN}. "
110
+ "Añádelos donde quieras la tabla y vuelve a ejecutar."
111
+ )
112
+ return patron.sub(lambda _m: bloque, texto, count=1)
@@ -0,0 +1,122 @@
1
+ """Proveedor CON token: API REST oficial de GitHub (5.000 peticiones/hora)."""
2
+ from typing import List, Optional
3
+
4
+ import requests
5
+
6
+ from . import pages, pages_health
7
+ from .errors import ErrorAuditoria, LimiteDeTasa, TokenInvalido, UsuarioNoEncontrado
8
+ from .models import ESTADO_ROTA, RepoInfo
9
+
10
+ API = "https://api.github.com"
11
+ POR_PAGINA = 100
12
+ MAX_PAGINAS = 100
13
+ PREFIJOS_LICENCIA = ("license", "licence", "copying")
14
+
15
+
16
+ class GitHubAPIProvider:
17
+ def __init__(self, usuario: str, dominio: str, session: requests.Session, token: Optional[str] = None):
18
+ self.usuario = usuario
19
+ self.dominio = dominio
20
+ self.session = session
21
+ # El token solo se envía a api.github.com, nunca a otros hosts.
22
+ self._headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
23
+ if token:
24
+ self._headers["Authorization"] = f"Bearer {token}"
25
+
26
+ def _get(self, ruta: str, **kwargs):
27
+ res = self.session.get(f"{API}{ruta}", headers=self._headers, **kwargs)
28
+ if res.status_code == 401:
29
+ raise TokenInvalido("GitHub rechazó el token (401). Comprueba que sea válido y no haya caducado.")
30
+ if res.status_code in (403, 429):
31
+ headers = getattr(res, "headers", {}) or {}
32
+ if res.status_code == 429 or headers.get("X-RateLimit-Remaining") == "0" or "Retry-After" in headers:
33
+ raise LimiteDeTasa("Se agotó el límite de la API de GitHub. Espera a que se restablezca o usa un token.")
34
+ return res
35
+
36
+ def obtener_repositorios(self, on_progress=None) -> List[RepoInfo]:
37
+ repos: List[RepoInfo] = []
38
+ for page in range(1, MAX_PAGINAS + 1):
39
+ if on_progress:
40
+ on_progress(page, len(repos))
41
+ res = self._get(f"/users/{self.usuario}/repos", params={"per_page": POR_PAGINA, "page": page})
42
+ if res.status_code == 404:
43
+ raise UsuarioNoEncontrado(f"El usuario '{self.usuario}' no existe.")
44
+ if res.status_code != 200:
45
+ raise ErrorAuditoria(f"La API de GitHub respondió {res.status_code} al listar los repositorios.")
46
+
47
+ data = res.json()
48
+ if not isinstance(data, list):
49
+ break
50
+ for raw in data:
51
+ nombre = raw.get("name")
52
+ if not nombre:
53
+ continue
54
+ tiene_pages = bool(raw.get("has_pages", False))
55
+ repos.append(RepoInfo(
56
+ nombre=nombre,
57
+ tiene_pages=tiene_pages,
58
+ url_pages=pages.construir_url_pages(self.usuario, nombre, self.dominio) if tiene_pages else None,
59
+ tiene_licencia=raw.get("license") is not None,
60
+ tiene_descripcion=bool((raw.get("description") or "").strip()),
61
+ descripcion=(raw.get("description") or "").strip() or None,
62
+ es_fork=bool(raw.get("fork", False)),
63
+ esta_archivado=bool(raw.get("archived", False)),
64
+ ))
65
+ if len(data) < POR_PAGINA:
66
+ break
67
+ return repos
68
+
69
+ def _archivos_raiz(self, nombre: str) -> List[str]:
70
+ res = self._get(f"/repos/{self.usuario}/{nombre}/contents/")
71
+ if res.status_code != 200: # 404 = repositorio vacío
72
+ return []
73
+ datos = res.json()
74
+ return [f.get("name", "").lower() for f in datos] if isinstance(datos, list) else []
75
+
76
+ def enriquecer_repo(self, repo: RepoInfo) -> RepoInfo:
77
+ # Releases vía feed Atom: no consume cuota de la API.
78
+ atom = self.session.get(f"https://github.com/{self.usuario}/{repo.nombre}/releases.atom")
79
+ repo.tiene_releases = atom.status_code == 200 and "<entry>" in atom.text
80
+
81
+ # README, LICENSE y .gitignore con una sola llamada al contenido raíz.
82
+ archivos = self._archivos_raiz(repo.nombre)
83
+ repo.tiene_readme = any(n.startswith("readme") for n in archivos)
84
+ repo.tiene_gitignore = ".gitignore" in archivos
85
+ repo.tiene_licencia = repo.tiene_licencia or any(n.startswith(PREFIJOS_LICENCIA) for n in archivos)
86
+
87
+ if repo.tiene_pages:
88
+ self._comprobar_pages(repo)
89
+ return repo
90
+
91
+ def _datos_pages(self, nombre: str) -> Optional[dict]:
92
+ res = self._get(f"/repos/{self.usuario}/{nombre}/pages")
93
+ if res.status_code != 200:
94
+ return None
95
+ datos = res.json()
96
+ return datos if isinstance(datos, dict) else None
97
+
98
+ def _comprobar_pages(self, repo: RepoInfo) -> None:
99
+ """Pages activadas según la API: aquí cualquier fallo de la web es una Pages ROTA."""
100
+ datos = self._datos_pages(repo.nombre) or {}
101
+ url = datos.get("html_url") or repo.url_pages
102
+ if url and pages.es_raiz(self.usuario, repo.nombre):
103
+ url = url.rstrip("/")
104
+ salud = None
105
+ if url and url.startswith("http://"):
106
+ # La API devuelve http:// si HTTPS no está forzado; preferimos https si la web responde bien.
107
+ https = "https://" + url[len("http://"):]
108
+ candidata = pages_health.inspeccionar(self.session, https, esperada=True)
109
+ if not candidata.problemas:
110
+ salud, url = candidata, https
111
+ if salud is None:
112
+ salud = pages_health.inspeccionar(self.session, url, esperada=True)
113
+ if datos.get("status") == "errored":
114
+ salud.problemas.append("build-fallido")
115
+ elif datos.get("status") == "building":
116
+ salud.avisos.append("build-en-curso")
117
+ if datos.get("https_enforced") is False:
118
+ salud.avisos.append("https-no-forzado")
119
+ if salud.problemas:
120
+ salud.estado = ESTADO_ROTA
121
+ repo.pages_salud = salud
122
+ repo.url_pages = salud.url_final or url
@@ -0,0 +1,116 @@
1
+ """Proveedor SIN token: páginas públicas de GitHub (HTML, raw y feeds Atom).
2
+
3
+ Es un modo de "mejor esfuerzo": depende del marcado público de GitHub, que puede
4
+ cambiar. Para resultados más fiables usa la API con token.
5
+ """
6
+ import re
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import requests
10
+
11
+ import html as _html
12
+
13
+ from . import pages, pages_health
14
+ from .errors import ErrorAuditoria, LimiteDeTasa, UsuarioNoEncontrado
15
+ from .models import RepoInfo
16
+
17
+ URL = "https://github.com"
18
+ RAW = "https://raw.githubusercontent.com"
19
+ MAX_PAGINAS = 100
20
+ CANDIDATOS_README = ("README.md", "README", "README.rst", "README.txt", "readme.md")
21
+ CANDIDATOS_LICENCIA = ("LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING")
22
+
23
+
24
+ def _texto_descripcion(bloque: str) -> Optional[str]:
25
+ m = re.search(r'itemprop="description"[^>]*>(.*?)</p>', bloque, re.DOTALL)
26
+ if not m:
27
+ return None
28
+ texto = " ".join(_html.unescape(re.sub(r"<[^>]+>", "", m.group(1))).split())
29
+ return texto or None
30
+
31
+
32
+ def parsear_listado(html: str, usuario: str) -> List[Dict[str, Any]]:
33
+ """Extrae los repos de una página del perfil con descripción/fork/archivado.
34
+
35
+ Si no se reconoce el marcado por repositorio, devuelve solo los nombres con
36
+ los metadatos a ``None`` (desconocidos).
37
+ """
38
+ patron = re.compile(
39
+ r'href="/' + re.escape(usuario) + r'/([^"/?#]+)"[^>]*itemprop="name codeRepository"',
40
+ re.IGNORECASE,
41
+ )
42
+ repos: List[Dict[str, Any]] = []
43
+ for bloque in re.split(r'<li\b[^>]*itemprop="owns"', html)[1:]:
44
+ m = patron.search(bloque)
45
+ if m:
46
+ repos.append({
47
+ "nombre": m.group(1),
48
+ "descripcion_texto": _texto_descripcion(bloque),
49
+ "descripcion": 'itemprop="description"' in bloque,
50
+ "fork": "Forked from" in bloque,
51
+ "archivado": re.search(r">\s*Archived\s*<", bloque) is not None,
52
+ })
53
+ if repos:
54
+ return repos
55
+ return [
56
+ {"nombre": n, "descripcion": None, "fork": None, "archivado": None, "descripcion_texto": None}
57
+ for n in dict.fromkeys(patron.findall(html))
58
+ ]
59
+
60
+
61
+ class GitHubWebProvider:
62
+ def __init__(self, usuario: str, dominio: str, session: requests.Session):
63
+ self.usuario = usuario
64
+ self.dominio = dominio
65
+ self.session = session
66
+
67
+ def obtener_repositorios(self, on_progress=None) -> List[RepoInfo]:
68
+ vistos: Dict[str, Dict[str, Any]] = {}
69
+ for page in range(1, MAX_PAGINAS + 1):
70
+ if on_progress:
71
+ on_progress(page, len(vistos))
72
+ res = self.session.get(f"{URL}/{self.usuario}", params={"page": page, "tab": "repositories"})
73
+ if res.status_code == 404 and page == 1:
74
+ raise UsuarioNoEncontrado(f"El usuario '{self.usuario}' no existe.")
75
+ if res.status_code == 429:
76
+ raise LimiteDeTasa("GitHub limitó las peticiones (429). Prueba más tarde o usa un token (-k).")
77
+ if res.status_code != 200:
78
+ if page == 1:
79
+ raise ErrorAuditoria(f"GitHub respondió {res.status_code} al listar los repositorios.")
80
+ break
81
+ nuevos = [x for x in parsear_listado(res.text, self.usuario) if x["nombre"] not in vistos]
82
+ if not nuevos:
83
+ break
84
+ for x in nuevos:
85
+ vistos[x["nombre"]] = x
86
+
87
+ return [
88
+ RepoInfo(
89
+ nombre=x["nombre"],
90
+ tiene_descripcion=x["descripcion"],
91
+ descripcion=x.get("descripcion_texto"),
92
+ es_fork=x["fork"],
93
+ esta_archivado=x["archivado"],
94
+ )
95
+ for x in vistos.values()
96
+ ]
97
+
98
+ def _existe(self, url: str) -> bool:
99
+ return self.session.head(url, allow_redirects=True).status_code == 200
100
+
101
+ def enriquecer_repo(self, repo: RepoInfo) -> RepoInfo:
102
+ atom = self.session.get(f"{URL}/{self.usuario}/{repo.nombre}/releases.atom")
103
+ repo.tiene_releases = atom.status_code == 200 and "<entry>" in atom.text
104
+
105
+ raw = f"{RAW}/{self.usuario}/{repo.nombre}/HEAD/"
106
+ repo.tiene_readme = any(self._existe(raw + f) for f in CANDIDATOS_README)
107
+ repo.tiene_licencia = any(self._existe(raw + f) for f in CANDIDATOS_LICENCIA)
108
+ repo.tiene_gitignore = self._existe(raw + ".gitignore")
109
+
110
+ # La comprobación de Pages se hace aquí (en paralelo), no al listar.
111
+ url = pages.construir_url_pages(self.usuario, repo.nombre, self.dominio)
112
+ salud = pages_health.inspeccionar(self.session, url, esperada=False)
113
+ repo.tiene_pages = salud.ok
114
+ repo.url_pages = salud.url_final if salud.ok else None
115
+ repo.pages_salud = salud if salud.ok else None
116
+ return repo
@@ -0,0 +1,44 @@
1
+ """Sesión HTTP con reintentos y timeout por defecto.
2
+
3
+ IMPORTANTE: la sesión NO lleva credenciales. El token de GitHub se añade
4
+ únicamente en las peticiones a api.github.com (ver github_api.py), de modo que
5
+ nunca viaja a github.com, raw.githubusercontent.com ni a tu dominio.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import requests
10
+ from requests.adapters import HTTPAdapter
11
+ from urllib3.util.retry import Retry
12
+
13
+ from . import __version__
14
+
15
+ USER_AGENT = f"Mozilla/5.0 (compatible; github-pages-auditor/{__version__}; +https://github.com/alberto2005-coder/github-pages-auditor)"
16
+
17
+
18
+ class SesionHttp(requests.Session):
19
+ """requests.Session que aplica un timeout por defecto a todas las peticiones."""
20
+
21
+ def __init__(self, timeout: float = 10.0) -> None:
22
+ super().__init__()
23
+ self.request_timeout = timeout
24
+
25
+ def request(self, method, url, **kwargs):
26
+ kwargs.setdefault("timeout", self.request_timeout)
27
+ return super().request(method, url, **kwargs)
28
+
29
+
30
+ def crear_sesion_http(timeout: float = 10.0, pool: int = 50) -> SesionHttp:
31
+ session = SesionHttp(timeout=timeout)
32
+ retries = Retry(
33
+ total=3,
34
+ backoff_factor=1.0,
35
+ status_forcelist=[429, 500, 502, 503, 504],
36
+ allowed_methods=["GET", "HEAD"],
37
+ respect_retry_after_header=True,
38
+ raise_on_status=False, # devolver la última respuesta en vez de lanzar RetryError
39
+ )
40
+ adapter = HTTPAdapter(max_retries=retries, pool_connections=pool, pool_maxsize=pool)
41
+ session.mount("http://", adapter)
42
+ session.mount("https://", adapter)
43
+ session.headers["User-Agent"] = USER_AGENT
44
+ return session