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,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,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
|