feat: Introduce bot-digitador service for automated data entry in Integración Total. Add CLI and GUI interfaces for CSV processing, implement auto-start functionality on Windows, and enhance README with setup instructions and usage details. Include necessary configurations in docker-compose for service integration.

This commit is contained in:
erik
2026-08-09 21:26:56 -05:00
parent a3b4c7fc47
commit e9b766281a
74 changed files with 11177 additions and 115 deletions
+9
View File
@@ -37,6 +37,15 @@ python run_gui.py
Configura URL API (ej. `https://pys1.builker.xyz/bot-descarga` o `http://127.0.0.1:8189/bot-descarga`), Worker ID y token. Pulsa **Conectar agent**. Queda idle hasta que el desktop asigne un job.
### Autoarranque en Windows
En la misma GUI:
1. Marca **Iniciar con Windows** (crea un `.bat` en la carpeta Startup del usuario).
2. Deja **Conectar al abrir** activo para que tras el login el agent quede online solo.
Tras reiniciar el PC e iniciar sesión, se abre la GUI y se registra el worker automáticamente.
## Arranque solo agent (CLI)
```bash
+92 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import socket
import sys
import threading
import customtkinter as ctk
@@ -11,6 +12,11 @@ from bot_descarga.ui.settings_store import (
normalize_api_url,
save_gui_settings,
)
from bot_descarga.ui.windows_autostart import (
disable_autostart,
enable_autostart,
is_autostart_enabled,
)
from bot_descarga.worker.agent import WorkerAgent
ctk.set_appearance_mode("dark")
@@ -24,7 +30,7 @@ class BotDashboard(ctk.CTk):
super().__init__()
self.title("Bot de Descargas PDF — Agent")
self.geometry("920x680")
self.geometry("920x720")
self.resizable(False, False)
self._agent: WorkerAgent | None = None
@@ -36,6 +42,10 @@ class BotDashboard(ctk.CTk):
self.protocol("WM_DELETE_WINDOW", self._on_close)
# Tras login de Windows: si está marcado, conectar solo
if bool(load_gui_settings().get("auto_connect")):
self.after(800, self._maybe_auto_connect)
def _construir_ui(self):
root = ctk.CTkFrame(self, corner_radius=18)
root.pack(fill="both", expand=True, padx=18, pady=18)
@@ -81,7 +91,7 @@ class BotDashboard(ctk.CTk):
self.entry_worker.pack(side="left", fill="x", expand=True)
row_token = ctk.CTkFrame(config, fg_color="transparent")
row_token.pack(fill="x", padx=18, pady=(0, 14))
row_token.pack(fill="x", padx=18, pady=(0, 8))
ctk.CTkLabel(row_token, text="Token", width=90, anchor="w").pack(side="left")
self.entry_token = ctk.CTkEntry(
row_token,
@@ -97,6 +107,23 @@ class BotDashboard(ctk.CTk):
command=self._toggle_token,
).pack(side="left")
row_auto = ctk.CTkFrame(config, fg_color="transparent")
row_auto.pack(fill="x", padx=18, pady=(0, 14))
self.chk_autostart = ctk.CTkCheckBox(
row_auto,
text="Iniciar con Windows",
command=self._on_autostart_toggle,
)
self.chk_autostart.pack(side="left", padx=(0, 18))
self.chk_auto_connect = ctk.CTkCheckBox(
row_auto,
text="Conectar al abrir",
command=self._on_auto_connect_toggle,
)
self.chk_auto_connect.pack(side="left")
if sys.platform != "win32":
self.chk_autostart.configure(state="disabled")
status = ctk.CTkFrame(root, corner_radius=14)
status.pack(fill="x", padx=24, pady=(0, 14))
@@ -158,19 +185,77 @@ class BotDashboard(ctk.CTk):
)
self.text_logs.pack(fill="both", expand=True, padx=18, pady=(0, 16))
def _persist_flags(self):
save_gui_settings(
normalize_api_url(self.entry_api.get()),
self.entry_worker.get(),
self.entry_token.get(),
autostart_windows=bool(self.chk_autostart.get()),
auto_connect=bool(self.chk_auto_connect.get()),
)
def _cargar_settings(self):
s = load_gui_settings()
self.entry_api.insert(0, s.get("api_url") or DEFAULT_API_URL)
self.entry_worker.insert(0, s.get("worker_id") or socket.gethostname())
self.entry_token.insert(0, s.get("token") or "")
want_autostart = bool(s.get("autostart_windows"))
if want_autostart and sys.platform == "win32":
try:
if not is_autostart_enabled():
enable_autostart()
self.chk_autostart.select()
except Exception as e:
self._log(f"No se pudo restaurar autoarranque: {e}")
elif is_autostart_enabled():
# Script presente aunque el flag esté off → marcar checkbox
self.chk_autostart.select()
if bool(s.get("auto_connect")):
self.chk_auto_connect.select()
def _guardar_settings(self):
api = normalize_api_url(self.entry_api.get())
self.entry_api.delete(0, "end")
self.entry_api.insert(0, api)
save_gui_settings(api, self.entry_worker.get(), self.entry_token.get())
self._persist_flags()
self._log("Configuración guardada en data/gui_settings.json")
def _on_autostart_toggle(self):
if sys.platform != "win32":
return
enabled = bool(self.chk_autostart.get())
try:
if enabled:
path = enable_autostart()
self._log(f"Autoarranque activado: {path}")
# Tiene sentido conectar al abrir si arranca con Windows
if not self.chk_auto_connect.get():
self.chk_auto_connect.select()
else:
disable_autostart()
self._log("Autoarranque desactivado.")
self._persist_flags()
except Exception as e:
self._log(f"Error configurando autoarranque: {e}")
if enabled:
self.chk_autostart.deselect()
else:
self.chk_autostart.select()
def _on_auto_connect_toggle(self):
self._persist_flags()
def _maybe_auto_connect(self):
if self._connected:
return
if not self.entry_worker.get().strip() or not self.entry_token.get().strip():
self._log("Auto-conectar omitido: falta Worker ID o token.")
return
self._log("Conectando automáticamente…")
self._conectar()
def _toggle_token(self):
self._token_visible = not self._token_visible
self.entry_token.configure(show="" if self._token_visible else "•")
@@ -195,7 +280,7 @@ class BotDashboard(ctk.CTk):
self.entry_api.delete(0, "end")
self.entry_api.insert(0, api)
save_gui_settings(api, worker_id, token)
self._persist_flags()
self._agent = WorkerAgent(
api_url=api,
@@ -251,6 +336,9 @@ class BotDashboard(ctk.CTk):
self.entry_api.configure(state=state)
self.entry_worker.configure(state=state)
self.entry_token.configure(state=state)
if sys.platform == "win32":
self.chk_autostart.configure(state="normal")
self.chk_auto_connect.configure(state="normal")
def _limpiar_logs(self):
self.text_logs.delete("1.0", "end")
@@ -21,6 +21,8 @@ def load_gui_settings() -> Dict[str, Any]:
"api_url": DEFAULT_API_URL,
"worker_id": "",
"token": DEFAULT_TOKEN,
"autostart_windows": False,
"auto_connect": False,
}
if not _SETTINGS_PATH.exists():
return defaults
@@ -33,13 +35,26 @@ def load_gui_settings() -> Dict[str, Any]:
return defaults
def save_gui_settings(api_url: str, worker_id: str, token: str) -> None:
def save_gui_settings(
api_url: str,
worker_id: str,
token: str,
*,
autostart_windows: bool | None = None,
auto_connect: bool | None = None,
) -> None:
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
current = load_gui_settings()
payload = {
**current,
"api_url": api_url.strip(),
"worker_id": worker_id.strip(),
"token": token.strip(),
}
if autostart_windows is not None:
payload["autostart_windows"] = bool(autostart_windows)
if auto_connect is not None:
payload["auto_connect"] = bool(auto_connect)
_SETTINGS_PATH.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
@@ -0,0 +1,74 @@
"""Autoarranque con Windows (carpeta Startup del usuario)."""
from __future__ import annotations
import os
import sys
from pathlib import Path
TASK_BASENAME = "IPSExtractor-BotDescarga"
def bot_root() -> Path:
"""Raíz del paquete bots/bot_descarga (donde está run_gui.py)."""
return Path(__file__).resolve().parents[2]
def startup_dir() -> Path:
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
def startup_script_path() -> Path:
return startup_dir() / f"{TASK_BASENAME}.bat"
def resolve_python(root: Path | None = None) -> Path:
root = root or bot_root()
for candidate in (
root / ".venv" / "Scripts" / "pythonw.exe",
root / ".venv" / "Scripts" / "python.exe",
root / ".venv" / "bin" / "python",
):
if candidate.exists():
return candidate
return Path(sys.executable)
def is_autostart_enabled() -> bool:
if sys.platform != "win32":
return False
return startup_script_path().exists()
def enable_autostart(root: Path | None = None) -> Path:
"""Crea un .bat en Startup que abre la GUI del bot tras el login."""
if sys.platform != "win32":
raise RuntimeError("El autoarranque solo está disponible en Windows.")
root = (root or bot_root()).resolve()
gui = root / "run_gui.py"
if not gui.exists():
raise FileNotFoundError(f"No se encontró run_gui.py en {root}")
py = resolve_python(root)
dest = startup_script_path()
dest.parent.mkdir(parents=True, exist_ok=True)
# start "" deja que el .bat termine; la GUI queda como proceso independiente.
content = (
"@echo off\r\n"
f'cd /d "{root}"\r\n'
f'start "" "{py}" "{gui}"\r\n'
)
dest.write_text(content, encoding="utf-8")
return dest
def disable_autostart() -> bool:
"""Elimina el .bat de Startup. True si había algo que borrar."""
path = startup_script_path()
if path.exists():
path.unlink()
return True
return False
+16 -4
View File
@@ -131,13 +131,14 @@ class WorkerAgent:
def _run_job(self, job: dict) -> None:
job_id = job["id"]
downloads_prefix = (job.get("downloads_prefix") or "").strip() or f"downloads/{job_id}/"
self._uploaded_keys = []
work_dir = self.staging_root / job_id
out_dir = work_dir / "out"
out_dir.mkdir(parents=True, exist_ok=True)
excel_path = work_dir / (Path(job.get("excel_key") or "input.xlsx").name)
self._log(f"[worker {self.worker_id}] job {job_id} iniciado")
self._log(f"[worker {self.worker_id}] job {job_id} iniciado (prefix={downloads_prefix})")
try:
self.api.download_excel(job_id, excel_path)
@@ -168,7 +169,14 @@ class WorkerAgent:
self._log(f"[worker] no se pudo enviar evento: {e}")
def on_pdf_saved(nit: str, radicado: str, local_path: str) -> None:
key = self.minio.upload_pdf(job_id, nit, radicado, local_path)
key = self.minio.upload_pdf(
job_id,
nit,
radicado,
local_path,
downloads_prefix=downloads_prefix,
skip_if_exists=True,
)
self._uploaded_keys.append(key)
service = DownloadService(
@@ -181,7 +189,11 @@ class WorkerAgent:
summary = service.run()
try:
self.minio.write_manifest(job_id, self._uploaded_keys)
self.minio.write_manifest(
job_id,
self._uploaded_keys,
downloads_prefix=downloads_prefix,
)
except Exception as e:
self._log(f"[worker] manifest no escrito: {e}")
@@ -194,7 +206,7 @@ class WorkerAgent:
"exitosos": summary.exitosos,
"fallidos": fallidos_count,
"cancelled": summary.cancelled,
"downloads_prefix": job.get("downloads_prefix"),
"downloads_prefix": downloads_prefix,
"uploaded_count": len(self._uploaded_keys),
}
@@ -40,10 +40,22 @@ class MinioSettings:
)
def pdf_key_from_prefix(downloads_prefix: str, nit: str, radicado: str) -> str:
prefix = (downloads_prefix or "").strip().rstrip("/")
if not prefix:
raise ValueError("downloads_prefix vacío")
return f"{prefix}/{nit}/{radicado}.pdf"
def pdf_key(job_id: str, nit: str, radicado: str) -> str:
return f"downloads/{job_id}/{nit}/{radicado}.pdf"
def manifest_key_from_prefix(downloads_prefix: str) -> str:
prefix = (downloads_prefix or "").strip().rstrip("/")
return f"{prefix}/_manifest.json"
def manifest_key(job_id: str) -> str:
return f"downloads/{job_id}/_manifest.json"
@@ -89,8 +101,30 @@ class WorkerMinioUploader:
self._client = _s3_client(self.settings)
return self._client
def upload_pdf(self, job_id: str, nit: str, radicado: str, local_path: str | Path) -> str:
key = pdf_key(job_id, nit, radicado)
def object_exists(self, key: str) -> bool:
try:
self.client.head_object(Bucket=self.settings.bucket, Key=key)
return True
except Exception:
return False
def upload_pdf(
self,
job_id: str,
nit: str,
radicado: str,
local_path: str | Path,
*,
downloads_prefix: str | None = None,
skip_if_exists: bool = True,
) -> str:
"""Sube PDF bajo downloads_prefix (padre) o downloads/{job_id}/."""
if downloads_prefix:
key = pdf_key_from_prefix(downloads_prefix, nit, radicado)
else:
key = pdf_key(job_id, nit, radicado)
if skip_if_exists and self.object_exists(key):
return key
path = Path(local_path)
# put_object (no multipart) es más fiable en buckets anónimos.
self.client.put_object(
@@ -101,9 +135,23 @@ class WorkerMinioUploader:
)
return key
def write_manifest(self, job_id: str, keys: List[str]) -> str:
key = manifest_key(job_id)
payload = {"job_id": job_id, "files": keys, "count": len(keys)}
def write_manifest(
self,
job_id: str,
keys: List[str],
*,
downloads_prefix: str | None = None,
) -> str:
if downloads_prefix:
key = manifest_key_from_prefix(downloads_prefix)
else:
key = manifest_key(job_id)
payload = {
"job_id": job_id,
"downloads_prefix": downloads_prefix or f"downloads/{job_id}/",
"files": keys,
"count": len(keys),
}
self.client.put_object(
Bucket=self.settings.bucket,
Key=key,
@@ -0,0 +1,18 @@
from pathlib import Path
from bot_descarga.ui.windows_autostart import TASK_BASENAME, bot_root, resolve_python
def test_bot_root_has_run_gui():
root = bot_root()
assert (root / "run_gui.py").exists()
def test_resolve_python_returns_path():
py = resolve_python()
assert isinstance(py, Path)
assert str(py)
def test_task_basename():
assert "BotDescarga" in TASK_BASENAME