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.
- github_pages_auditor/__init__.py +12 -0
- github_pages_auditor/__main__.py +6 -0
- github_pages_auditor/audit.py +79 -0
- github_pages_auditor/config.py +152 -0
- github_pages_auditor/errors.py +21 -0
- github_pages_auditor/formatters.py +112 -0
- github_pages_auditor/github_api.py +122 -0
- github_pages_auditor/github_web.py +116 -0
- github_pages_auditor/http.py +44 -0
- github_pages_auditor/main.py +104 -0
- github_pages_auditor/models.py +123 -0
- github_pages_auditor/pages.py +51 -0
- github_pages_auditor/pages_health.py +101 -0
- github_pages_auditor/report.py +89 -0
- github_pages_auditor/rules.py +40 -0
- github_pages_auditor-1.2.0.dist-info/METADATA +520 -0
- github_pages_auditor-1.2.0.dist-info/RECORD +20 -0
- github_pages_auditor-1.2.0.dist-info/WHEEL +4 -0
- github_pages_auditor-1.2.0.dist-info/entry_points.txt +4 -0
- github_pages_auditor-1.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from . import creditos
|
|
7
|
+
from .audit import ejecutar_auditoria
|
|
8
|
+
from .config import Config, parse_args
|
|
9
|
+
from .errors import ErrorAuditoria
|
|
10
|
+
from .formatters import a_json, bloque_markdown, inyectar
|
|
11
|
+
from .report import generar_txt, renderizar_consola
|
|
12
|
+
from .rules import disparadas, evaluar
|
|
13
|
+
|
|
14
|
+
EXIT_OK, EXIT_HALLAZGOS, EXIT_ERROR, EXIT_SIN_REPOS, EXIT_CTRL_C = 0, 1, 2, 3, 130
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _contenido(config: Config, reporte, hallazgos, activadas) -> str:
|
|
18
|
+
if config.formato == "json":
|
|
19
|
+
# En stdout, JSON solo ASCII: sobrevive a cualquier codificación de consola (p. ej. cp1252 en Windows).
|
|
20
|
+
return a_json(reporte, hallazgos, config.fail_on, activadas, solo_ascii=config.salida_estandar)
|
|
21
|
+
if config.formato == "md":
|
|
22
|
+
return bloque_markdown(reporte)
|
|
23
|
+
return generar_txt(reporte)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _escribir_stdout(texto: str) -> None:
|
|
27
|
+
"""Escribe en UTF-8 aunque la consola use otra codificación (cp1252, etc.)."""
|
|
28
|
+
buffer = getattr(sys.stdout, "buffer", None)
|
|
29
|
+
if buffer is not None:
|
|
30
|
+
buffer.write(texto.encode("utf-8"))
|
|
31
|
+
buffer.flush()
|
|
32
|
+
else: # stdout sustituido (tests)
|
|
33
|
+
sys.stdout.write(texto)
|
|
34
|
+
sys.stdout.flush()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _carpeta_ok(ruta: str) -> bool:
|
|
38
|
+
return os.path.isdir(os.path.dirname(os.path.abspath(ruta)))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(args=None) -> int:
|
|
42
|
+
"""Devuelve 0 (ok), 1 (--fail-on disparado), 2 (error), 3 (sin repositorios) o 130 (Ctrl+C)."""
|
|
43
|
+
config = parse_args(args) # --help, --version y errores de uso salen aquí
|
|
44
|
+
# Con '-o -' el informe va a stdout y TODA la interfaz pasa a stderr (stdout queda limpio para pipes).
|
|
45
|
+
console = Console(no_color=config.sin_color, stderr=config.salida_estandar, quiet=config.quiet)
|
|
46
|
+
err = Console(stderr=True, no_color=config.sin_color)
|
|
47
|
+
|
|
48
|
+
if config.archivo_salida and not config.salida_estandar and not _carpeta_ok(config.archivo_salida):
|
|
49
|
+
carpeta = os.path.dirname(os.path.abspath(config.archivo_salida))
|
|
50
|
+
err.print(f"[bold red]❌ La carpeta de salida no existe: '{carpeta}'[/bold red]")
|
|
51
|
+
return EXIT_ERROR
|
|
52
|
+
if config.inject and not os.path.isfile(config.inject):
|
|
53
|
+
err.print(f"[bold red]❌ No existe el archivo para --inject: '{config.inject}'[/bold red]")
|
|
54
|
+
return EXIT_ERROR
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
reporte = ejecutar_auditoria(config, console=console)
|
|
58
|
+
except ErrorAuditoria as e:
|
|
59
|
+
err.print(f"[bold red]❌ {e}[/bold red]")
|
|
60
|
+
return EXIT_ERROR
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
err.print("\n[bold yellow]⚠️ Operación cancelada por el usuario.[/bold yellow]")
|
|
63
|
+
return EXIT_CTRL_C
|
|
64
|
+
|
|
65
|
+
if not reporte.repos:
|
|
66
|
+
err.print("[bold red]❌ No hay repositorios que analizar (usuario sin repos públicos o todos filtrados).[/bold red]")
|
|
67
|
+
return EXIT_SIN_REPOS
|
|
68
|
+
|
|
69
|
+
hallazgos = evaluar(reporte.repos)
|
|
70
|
+
activadas = disparadas(hallazgos, config.fail_on)
|
|
71
|
+
|
|
72
|
+
renderizar_consola(reporte, console=console)
|
|
73
|
+
try:
|
|
74
|
+
if config.archivo_salida:
|
|
75
|
+
contenido = _contenido(config, reporte, hallazgos, activadas)
|
|
76
|
+
if config.salida_estandar:
|
|
77
|
+
_escribir_stdout(contenido)
|
|
78
|
+
else:
|
|
79
|
+
with open(config.archivo_salida, "w", encoding="utf-8") as f:
|
|
80
|
+
f.write(contenido)
|
|
81
|
+
console.print(f"\n[bold green]✅ Reporte exportado a '[white]{config.archivo_salida}[/white]'[/bold green]")
|
|
82
|
+
if config.inject:
|
|
83
|
+
with open(config.inject, encoding="utf-8") as f:
|
|
84
|
+
original = f.read()
|
|
85
|
+
nuevo = inyectar(original, bloque_markdown(reporte, incluir_forks=False))
|
|
86
|
+
with open(config.inject, "w", encoding="utf-8") as f:
|
|
87
|
+
f.write(nuevo)
|
|
88
|
+
console.print(f"[bold green]✅ Tabla de demos actualizada en '[white]{config.inject}[/white]'[/bold green]")
|
|
89
|
+
except ErrorAuditoria as e:
|
|
90
|
+
err.print(f"[bold red]❌ {e}[/bold red]")
|
|
91
|
+
return EXIT_ERROR
|
|
92
|
+
except OSError as e:
|
|
93
|
+
err.print(f"[bold red]❌ No se pudo escribir la salida: {e}[/bold red]")
|
|
94
|
+
return EXIT_ERROR
|
|
95
|
+
|
|
96
|
+
console.print(f"[dim]{creditos()}[/dim]", highlight=False, soft_wrap=True)
|
|
97
|
+
if activadas:
|
|
98
|
+
err.print(f"[bold red]❌ --fail-on disparado por: {', '.join(activadas)}[/bold red]")
|
|
99
|
+
return EXIT_HALLAZGOS
|
|
100
|
+
return EXIT_OK
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
sys.exit(main())
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
ESTADO_OK = "ok" # la web responde y parece una página real
|
|
5
|
+
ESTADO_ROTA = "roto" # se esperaba una web (Pages activadas) pero falla
|
|
6
|
+
ESTADO_NINGUNA = "ninguna" # no hay Pages (o no se pueden afirmar)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PagesHealth:
|
|
11
|
+
"""Resultado de inspeccionar una URL de GitHub Pages."""
|
|
12
|
+
|
|
13
|
+
estado: str
|
|
14
|
+
url: str
|
|
15
|
+
url_final: Optional[str] = None
|
|
16
|
+
http_status: Optional[int] = None
|
|
17
|
+
ms: Optional[int] = None
|
|
18
|
+
titulo: Optional[str] = None
|
|
19
|
+
problemas: List[str] = field(default_factory=list) # errores: la página está rota
|
|
20
|
+
avisos: List[str] = field(default_factory=list) # avisos: funciona pero mejorable
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def ok(self) -> bool:
|
|
24
|
+
return self.estado == ESTADO_OK
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RepoInfo:
|
|
29
|
+
"""Resultado de auditar un repositorio.
|
|
30
|
+
|
|
31
|
+
``tiene_descripcion``, ``es_fork`` y ``esta_archivado`` valen ``None`` cuando
|
|
32
|
+
no se han podido determinar (no se listan en el informe).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
nombre: str
|
|
36
|
+
tiene_pages: bool = False
|
|
37
|
+
url_pages: Optional[str] = None
|
|
38
|
+
tiene_releases: bool = False
|
|
39
|
+
tiene_readme: bool = False
|
|
40
|
+
tiene_licencia: bool = False
|
|
41
|
+
tiene_gitignore: bool = False
|
|
42
|
+
tiene_descripcion: Optional[bool] = None
|
|
43
|
+
es_fork: Optional[bool] = None
|
|
44
|
+
esta_archivado: Optional[bool] = None
|
|
45
|
+
descripcion: Optional[str] = None
|
|
46
|
+
pages_salud: Optional[PagesHealth] = None
|
|
47
|
+
error: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def pages_rota(self) -> bool:
|
|
51
|
+
return self.pages_salud is not None and self.pages_salud.estado == ESTADO_ROTA
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def pages_viva(self) -> bool:
|
|
55
|
+
if not self.tiene_pages or not self.url_pages:
|
|
56
|
+
return False
|
|
57
|
+
return self.pages_salud is None or self.pages_salud.ok
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class AuditReport:
|
|
62
|
+
usuario: str
|
|
63
|
+
dominio_base: str
|
|
64
|
+
proveedor: str
|
|
65
|
+
repos: List[RepoInfo] = field(default_factory=list)
|
|
66
|
+
modo: str = "web" # "api" | "web" (identificador estable para el JSON)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def total_repos(self) -> int:
|
|
70
|
+
return len(self.repos)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def _analizados(self) -> List[RepoInfo]:
|
|
74
|
+
"""Repos sin error: de un repo que falló no se puede afirmar nada."""
|
|
75
|
+
return [r for r in self.repos if not r.error]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def con_error(self) -> List[RepoInfo]:
|
|
79
|
+
return [r for r in self.repos if r.error]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def con_pages(self) -> List[RepoInfo]:
|
|
83
|
+
return [r for r in self._analizados if r.pages_viva]
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def pages_con_avisos(self) -> List[RepoInfo]:
|
|
87
|
+
return [r for r in self._analizados if r.pages_viva and r.pages_salud and r.pages_salud.avisos]
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def pages_rotas(self) -> List[RepoInfo]:
|
|
91
|
+
return [r for r in self._analizados if r.pages_rota]
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def con_releases(self) -> List[RepoInfo]:
|
|
95
|
+
return [r for r in self._analizados if r.tiene_releases]
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def sin_nada(self) -> List[RepoInfo]:
|
|
99
|
+
return [r for r in self._analizados if not r.tiene_pages and not r.tiene_releases]
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def sin_readme(self) -> List[RepoInfo]:
|
|
103
|
+
return [r for r in self._analizados if not r.tiene_readme]
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def sin_licencia(self) -> List[RepoInfo]:
|
|
107
|
+
return [r for r in self._analizados if not r.tiene_licencia]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def sin_gitignore(self) -> List[RepoInfo]:
|
|
111
|
+
return [r for r in self._analizados if not r.tiene_gitignore]
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def sin_descripcion(self) -> List[RepoInfo]:
|
|
115
|
+
return [r for r in self._analizados if r.tiene_descripcion is False]
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def forks(self) -> List[RepoInfo]:
|
|
119
|
+
return [r for r in self._analizados if r.es_fork]
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def archivados(self) -> List[RepoInfo]:
|
|
123
|
+
return [r for r in self._analizados if r.esta_archivado]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from urllib.parse import urlparse
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def es_raiz(usuario: str, nombre_repo: str) -> bool:
|
|
8
|
+
"""Solo ``<usuario>.github.io`` es el sitio raíz.
|
|
9
|
+
|
|
10
|
+
Un repo llamado igual que el usuario es el README del perfil, no un sitio.
|
|
11
|
+
"""
|
|
12
|
+
return nombre_repo.lower() == f"{usuario.lower()}.github.io"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def construir_url_pages(usuario: str, nombre_repo: str, dominio_base: str) -> str:
|
|
16
|
+
dominio_base = dominio_base.rstrip("/")
|
|
17
|
+
if es_raiz(usuario, nombre_repo):
|
|
18
|
+
return dominio_base
|
|
19
|
+
return f"{dominio_base}/{nombre_repo}/"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolver_pages(session: requests.Session, url: str) -> Optional[str]:
|
|
23
|
+
"""Devuelve la URL efectiva de la página si existe, o ``None``.
|
|
24
|
+
|
|
25
|
+
- Respuesta 200 en el mismo dominio: la URL pedida.
|
|
26
|
+
- Redirección desde ``*.github.io`` a otro dominio con 200: es el dominio
|
|
27
|
+
personalizado que GitHub tiene configurado en esas Pages, y se devuelve
|
|
28
|
+
la URL final (así se descubre el dominio real sin indicarlo con -w).
|
|
29
|
+
- Cualquier otra redirección a otro dominio se descarta (p. ej. parking).
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
response = session.get(url, allow_redirects=True, stream=True)
|
|
33
|
+
except requests.RequestException:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
if response.status_code != 200:
|
|
37
|
+
return None
|
|
38
|
+
final = getattr(response, "url", None) or url
|
|
39
|
+
host_pedido = urlparse(url).netloc.lower()
|
|
40
|
+
host_final = urlparse(final).netloc.lower()
|
|
41
|
+
if host_final == host_pedido:
|
|
42
|
+
return url
|
|
43
|
+
if host_pedido.endswith(".github.io"):
|
|
44
|
+
return final
|
|
45
|
+
return None
|
|
46
|
+
finally:
|
|
47
|
+
response.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def comprobar_pages(session: requests.Session, url: str) -> bool:
|
|
51
|
+
return resolver_pages(session, url) is not None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Salud de GitHub Pages: más allá de "responde 200".
|
|
2
|
+
|
|
3
|
+
Detecta 404 / sin ``index.html``, errores TLS, timeouts, redirecciones a otro
|
|
4
|
+
dominio o en bucle, páginas vacías o que no son HTML, y lentitud.
|
|
5
|
+
|
|
6
|
+
``esperada=True`` significa "sabemos que estas Pages deberían existir"
|
|
7
|
+
(modo API: ``has_pages``). Entonces cualquier fallo es ``roto``. Con
|
|
8
|
+
``esperada=False`` (modo web) un fallo solo significa "no hay Pages".
|
|
9
|
+
"""
|
|
10
|
+
import html as _html
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from typing import Optional
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from .models import ESTADO_NINGUNA, ESTADO_OK, ESTADO_ROTA, PagesHealth
|
|
19
|
+
|
|
20
|
+
LIMITE_BYTES = 65536
|
|
21
|
+
MAX_REDIRECCIONES = 5
|
|
22
|
+
UMBRAL_LENTA_MS = 3000
|
|
23
|
+
|
|
24
|
+
_TITULO = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
|
25
|
+
_HTML = re.compile(r"<(!doctype\s+html|html|head|body)\b", re.IGNORECASE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _leer_inicio(response) -> str:
|
|
29
|
+
"""Lee solo el primer fragmento del cuerpo (no descarga páginas enormes)."""
|
|
30
|
+
try:
|
|
31
|
+
trozo = next(iter(response.iter_content(LIMITE_BYTES)), b"")
|
|
32
|
+
except (AttributeError, StopIteration, requests.RequestException):
|
|
33
|
+
trozo = (getattr(response, "text", "") or "")[:LIMITE_BYTES]
|
|
34
|
+
return trozo.decode("utf-8", "replace") if isinstance(trozo, bytes) else str(trozo)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _titulo(cuerpo: str) -> Optional[str]:
|
|
38
|
+
m = _TITULO.search(cuerpo)
|
|
39
|
+
if not m:
|
|
40
|
+
return None
|
|
41
|
+
texto = " ".join(_html.unescape(m.group(1)).split())
|
|
42
|
+
return texto[:120] or None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fallo(url: str, esperada: bool, problema: str) -> PagesHealth:
|
|
46
|
+
return PagesHealth(
|
|
47
|
+
estado=ESTADO_ROTA if esperada else ESTADO_NINGUNA, url=url, problemas=[problema]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def inspeccionar(session, url: str, esperada: bool = False) -> PagesHealth:
|
|
52
|
+
inicio = time.monotonic()
|
|
53
|
+
try:
|
|
54
|
+
response = session.get(url, allow_redirects=True, stream=True)
|
|
55
|
+
except requests.exceptions.SSLError:
|
|
56
|
+
return _fallo(url, esperada, "tls-error")
|
|
57
|
+
except requests.exceptions.Timeout:
|
|
58
|
+
return _fallo(url, esperada, "timeout")
|
|
59
|
+
except requests.exceptions.TooManyRedirects:
|
|
60
|
+
return _fallo(url, esperada, "bucle-de-redirecciones")
|
|
61
|
+
except requests.RequestException:
|
|
62
|
+
return _fallo(url, esperada, "sin-conexion")
|
|
63
|
+
|
|
64
|
+
ms = int((time.monotonic() - inicio) * 1000)
|
|
65
|
+
problemas, avisos = [], []
|
|
66
|
+
titulo = None
|
|
67
|
+
try:
|
|
68
|
+
status = response.status_code
|
|
69
|
+
final = getattr(response, "url", None) or url
|
|
70
|
+
saltos = len(getattr(response, "history", None) or [])
|
|
71
|
+
host_pedido = urlparse(url).netloc.lower()
|
|
72
|
+
host_final = urlparse(final).netloc.lower()
|
|
73
|
+
|
|
74
|
+
if status != 200:
|
|
75
|
+
problemas.append("404" if status == 404 else f"http-{status}")
|
|
76
|
+
else:
|
|
77
|
+
if host_final != host_pedido and not host_pedido.endswith(".github.io"):
|
|
78
|
+
problemas.append("redireccion-externa") # dominio aparcado, etc.
|
|
79
|
+
if saltos > MAX_REDIRECCIONES:
|
|
80
|
+
problemas.append("demasiadas-redirecciones")
|
|
81
|
+
cuerpo = _leer_inicio(response)
|
|
82
|
+
if not cuerpo.strip():
|
|
83
|
+
problemas.append("cuerpo-vacio")
|
|
84
|
+
elif not _HTML.search(cuerpo):
|
|
85
|
+
avisos.append("sin-html")
|
|
86
|
+
titulo = _titulo(cuerpo)
|
|
87
|
+
if ms > UMBRAL_LENTA_MS:
|
|
88
|
+
avisos.append("lenta")
|
|
89
|
+
finally:
|
|
90
|
+
response.close()
|
|
91
|
+
|
|
92
|
+
if not problemas:
|
|
93
|
+
estado = ESTADO_OK
|
|
94
|
+
else:
|
|
95
|
+
estado = ESTADO_ROTA if esperada else ESTADO_NINGUNA
|
|
96
|
+
# URL efectiva: la pedida, o el dominio personalizado al que GitHub redirige.
|
|
97
|
+
url_final = url if host_final == host_pedido else final
|
|
98
|
+
return PagesHealth(
|
|
99
|
+
estado=estado, url=url, url_final=url_final if estado == ESTADO_OK or esperada else None,
|
|
100
|
+
http_status=status, ms=ms, titulo=titulo, problemas=problemas, avisos=avisos,
|
|
101
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
from . import __version__, creditos
|
|
6
|
+
from .models import AuditReport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def renderizar_consola(report: AuditReport, sin_color: bool = False, console: Console = None):
|
|
10
|
+
console = console or Console(no_color=sin_color)
|
|
11
|
+
|
|
12
|
+
console.print(Panel.fit(
|
|
13
|
+
f"[bold cyan]GitHub Profile & Pages Auditor[/bold cyan] [dim]v{__version__}[/dim]\n"
|
|
14
|
+
f"[dim]Usuario: [bold white]{report.usuario}[/bold white] | "
|
|
15
|
+
f"Dominio: [bold yellow]{report.dominio_base}[/bold yellow] | "
|
|
16
|
+
f"Proveedor: [bold green]{report.proveedor}[/bold green][/dim]",
|
|
17
|
+
border_style="cyan",
|
|
18
|
+
))
|
|
19
|
+
|
|
20
|
+
t_pages = Table(title="🌐 GitHub Pages Activas", style="cyan", header_style="bold magenta")
|
|
21
|
+
t_pages.add_column("Repositorio", style="bold white", width=30)
|
|
22
|
+
t_pages.add_column("Enlace Desplegado", style="blue underline")
|
|
23
|
+
for r in report.con_pages:
|
|
24
|
+
t_pages.add_row(r.nombre, r.url_pages or "")
|
|
25
|
+
console.print(t_pages)
|
|
26
|
+
|
|
27
|
+
t = Table(title="🛡️ Resumen de Auditoría", style="green", header_style="bold yellow")
|
|
28
|
+
t.add_column("Métrica / Categoría", style="bold white")
|
|
29
|
+
t.add_column("Cantidad", justify="right", style="bold cyan")
|
|
30
|
+
t.add_column("Estado", style="bold")
|
|
31
|
+
|
|
32
|
+
def badge(lista, msg="faltantes"):
|
|
33
|
+
return f"[bold red]⚠️ {len(lista)} {msg}[/bold red]" if lista else "[bold green]✅ OK[/bold green]"
|
|
34
|
+
|
|
35
|
+
t.add_row("Total Repositorios", str(report.total_repos), "[bold blue]Analizados[/bold blue]")
|
|
36
|
+
t.add_row("GitHub Pages", str(len(report.con_pages)), "[bold green]Activas[/bold green]")
|
|
37
|
+
t.add_row("Releases Publicadas", str(len(report.con_releases)), "[bold green]Con Descargas[/bold green]")
|
|
38
|
+
t.add_row("Sin Web ni Releases", str(len(report.sin_nada)), badge(report.sin_nada, "inactivos"))
|
|
39
|
+
t.add_row("Sin README", str(len(report.sin_readme)), badge(report.sin_readme))
|
|
40
|
+
t.add_row("Sin Licencia", str(len(report.sin_licencia)), badge(report.sin_licencia))
|
|
41
|
+
t.add_row("Sin .gitignore", str(len(report.sin_gitignore)), badge(report.sin_gitignore))
|
|
42
|
+
t.add_row("Sin Descripción", str(len(report.sin_descripcion)), badge(report.sin_descripcion))
|
|
43
|
+
if report.pages_rotas:
|
|
44
|
+
t.add_row("Pages rotas", str(len(report.pages_rotas)), badge(report.pages_rotas, "rotas"))
|
|
45
|
+
if report.pages_con_avisos:
|
|
46
|
+
t.add_row("Pages con avisos", str(len(report.pages_con_avisos)), badge(report.pages_con_avisos, "avisos"))
|
|
47
|
+
t.add_row("Forks", str(len(report.forks)), "[dim]Externos[/dim]")
|
|
48
|
+
t.add_row("Archivados", str(len(report.archivados)), "[dim]Solo lectura[/dim]")
|
|
49
|
+
if report.con_error:
|
|
50
|
+
t.add_row("No analizados", str(len(report.con_error)), badge(report.con_error, "con error"))
|
|
51
|
+
console.print(t)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _seccion(titulo, nombres):
|
|
55
|
+
return ["", f"=== {titulo} ===", *([f"- {n}" for n in nombres] or ["Ninguno"])]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def generar_txt(report: AuditReport) -> str:
|
|
59
|
+
lineas = [
|
|
60
|
+
f"=== ENLACES DE GITHUB PAGES PARA: {report.usuario} ===",
|
|
61
|
+
f"Dominio base: {report.dominio_base}",
|
|
62
|
+
f"Proveedor de datos: {report.proveedor}",
|
|
63
|
+
"",
|
|
64
|
+
]
|
|
65
|
+
lineas += [f"- {r.nombre:<32} -> {r.url_pages}" for r in report.con_pages] or [
|
|
66
|
+
"No se encontraron repositorios con GitHub Pages."]
|
|
67
|
+
lineas += _seccion("REPOSITORIOS CON RELEASES", [r.nombre for r in report.con_releases])
|
|
68
|
+
lineas += _seccion("REPOSITORIOS SIN RELEASES NI GITHUB PAGES", [r.nombre for r in report.sin_nada])
|
|
69
|
+
lineas += _seccion("REPOSITORIOS SIN README", [r.nombre for r in report.sin_readme])
|
|
70
|
+
lineas += _seccion("REPOSITORIOS SIN LICENCIA", [r.nombre for r in report.sin_licencia])
|
|
71
|
+
lineas += _seccion("REPOSITORIOS SIN .GITIGNORE", [r.nombre for r in report.sin_gitignore])
|
|
72
|
+
lineas += _seccion("REPOSITORIOS SIN DESCRIPCIÓN (ABOUT)", [r.nombre for r in report.sin_descripcion])
|
|
73
|
+
lineas += _seccion("REPOSITORIOS QUE SON FORKS", [r.nombre for r in report.forks])
|
|
74
|
+
lineas += _seccion("REPOSITORIOS ARCHIVADOS", [r.nombre for r in report.archivados])
|
|
75
|
+
if report.pages_rotas:
|
|
76
|
+
lineas += _seccion("GITHUB PAGES ROTAS", [
|
|
77
|
+
f"{r.nombre}: {', '.join(r.pages_salud.problemas)}" for r in report.pages_rotas])
|
|
78
|
+
if report.pages_con_avisos:
|
|
79
|
+
lineas += _seccion("GITHUB PAGES CON AVISOS", [
|
|
80
|
+
f"{r.nombre}: {', '.join(r.pages_salud.avisos)}" for r in report.pages_con_avisos])
|
|
81
|
+
if report.con_error:
|
|
82
|
+
lineas += _seccion("REPOSITORIOS NO ANALIZADOS (ERROR DE RED)", [f"{r.nombre}: {r.error}" for r in report.con_error])
|
|
83
|
+
lineas += ["", "-" * 60, f"Generado con github-pages-auditor v{__version__}", creditos(sep="\n")]
|
|
84
|
+
return "\n".join(lineas) + "\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def exportar_txt(report: AuditReport, ruta_salida: str):
|
|
88
|
+
with open(ruta_salida, "w", encoding="utf-8") as f:
|
|
89
|
+
f.write(generar_txt(report))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Reglas evaluables para ``--fail-on`` (y para la sección ``findings`` del JSON)."""
|
|
2
|
+
from typing import Callable, Dict, List, Sequence
|
|
3
|
+
|
|
4
|
+
from .models import RepoInfo
|
|
5
|
+
|
|
6
|
+
# id estable (parte del contrato público) -> (descripción, predicado "hay hallazgo")
|
|
7
|
+
REGLAS: Dict[str, tuple] = {
|
|
8
|
+
"missing-readme": ("Repositorio sin README", lambda r: not r.tiene_readme),
|
|
9
|
+
"missing-license": ("Repositorio sin licencia", lambda r: not r.tiene_licencia),
|
|
10
|
+
"missing-gitignore": ("Repositorio sin .gitignore", lambda r: not r.tiene_gitignore),
|
|
11
|
+
"missing-description": ("Repositorio sin descripción (About)", lambda r: r.tiene_descripcion is False),
|
|
12
|
+
"broken-pages": ("GitHub Pages activadas pero rotas", lambda r: r.pages_rota),
|
|
13
|
+
}
|
|
14
|
+
REGLA_ERRORES = "analysis-errors" # repos que no se pudieron analizar (red)
|
|
15
|
+
TODAS = tuple(REGLAS) + (REGLA_ERRORES,)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parsear_fail_on(texto: str) -> Sequence[str]:
|
|
19
|
+
"""'missing-license,broken-pages' | 'any' -> tupla validada de ids."""
|
|
20
|
+
ids = [t.strip().lower() for t in texto.split(",") if t.strip()]
|
|
21
|
+
if "any" in ids:
|
|
22
|
+
return TODAS
|
|
23
|
+
desconocidas = [i for i in ids if i not in TODAS]
|
|
24
|
+
if desconocidas:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"reglas desconocidas: {', '.join(desconocidas)}. Válidas: any, {', '.join(TODAS)}"
|
|
27
|
+
)
|
|
28
|
+
return tuple(dict.fromkeys(ids))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def evaluar(repos: List[RepoInfo]) -> Dict[str, List[str]]:
|
|
32
|
+
"""Devuelve {regla: [repos con hallazgo]} para TODAS las reglas (listas vacías incluidas)."""
|
|
33
|
+
analizados = [r for r in repos if not r.error]
|
|
34
|
+
resultado = {rid: [r.nombre for r in analizados if pred(r)] for rid, (_, pred) in REGLAS.items()}
|
|
35
|
+
resultado[REGLA_ERRORES] = [r.nombre for r in repos if r.error]
|
|
36
|
+
return resultado
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def disparadas(hallazgos: Dict[str, List[str]], fail_on: Sequence[str]) -> List[str]:
|
|
40
|
+
return [rid for rid in fail_on if hallazgos.get(rid)]
|