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
+7
View File
@@ -0,0 +1,7 @@
BOT_API_URL=http://localhost:8189/bot-digitador
BOT_API_TOKEN=dev-token-change-me
BOT_WORKER_ID=
DIGITADOR_APP_PATH=C:\Users\Pysctas185\Documents\Integracion Total.lnk
MINIO_ENDPOINT=https://s3.builker.xyz
MINIO_BUCKET=pys-cuentas-medicas
MINIO_ANONYMOUS=1
+92
View File
@@ -0,0 +1,92 @@
# Bot Digitador (agent Windows)
Agent que **digita** radicados en **Integración Total** (pywinauto + OpenCV).
Vive en este monorepo bajo `bots/` (no es un microservicio de `services/`).
- **No descarga PDFs.** Solo digitación UI + reportes CSV/log.
- **No expone puertos.** Solo HTTP saliente al gateway IPSExtractor y PutObject a MinIO.
- La API de control se espera en `…/bot-digitador` (mismo patrón que `bot_descarga`).
## Requisitos en la PC
- Windows con **Integración Total** instalado e iniciado sesión
- Python 3.11+
- Plantillas OpenCV en `template/` (ver [template/README.md](template/README.md))
- Acceso de red al gateway (`BOT_API_URL`) y a MinIO
- Token `BOT_API_TOKEN`
## Instalación
```bash
cd bots/bot_digitador
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
copy .env.example .env
# Editar .env (especialmente DIGITADOR_APP_PATH)
```
Copia los PNG de plantillas a `template/`.
## Arranque con GUI
```bash
python run_gui.py
```
Configura URL API (ej. `http://localhost:8189/bot-digitador`), Worker ID, token y
ruta de Integración Total (`DIGITADOR_APP_PATH`). Pulsa **Conectar agent**.
Queda idle hasta que el desktop asigne un job CSV.
### Autoarranque en Windows
1. Marca **Iniciar con Windows**.
2. Deja **Conectar al abrir** activo.
## Arranque solo agent (CLI)
```bash
python -m bot_digitador.worker \
--api-url http://localhost:8189/bot-digitador \
--worker-id PC-DIGITADOR-01 \
--token %BOT_API_TOKEN%
```
## Modo local (sin API)
Requiere Integración Total abierto y visible:
```bash
python main.py --csv datos.csv
python main.py --csv datos.csv --delimiter "|" --date-format dd/mm/yyyy --app-path "C:\ruta\Integracion Total.lnk"
```
Reportes en la carpeta de salida: `RESULTADOS.csv`, `OMITIDOS.csv`, `ejecucion_bot.log`.
## Arquitectura
```
Desktop IPSExtractor → Gateway :8189 → API bot-digitador
↑
PC bot (esta carpeta) ────────┘ HTTP outbound
└─ PutObject reportes → MinIO …/digitados/{job_id}/
└─ pywinauto → Integración Total
```
## Variables
Ver [`.env.example`](.env.example):
| Variable | Uso |
|----------|-----|
| `BOT_API_URL` | Base del API vía gateway (`…/bot-digitador`) |
| `BOT_API_TOKEN` | Bearer compartido con el servicio |
| `DIGITADOR_APP_PATH` | Acceso directo / ejecutable de Integración Total |
| `MINIO_*` | Bucket de reportes; por defecto anónimo |
## CSV de entrada
Columnas requeridas: `radicado`, `documento`, `ingreso`, `total_cargos`,
`autorizacion`, `tipo_documento`, `tipo_servicio`, `copago`, `cuota_moderadora`.
Acepta delimitador `,` o `|` (auto-detección).
@@ -0,0 +1,3 @@
"""Bot Digitador — automatización de digitación en Integración Total."""
__version__ = "1.0.0"
@@ -0,0 +1,3 @@
from bot_digitador.core.digitador_service import DigitadorRunSummary, DigitadorService
__all__ = ["DigitadorService", "DigitadorRunSummary"]
@@ -0,0 +1,58 @@
"""Coordenadas, estrategia de documentos y constantes de Integración Total."""
from __future__ import annotations
import os
DOC_COORDS = {
"CC": (796, 343),
"TI": (796, 549),
"CE": (796, 329),
"AS": (796, 296),
"CD": (796, 314),
"CN": (796, 371),
"ME": (796, 378),
"NI": (796, 412),
"NU": (796, 453),
"PS": (796, 450),
"PE": (796, 467),
"PT": (796, 483),
"PJ": (796, 503),
"RC": (796, 520),
"SC": (796, 536),
}
DOC_STRATEGY = [
"CC", "TI", "CE", "RC", "PS", "PT", "PE",
"AS", "ME", "CN", "SC", "CD", "NI", "NU", "PJ",
]
SERVICES_COORDS = {
"ambulancias y elem.": (1033, 367),
"ambulatorio": (1033, 387),
"c. externa": (1033, 405),
"capacitacion": (1033, 417),
"cirugia ambulatoria": (1033, 432),
"fomento y prevencion": (1033, 449),
"honorarios": (1033, 467),
}
COORD_CLOSE_ERROR = (1223, 704)
DEFAULT_APP_PATH = os.getenv(
"DIGITADOR_APP_PATH",
r"C:\Users\Pysctas185\Documents\Integracion Total.lnk",
)
PROCESS_NAME = "SaludTotal.Health.WinClient.TotalIntegration.exe"
REINICIO_CADA = 6
TIMEOUT_REGISTRO = 120
TEMPLATE_ERROR = "error.png"
TEMPLATE_SUCCESS = "success.png"
TEMPLATE_BIEN = "bien.png"
TEMPLATE_DIFERENCIAS = "flecha_diferencias_abierto.png"
TEMPLATE_DIFERENCIAS_CERRADO = "flecha_diferencias_cerrado.png"
TEMPLATE_FECHA_VACIA_SLASH = "fecha_vacia_slash.png"
TEMPLATE_FECHA_VACIA_BLANCO = "fecha_vacia_blanco.png"
TEMPLATE_POPUP_NOMBRES = "popup_nombres.png"
TEMPLATE_RADICADO_EXISTENTE = "radicado_existente.png"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
from bot_digitador.models.events import DigitadorEvent, create_event
__all__ = ["DigitadorEvent", "create_event"]
@@ -0,0 +1,50 @@
"""Eventos simples para callbacks del DigitadorService."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, Optional
@dataclass
class DigitadorEvent:
event_type: str
message: str
radicado: Optional[str] = None
index: Optional[int] = None
total: Optional[int] = None
metadata: Dict[str, Any] = field(default_factory=dict)
timestamp: str = ""
def __post_init__(self) -> None:
if not self.timestamp:
self.timestamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def to_dict(self) -> dict:
return asdict(self)
def display_message(self) -> str:
prefix = ""
if self.index is not None and self.total is not None:
prefix = f"[{self.index}/{self.total}] "
return f"{prefix}{self.message}"
def create_event(
event_type: str,
message: str,
*,
radicado: Optional[str] = None,
index: Optional[int] = None,
total: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> DigitadorEvent:
return DigitadorEvent(
event_type=event_type,
message=message,
radicado=radicado,
index=index,
total=total,
metadata=metadata or {},
)
@@ -0,0 +1 @@
"""UI del agent digitador."""
@@ -0,0 +1,368 @@
from __future__ import annotations
import socket
import sys
import threading
import customtkinter as ctk
from bot_digitador.core.coords import DEFAULT_APP_PATH
from bot_digitador.ui.settings_store import (
DEFAULT_API_URL,
load_gui_settings,
normalize_api_url,
save_gui_settings,
)
from bot_digitador.ui.windows_autostart import (
disable_autostart,
enable_autostart,
is_autostart_enabled,
)
from bot_digitador.worker.agent import WorkerAgent
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
class BotDashboard(ctk.CTk):
"""GUI del agent digitador: configura API remota y conecta como worker idle."""
def __init__(self):
super().__init__()
self.title("Bot Digitador — Integración Total")
self.geometry("960x780")
self.resizable(False, False)
self._agent: WorkerAgent | None = None
self._agent_thread: threading.Thread | None = None
self._connected = False
self._construir_ui()
self._cargar_settings()
self.protocol("WM_DELETE_WINDOW", self._on_close)
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)
ctk.CTkLabel(
root,
text="Bot Digitador",
font=ctk.CTkFont(size=26, weight="bold"),
).pack(pady=(18, 4))
ctk.CTkLabel(
root,
text="Agent remoto — digita en Integración Total (pywinauto + OpenCV)",
text_color="gray",
font=ctk.CTkFont(size=13),
).pack(pady=(0, 18))
config = ctk.CTkFrame(root, corner_radius=14)
config.pack(fill="x", padx=24, pady=(0, 14))
ctk.CTkLabel(
config,
text="API remota (IPSExtractor)",
font=ctk.CTkFont(size=16, weight="bold"),
).pack(anchor="w", padx=18, pady=(14, 8))
row_api = ctk.CTkFrame(config, fg_color="transparent")
row_api.pack(fill="x", padx=18, pady=(0, 8))
ctk.CTkLabel(row_api, text="URL API", width=100, anchor="w").pack(side="left")
self.entry_api = ctk.CTkEntry(
row_api,
placeholder_text=DEFAULT_API_URL,
)
self.entry_api.pack(side="left", fill="x", expand=True)
row_id = ctk.CTkFrame(config, fg_color="transparent")
row_id.pack(fill="x", padx=18, pady=(0, 8))
ctk.CTkLabel(row_id, text="Worker ID", width=100, anchor="w").pack(side="left")
self.entry_worker = ctk.CTkEntry(
row_id,
placeholder_text=socket.gethostname() or "PC-01",
)
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, 8))
ctk.CTkLabel(row_token, text="Token", width=100, anchor="w").pack(side="left")
self.entry_token = ctk.CTkEntry(
row_token,
placeholder_text="BOT_API_TOKEN",
show="•",
)
self.entry_token.pack(side="left", fill="x", expand=True, padx=(0, 8))
self._token_visible = False
ctk.CTkButton(
row_token,
text="👁",
width=40,
command=self._toggle_token,
).pack(side="left")
row_app = ctk.CTkFrame(config, fg_color="transparent")
row_app.pack(fill="x", padx=18, pady=(0, 8))
ctk.CTkLabel(row_app, text="APP_PATH", width=100, anchor="w").pack(side="left")
self.entry_app = ctk.CTkEntry(
row_app,
placeholder_text=DEFAULT_APP_PATH,
)
self.entry_app.pack(side="left", fill="x", expand=True)
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))
self.lbl_estado = ctk.CTkLabel(
status,
text="Estado: desconectado",
font=ctk.CTkFont(size=15, weight="bold"),
)
self.lbl_estado.pack(anchor="w", padx=18, pady=(14, 2))
self.lbl_hint = ctk.CTkLabel(
status,
text="Configura la API y pulsa Conectar. Este PC no expone puertos.",
text_color="gray",
font=ctk.CTkFont(size=13),
)
self.lbl_hint.pack(anchor="w", padx=18, pady=(0, 14))
botones = ctk.CTkFrame(root, fg_color="transparent")
botones.pack(fill="x", padx=24, pady=(0, 14))
self.btn_connect = ctk.CTkButton(
botones,
text="▶ Conectar agent",
height=42,
width=180,
font=ctk.CTkFont(size=14, weight="bold"),
command=self._toggle_connect,
)
self.btn_connect.pack(side="left", padx=(0, 10))
ctk.CTkButton(
botones,
text="Guardar config",
height=42,
width=140,
command=self._guardar_settings,
).pack(side="left", padx=(0, 10))
ctk.CTkButton(
botones,
text="Limpiar logs",
height=42,
width=130,
command=self._limpiar_logs,
).pack(side="left")
logs_frame = ctk.CTkFrame(root, corner_radius=14)
logs_frame.pack(fill="both", expand=True, padx=24, pady=(0, 18))
ctk.CTkLabel(
logs_frame,
text="Detalle del agent",
font=ctk.CTkFont(size=15, weight="bold"),
).pack(anchor="w", padx=18, pady=(12, 6))
self.text_logs = ctk.CTkTextbox(
logs_frame, font=ctk.CTkFont(family="Courier", size=12)
)
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(),
app_path=self.entry_app.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 "")
self.entry_app.insert(0, s.get("app_path") or DEFAULT_APP_PATH)
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():
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)
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}")
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 "•")
def _toggle_connect(self):
if self._connected:
self._desconectar()
else:
self._conectar()
def _conectar(self):
api = normalize_api_url(self.entry_api.get())
worker_id = self.entry_worker.get().strip()
token = self.entry_token.get().strip()
app_path = self.entry_app.get().strip() or DEFAULT_APP_PATH
if not worker_id:
self._log("Debes indicar un Worker ID.")
return
if not token:
self._log("Debes indicar el token (BOT_API_TOKEN).")
return
self.entry_api.delete(0, "end")
self.entry_api.insert(0, api)
self._persist_flags()
self._agent = WorkerAgent(
api_url=api,
worker_id=worker_id,
token=token,
app_path=app_path,
log_func=self._log,
)
self._connected = True
self.btn_connect.configure(text="⏹ Desconectar", fg_color="#c0392b", hover_color="#a93226")
self.lbl_estado.configure(text=f"Estado: conectado como {worker_id}")
self.lbl_hint.configure(text=f"API: {api} — esperando jobs del desktop…")
self._set_config_enabled(False)
self._agent_thread = threading.Thread(
target=self._run_agent,
daemon=True,
)
self._agent_thread.start()
self._log(f"Conectando a {api}…")
def _run_agent(self):
try:
assert self._agent is not None
self._agent.run_forever()
except Exception as e:
self._log(f"Error del agent: {e}")
finally:
self.after(0, self._on_agent_stopped)
def _desconectar(self):
if self._agent:
self._agent.stop()
self._log("Solicitando desconexión…")
self.btn_connect.configure(state="disabled", text="Desconectando…")
def _on_agent_stopped(self):
self._connected = False
self._agent = None
self.btn_connect.configure(
state="normal",
text="▶ Conectar agent",
fg_color=["#3B8ED0", "#1F6AA5"],
hover_color=["#36719F", "#144870"],
)
self.lbl_estado.configure(text="Estado: desconectado")
self.lbl_hint.configure(
text="Configura la API y pulsa Conectar. Este PC no expone puertos."
)
self._set_config_enabled(True)
def _set_config_enabled(self, enabled: bool):
state = "normal" if enabled else "disabled"
self.entry_api.configure(state=state)
self.entry_worker.configure(state=state)
self.entry_token.configure(state=state)
self.entry_app.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")
def _log(self, mensaje: str):
def _escribir():
self.text_logs.insert("end", mensaje + "\n")
self.text_logs.see("end")
self.after(0, _escribir)
def _on_close(self):
if self._agent:
self._agent.stop()
self.destroy()
@@ -0,0 +1,79 @@
"""Persistencia simple de configuración de la GUI del worker digitador."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict
from bot_digitador.core.coords import DEFAULT_APP_PATH
DEFAULT_API_URL = os.getenv(
"BOT_API_URL",
"http://localhost:8189/bot-digitador",
)
DEFAULT_TOKEN = os.getenv("BOT_API_TOKEN", "dev-token-change-me")
_SETTINGS_PATH = Path("data") / "gui_settings.json"
def load_gui_settings() -> Dict[str, Any]:
defaults = {
"api_url": DEFAULT_API_URL,
"worker_id": "",
"token": DEFAULT_TOKEN,
"app_path": os.getenv("DIGITADOR_APP_PATH", DEFAULT_APP_PATH),
"autostart_windows": False,
"auto_connect": False,
}
if not _SETTINGS_PATH.exists():
return defaults
try:
data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return defaults
return {**defaults, **data}
except Exception:
return defaults
def save_gui_settings(
api_url: str,
worker_id: str,
token: str,
*,
app_path: str | None = None,
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 app_path is not None:
payload["app_path"] = app_path.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",
)
def normalize_api_url(url: str) -> str:
"""Normaliza la URL base del API bot-digitador."""
u = (url or "").strip().rstrip("/")
if not u:
return DEFAULT_API_URL.rstrip("/")
if u.endswith("/bot-digitador"):
return u
if "builker.xyz" in u or u.endswith(":8189"):
return f"{u}/bot-digitador"
return u
@@ -0,0 +1,73 @@
"""Autoarranque con Windows (carpeta Startup del usuario)."""
from __future__ import annotations
import os
import sys
from pathlib import Path
TASK_BASENAME = "IPSExtractor-BotDigitador"
def bot_root() -> Path:
"""Raíz del paquete bots/bot_digitador (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)
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
@@ -0,0 +1 @@
"""Worker remoto del bot digitador."""
@@ -0,0 +1,6 @@
"""Permite: python -m bot_digitador.worker"""
from bot_digitador.worker.run import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,315 @@
from __future__ import annotations
import os
import socket
import tempfile
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
import httpx
from bot_digitador.core.coords import DEFAULT_APP_PATH
from bot_digitador.core.digitador_service import DigitadorService
from bot_digitador.worker.minio_upload import MinioSettings, WorkerMinioUploader
JOB_COMPLETED = "completed"
JOB_FAILED = "failed"
JOB_CANCELLED = "cancelled"
REPORT_FILES = ("RESULTADOS.csv", "OMITIDOS.csv", "ejecucion_bot.log")
class RemoteApiClient:
"""Cliente HTTP hacia la API bot-digitador (vía gateway). Sin puertos locales."""
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self.token = token
self._client = httpx.Client(
timeout=httpx.Timeout(120.0, connect=10.0),
headers={"Authorization": f"Bearer {token}"},
)
def close(self) -> None:
self._client.close()
def _url(self, path: str) -> str:
return f"{self.base_url}{path}"
def register(self, worker_id: str, hostname: str = "", max_concurrent: int = 1) -> dict:
r = self._client.post(
self._url("/workers/register"),
json={
"worker_id": worker_id,
"hostname": hostname or socket.gethostname(),
"max_concurrent": max_concurrent,
},
)
r.raise_for_status()
return r.json()
def heartbeat(self, worker_id: str) -> dict:
r = self._client.post(
self._url("/workers/heartbeat"),
json={"worker_id": worker_id},
)
r.raise_for_status()
return r.json()
def claim_pending(self, worker_id: str) -> Optional[dict]:
r = self._client.get(
self._url("/workers/me/pending"),
params={"worker_id": worker_id},
)
r.raise_for_status()
data = r.json()
return data.get("job")
def download_file(self, job_id: str, dest: Path) -> Path:
r = self._client.get(self._url(f"/jobs/{job_id}/file"))
r.raise_for_status()
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(r.content)
return dest
def post_event(self, job_id: str, event: Dict[str, Any]) -> None:
r = self._client.post(self._url(f"/jobs/{job_id}/events"), json=event)
r.raise_for_status()
def cancel_flag(self, job_id: str) -> bool:
r = self._client.get(self._url(f"/jobs/{job_id}/cancel-flag"))
r.raise_for_status()
return bool(r.json().get("cancel_requested"))
def patch_job(self, job_id: str, **body) -> dict:
r = self._client.patch(self._url(f"/jobs/{job_id}"), json=body)
r.raise_for_status()
return r.json()
class WorkerAgent:
"""Agent idle: heartbeat + claim jobs de digitación asignados."""
def __init__(
self,
api_url: str,
worker_id: str,
token: str,
poll_seconds: float = 8.0,
staging_root: Optional[Path] = None,
app_path: Optional[str] = None,
date_format: str = "dd/mm/yyyy",
delimiter: str = "|",
log_func=None,
):
self.api = RemoteApiClient(api_url, token)
self.worker_id = worker_id
self.poll_seconds = poll_seconds
self.staging_root = staging_root or Path(tempfile.gettempdir()) / "bot_digitador"
self.app_path = app_path or os.getenv("DIGITADOR_APP_PATH") or DEFAULT_APP_PATH
self.date_format = date_format
self.delimiter = delimiter
self.minio = WorkerMinioUploader(MinioSettings.from_env())
self._running = True
self._uploaded_keys: List[str] = []
self._log = log_func or print
def stop(self) -> None:
self._running = False
def run_forever(self) -> None:
self.api.register(self.worker_id)
self._log(f"[worker {self.worker_id}] registrado — idle (esperando jobs de digitación)")
try:
while self._running:
try:
self.api.heartbeat(self.worker_id)
job = self.api.claim_pending(self.worker_id)
if job:
self._run_job(job)
else:
time.sleep(self.poll_seconds)
except KeyboardInterrupt:
raise
except Exception as e:
self._log(f"[worker {self.worker_id}] error en loop: {e}")
time.sleep(self.poll_seconds)
finally:
self.api.close()
self._log(f"[worker {self.worker_id}] detenido")
def _run_job(self, job: dict) -> None:
job_id = job["id"]
reports_prefix = (job.get("reports_prefix") or "").strip() or f"digitados/{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)
csv_name = Path(job.get("csv_key") or job.get("file_key") or "input.csv").name
if not csv_name.lower().endswith(".csv"):
csv_name = "input.csv"
csv_path = work_dir / csv_name
self._log(f"[worker {self.worker_id}] job {job_id} iniciado (prefix={reports_prefix})")
try:
self.api.download_file(job_id, csv_path)
def should_cancel() -> bool:
try:
return self.api.cancel_flag(job_id)
except Exception:
return False
def on_event(event) -> None:
self._log(event.display_message())
payload = {
"event_type": event.event_type,
"message": event.message,
"radicado": event.radicado,
"index": event.index,
"total": event.total,
"metadata": event.metadata,
"timestamp": event.timestamp,
}
try:
self.api.post_event(job_id, payload)
except Exception as e:
self._log(f"[worker] no se pudo enviar evento: {e}")
date_format = job.get("date_format") or self.date_format
delimiter = job.get("delimiter") or self.delimiter
service = DigitadorService(
csv_path=str(csv_path),
output_dir=str(out_dir),
date_format=date_format,
delimiter=delimiter,
app_path=self.app_path,
event_callback=on_event,
should_cancel=should_cancel,
)
summary = service.run()
# En shards, nombres únicos bajo el prefijo compartido del padre
# para no pisar RESULTADOS.csv / OMITIDOS.csv entre bots.
shard_index = job.get("shard_index")
shard_suffix = ""
if shard_index is not None:
try:
shard_suffix = f"_shard_{int(shard_index) + 1}"
except (TypeError, ValueError):
shard_suffix = f"_shard_{shard_index}"
def remote_name(local_name: str) -> str:
if not shard_suffix:
return local_name
stem = Path(local_name).stem
suffix = Path(local_name).suffix
return f"{stem}{shard_suffix}{suffix}"
for name in REPORT_FILES:
local = out_dir / name
if not local.exists():
continue
try:
key = self.minio.upload_report(
job_id,
local,
reports_prefix=reports_prefix,
object_name=remote_name(name),
)
self._uploaded_keys.append(key)
self._log(f"[worker] subido {key}")
except Exception as e:
self._log(f"[worker] error subiendo {name}: {e}")
try:
self.minio.write_manifest(
job_id,
self._uploaded_keys,
reports_prefix=reports_prefix,
manifest_name=(
f"_manifest{shard_suffix}.json" if shard_suffix else "_manifest.json"
),
extra={
"exitosos": summary.exitosos,
"fallidos": summary.fallidos,
"existentes": summary.existentes,
"omitidos": summary.omitidos,
"timeouts": summary.timeouts,
"shard_index": shard_index,
},
)
except Exception as e:
self._log(f"[worker] manifest no escrito: {e}")
result = {
"total": summary.total,
"exitosos": summary.exitosos,
"fallidos": summary.fallidos,
"existentes": summary.existentes,
"omitidos": summary.omitidos,
"timeouts": summary.timeouts,
"cancelled": summary.cancelled,
"reports_prefix": reports_prefix,
"uploaded_count": len(self._uploaded_keys),
}
if summary.cancelled:
status = JOB_CANCELLED
error_msg = None
elif summary.fatal_error:
status = JOB_FAILED
error_msg = summary.fatal_error
elif summary.fallidos and summary.exitosos == 0 and summary.existentes == 0:
status = JOB_FAILED
error_msg = "Todos los radicados fallaron"
else:
status = JOB_COMPLETED
error_msg = None
if error_msg:
try:
self.api.post_event(
job_id,
{
"event_type": "warning",
"message": error_msg,
"metadata": {"fatal": bool(summary.fatal_error)},
},
)
except Exception:
pass
self.api.patch_job(
job_id,
status=status,
result=result,
error=error_msg,
)
self._log(f"[worker {self.worker_id}] job {job_id} → {status}")
except Exception as e:
self._log(f"[worker {self.worker_id}] job {job_id} error: {e}")
try:
self.api.post_event(
job_id,
{
"event_type": "warning",
"message": f"Error fatal: {e}",
"metadata": {"fatal": True},
},
)
except Exception:
pass
try:
self.api.patch_job(
job_id,
status=JOB_FAILED,
error=str(e),
)
except Exception:
pass
@@ -0,0 +1,163 @@
"""Cliente MinIO para subir reportes del digitador (solo salida)."""
from __future__ import annotations
import json
import mimetypes
import os
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
@dataclass(frozen=True)
class MinioSettings:
endpoint: str = "https://s3.builker.xyz"
bucket: str = "pys-cuentas-medicas"
access_key: str = ""
secret_key: str = ""
region: str = "us-east-1"
anonymous: bool = True
@classmethod
def from_env(cls) -> "MinioSettings":
access_key = (os.getenv("MINIO_ACCESS_KEY") or "").strip()
secret_key = (os.getenv("MINIO_SECRET_KEY") or "").strip()
anon_flag = (os.getenv("MINIO_ANONYMOUS") or "").strip().lower()
if anon_flag in ("0", "false", "no"):
anonymous = False
elif anon_flag in ("1", "true", "yes"):
anonymous = True
else:
anonymous = not (access_key and secret_key)
return cls(
endpoint=os.getenv("MINIO_ENDPOINT", "https://s3.builker.xyz").rstrip("/"),
bucket=os.getenv("MINIO_BUCKET", "pys-cuentas-medicas"),
access_key=access_key,
secret_key=secret_key,
region=os.getenv("MINIO_REGION", "us-east-1"),
anonymous=anonymous,
)
def report_key_from_prefix(reports_prefix: str, filename: str) -> str:
prefix = (reports_prefix or "").strip().rstrip("/")
if not prefix:
raise ValueError("reports_prefix vacío")
name = Path(filename).name
return f"{prefix}/{name}"
def report_key(job_id: str, filename: str) -> str:
return f"digitados/{job_id}/{Path(filename).name}"
def manifest_key_from_prefix(reports_prefix: str) -> str:
prefix = (reports_prefix or "").strip().rstrip("/")
return f"{prefix}/_manifest.json"
def manifest_key(job_id: str) -> str:
return f"digitados/{job_id}/_manifest.json"
def _s3_client(settings: MinioSettings):
import boto3
from botocore import UNSIGNED
from botocore.client import Config
s3_cfg = {
"addressing_style": "path",
"request_checksum_calculation": "when_required",
"response_checksum_validation": "when_required",
}
if settings.anonymous:
return boto3.client(
"s3",
endpoint_url=settings.endpoint,
region_name=settings.region,
config=Config(signature_version=UNSIGNED, s3=s3_cfg),
)
return boto3.client(
"s3",
endpoint_url=settings.endpoint,
aws_access_key_id=settings.access_key,
aws_secret_access_key=settings.secret_key,
region_name=settings.region,
config=Config(signature_version="s3v4", s3=s3_cfg),
)
class WorkerMinioUploader:
"""PutObject de reportes CSV/log y manifest bajo digitados/."""
def __init__(self, settings: Optional[MinioSettings] = None):
self.settings = settings or MinioSettings.from_env()
self._client = None
@property
def client(self):
if self._client is None:
self._client = _s3_client(self.settings)
return self._client
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_report(
self,
job_id: str,
local_path: str | Path,
*,
reports_prefix: str | None = None,
object_name: str | None = None,
content_type: str | None = None,
) -> str:
path = Path(local_path)
filename = object_name or path.name
if reports_prefix:
key = report_key_from_prefix(reports_prefix, filename)
else:
key = report_key(job_id, filename)
ctype = content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
self.client.put_object(
Bucket=self.settings.bucket,
Key=key,
Body=path.read_bytes(),
ContentType=ctype,
)
return key
def write_manifest(
self,
job_id: str,
keys: List[str],
*,
reports_prefix: str | None = None,
manifest_name: str = "_manifest.json",
extra: Optional[dict] = None,
) -> str:
if reports_prefix:
key = report_key_from_prefix(reports_prefix, manifest_name)
else:
key = report_key(job_id, manifest_name)
payload = {
"job_id": job_id,
"reports_prefix": reports_prefix or f"digitados/{job_id}/",
"files": keys,
"count": len(keys),
}
if extra:
payload.update(extra)
self.client.put_object(
Bucket=self.settings.bucket,
Key=key,
Body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
return key
@@ -0,0 +1,64 @@
"""CLI del agent worker digitador: idle hasta que el usuario asigne un job."""
from __future__ import annotations
import argparse
import os
import sys
from bot_digitador.core.coords import DEFAULT_APP_PATH
from bot_digitador.worker.agent import WorkerAgent
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Agent BotDigitador: registra el PC y espera jobs de digitación."
)
p.add_argument(
"--api-url",
default=os.getenv("BOT_API_URL", "http://localhost:8189/bot-digitador"),
help="URL del API vía gateway (default: …/bot-digitador)",
)
p.add_argument(
"--worker-id",
required=True,
help="Identificador único de este PC (ej. PC-DIGITADOR-01)",
)
p.add_argument(
"--token",
default=os.getenv("BOT_API_TOKEN", "dev-token-change-me"),
help="Bearer token (BOT_API_TOKEN)",
)
p.add_argument(
"--app-path",
default=os.getenv("DIGITADOR_APP_PATH", DEFAULT_APP_PATH),
help="Ruta al acceso directo / ejecutable de Integración Total",
)
p.add_argument(
"--poll",
type=float,
default=8.0,
help="Segundos entre heartbeat/claim",
)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
agent = WorkerAgent(
api_url=args.api_url,
worker_id=args.worker_id,
token=args.token,
poll_seconds=args.poll,
app_path=args.app_path,
)
try:
agent.run_forever()
except KeyboardInterrupt:
print("\nDetenido por usuario")
agent.stop()
return 0
if __name__ == "__main__":
sys.exit(main())
+106
View File
@@ -0,0 +1,106 @@
"""CLI local del Bot Digitador (sin API remota)."""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from bot_digitador.core.coords import DEFAULT_APP_PATH
from bot_digitador.core.digitador_service import DigitadorService
from bot_digitador.models.events import DigitadorEvent
def _print_event(event: DigitadorEvent) -> None:
print(event.display_message(), flush=True)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Digita radicados en Integración Total desde un CSV local."
)
parser.add_argument(
"--csv",
required=True,
help="Ruta al CSV de entrada (coma o pipe)",
)
parser.add_argument(
"--output-dir",
default=None,
help="Carpeta de reportes (default: <csv_dir>/reportes_bot/<timestamp>)",
)
parser.add_argument(
"--delimiter",
choices=[",", "|"],
default="|",
help="Delimitador CSV (se auto-detecta si no coincide con columnas)",
)
parser.add_argument(
"--date-format",
choices=["dd/mm/yyyy", "mm/dd/yyyy"],
default="dd/mm/yyyy",
help="Formato preferido de fechas en el CSV",
)
parser.add_argument(
"--app-path",
default=os.getenv("DIGITADOR_APP_PATH", DEFAULT_APP_PATH),
help="Ruta al acceso directo / ejecutable de Integración Total",
)
parser.add_argument(
"--reinicio-cada",
type=int,
default=6,
help="Reinicio completo de la app cada N registros",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
csv_path = Path(args.csv).resolve()
if not csv_path.exists():
print(f"CSV no encontrado: {csv_path}", file=sys.stderr)
return 2
if args.output_dir:
output_dir = Path(args.output_dir).resolve()
else:
from datetime import datetime
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_dir = csv_path.parent / "reportes_bot" / stamp
output_dir.mkdir(parents=True, exist_ok=True)
service = DigitadorService(
csv_path=str(csv_path),
output_dir=str(output_dir),
date_format=args.date_format,
delimiter=args.delimiter,
app_path=args.app_path,
reinicio_cada=args.reinicio_cada,
event_callback=_print_event,
)
summary = service.run()
print(
f"Resumen: OK={summary.exitosos}/{summary.total}, "
f"fallidos={summary.fallidos}, existentes={summary.existentes}, "
f"omitidos={summary.omitidos}, timeouts={summary.timeouts}, "
f"cancelled={summary.cancelled}",
flush=True,
)
print(f"Reportes en: {summary.output_dir}", flush=True)
if summary.fatal_error:
print(f"Error fatal: {summary.fatal_error}", file=sys.stderr)
return 1
if summary.cancelled:
return 130
return 0 if summary.fallidos == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+8
View File
@@ -0,0 +1,8 @@
httpx>=0.27.0
boto3>=1.34.0
pywinauto>=0.6.8
opencv-python-headless>=4.8.0
numpy>=1.24.0
customtkinter>=5.2.0
python-dotenv>=1.0.0
Pillow>=10.0.0
+12
View File
@@ -0,0 +1,12 @@
"""Punto de entrada de la interfaz gráfica del bot digitador."""
from bot_digitador.ui.dashboard import BotDashboard
def main():
app = BotDashboard()
app.mainloop()
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
# Plantillas OpenCV
Copia aquí los PNG necesarios desde la carpeta `template/` del script original
(`bot_saludtotal.py` / proyecto de digitación):
- `error.png`
- `success.png`
- `bien.png`
- `flecha_diferencias_abierto.png`
- `flecha_diferencias_cerrado.png`
- `fecha_vacia_slash.png`
- `fecha_vacia_blanco.png`
- `popup_nombres.png`
- `radicado_existente.png`
Sin estos archivos el bot no puede validar pantallas de Integración Total.