Herramientas Informaticas

Categoría: Uncategorized Página 1 de 15

🌐 Arquitectura, Integración y Estabilidad: Implementación del Módulo sapuserwh con SAP Service Layer y Solución Definitiva a Conflictos de Ciclo de Vida en Frontend

Entrada fija

📌 1. Introducción y Contexto de la Arquitectura Empresarial

En el desarrollo de software corporativo contemporáneo, la integración entre sistemas de planificación de recursos empresariales (ERP, por sus siglas en inglés) y plataformas web personalizadas representa uno de los desafíos más críticos y de mayor impacto operativo. Históricamente, interactuar con SAP Business One requería el uso intensivo de la API DI (Data Interface API), una tecnología basada en componentes COM de Microsoft Windows de 32 y 64 bits. Aunque la DI API sigue siendo robusta, presenta limitaciones severas en entornos modernos: fuerte acoplamiento a servidores Windows, problemas de escalabilidad en entornos multi-hilo, consumo elevado de memoria y una marcada dificultad para su despliegue dentro de arquitecturas basadas en contenedores Linux, microservicios o aplicaciones web desacopladas.

Con la llegada de la arquitectura SAP HANA y la modernización de los entornos basados en Microsoft SQL Server, SAP introdujo la Service Layer. Este componente revolucionó el ecosistema de integración al ofrecer una interfaz de programación de aplicaciones de tipo RESTful, basada en el protocolo estándar OData (Open Data Protocol versión 3 y 4), con transporte de datos estructurados en formato JSON a través de HTTPS. Gracias a la Service Layer, cualquier lenguaje con soporte para llamadas HTTP de alto rendimiento —como PHP, Python, Node.js o Go— puede ejecutar operaciones CRUD completas (crear, leer, actualizar y eliminar) sobre los objetos de negocio nativos y las tablas o campos de usuario (UDOs y UDFs) de SAP Business One.

       +--------------------------------------------------------------+
       |                  Navegador del Administrador                 |
       |                (Interfaz Web HTML5 / DataTables)             |
       +-------------------------------+------------------------------+
                                       |
                                       | Peticiones HTTP / AJAX
                                       v
       +--------------------------------------------------------------+
       |               Backend Web (PHP / CodeIgniter 4)              |
       |     Controlador: ServiceLayerController::sapuserwh()         |
       |     Librería: SAPServiceLayerClient (Gestión de Sesión)      |
       +-------------------------------+------------------------------+
                                       |
                                       | REST / OData sobre HTTPS (JSON)
                                       v
       +--------------------------------------------------------------+
       |                 SAP B1 Service Layer Engine                  |
       |              (B1SESSION + ROUTEID / Balanceo)                |
       +-------------------------------+------------------------------+
                                       |
                                       | Conexión Nativa de Datos
                                       v
       +--------------------------------------------------------------+
       |              Base de Datos (SAP HANA / MS SQL)               |
       |         Tablas Nativas: OUSR, OWHS | UDOs / UDTs             |
       +--------------------------------------------------------------+

Sin embargo, trasladar la complejidad del modelo relacional y de permisos de un ERP a una plataforma web ligera no es una tarea trivial. Uno de los puntos operativos más sensibles en la logística y administración diaria de una empresa es la asignación y restricción de almacenes por usuario (User Warehouse Assignment). Si un operador de almacén o un vendedor cuenta con acceso a almacenes que no le corresponden, se generan inconsistencias en el inventario físico, transferencias erróneas y descuadres contables.

Para resolver esta necesidad específica de gobernanza operativa, se desarrolló el módulo administrativo sapuserwh. En este artículo técnico exhaustivo se detallan los objetivos de diseño del módulo, su arquitectura backend y frontend, el análisis forense del error de ejecución JavaScript TypeError: $(...).DataTable is not a function que paralizó la vista interactiva, y la solución definitiva implementada bajo estándares de ingeniería de software.

🎯 2. Objetivos y Alcance del Módulo sapuserwh

El módulo sapuserwh nació con una misión clara: desacoplar y democratizar la administración de almacenes asignados a usuarios sin obligar a los administradores a abrir el cliente pesado de SAP Business One, optimizando tanto el consumo de licencias profesionales como los tiempos de respuesta del equipo de sistemas.

+----------------------------------------------------------------------------------------------------+
|                                    OBJETIVOS DEL MÓDULO sapuserwh                                  |
+----------------------------------------------------------------------------------------------------+
| 1. Centralización Operativa  -> Mapeo visual e intuitivo entre cuentas OUSR y almacenes OWHS.     |
| 2. Reducción de Latencia     -> Consultas optimizadas con filtros OData ($select, $filter).       |
| 3. Independencia de Cliente  -> Configuración remota 100% web desde cualquier dispositivo.        |
| 4. Auditoría y Control       -> Prevenir fugas de stock y errores humanos en traslados y ventas.   |
| 5. Experiencia de Usuario    -> Interfaz dinámica con DataTables, búsqueda en vivo y exportaciones.|
+----------------------------------------------------------------------------------------------------+

🔹 Metas funcionales principales

  1. Centralización del Mapeo Usuario-Almacén: Brindar a los supervisores de operaciones una matriz clara y procesable donde puedan visualizar qué usuarios tienen acceso a cuáles almacenes físicos y lógicos (almacén general, almacén de mermas, almacén de tránsito, almacenes locales por sucursal).
  2. Consultas en Tiempo Real vía OData: Evitar la duplicación de datos y la sincronización asíncrona desfasada. La información mostrada debe reflejar el estado vivo del motor transaccional de SAP B1 mediante peticiones optimizadas con los operadores $select, $filter y $expand.
  3. Optimización de Licenciamiento: El cliente tradicional de escritorio de SAP Business One requiere licencias dedicadas de tipo Profesional o Limitada para configurar catálogos y accesos. Al exponer esta administración a través de una aplicación web intermediaria conectada mediante un usuario técnico a la Service Layer, se optimiza el uso de terminales y licencias de escritorio.
  4. Resiliencia y Usabilidad en Frontend: En organizaciones con cientos de usuarios y decenas de almacenes, el volumen de combinaciones posibles supera fácilmente los millares de registros. Presentar esta información en una tabla HTML convencional resulta inviable; se requiere paginación en el cliente, búsqueda instantánea por cualquier columna, ordenamiento alfanumérico y capacidades de exportación a formatos estándar (Excel, CSV, PDF y portapapeles).

🏗️ 3. Arquitectura del Backend: CodeIgniter 4 y SAP Service Layer

Para garantizar un rendimiento sobresaliente, bajo consumo de recursos en el servidor y una estructura de carpetas modular y mantenible, la solución se estructuró sobre el framework PHP CodeIgniter 4, aprovechando su motor de enrutamiento rápido, controladores organizados y abstracción de dependencias.

🔑 3.1. Ciclo de Vida de la Sesión en la Service Layer

La Service Layer es un servicio con estado (stateful) que utiliza cookies HTTP para mantener el contexto de la transacción:

  • B1SESSION: Identificador de la sesión autenticada del usuario técnico de SAP.
  • ROUTEID: Identificador del nodo de balanceo de carga cuando la Service Layer está desplegada en alta disponibilidad.

Cada llamada debe enviar estas dos cookies en la cabecera Cookie: B1SESSION=...; ROUTEID=.... Si la sesión caduca (por inactividad, típicamente configurada en 30 minutos dentro de b1s.conf), el backend de CodeIgniter 4 debe atrapar el código de error HTTP 401 Unauthorized, negociar un nuevo /Login de manera transparente y reintentar la petición sin impactar al usuario final.

       [ Petición Backend ] ---> ¿Sesión en Cache válida?
                                         |
                       +-----------------+-----------------+
                       |                                   |
                     ( SÍ )                              ( NO )
                       |                                   |
                       v                                   v
             Reutilizar Cookies               Llamada POST /Login a SL
             B1SESSION + ROUTEID                           |
                       |                                   v
                       |                         Guardar nuevas Cookies
                       |                         en Cache (TTL: 25 min)
                       +-----------------+-----------------+
                                         |
                                         v
                      Ejecutar Petición GET/POST con Datos

💻 3.2. Implementación del Cliente Service Layer en PHP

A continuación se presenta la arquitectura del cliente de comunicación encapsulado en un servicio especializado de CodeIgniter 4:

PHP

<?php

namespace App\Libraries;

use CodeIgniter\HTTP\CURLRequest;
use Config\Services;
use Exception;

/**
 * Cliente de bajo nivel para comunicación de alta eficiencia con SAP Service Layer.
 * Gestiona autenticación, cookies de balanceo, reintentos y mapeo de errores OData.
 */
class SAPServiceLayerClient
{
    private string $serviceLayerUrl;
    private string $companyDB;
    private string $username;
    private string $password;
    private ?string $sessionId = null;
    private ?string $routeId = null;
    private CURLRequest $client;

    public function __construct()
    {
        $this->serviceLayerUrl = rtrim(env('SAP_SL_URL', 'https://192.168.1.100:50000/b1s/v1'), '/');
        $this->companyDB       = env('SAP_SL_COMPANY_DB', 'SBODEMOMX');
        $this->username        = env('SAP_SL_USER', 'manager');
        $this->password        = env('SAP_SL_PASS', '1234');

        // Inicializamos el cliente cURL integrado de CodeIgniter 4 desactivando
        // la verificación estricta de certificados SSL en entornos de red local/self-signed
        $this->client = Services::curlrequest([
            'base_URI' => $this->serviceLayerUrl . '/',
            'timeout'  => 30.0,
            'verify'   => false,
            'http_errors' => false,
        ]);

        $this->restaurarSesionDesdeCache();
    }

    /**
     * Autentica el cliente ante la Service Layer de SAP Business One.
     */
    public function login(): bool
    {
        $payload = [
            'CompanyDB' => $this->companyDB,
            'UserName'  => $this->username,
            'Password'  => $this->password,
        ];

        try {
            $response = $this->client->post('Login', [
                'headers' => [
                    'Content-Type' => 'application/json; charset=utf-8',
                    'Accept'       => 'application/json',
                ],
                'body' => json_encode($payload),
            ]);

            $statusCode = $response->getStatusCode();
            $body = json_decode($response->getBody(), true);

            if ($statusCode === 200 && isset($body['SessionId'])) {
                $this->sessionId = $body['SessionId'];

                // Extraemos la cookie ROUTEID para mantener la afinidad de balanceador
                $rawCookies = $response->getHeaderLine('Set-Cookie');
                if (preg_match('/ROUTEID=(.[^;]+)/', $rawCookies, $matches)) {
                    $this->routeId = $matches[1];
                }

                $this->persistirSesionEnCache();
                return true;
            }

            log_message('error', '[SAP SL Login Error] Status: ' . $statusCode . ' - ' . json_encode($body));
            return false;
        } catch (Exception $e) {
            log_message('critical', '[SAP SL Conexión Fallida] ' . $e->getMessage());
            return false;
        }
    }

    /**
     * Ejecuta una petición GET a la Service Layer asegurando la validez del token de sesión.
     */
    public function get(string $endpoint, array $queryParams = []): array
    {
        if (!$this->sessionId) {
            if (!$this->login()) {
                throw new Exception('No fue posible autenticar la sesión con SAP Service Layer.');
            }
        }

        $headers = [
            'Accept'       => 'application/json',
            'Content-Type' => 'application/json',
            'Cookie'       => 'B1SESSION=' . $this->sessionId . ($this->routeId ? '; ROUTEID=' . $this->routeId : ''),
        ];

        $response = $this->client->get($endpoint, [
            'headers' => $headers,
            'query'   => $queryParams,
        ]);

        // Si la sesión expiró remotamente en el servidor de SAP, reintentamos una vez con nuevo login
        if ($response->getStatusCode() === 401) {
            log_message('info', '[SAP SL] Sesión expirada. Renovando credenciales...');
            if ($this->login()) {
                $headers['Cookie'] = 'B1SESSION=' . $this->sessionId . ($this->routeId ? '; ROUTEID=' . $this->routeId : '');
                $response = $this->client->get($endpoint, [
                    'headers' => $headers,
                    'query'   => $queryParams,
                ]);
            } else {
                throw new Exception('Fallo la renovación automática de sesión ante SAP B1.');
            }
        }

        $body = json_decode($response->getBody(), true);
        if ($response->getStatusCode() >= 400) {
            $errorMsg = $body['error']['message']['value'] ?? 'Error desconocido en petición a Service Layer';
            throw new Exception("Error en Service Layer ({$response->getStatusCode()}): {$errorMsg}");
        }

        return $body ?? [];
    }

    private function persistirSesionEnCache(): void
    {
        $cache = Services::cache();
        $cache->save('sap_sl_session_id', $this->sessionId, 1500); // 25 minutos de TTL
        if ($this->routeId) {
            $cache->save('sap_sl_route_id', $this->routeId, 1500);
        }
    }

    private function restaurarSesionDesdeCache(): void
    {
        $cache = Services::cache();
        $this->sessionId = $cache->get('sap_sl_session_id');
        $this->routeId   = $cache->get('sap_sl_route_id');
    }
}

🎮 3.3. El Controlador: ServiceLayerController.php

El controlador tiene la responsabilidad de orquestar la obtención de datos, correlacionar la información de usuarios (Users / tabla OUSR), almacenes (Warehouses / tabla OWHS) y la tabla intermedia de asignaciones personalizadas o UDO (@USER_WH), y despachar la vista enriquecida al motor de renderizado:

PHP

<?php

namespace App\Controllers\Admin\ServiceLayer;

use App\Controllers\BaseController;
use App\Libraries\SAPServiceLayerClient;
use Exception;

class ServiceLayerController extends BaseController
{
    /**
     * Muestra la vista principal de asignación de Almacenes por Usuario (sapuserwh).
     */
    public function sapuserwh()
    {
        $slClient = new SAPServiceLayerClient();
        $data = [
            'page_title' => 'Gestión de Usuarios y Almacenes - SAP Business One',
            'usuarios'   => [],
            'almacenes'  => [],
            'relaciones' => [],
            'error'      => null,
        ];

        try {
            // 1. Obtener lista de usuarios activos de SAP B1
            $usuariosResponse = $slClient->get('Users', [
                '$select' => 'InternalKey,UserCode,UserName,eMail,Locked',
                '$filter' => "Locked eq 'tNO'",
                '$orderby' => 'UserName asc',
            ]);
            $data['usuarios'] = $usuariosResponse['value'] ?? [];

            // 2. Obtener lista de almacenes disponibles
            $almacenesResponse = $slClient->get('Warehouses', [
                '$select' => 'WarehouseCode,WarehouseName,Inactive',
                '$filter' => "Inactive eq 'tNO'",
                '$orderby' => 'WarehouseCode asc',
            ]);
            $data['almacenes'] = $almacenesResponse['value'] ?? [];

            // 3. Consultar la tabla de usuario / UDO donde se guarda la relación (@USER_WH)
            // Estructura: Code, Name, U_UserCode, U_WhsCode, U_AllowSales, U_AllowTransfer
            $relacionesResponse = $slClient->get('U_USER_WH', [
                '$top' => 5000,
            ]);
            $data['relaciones'] = $relacionesResponse['value'] ?? [];

        } catch (Exception $e) {
            log_message('error', '[sapuserwh Controller Error] ' . $e->getMessage());
            $data['error'] = $e->getMessage();
        }

        // Renderizado utilizando la estructura de vistas de CodeIgniter 4
        return view('admin/servicelayer/sapuserwh', $data);
    }
}

💥 4. Análisis Forense: Anatomía de la Falla en Frontend

Durante las pruebas funcionales de despliegue, al cargar la ruta http://localhost:8080/admin/servicelayer/sapuserwh, la tabla no renderizaba las funciones de filtrado ni paginación, y la consola del navegador Developer Tools (F12) arrojaba la siguiente excepción de JavaScript:

Plaintext

jquery.min.js:2 jQuery.Deferred exception: $(...).DataTable is not a function TypeError: $(...).DataTable is not a function
    at HTMLDocument.<anonymous> (http://localhost:8080/admin/servicelayer/sapuserwh:860:43)
    at e (https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js:2:29453)
    at t (https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js:2:29755) undefined
k.Deferred.exceptionHook @ jquery.min.js:2
t @ jquery.min.js:2
setTimeout
(anonymous) @ jquery.min.js:2
c @ jquery.min.js:2
fireWith @ jquery.min.js:2
fire @ jquery.min.js:2
c @ jquery.min.js:2
fireWith @ jquery.min.js:2
ready @ jquery.min.js:2
B @ jquery.min.js:2
jquery.min.js:2 Uncaught TypeError: $(...).DataTable is not a function
(anonymous) @ sapuserwh:860

🔬 4.1. ¿Qué significa exactamente TypeError: $(...).DataTable is not a function?

En el modelo de objetos de JavaScript y la arquitectura interna de jQuery, los plugins extienden el objeto prototípico jQuery.fn (que es un alias directo de jQuery.prototype).

Cuando la librería DataTables se ejecuta en el navegador, realiza internamente una operación similar a esta:

JavaScript

(function(factory) {
    if (typeof define === 'function' && define.amd) {
        // Soporte para AMD / RequireJS
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        // Soporte para CommonJS / Node
        module.exports = factory(require('jquery'));
    } else {
        // Entorno tradicional de navegador: adjuntar al jQuery global disponible
        factory(jQuery);
    }
}(function($) {
    // Aquí DataTables extiende la instancia activa de jQuery
    $.fn.dataTable = function(options) { /* lógica interna */ };
    $.fn.DataTable = function(options) { /* lógica de API moderna */ };
}));

Cuando el motor de JavaScript evalúa la expresión $('#tablaUsuariosAlmacen').DataTable(), busca la propiedad DataTable dentro de la cadena de prototipos del objeto retornado por $(). El error TypeError: $(...).DataTable is not a function significa categóricamente que en el momento exacto de la llamada, la función DataTable no existe dentro de $.fn.

🕵️ 4.2. ¿Por qué ocurrió este error en la vista sapuserwh?

Tras una inspección minuciosa del árbol DOM y la cronología de solicitudes en la pestaña Network (Red), se identificó la causa raíz: colisión por doble carga de jQuery y alteración del ciclo de vida en el motor de plantillas.

+-------------------------------------------------------------------------------------------------------+
|                                    CRONOLOGÍA DEL ERROR EN EL NAVEGADOR                               |
+-------------------------------------------------------------------------------------------------------+
| 1. El Layout Maestro carga jQuery 3.4.1 en el <head> o footer.                                        |
|    -> window.jQuery queda inicializado en Memoria A.                                                  |
| 2. El Layout Maestro carga DataTables (jquery.dataTables.min.js).                                      |
|    -> DataTables se registra dentro de Memoria A: window.jQuery.fn.DataTable = [Function]              |
| 3. La vista hija (sapuserwh.php) incluye por error una segunda etiqueta:                              |
|    <script src=".../jquery.min.js"></script>                                                          |
|    -> ¡DESASTRE!: Se crea Memoria B, SOBREESCRIBIENDO window.jQuery y window.$ con una copia limpia.   |
| 4. window.jQuery.fn.DataTable queda destruido y eliminado del ámbito global.                          |
| 5. Se ejecuta $(document).ready() en la línea 860:                                                    |
|    Llama a $(...).DataTable() sobre la instancia B -> LANZA TypeError: is not a function.             |
+-------------------------------------------------------------------------------------------------------+

Este fenómeno es común en arquitecturas MVC donde se utilizan componentes o parciales reutilizables. Un desarrollador incluye scripts dentro de una vista pensando que no están presentes en el layout principal, o las secciones de inyección (sections) se renderizan en un orden cronológico incorrecto respecto a los archivos del catálogo de librerías (vendor).

🛠️️ 5. La Solución Técnica Implementada

Para erradicar el problema de raíz y garantizar que la arquitectura sea escalable para todos los futuros módulos administrativos de la aplicación, se implementó una reestructuración basada en tres pilares:

📐 Pilar 1: Definición Estricta del Layout Maestro

Se reorganizó la plantilla base (app/Views/layouts/admin_layout.php) dividiendo con claridad la inyección de estilos (styles), librerías de terceros compartidas (vendor_scripts) y scripts específicos de cada página (page_scripts).

📦 Pilar 2: Eliminación de Redundancias

Se purgó cualquier referencia local o remota a jquery.min.js dentro de las vistas hijas. jQuery debe ser un recurso singleton en el contexto de ejecución de la ventana del navegador (window).

⏱️ Pilar 3: Respeto al Ciclo de Vida del DOM

Se encapsuló la inicialización de los plugins dentro de bloques que garantizan que tanto el DOM como los scripts diferidos estén completamente parseados y listos antes de invocar la API de DataTables.

📝 6. Código Completo de la Solución: Frontend y Vistas

A continuación se presenta la implementación de la vista y la plantilla maestra, estructurada con buenas prácticas de desarrollo web corporativo.

🏛️ 6.1. Layout Maestro: app/Views/layouts/admin_layout.php

HTML

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= esc($page_title ?? 'Panel Administrativo SAP B1') ?></title>

    <!-- Hojas de Estilo Base -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
    
    <!-- DataTables CSS (Bootstrap 4 Integration) -->
    <link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap4.min.css">
    <link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap4.min.css">
    <link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.5.0/css/responsive.bootstrap4.min.css">

    <style>
        body {
            background-color: #f4f6f9;
            font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        }
        .navbar-brand-sap {
            font-weight: 700;
            color: #0b2545 !important;
            letter-spacing: 0.5px;
        }
        .card-sap {
            border-top: 3px solid #007bff;
            box-shadow: 0 0 1px rgba(0,0,0,.125), 0 1px 3px rgba(0,0,0,.2);
        }
        .badge-sap-success {
            background-color: #28a745;
            color: #fff;
        }
    </style>

    <!-- Inyección de estilos específicos de vistas secundarias -->
    <?= $this->renderSection('styles') ?>
</head>
<body class="hold-transition sidebar-mini layout-fixed">

    <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
        <a class="navbar-brand navbar-brand-sap text-white" href="#">
            <i class="fas fa-cubes text-primary mr-2"></i>Cesar Systems - SAP B1 Portal
        </a>
    </nav>

    <main class="container-fluid px-4">
        <!-- Renderizado del contenido central de la vista hija -->
        <?= $this->renderSection('content') ?>
    </main>

    <!-- ======================================================= -->
    <!-- SECUENCIA CRÍTICA DE CARGA DE SCRIPTS (VENDOR LIBRARIES) -->
    <!-- ======================================================= -->
    
    <!-- 1. ÚNICA CARGA GLOBAL DE JQUERY -->
    <script src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js"></script>

    <!-- 2. Bootstrap Bundle (incluye Popper.js) -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>

    <!-- 3. Núcleo de DataTables y extensiones -->
    <script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
    <script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap4.min.js"></script>
    
    <!-- Extensiones de Botones para Exportación -->
    <script src="https://cdn.datatables.net/buttons/2.4.2/js/dataTables.buttons.min.js"></script>
    <script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.bootstrap4.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.53/pdfmake.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.53/vfs_fonts.js"></script>
    <script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
    <script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.print.min.js"></script>
    <script src="https://cdn.datatables.net/responsive/2.5.0/js/dataTables.responsive.min.js"></script>
    <script src="https://cdn.datatables.net/responsive/2.5.0/js/responsive.bootstrap4.min.js"></script>

    <!-- 4. RENDERIZADO EXCLUSIVO DE SCRIPTS DE PÁGINA (DESPUÉS DE TODAS LAS LIBRERÍAS) -->
    <?= $this->renderSection('scripts') ?>
</body>
</html>

📄 6.2. Vista Especializada: app/Views/admin/servicelayer/sapuserwh.php

PHP

<?= $this->extend('layouts/admin_layout') ?>

<?= $this->section('content') ?>

<div class="row mb-3">
    <div class="col-12">
        <div class="d-flex justify-content-between align-items-center">
            <h1 class="h3 font-weight-bold text-gray-800">
                <i class="fas fa-warehouse text-primary mr-2"></i>Asignación de Almacenes a Usuarios (SAP B1)
            </h1>
            <button type="button" class="btn btn-primary shadow-sm" data-toggle="modal" data-target="#modalAsignar">
                <i class="fas fa-plus-circle mr-1"></i> Nueva Asignación
            </button>
        </div>
        <p class="text-muted">
            Administración centralizada de autorizaciones de almacén mediante SAP Service Layer (REST / OData).
        </p>
    </div>
</div>

<?php if (!empty($error)): ?>
    <div class="alert alert-danger alert-dismissible fade show shadow-sm" role="alert">
        <strong><i class="fas fa-exclamation-triangle mr-1"></i> Error en Service Layer:</strong> <?= esc($error) ?>
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
            <span aria-hidden="true">&times;</span>
        </button>
    </div>
<?php endif; ?>

<div class="card card-sap shadow mb-4">
    <div class="card-header py-3 bg-white d-flex justify-content-between align-items-center">
        <h6 class="m-0 font-weight-bold text-primary">Matriz de Relaciones Activas (@USER_WH)</h6>
        <span class="badge badge-info"><?= count($relaciones) ?> Registros cargados</span>
    </div>
    <div class="card-body">
        <div class="table-responsive">
            <table class="table table-bordered table-hover table-striped w-100" id="tablaSapUserWh">
                <thead class="thead-dark">
                    <tr>
                        <th style="width: 80px;">Código</th>
                        <th>Usuario SAP</th>
                        <th>Código Almacén</th>
                        <th>Nombre del Almacén</th>
                        <th style="width: 120px;" class="text-center">Ventas</th>
                        <th style="width: 120px;" class="text-center">Traslados</th>
                        <th style="width: 100px;" class="text-center">Acciones</th>
                    </tr>
                </thead>
                <tbody>
                    <?php if (!empty($relaciones)): ?>
                        <?php foreach ($relaciones as $item): ?>
                            <tr>
                                <td><?= esc($item['Code'] ?? 'N/A') ?></td>
                                <td>
                                    <strong><?= esc($item['U_UserCode'] ?? '') ?></strong>
                                </td>
                                <td>
                                    <span class="badge badge-secondary"><?= esc($item['U_WhsCode'] ?? '') ?></span>
                                </td>
                                <td>
                                    <?= esc($item['Name'] ?? 'Sin descripción') ?>
                                </td>
                                <td class="text-center">
                                    <?php if (($item['U_AllowSales'] ?? 'N') === 'Y'): ?>
                                        <span class="badge badge-success"><i class="fas fa-check mr-1"></i>Permitido</span>
                                    <?php else: ?>
                                        <span class="badge badge-danger"><i class="fas fa-times mr-1"></i>Bloqueado</span>
                                    <?php endif; ?>
                                </td>
                                <td class="text-center">
                                    <?php if (($item['U_AllowTransfer'] ?? 'N') === 'Y'): ?>
                                        <span class="badge badge-success"><i class="fas fa-check mr-1"></i>Permitido</span>
                                    <?php else: ?>
                                        <span class="badge badge-danger"><i class="fas fa-times mr-1"></i>Bloqueado</span>
                                    <?php endif; ?>
                                </td>
                                <td class="text-center">
                                    <button class="btn btn-sm btn-outline-danger btn-eliminar" data-code="<?= esc($item['Code']) ?>" title="Revocar asignación">
                                        <i class="fas fa-trash-alt"></i>
                                    </button>
                                </td>
                            </tr>
                        <?php endforeach; ?>
                    <?php endif; ?>
                </tbody>
            </table>
        </div>
    </div>
</div>

<!-- Modal para Nueva Asignación -->
<div class="modal fade" id="modalAsignar" tabindex="-1" role="dialog" aria-labelledby="modalAsignarLabel" aria-hidden="true">
    <div class="modal-dialog modal-dialog-centered" role="document">
        <div class="modal-content">
            <div class="modal-header bg-primary text-white">
                <h5 class="modal-title" id="modalAsignarLabel"><i class="fas fa-link mr-1"></i> Vincular Usuario con Almacén</h5>
                <button type="button" class="close text-white" data-dismiss="modal" aria-label="Cerrar">
                    <span aria-hidden="true">&times;</span>
                </button>
            </div>
            <form id="formAsignacion">
                <div class="modal-body">
                    <div class="form-group">
                        <label for="selectUsuario" class="font-weight-bold">Usuario SAP (OUSR):</label>
                        <select class="form-control" id="selectUsuario" name="user_code" required>
                            <option value="">Seleccione un usuario activo...</option>
                            <?php foreach ($usuarios as $usr): ?>
                                <option value="<?= esc($usr['UserCode']) ?>">
                                    <?= esc($usr['UserName']) ?> (<?= esc($usr['UserCode']) ?>)
                                </option>
                            <?php endforeach; ?>
                        </select>
                    </div>

                    <div class="form-group">
                        <label for="selectAlmacen" class="font-weight-bold">Almacén (OWHS):</label>
                        <select class="form-control" id="selectAlmacen" name="whs_code" required>
                            <option value="">Seleccione un almacén físico...</option>
                            <?php foreach ($almacenes as $wh): ?>
                                <option value="<?= esc($wh['WarehouseCode']) ?>">
                                    [<?= esc($wh['WarehouseCode']) ?>] <?= esc($wh['WarehouseName']) ?>
                                </option>
                            <?php endforeach; ?>
                        </select>
                    </div>

                    <div class="custom-control custom-checkbox mb-2">
                        <input type="checkbox" class="custom-control-input" id="checkVentas" name="allow_sales" value="Y" checked>
                        <label class="custom-control-label" for="checkVentas">Permitir selección en Documentos de Venta</label>
                    </div>

                    <div class="custom-control custom-checkbox">
                        <input type="checkbox" class="custom-control-input" id="checkTraslados" name="allow_transfers" value="Y" checked>
                        <label class="custom-control-label" for="checkTraslados">Permitir origen/destino en Solicitudes de Traslado</label>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancelar</button>
                    <button type="submit" class="btn btn-primary" id="btnGuardar">
                        <i class="fas fa-save mr-1"></i> Guardar en SAP
                    </button>
                </div>
            </form>
        </div>
    </div>
</div>

<?= $this->endSection() ?>

<!-- SECCIÓN DE SCRIPTS: Se renderiza después de jQuery y DataTables -->
<?= $this->section('scripts') ?>
<script>
    // Se asegura de que el código no se invoque hasta que el documento y sus dependencias estén listos
    $(document).ready(function() {
        console.log("Inicializando DataTables para el módulo sapuserwh...");

        // Verificación diagnóstica preventiva en entorno de desarrollo
        if (typeof $.fn.DataTable === 'undefined') {
            console.error("FATAL: El plugin DataTables no está montado sobre la instancia global de jQuery.");
            return;
        }

        // Inicialización robusta con configuración en español y botones de exportación
        var tabla = $('#tablaSapUserWh').DataTable({
            responsive: true,
            lengthChange: true,
            autoWidth: false,
            pageLength: 25,
            lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, "Todos"]],
            order: [[1, 'asc']], // Ordenar por nombre de usuario por defecto
            dom: '<"row"<"col-md-6"B><"col-md-6"f>><"row"<"col-md-12"tr>><"row"<"col-md-5"i><"col-md-7"p>>',
            buttons: [
                {
                    extend: 'copyHtml5',
                    text: '<i class="fas fa-copy mr-1"></i> Copiar',
                    className: 'btn btn-sm btn-secondary',
                    exportOptions: { columns: [0, 1, 2, 3, 4, 5] }
                },
                {
                    extend: 'excelHtml5',
                    text: '<i class="fas fa-file-excel mr-1 text-success"></i> Excel',
                    className: 'btn btn-sm btn-secondary',
                    title: 'Exportacion_Usuarios_Almacenes_SAP',
                    exportOptions: { columns: [0, 1, 2, 3, 4, 5] }
                },
                {
                    extend: 'pdfHtml5',
                    text: '<i class="fas fa-file-pdf mr-1 text-danger"></i> PDF',
                    className: 'btn btn-sm btn-secondary',
                    orientation: 'portrait',
                    pageSize: 'LETTER',
                    exportOptions: { columns: [0, 1, 2, 3, 4, 5] }
                },
                {
                    extend: 'print',
                    text: '<i class="fas fa-print mr-1"></i> Imprimir',
                    className: 'btn btn-sm btn-secondary',
                    exportOptions: { columns: [0, 1, 2, 3, 4, 5] }
                }
            ],
            language: {
                processing:     "Procesando solicitud...",
                search:         "<i class='fas fa-search mr-1'></i>Buscar:",
                lengthMenu:    "Mostrar _MENU_ registros por página",
                info:           "Mostrando registros del _START_ al _END_ de un total de _TOTAL_",
                infoEmpty:      "Mostrando 0 a 0 de 0 registros",
                infoFiltered:   "(filtrado de _MAX_ registros en total)",
                infoPostFix:    "",
                loadingRecords: "Cargando catálogo desde Service Layer...",
                zeroRecords:    "No se encontraron asignaciones que coincidan con la búsqueda",
                emptyTable:     "No existen relaciones registradas en el sistema",
                paginate: {
                    first:    "<i class='fas fa-angle-double-left'></i>",
                    previous: "<i class='fas fa-angle-left'></i>",
                    next:     "<i class='fas fa-angle-right'></i>",
                    last:     "<i class='fas fa-angle-double-right'></i>"
                },
                aria: {
                    sortAscending:  ": Activar para ordenar la columna de manera ascendente",
                    sortDescending: ": Activar para ordenar la columna de manera descendente"
                }
            }
        });

        // Manejador del formulario de nueva asignación
        $('#formAsignacion').on('submit', function(e) {
            e.preventDefault();
            
            var submitBtn = $('#btnGuardar');
            submitBtn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin mr-1"></i> Guardando...');

            var payload = {
                user_code: $('#selectUsuario').val(),
                whs_code: $('#selectAlmacen').val(),
                allow_sales: $('#checkVentas').is(':checked') ? 'Y' : 'N',
                allow_transfers: $('#checkTraslados').is(':checked') ? 'Y' : 'N'
            };

            // Simulación de envío AJAX al endpoint del controlador
            $.ajax({
                url: '<?= base_url('admin/servicelayer/sapuserwh/store') ?>',
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json',
                headers: {
                    'X-Requested-With': 'XMLHttpRequest'
                },
                success: function(response) {
                    alert('Asignación guardada con éxito en SAP B1.');
                    location.reload();
                },
                error: function(xhr) {
                    var errorMsg = "Ocurrió un error al intentar registrar la asignación.";
                    if (xhr.responseJSON && xhr.responseJSON.message) {
                        errorMsg = xhr.responseJSON.message;
                    }
                    alert(errorMsg);
                    submitBtn.prop('disabled', false).html('<i class="fas fa-save mr-1"></i> Guardar en SAP');
                }
            });
        });

        // Manejador del botón eliminar / revocar
        $('#tablaSapUserWh').on('click', '.btn-eliminar', function() {
            var codigoRegistro = $(this).data('code');
            if (confirm('¿Está seguro de revocar la autorización [' + codigoRegistro + '] en SAP B1?')) {
                // Lógica de eliminación vía DELETE a Service Layer
                console.log('Eliminando registro: ' + codigoRegistro);
            }
        });
    });
</script>
<?= $this->endSection() ?>

🚀 7. Buenas Prácticas y Optimización para Integraciones Críticas

Integrar aplicaciones web con SAP Business One mediante la Service Layer exige contemplar consideraciones de rendimiento y fiabilidad que van más allá del código de la interfaz gráfica.

+-------------------------------------------------------------------------------------------------------+
|                                  CHECKLIST DE BUENAS PRÁCTICAS SL + WEB                               |
+-------------------------------------------------------------------------------------------------------+
| 1. Minimizar tráfico de red   -> Emplear siempre $select con las columnas estrictamente necesarias.   |
| 2. Paginación del lado servidor-> Si la tabla supera los 5,000 registros, usar serverSide: true.      |
| 3. Pooling de Sesión          -> Almacenar B1SESSION en Memcached o Redis para evitar logins masivos. |
| 4. Desconexión Controlada     -> Invocar POST /Logout al reiniciar servicios o agotar procesos batch.  |
| 5. Aislamiento de Librerías   -> Prevenir scripts concurrentes o CDNs desalineados en layouts.        |
+-------------------------------------------------------------------------------------------------------+

1. Filtrado OData en el Servidor ($select y $filter)

Nunca se debe solicitar una entidad completa (GET /b1s/v1/Users) sin parámetros. Los objetos de negocio de SAP contienen cientos de propiedades nativas y campos calculados que saturan la memoria del servidor de aplicaciones y aumentan la latencia de red. La regla de oro es especificar siempre los campos necesarios:

HTTP

GET /b1s/v1/Users?$select=UserCode,UserName,eMail&$filter=Locked eq 'tNO' HTTP/1.1

2. Paginación Server-Side en DataTables

Para tablas donde el volumen de registros supera los 5,000 elementos, la estrategia de renderizar todo el HTML en el DOM degrada el rendimiento del navegador cliente. En esos escenarios, se debe habilitar el modo serverSide: true de DataTables, transformando las peticiones de búsqueda, ordenamiento y paginación en consultas directas que el backend traduce a parámetros OData $skip y $top.

3. Manejo de Timeouts y Concurrencia

La Service Layer cuenta con un límite de conexiones simultáneas definido en su archivo de configuración Apache (httpd.conf / b1s.conf). Realizar múltiples llamadas concurrentes desordenadas desde una misma página web puede agotar el pool de conexiones. Se recomienda centralizar las consultas compuestas en el backend y entregar un único payload consolidado a la vista.

🧭 8. Checklist Preventivo para Evitar Conflictos con DataTables

Para evitar que un error similar se repita en otros módulos o vistas administrativas del ecosistema, sigue esta lista de verificación antes de cada despliegue a producción:

  1. Auditoría de Red: Abrir DevTools (Ctrl + Shift + I o F12), ir a la pestaña Network, filtrar por jquery y recargar con Ctrl + F5. Debe existir exactamente un solo archivo de la librería jQuery descargado.
  2. Inspección en Consola: Ejecutar en la consola interactiva:JavaScriptconsole.log("jQuery version:", $.fn.jquery); console.log("DataTables disponible:", typeof $.fn.DataTable === 'function'); Si la segunda sentencia devuelve false, existe una sobreescritura de scripts en la página.
  3. Validación de la Sección de Scripts: Comprobar que en las vistas Blade o CodeIgniter, la directiva $this->section('scripts') no contenga enlaces a librerías base, sino únicamente la lógica de negocio JavaScript y los inicializadores de la vista.

🤝 9. Comunidad, Código Abierto y Redes Sociales

El desarrollo de software robusto se fundamenta en compartir experiencias reales, resolver incidencias técnicas complejas y documentar soluciones que ahorren horas de depuración a otros ingenieros. Si este artículo técnico y las librerías de integración te resultaron útiles, súmate a la comunidad y sigue de cerca las próximas publicaciones, tutoriales y liberaciones de código abierto.

🌐 Canales Oficiales y Redes del Proyecto

  • 💻 Repositorio de Código Abierto (GitHub):https://github.com/julio101290Explora los paquetes, módulos, repositorios de backend y utilidades creadas para optimizar plataformas empresariales y entornos de desarrollo.
  • 🎥 Canal Principal de Tutoriales y Streaming (YouTube):https://youtube.com/@cesarsystemsGuías en video sobre desarrollo web, administración de sistemas en Linux, bases de datos y desarrollo backend.
  • 📺 Canal de Video Descentralizado (Odysee):https://odysee.com/@JulioCesarLeyvaRodriguezTodo el contenido audiovisual técnico respaldado en la red descentralizada LBRY/Odysee.
  • ☕ Apoyo y Patrocinio del Proyecto (Patreon):https://www.patreon.com/c/u74078772Colabora con el mantenimiento de servidores, producción de contenido educativo independiente y liberación continua de herramientas libres.
  • 📝 Bitácora y Blog Técnico Oficial:https://shalom-now.blogspot.comArtículos en profundidad, notas de arquitectura, comandos de terminal y manuales de referencia para administradores de sistemas y programadores.

🏗️ 1. Arquitectura Híbrida: ¿Por Qué Separar Lectura (ODBC) y Escritura (Service Layer)?

Entrada fija

Cuando abordamos integraciones profesionales con SAP Business One, uno de los errores más graves que cometen los desarrolladores principiantes es intentar escribir directamente en las tablas de la base de datos mediante sentencias SQL INSERT o UPDATE.

⚠️️ El Riesgo de Escribir Directamente en la Base de Datos

Escribir directamente en el motor de base de datos (ya sea SAP HANA o Microsoft SQL Server) no solo viola las directivas de soporte oficial de SAP (invalidando las garantías de mantenimiento del partner), sino que pasa por alto:

  • La ejecución de transacciones concurrentes seguras y bloqueos a nivel de aplicación.
  • Los triggers y mecanismos internos de versionado (LogInst, UserSign, UpdateDate, UpdateTime).
  • La validación de integridad referencial cruzada que los UDOs gestionan internamente.

⚡ El Problema de Usar Service Layer para Consultas Masivas

Por otro lado, depender al 100% de Service Layer para consultar listados extensos, paginaciones en DataTables y filtros complejos con múltiples JOINs puede ralentizar considerablemente la experiencia de usuario. Service Layer es un backend basado en OData/REST que agrega una capa intermedia de serialización JSON y procesamiento HTTP. Para cargar un catálogo con miles de registros en un DataTables interactivo, una llamada OData compleja suele demorar cientos de milisegundos más que una consulta nativa en memoria.

💡 La Solución Elegida: Patrón Híbrido CQRS Simplificado

Nuestra solución adopta lo mejor de ambos mundos mediante una separación limpia de responsabilidades:

                  ┌────────────────────────────────────────────────────────┐
                  │                 Interfaz Web (Frontend)                │
                  │        DataTables / Select2 / Bootstrap / jQuery       │
                  └────────────┬──────────────────────────────▲────────────┘
                               │                              │
                    Lecturas / Filtros AJAX            Respuestas JSON
                               │                              │
                               ▼                              │
┌─────────────────────────────────────────────────────────────┴────────────────────────────────┐
│                       Controlador CodeIgniter 4 (SapUserAuthWHController)                   │
├──────────────────────────────────────────────┬───────────────────────────────────────────────┤
│            FLUJO DE LECTURA (READ)           │           FLUJO DE ESCRITURA (WRITE)          │
│                                              │                                               │
│  - Consultas SELECT optimizadas              │  - Validación de negocio y unicidad           │
│  - Limit / Offset para paginación rápida     │  - Conexión vía cURL a REST API               │
│  - Conexión nativa HDBODBC                   │  - Inyección a UDO oficial 'AutCompra'         │
│  - Consulta directa a HANA en memoria        │  - Login con token de sesión B1SESSION        │
└──────────────────────┬───────────────────────┴───────────────────────▲───────────────────────┘
                       │                                               │
                SQL Nativo (ODBC)                               JSON Payload (REST)
                       │                                               │
                       ▼                                               │
        ┌──────────────────────────────┐                ┌──────────────┴───────────────┐
        │   Base de Datos SAP HANA     │                │   SAP B1 Service Layer       │
        │   OWHS, OUSR, @AUTORIZACOMPRA│                │   Motor OData Transaccional  │
        └──────────────────────────────┘                └──────────────┬───────────────┘
                                                                       │
                                                            Escritura / Validación
                                                                       │
                                                                       ▼
                                                        ┌──────────────────────────────┐
                                                        │   Tablas de Usuario (UDO)    │
                                                        │   @AUTORIZACOMPRA            │
                                                        │   @AUTORIZACOMPRADET         │
                                                        └──────────────────────────────┘
  1. Lectura (Read Engine): Se procesa a través de la extensión odbc de PHP utilizando el driver oficial de SAP HANA (HDBODBC). Ejecuta sentencias SELECT directas contra las tablas maestras (OWHS, OUSR, @AUTORIZACOMPRA, @AUTORIZACOMPRADET) entregando tiempos de respuesta inferiores a 50 milisegundos.
  2. Escritura (Write Engine): Se procesa exclusivamente a través del SAP Service Layer. Las peticiones POST (creación), PATCH (modificación) y DELETE (eliminación) se transmiten en formato JSON respetando la convención de colecciones del UDO registrado.

📊 2. Modelado de Datos: Estructura de las Tablas de Usuario (UDO)

Para comprender cómo interactúa el controlador con el Service Layer, analicemos la estructura exacta de las dos tablas que componen nuestro objeto de negocio.

A. Tabla Cabecera: @AUTORIZACOMPRA (Tipo: Documento / Master Data)

Esta tabla define la entidad principal. Cada registro representa un almacén de SAP que ha sido habilitado para el circuito de autorizaciones:

ColumnaTipo de DatoLongitudDescripción / Función
CodeAlfanumérico50Código del Almacén (FK lógica con OWHS."WhsCode"). Ejemplo: VGZ, PLM.
NameAlfanumérico100Nombre descriptivo del almacén. Ejemplo: VIRTUAL GASOLINA MAZATLAN.
DocEntryNuméricoEnteroIdentificador secuencial autonumérico generado por SAP.
CanceledCarácter1Bandera de cancelación (Y/N).
ObjectAlfanumérico20Identificador del UDO registrado en SAP (AutCompra).
UserSignNuméricoEnteroUsuario que dio de alta el registro.
CreateDateFechaDatetimeFecha de creación del registro en el sistema.
UpdateDateFechaDatetimeFecha de la última modificación.

Un registro real en esta tabla luce de la siguiente manera:

SQL

Code: 'VGZ'
Name: 'VIRUTAL GASOLINA MAZATLAN'
DocEntry: 1
Canceled: 'N'
Object: 'AutCompra'
DataSource: 'I'
CreateDate: '2026-01-05 00:00:00'

B. Tabla Detalle: @AUTORIZACOMPRADET (Tipo: Líneas de Documento)

Esta tabla almacena la relación 1 a N de los usuarios asignados a dicho almacén y las banderas específicas de autorización que poseen:

ColumnaTipo de DatoLongitudDescripción / Función
CodeAlfanumérico50Clave foránea que referencia al Code de la cabecera.
LineIdNuméricoEnteroNúmero consecutivo de línea dentro del documento (1, 2, 3…).
ObjectAlfanumérico20Identificador del UDO (AutCompra).
U_USERIDNumérico/Texto32ID interno del usuario en SAP (corresponde a OUSR."USERID").
U_SolCompCarácter1¿Tiene permiso para crear Solicitudes de Compra? (Y/N).
U_PedidoCarácter1¿Tiene permiso para generar Pedidos / Órdenes de Compra? (Y/N).
U_UserNameAlfanumérico100Nombre real completo del colaborador (ej. EDUARDO GRANADOS).
U_FolioUserAlfanumérico50Código nemotécnico o serie del usuario en SAP (USER_CODE, ej. MZGTEPZA).

Un registro real de línea luce así:

SQL

Code: 'VGZ'
LineId: 1
Object: 'AutCompra'
U_USERID: 235
U_SolComp: 'Y'
U_Pedido: 'Y'
U_UserName: 'EDUARDO GRANADOS'
U_FolioUser: 'MZGTEPZA'

⚙️ 3. El Controlador a Fondo: Implementación de SapUserAuthWHController.php

El controlador es la pieza central encargada de orquestar el flujo de datos. Está diseñado bajo el estándar de CodeIgniter 4, aprovechando traits de respuesta JSON y desacoplando las dependencias mediante modelos modulares.

Examinemos detalladamente los aspectos técnicos más sobresalientes de su código.

3.1 Conexión ODBC y Manejo de Esquemas en SAP HANA

Al conectarse a SAP HANA mediante ODBC, no basta con autenticar la sesión; es imperativo apuntar al esquema exacto donde reside la compañía (companyDB).

PHP

private function connectODBC() {
    $dataConect = $this->serviceLayerModel->first();
    if (!$dataConect) {
        throw new \Exception('No se encontró configuración de conexión SAP.');
    }

    $conn = odbc_connect(
        $dataConect['nameODBC'],
        $dataConect['userODBC'],
        $dataConect['passwordODBC']
    );

    if (!$conn) {
        throw new \Exception('Error conexión ODBC: ' . odbc_errormsg());
    }

    // Fijamos el esquema de la base de datos de SAP en HANA
    if (!odbc_exec($conn, 'SET SCHEMA "' . $dataConect['companyDB'] . '"')) {
        throw new \Exception('Error SET SCHEMA: ' . odbc_errormsg($conn));
    }

    return $conn;
}

Nota de Arquitectura: En SAP HANA, todas las tablas y esquemas deben delimitarse con comillas dobles (") si contienen caracteres especiales o prefijos como @ (ejemplo: "@AUTORIZACOMPRA"). Si se omiten las comillas dobles, el analizador léxico de HANA convertirá el identificador a mayúsculas o arrojará un error de sintaxis inmediata.

3.2 Listado Server-Side para DataTables con Subconsultas

Para evitar el problema de las consultas N+1 al calcular cuántos usuarios tiene configurados cada almacén, implementamos una subconsulta correlacionada directamente en la sentencia de extracción:

PHP

$sql = "
    SELECT
        T0.\"Code\",
        T0.\"Name\",
        T0.\"DocEntry\",
        T0.\"CreateDate\",
        (
            SELECT COUNT(1) 
            FROM \"@AUTORIZACOMPRADET\" D 
            WHERE D.\"Code\" = T0.\"Code\"
        ) AS \"UsersCount\"
    FROM \"@AUTORIZACOMPRA\" T0
    {$whereExtra}
    ORDER BY {$orderBy} {$orderDir}
    LIMIT {$length} OFFSET {$start}
";

Esto permite que el DataTable muestre una insignia dinámica con el número exacto de colaboradores autorizados por cada sucursal sin penalizar el rendimiento del servidor.

3.3 Catálogos Predictivos para Select2: Almacenes y Usuarios SAP

Para proporcionar una experiencia de usuario fluida, el módulo cuenta con dos endpoints ligeros que responden a eventos de búsqueda tipo typeahead (Select2 con AJAX):

Catálogo de Almacenes (getWarehousesAjax)

Consulta los almacenes activos de SAP en la tabla maestra OWHS:

SQL

SELECT "WhsCode", "WhsName"
FROM OWHS
WHERE "Locked" = 'N'
  AND ("WhsCode" LIKE '%BUSQUEDA%' OR "WhsName" LIKE '%BUSQUEDA%')
ORDER BY "WhsCode" ASC
LIMIT 40;

Catálogo de Usuarios (getSapUsersAjax)

Consulta la tabla OUSR para obtener el ID numérico (USERID), el código de usuario (USER_CODE) y el nombre completo (U_NAME). Al mapear la respuesta JSON, asignamos estratégicamente:

PHP

$data[] = [
    'id'        => $userId,                          // 235
    'text'      => $userCode . ' - ' . $userName,    // MZGTEPZA - EDUARDO GRANADOS
    'userCode'  => $userCode,                        // MZGTEPZA
    'userName'  => $userName,                        // EDUARDO GRANADOS
    'folioUser' => $userCode                         // Asignación directa para auto-llenado
];

3.4 Persistencia Transaccional mediante Service Layer

El método save() recibe los datos del formulario, incluyendo el arreglo de filas del detalle serializado en JSON. A continuación, realiza los siguientes pasos críticos:

  1. Validación de Unicidad en Creaciones: Si se trata de un nuevo almacén (isNew === 1), consulta vía ODBC que el Code no exista previamente en @AUTORIZACOMPRA.
  2. Autenticación en Service Layer: Invoca al controlador de autenticación para obtener un SessionId válido y genera la cookie requerida:PHP$cookie = "B1SESSION=" . $conexionSap->SessionId . "; ROUTEID=.node1";
  3. Construcción del Payload OData:En el estándar de Service Layer, cuando un UDO posee tablas hijas, la colección dependiente debe llamarse exactamente con el nombre de la tabla sin el carácter @, seguido del sufijo Collection. Por ende, para @AUTORIZACOMPRADET, la clave obligatoria en el JSON es AUTORIZACOMPRADETCollection:

PHP

$payload = [
    'Name'                        => $name,
    'AUTORIZACOMPRADETCollection' => $linesPayload
];

if ($isNew === 1) {
    $payload['Code'] = $code;
    $url             = $slRoot . "/AutCompra";
    $method          = 'POST';
} else {
    // Al actualizar, se apunta a la clave primaria en la URL
    $url    = $slRoot . "/AutCompra('" . rawurlencode($code) . "')";
    $method = 'PATCH';
}
  1. Registro de Auditoría: Toda operación exitosa se documenta en la bitácora del sistema mediante LogModel, guardando el usuario que ejecutó la acción y la fecha.

💻 4. La Vista Interactiva (sapUserAuthWH.php): Experiencia de Usuario sin Fricción

El frontend fue concebido para minimizar los clics y eliminar la posibilidad de introducir datos inconsistentes. Utiliza Bootstrap 4, AdminLTE 3, DataTables, Select2 y SweetAlert2.

4.1 Estructura del Modal Extendido (modal-xl)

La interfaz del formulario de captura se organiza visualmente en dos bloques bien diferenciados:

  1. Card de Cabecera (Almacén):
    • En modo creación, presenta un selector dinámico Select2 que consulta OWHS. Al seleccionar una bodega, el campo descriptivo Nombre del Almacén se autocompleta inmediatamente.
    • En modo edición, el selector se oculta y en su lugar se presenta un campo de texto plano de solo lectura para evitar alteraciones accidentales de la clave primaria (Code).
  2. Card de Detalle (Usuarios y Derechos):
    • Cuenta con una barra superior de captura rápida compuesta por:
      • Selector AJAX de usuarios SAP (#selectNewUser).
      • Switch de Solicitud de Compra (#checkNewSolComp).
      • Switch de Pedido (#checkNewPedido).
      • Campo de Folio (#inputNewFolioUser), que se auto-rellena con el USER_CODE en el instante en que se selecciona un usuario.
      • Botón de inserción directa con icono +.
    • Una tabla dinámica en memoria (#tableAuthDetails), donde cada fila agregada cuenta con switches activos para alterar permisos sobre la marcha, inputs para ajustar el folio y un botón para eliminar la fila.

4.2 Automatización del Folio de Usuario con Select2

El evento JavaScript que conecta la selección del colaborador con la asignación automática del folio opera de la siguiente manera:

JavaScript

$('#selectNewUser').select2({
    dropdownParent: $('#modalAuthWH'),
    placeholder: 'Buscar usuario SAP...',
    allowClear: true,
    ajax: {
        url: baseControllerUrl + '/getSapUsersAjax',
        dataType: 'json',
        delay: 250,
        data: function (params) {
            return { searchTerm: params.term || '' };
        },
        processResults: function (data) {
            return { results: data.data || [] };
        }
    }
}).on('select2:select', function (e) {
    // Al seleccionar el usuario, recuperamos su USER_CODE y lo asignamos al input
    var selectedData = e.params.data;
    $('#inputNewFolioUser').val(selectedData.userCode || '');
}).on('select2:clear', function () {
    // Si se limpia el selector, reseteamos el campo de folio
    $('#inputNewFolioUser').val('');
});

4.3 Validación de Duplicados en Tiempo Real en el DOM

Para prevenir que un usuario sea dado de alta dos veces en el mismo almacén, antes de insertar la fila se recorre el atributo de datos data-userid de la tabla:

JavaScript

var userId = userData.id;
var exists = false;

$('#tbodyAuthDetails tr').each(function () {
    if ($(this).data('userid') == userId) {
        exists = true;
        return false;
    }
});

if (exists) {
    Swal.fire('Atención', 'El usuario seleccionado ya se encuentra en la lista de autorizaciones.', 'warning');
    return;
}

🚦 5. Configuración de Rutas en CodeIgniter 4

Para integrar este controlador dentro de la estructura de enrutamiento de la aplicación y garantizar que esté protegido por el sistema de control de acceso basado en roles (RBAC), definimos las siguientes directivas dentro del archivo de rutas del módulo:

PHP

// =========================================================================
// RUTAS PARA AUTORIZACIÓN DE ALMACENES SAP (@AUTORIZACOMPRA / AutCompra)
// =========================================================================

// Listado principal y endpoint de datos para DataTables
$routes->get('servicelayer/sapuserauthwh',
    'SapUserAuthWHController::index',
    [
        'filter'    => 'permission:SAPUserAuthWH-permission',
        'namespace' => 'julio101290\boilerplateservicelayer\Controllers'
    ]
);

// Consulta de un almacén y sus usuarios para edición (JSON)
$routes->get('servicelayer/sapuserauthwh/getAuthWH/(:segment)',
    'SapUserAuthWHController::getAuthWH/$1',
    ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Catálogo AJAX de almacenes activos (OWHS)
$routes->get('servicelayer/sapuserauthwh/getWarehousesAjax',
    'SapUserAuthWHController::getWarehousesAjax',
    ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Catálogo AJAX de usuarios activos de SAP (OUSR)
$routes->get('servicelayer/sapuserauthwh/getSapUsersAjax',
    'SapUserAuthWHController::getSapUsersAjax',
    ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Guardado transaccional (Creación POST y Actualización PATCH en Service Layer)
$routes->post('servicelayer/sapuserauthwh/save',
    'SapUserAuthWHController::save',
    ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Eliminación de la autorización por almacén
$routes->post('servicelayer/sapuserauthwh/delete/(:segment)',
    'SapUserAuthWHController::delete/$1',
    ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

🔍 6. Casos Reales de Resolución de Problemas (Troubleshooting en SAP HANA)

Durante el desarrollo de esta integración nos topamos con comportamientos particulares de SAP HANA y del Service Layer que vale la pena documentar para ahorrar horas de depuración a otros ingenieros de software.

Problema 1: El Error de Sensibilidad a Mayúsculas en HANA (General error;260 invalid column name: LOCKED)

💥 Síntoma:

Al ejecutar la búsqueda de usuarios de SAP desde el frontend, el backend respondía con un error 500 y el siguiente mensaje de ODBC:

JSON

{
    "data": [],
    "error": true,
    "message": "odbc_exec(): SQL error: [SAP AG][LIBODBCHDB SO][HDBODBC] General error;260 invalid column name: LOCKED: line 4 col 24 (at pos 108), SQL state S1000 in SQLExecDirect"
}

🧐 Causa Raíz:

A diferencia de Microsoft SQL Server (que suele configurarse con intercalaciones Case-Insensitive como SQL_Latin1_General_CP1_CI_AS), SAP HANA es estrictamente Case-Sensitive cuando los nombres de columna se envuelven entre comillas dobles.

En el catálogo interno de SAP, el campo que indica si un usuario está bloqueado se llama "Locked" (con la primera letra mayúscula y el resto minúsculas). Al escribir en el query SQL:

SQL

WHERE "LOCKED" = 'N' -- ❌ Error en HANA

HANA busca literalmente una columna en mayúsculas sostenidas, no la encuentra en el diccionario de datos de OUSR y revienta la ejecución.

✅ Solución:

Ajustar la sentencia respetando la convención PascalCase y considerando valores nulos:

SQL

WHERE ("Locked" = 'N' OR "Locked" IS NULL) -- ✔️ Correcto

Problema 2: Intento de Consulta de Columnas Inexistentes (U_FolioUser en OUSR)

💥 Síntoma:

Al intentar precargar el folio directamente desde la consulta de usuarios:

JSON

{
    "data": [],
    "error": true,
    "message": "odbc_exec(): SQL error: [SAP AG][LIBODBCHDB SO][HDBODBC] General error;260 invalid column name: U_FolioUser: line 2 col 57 (at pos 58), SQL state S1000 in SQLExecDirect"
}

🧐 Causa Raíz:

Se asumió inicialmente que OUSR contaba con un campo de usuario personalizado llamado U_FolioUser. Al realizar una introspección de metadatos sobre la estructura física de OUSR:

SQL

SELECT COLUMN_NAME 
FROM TABLE_COLUMNS 
WHERE TABLE_NAME = 'OUSR' AND COLUMN_NAME LIKE 'U_%';

Se constató que los únicos campos de usuario presentes en esa instalación eran:

  • U_GLO_CostCenter
  • U_empID

El valor de ejemplo que se requería almacenar en U_FolioUser dentro de @AUTORIZACOMPRADET (por ejemplo, MZGTEPZA) no era más que el propio código de inicio de sesión de SAP (USER_CODE).

✅ Solución:

Eliminar la columna inexistente del SELECT y asignar directamente USER_CODE como el valor por defecto para el folio del usuario.

Problema 3: Diferencia de Tipos de Datos en U_USERID (Entero vs String)

💥 Síntoma:

Al enviar el payload a Service Layer, la API retornaba un error HTTP 400 Bad Request:

JSON

{
    "error": {
        "code": -1000,
        "message": {
            "lang": "en-us",
            "value": "Property 'U_USERID' of 'AutCompra' is invalid. Expected type is Edm.String"
        }
    }
}

🧐 Causa Raíz:

Al registrar un campo de usuario (UDF) en SAP Business One mediante la herramienta nativa Herramientas -> Herramientas de personalización -> Campos definidos por el usuario, el administrador puede configurarlo como tipo Alfanumérico (Texto) o tipo Numérico (Entero).

Si en SAP se definió como Alfanumérico, el Service Layer espera recibir "235" en formato cadena, no el entero primitivo 235.

✅ Solución:

Garantizar la compatibilidad en el array de PHP convirtiendo el tipo según corresponda:

PHP

'U_USERID' => (string) $userId, // Si el UDF fue creado como Alfanumérico
// O bien:
'U_USERID' => (int) $userId,    // Si el UDF fue creado como Numérico

📈 7. Impacto y Beneficios de Negocio

La implementación de este módulo no representó únicamente una mejora técnica en el stack de software; supuso una transformación tangible en el día a día operativo de la empresa:

┌───────────────────────────────────────┬───────────────────────────────────────┐
│         ANTES (MÉTODO NATIVO)         │       AHORA (MÓDULO WEB CI4 / SL)     │
├───────────────────────────────────────┼───────────────────────────────────────┤
│ ❌ Requiere licencia activa de SAP    │ ✅ Acceso vía navegador web sin       │
│    Business One para cada operador.   │    consumir licencias profesionales.  │
│                                       │                                       │
│ ❌ Captura manual de USERID numérico  │ ✅ Selectores predictivos Select2     │
│    abriendo ventanas auxiliares.      │    con búsqueda por nombre y código.  │
│                                       │                                       │
│ ❌ Transcripción manual propensa a    │ ✅ Autocompletado inmediato de        │
│    errores del código de folio/serie. │    USER_CODE en el campo de folio.    │
│                                       │                                       │
│ ❌ Tiempos de registro de 5 a 10      │ ✅ Configuración completa en menos    │
│    minutos por almacén.               │    de 30 segundos por sucursal.       │
│                                       │                                       │
│ ❌ Sin control visual consolidado     │ ✅ Vista en tabla con contador de     │
│    de cuántos usuarios están activos. │    usuarios asignados en vivo.        │
└───────────────────────────────────────┴───────────────────────────────────────┘
  1. Eficiencia Temporal: Reducción del 90% en el tiempo necesario para dar de alta o ajustar permisos de compras por sucursal.
  2. Cero Errores de Integridad: La combinación de lectura ODBC y validación Service Layer imposibilita la creación de huérfanos o almacenes inexistentes.
  3. Auditoría Clara: Cada cambio queda registrado en la bitácora interna (LogModel), permitiendo saber con exactitud qué usuario web aplicó las modificaciones.

📋 8. Resumen de Buenas Prácticas para Integraciones con SAP B1

Si estás planificando desarrollar extensiones web o móviles que interactúen con SAP Business One, ten presentes estas directrices aprendidas durante el proyecto:

  • 🛡️ Respeta la Regla de Oro: Utiliza ODBC exclusivamente para consultas de lectura (SELECT). Todas las escrituras deben canalizarse a través de Service Layer o DI API.
  • 🔡 Cuida el Case-Sensitivity en SAP HANA: Comprueba siempre el nombre exacto de tablas y columnas tal y como están registradas en el catálogo del sistema.
  • 📦 Respeta la Nomenclatura de UDOs en Service Layer: Para tablas hijas, recuerda siempre la estructura [NombreTablaSinArroba]Collection.
  • ⚡ Optimiza con Paginación Server-Side: Evita cargar tablas completas en memoria del navegador. Implementa LIMIT y OFFSET en el motor de base de datos para manejar miles de registros sin degradar la experiencia de usuario.
  • 🧩 Modulariza tu Código: Encapsula la lógica de autenticación y consumo de APIs en servicios dedicados para que tus controladores permanezcan limpios y mantenibles.

🌐 Conecta con la Comunidad y Sigue el Proyecto

El desarrollo de integraciones para sistemas ERP como SAP Business One, el software libre y la creación de herramientas de productividad en Linux y PHP son temas que comparto de manera constante en mis plataformas y canales.

Si te interesa profundizar en el código, acceder a repositorios, ver videotutoriales detallados o apoyar el desarrollo de nuevos paquetes de código abierto, te invito a seguirme en todas mis redes oficiales:

¡Déjame en los comentarios tus dudas o cuéntame cómo gestionas las autorizaciones de usuario en tus proyectos de SAP Business One! 🚀💬

🤠 ¡A la Segura y sin Tumbar el Blog! Cómo Poner el Botón de WhatsApp en el Menú de Blogger Usando Puros Gadgets (Cero Modificaciones al XML) 📱🔥

Entrada fija

Si tienes un blog en Blogger —ya sea que subas fotos de tus salidas de fin de semana, tutoriales de programación, recetas o notas de tu día a día— seguro ya te diste cuenta de la tremenda pedrada con la que te topas cuando quieres que la gente comparta tus publicaciones por WhatsApp 🤦‍♂️.

Te vas a tu página, abres una entrada, le das clic al bonito botón de compartir y la plataforma te despliega un menú con Facebook, X (Twitter), Pinterest y Correo electrónico… pero de WhatsApp, ¡absolutamente nada! 📉 Google diseñó esas plantillas modernas (Emporio, Soho, Contempo, Notable) hace varios años y, por alguna razón que nadie comprende, dejaron fuera de la jugada a la aplicación de mensajería más utilizada en México y en toda Latinoamérica 📱💬.

Cualquiera pensaría: “Pues me meto al código del tema y lo pego a mano”. ¡Grave error, compadre! Cuando entras a Tema > Editar HTML y le mueves a una coma, el validador de Blogger te avienta en la cara errores del demonio como:

“The widget settings in widget with id AdSense1 is not valid. An internal error occurred. Please try again.” 💥

Ahí es donde la mayoría se frustra, tira la toalla o termina desconfigurando la plantilla completa.

Pero aquí te traigo la buena noticia: no necesitas tocar ni una sola línea del código XML de tu plantilla. La forma más limpia, rápida, segura y que funciona al cien por ciento es meter un script ligero mediante un Gadget de HTML/Javascript desde el panel de Diseño 🛠️✨.

En este tutorial te voy a explicar paso a pasito cómo lograr que WhatsApp aparezca con su icono oficial verde dentro de ese menú desplegable, tanto en la portada como en las entradas individuales, sin riesgos y en menos de cinco minutos 🚀🌵.

1. La neta del planeta: ¿Por qué NO debemos tocar el XML de Blogger? 🛑⚠️

Antes de irnos a la solución práctica, hay que entender por qué meterle mano al XML de las plantillas oficiales de Blogger es una auténtica pesadilla.

Las plantillas modernas de Blogger (las que tienen nombres como Emporio, Soho, etc.) no están hechas con HTML normalito como cualquier página web sencilla. Están empaquetadas en un dialecto propietario de Google lleno de etiquetas XML complejas (<b:includable>, <b:widget>, <b:defaultmarkups>). Todo el sistema de botones para compartir viene encapsulado dentro de scripts que se descargan de servidores remotos de Google.

Cuando tú intentas editar el código desde el panel de Editar HTML, Blogger pasa todo el archivo por un filtro validador superestricto. Si tu blog tiene widgets de anuncios automáticos que no has configurado (como los famosos bloques vacíos AdSense1 o AdSense2), el validador se congela y te bloquea el botón de guardar.

Los peligros de modificar la plantilla a mano:

  • El temido error de validación: El error de AdSense1 te impide guardar cambios legítimos.
  • Riesgo de borrar etiquetas de cierre: Si borras un </b:includable> o una llave por accidente, el diseño entero colapsa y tu sitio muestra una pantalla en blanco.
  • Se pierde al cambiar de tema: Si el día de mañana decides cambiar el estilo visual de tu blog, todo lo que le metiste a pedal y manivela al XML se borra y tienes que empezar desde cero.

Por eso, la regla de oro para no complicarse la existencia es: deja el XML en paz y aprovecha el sistema de Gadgets modulares 💡.

2. El truco maestro: Inyección DOM mediante Gadget HTML/JavaScript 🧠⚡

¿Cómo le hacemos para que WhatsApp aparezca adentro del menú de Blogger sin editar la plantilla? Muy fácil: usamos una técnica de desarrollo web llamada inyección en el DOM (Document Object Model).

En palabras sencillas:

  1. Dejamos que Blogger cargue la página tal y como sabe hacerlo, pintando sus botones habituales (Facebook, X, Pinterest).
  2. Agregamos un Gadget invisible que contiene un pequeño script en JavaScript nativo.
  3. En una fracción de segundo, el script busca en la pantalla todos los menús que tengan la clase .share-buttons.
  4. El script detecta automáticamente el título de la foto o artículo y el enlace exacto donde está parado el visitante.
  5. Finalmente, crea un nuevo elemento de lista (<li>) con el icono oficial de WhatsApp y lo acomoda junto a las demás redes sociales.

Todo ocurre de forma instantánea. El visitante nunca nota la diferencia; para él, WhatsApp siempre estuvo integrado de fábrica en el blog 🚀.

3. El código JavaScript definitivo para el Gadget 💻📄

Este es el script optimizado que vamos a utilizar. Es ultraligero (pesa menos de 1 Kilobyte), no utiliza librerías pesadas como jQuery y lleva el icono vectorial de WhatsApp (SVG) incrustado en código puro para que no gaste ancho de banda descargando imágenes externas:

HTML

<script>
//<![CDATA[
function agregarWhatsAppCompartir() {
  // 1. Buscamos todos los menús desplegables de compartir presentes en la pantalla
  var shareMenus = document.querySelectorAll('.share-buttons');
  
  shareMenus.forEach(function(menu) {
    // Si este menú ya tiene el botón de WhatsApp inyectado, saltamos al siguiente (evita duplicados)
    if (menu.querySelector('.sharing-whatsapp-item')) return;

    // 2. Buscamos el contenedor del post correspondiente para extraer sus datos
    var postContainer = menu.closest('.post-wrapper') || menu.closest('.post') || document;
    var titleEl = postContainer.querySelector('.post-title');
    var linkEl = postContainer.querySelector('.post-title a') || postContainer.querySelector('a[href*=".html"]');

    // 3. Obtenemos título y URL limpios
    var title = titleEl ? titleEl.innerText.trim() : document.title;
    // Si linkEl no existe (común cuando estás dentro de la entrada individual), usamos la URL actual
    var url = linkEl ? linkEl.href : window.location.href;

    // 4. Armamos el enlace de la API universal de WhatsApp con codificación segura
    var waLink = 'https://api.whatsapp.com/send?text=' + encodeURIComponent(title + ' - ' + url);

    // 5. Fabricamos el elemento de lista respetando las clases y estructura nativa del tema
    var li = document.createElement('li');
    li.className = 'sharing-whatsapp-item';
    li.innerHTML = '<a class="sharing-platform-button" href="' + waLink + '" target="_blank" rel="noopener noreferrer" role="menuitem" tabindex="-1">' +
      '<svg class="touch-icon" style="position: absolute; top: 12px; left: 16px; width: 24px; height: 24px; fill: #25D366;" viewBox="0 0 24 24">' +
        '<path d="M12.04 2C6.58 2 2.13 6.45 2.13 11.91C2.13 13.66 2.59 15.36 3.45 16.86L2.05 22L7.3 20.62C8.75 21.41 10.38 21.83 12.04 21.83C17.5 21.83 21.95 17.38 21.95 11.92C21.95 9.27 20.92 6.78 19.05 4.91C17.18 3.04 14.69 2 12.04 2M12.05 3.67C14.25 3.67 16.31 4.53 17.87 6.09C19.42 7.65 20.28 9.72 20.28 11.92C20.28 16.46 16.58 20.15 12.04 20.15C10.56 20.15 9.11 19.76 7.85 19L7.55 18.83L4.43 19.65L5.26 16.61L5.06 16.29C4.24 15 3.8 13.47 3.8 11.91C3.81 7.37 7.5 3.67 12.05 3.67M9.53 7.03C9.34 7.03 9.04 7.1 8.79 7.37C8.54 7.65 7.83 8.31 7.83 9.66C7.83 11.01 8.81 12.31 8.95 12.5C9.09 12.69 11.02 15.66 13.97 16.94C14.67 17.24 15.22 17.42 15.64 17.56C16.35 17.78 17 17.75 17.5 17.68C18.07 17.59 19.26 16.96 19.5 16.27C19.76 15.58 19.76 15 19.68 14.88C19.6 14.75 19.42 14.68 19.14 14.54C18.86 14.4 17.5 13.73 17.25 13.64C17 13.54 16.84 13.5 16.68 13.73C16.51 13.97 16.05 14.54 15.91 14.68C15.77 14.83 15.63 14.85 15.35 14.71C15.07 14.57 14.17 14.27 13.1 13.32C12.27 12.58 11.71 11.67 11.57 11.43C11.43 11.29 11.56 11.21 11.7 11.07C11.83 10.94 11.98 10.74 12.12 10.58C12.26 10.42 12.31 10.3 12.4 10.12C12.5 9.93 12.45 9.77 12.38 9.63C12.31 9.5 11.76 8.16 11.53 7.61C11.31 7.07 11.09 7.15 10.92 7.14C10.76 7.14 10.58 7.13 10.39 7.13C10.2 7.13 9.9 7.2 9.65 7.47C9.4 7.74 8.7 8.4 8.7 9.75C8.7 11.1 9.68 12.4 9.82 12.59L9.53 7.03Z"/>' +
      '</svg>' +
      '<span class="platform-sharing-text">WhatsApp</span>' +
    '</a>';

    // 6. Insertamos la opción al final de la lista de compartir
    menu.appendChild(li);
  });
}

// Ejecutamos en cuanto cargue el DOM
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', agregarWhatsAppCompartir);
} else {
  agregarWhatsAppCompartir();
}
//]]>
</script>

4. El detalle fino: El secreto de las dos barras laterales en Blogger 🕵️‍♂️📐

Aquí es donde está el truco que casi nadie te cuenta y por el cual mucha gente cree que los gadgets no funcionan.

Si vas al panel de Diseño de tu blog, pegas el gadget en la barra lateral y te vas a probarlo, notarás algo curioso:

  • En la página principal (portada): ¡Funciona de maravilla! Le picas al icono de compartir de cualquier tarjeta y sale WhatsApp listo para mandar.
  • En la entrada individual (artículo completo): ¡Desaparece por completo! 😱

¿Por qué pasa esta rareza?

En plantillas modernas de Blogger como Emporio o Soho, Google programó dos barras laterales totalmente independientes:

  1. Barra lateral (sidebar_feed): Es la barra lateral que se muestra cuando la gente está en la portada, en categorías o en archivos del blog.
  2. Barra lateral de página de entrada (sidebar_item): Es una sección secundaria que la plantilla activa únicamente cuando el lector le da clic a un post y entra a leerlo en su propia URL (.html).

Si pones el gadget nada más en la primera barra, en el momento en que alguien entra a ver una foto o artículo específico, Blogger simplemente no carga esa barra lateral y, por lo tanto, el script no se ejecuta.

¿La solución? Poner el gadget en ambas secciones de diseño. Así de sencillo 🎯.

5. Guía de Instalación Paso a Paso (Para no errarle) 🛠️📋

Sigue estos pasos al pie de la letra desde el panel de control de Blogger:

Paso 1: Ir al Diseñador

Entra a tu cuenta de Blogger y en el menú lateral izquierdo selecciona la opción Diseño.

Paso 2: Agregar el Gadget para la Portada

  1. Localiza el bloque llamado Barra lateral (o Sidebar).
  2. Haz clic en el enlace que dice Añadir un gadget (+).
  3. En la lista emergente que te aparece, busca y selecciona HTML/Javascript.
  4. IMPORTANTE: Deja el campo Título completamente en blanco. Si le pones título, se va a ver una caja de texto vacía en tu blog. Al dejarlo vacío, el gadget es 100% invisible para los visitantes.
  5. En el cuadro grande de Contenido, pega todo el código que te dejé arriba en la sección 3.
  6. Haz clic en Guardar.

Paso 3: Agregar el Gadget para las Entradas Individuales

  1. En esa misma pantalla de Diseño, desplázate más abajo hasta encontrar otra caja llamada Sidebar (Item Page) o Barra lateral (Página de entrada).
  2. Haz clic en Añadir un gadget dentro de esa sección.
  3. Elige nuevamente HTML/Javascript.
  4. Vuelve a dejar el campo Título en blanco.
  5. Pega exactamente el mismo código en el campo Contenido.
  6. Haz clic en Guardar.

Paso 4: Guardar cambios generales

No se te vaya a olvidar este paso fundamental: en la esquina inferior derecha de la pantalla de Diseño de Blogger hay un botón circular con el icono de un disquete (Guardar). Dale clic para que se consoliden los cambios en tu plantilla.

¡Y listo el pollo! Abre tu blog en una pestaña nueva, prueba abrir el menú de compartir en la portada y luego entra a cualquier artículo para verificarlo. En ambos lugares verás la opción de WhatsApp perfectamente integrada con su icono verde brillante 🎉📱.

6. ¿Por qué esta solución es mil veces mejor que editar el XML? 📊⚖️

Si todavía te queda alguna duda de por qué este método por Gadget es la opción más inteligente para cualquier bloguero, aquí te dejo una comparativa directa:

CaracterísticaModificando el XML del TemaUsando Gadgets de HTML/Javascript
Dificultad técnicaAlta (hay que buscar etiquetas raras en miles de líneas).Mínima (copiar y pegar en dos cajas).
Riesgo de erroresCrítico (bloqueos por el bug de AdSense1).Cero (no interactúa con el validador de Blogger).
Riesgo de romper la webAlto (una etiqueta mal cerrada tira el sitio).Nulo (si el script falla, el blog sigue funcionando).
PersistenciaSe puede perder si restauras o limpias el tema.Queda respaldado en los widgets de tu blog.
Tiempo de implementaciónDe 30 minutos a horas resolviendo fallas.Menos de 3 minutos cronometrados.
Rendimiento y velocidadIgual.Ultraligero (JavaScript puro en el cliente).

7. Consejos Pro para que tus publicaciones se compartan con ganas 📸🔥

Ya que tienes el botón funcionando como relojito suizo, aquí van un par de recomendaciones técnicas para que cuando tus lectores manden el enlace por WhatsApp, la vista previa se vea profesional y llamativa:

1. Cuida la resolución de tus imágenes en Blogger

Blogger tiene la fea costumbre de comprimir las fotos agregando códigos de tamaño en las URLs (/s320/, /s640/). Cuando redactes tu entrada, asegúrate de que la foto principal esté configurada en tamaño “Original” o “Muy grande”. En el código HTML de la entrada, la URL de la imagen debería tener el fragmento /s1600/ o /s0/. Esto garantiza que WhatsApp jale una miniatura con nitidez impecable.

2. Formato liviano (WebP o JPG comprimido)

No subas imágenes crudas de 10 o 15 Megabytes directo de la cámara. Pásalas antes por un optimizador web o conviértelas a formato WebP. WhatsApp tiene un tiempo límite para generar la tarjeta de vista previa; si tu página o la imagen tardan una eternidad en responder, el chat de WhatsApp se rinde y solo mandará el texto plano sin la foto miniatura.

3. Títulos atractivos y directos

El script está programado para enviar el título de la publicación seguido de un guion y la URL limpia. Pon títulos descriptivos que despierten la curiosidad de quien recibe el mensaje en su teléfono (por ejemplo: “📸 Atardecer en el malecón de Topolobampo – Capturado con Pixel” en lugar de “Foto 0023”).

8. Preguntas Frecuentes de la Plebada (FAQ) ❓🤠

¿Funciona si la persona navega desde una computadora?

¡Claro que sí! Si el usuario está leyendo tu blog en una PC o laptop y le da clic a WhatsApp, el enlace universal lo mandará directo a la interfaz de WhatsApp Web con el mensaje precargado, listo para que elija a qué contacto o grupo mandárselo.

¿Se puede cambiar el texto o ponerle un emoji al botón?

Por supuesto. Si quieres que diga “Compartir en WhatsApp” o ponerle un icono coqueto, solo localiza esta línea dentro del script:

JavaScript

'<span class="platform-sharing-text">WhatsApp</span>'

Y cámbiala por:

JavaScript

'<span class="platform-sharing-text">WhatsApp 💬</span>'

¿Por qué mi botón aparece blanco y no completamente verde?

El botón se diseñó para mimetizarse al 100% con la estética nativa de Blogger. Los botones originales de Facebook, X y Pinterest tienen fondo claro y texto oscuro; lo que resalta es el icono de color. Nuestro botón hace exactamente lo mismo: mantiene la uniformidad visual del menú desplegable y pinta el icono oficial en verde WhatsApp (#25D366). Si quieres que todo el fondo del botón sea verde, tendrías que forzarle un estilo background-color por CSS, pero visualmente queda mucho más elegante adaptado al tema.

9. Conclusión y Checklist de Salida ✅🏁

A veces nos quebramos la cabeza intentando modificar archivos de sistema o peleándonos con código complejo, cuando la solución más elegante y efectiva está al alcance de un par de clics en las herramientas que la misma plataforma ya nos da. Con este truco de los gadgets, esquivaste el error de AdSense, protegiste la integridad de tu plantilla y le diste a tus visitantes la herramienta que más van a usar para mover tu contenido.

Antes de dar el trabajo por terminado, repasa esta lista rápida:

  1. ¿Creaste el Gadget en la Barra lateral principal?
  2. ¿Creaste el Gadget en la Barra lateral (Item Page) para las entradas individuales?
  3. ¿Dejaste el título vacío en ambos gadgets para que no aparezcan cajas feas en el diseño?
  4. ¿Le diste clic al botón de Guardar cambios abajo a la derecha en la sección Diseño?
  5. ¿Entraste desde tu celular a mandar una foto a un chat de prueba para verificar que el mensaje se arme al tiro?

¡Fierro por la 300! Ya tienes tu blog de fotos y contenidos perfectamente conectado a WhatsApp, sin enredos técnicos y listo para que la raza empiece a rolar tus publicaciones por todos lados 🚀🤠.

🤠 CAPÍTULO 1: «NOMÁS SON CINCO MINUTITOS, VIEJÓN»

Entrada fija

(O de cómo tres demonios de Linux casi me hacen aventar el servidor al monte) 🖥️💥

Prólogo: La mentira más grande del norte 🌙☕

Eran como las once y media de la noche, compa. En la calle ya no pasaba ni un alma, nomás se escuchaba a lo lejos el ladrido de los perros de la colonia y el zumbido del abanico de techo peleando por su vida contra el calorón.

Cualquier cristiano con dos dedos de frente ya se hubiera cenado unos tacos, apagado las luces y a dormir como los dioses. Pero uno es terco, plebe. Uno tiene esa maldición que cargamos los que le movemos a los fierros y a los servidores: el ego informático.

Miré la pantalla y me dije a mí mismo la frase más peligrosa y embustera que ha parido la humanidad desde que se inventó el fuego:

👉 «Nomás instalo rápido el contenedor de ONLYOFFICE en Docker, lo pego a mi Nextcloud para editar archivos en la nube, veo que jale y a mimir. En cinco minutitos queda al puro tiro, viejón». 🤦‍♂️

¡Ándale, pues! Esos «cinco minutitos» terminaron siendo una novela de misterio, traición, balazos criptográficos y brujería digital que ni la Rosa de Guadalupe se atrevería a escribir.

Acto I: La trampa del botón azul y la casilla embustera 🖱️🤡

La tirada se veía clarita y sin baches:

  • Nextcloud jalando en el servidor para guardar las carpetas de la chamba.
  • Un contenedor de Docker con ONLYOFFICE Document Server para no pagarle suscripciones en dólares a Don Bill Gates.
  • Su dominio bien peinado con DNS dinámico, puertos configurados y certificados listos para que viajara todo encriptado como secreto de Estado.

Entro a la interfaz web de Nextcloud, me voy derechito al menú de ONLYOFFICE Docs y veo los campos limpiecitos. Pongo la URL pública con su puerto: [https://nube.mi-rancho-digital.com:9443](https://nube.mi-rancho-digital.com:9443) 🌐.

Luego me topo con una casilla que te mira con ojos de ternura:

☑️ «Desactivar la verificación de certificados (inseguro)».

«¡Uf, una chulada!», pensé. Como mi certificado SSL es local y el navegador chilla diciendo que «la conexión no es segura», esa casilla es como enseñarle la credencial de elector a un guardia que ya te conoce: pásale, pariente, estás en tu casa.

Pego el token secreto (JWT) del contenedor, respiro hondo y le aplasto con orgullo norteño al botón azul de Guardar.

La ruedita de carga empezó a girar. Uno… dos… tres segundos… Esos segundos donde se te va el aire del pecho esperando el milagro.

¡Tómala, barbón! 💥

Me salta un letrero rojo atravesado de oreja a oreja en la pantalla:

❌ «Error al intentar establecer la conexión (Se ha producido un error en el servicio de documentos: Error while downloading the document file to be converted.) (versión 9.4.0.129)».

¡Ah, caray! Me quedé pasmado. ¿Cómo que error al descargar el archivo para convertir? ¿Cuál archivo, si yo nomás le di Guardar?

Para la raza que nunca ha configurado esto: cuando tú le das Guardar, Nextcloud no nomás pregunta «¿estás ahí, viejo?». No, señor. Nextcloud agarra un archivo chiquito de prueba, se lo manda a ONLYOFFICE y le dice: «A ver si muy machito, descárgalo de mi casa, conviértelo a tu formato y regrésame una foto miniatura para ver si es cierto que trabajas».

Y ahí, en ese viaje de ida y vuelta, la cosa había tronado como ejote.

Cerré el navegador. La web es puro cuento, compa; si quieres ver dónde está el cochinero, hay que meterse a la terminal a rascarle a los logs. 🕳️💻

Acto II: El cadenero de Node.js se puso flamenco (DEPTH_ZERO_SELF_SIGNED_CERT) 🥊👮‍♂️

Abro la consola de comandos, tiro un docker ps para ver al sospechoso y le meto una zancadilla al registro del contenedor a ver qué le dolía:

Bash

sudo docker exec -it onlyoffice_docs tail -n 80 /var/log/onlyoffice/documentserver/docservice/out.log

Y la pantalla me escupe una letanía de errores en rojo que parecía árbol de Navidad descompuesto:

Plaintext

[ERROR] nodeJS - error downloadFile:url=https://nube.mi-rancho-digital.com:9443/index.php/apps/onlyoffice/empty?...
code:DEPTH_ZERO_SELF_SIGNED_CERT Error: self-signed certificate

¡Míralo, qué chulo! 🤬

El error era claro como el agua de la presa: DEPTH_ZERO_SELF_SIGNED_CERT. ONLYOFFICE me mandó por un tubo porque el certificado de Nextcloud era autofirmado.

—«A ver, cabrón», le dije a la pantalla, «¿pos no te marqué en Nextcloud la casilla de ignorar los certificados?».

Pues sí, compa, pero esa casilla es pura pantalla. Nextcloud ignora los certificados cuando él le habla a ONLYOFFICE. Pero cuando ONLYOFFICE tiene que ir de regreso a tocarle la puerta a Nextcloud para descargar el archivo, el que viaja es un proceso de Node.js que vive adentro del contenedor de Docker.

Y Node.js no cree en la buena voluntad de la gente. Node.js es como esos policías gringos de película: si el papel no viene con el sello oficial del presidente, te tumba al suelo, te esposa y te cancela la llamada.

Y para acabarla de amolar, adentro de ONLYOFFICE el archivo default.json venía amarrado con alambre de púas: "rejectUnauthorized": true 🔒

O sea: «¡Aquí no entra nadie con certificado del pueblo, puro Let’s Encrypt o certificado de ricos!».

Dije: «A mí no me vas a ganar en mi propia máquina, mi rey». Me fajé la camisa y le metí mano a la configuración con un machetazo de sed:

Bash

# Obligar a Node.js a tragar certificados locales
sudo docker exec -it onlyoffice_docs sed -i 's/"rejectUnauthorized": true/"rejectUnauthorized": false/g' /etc/onlyoffice/documentserver/default.json

# Y por si las moscas, inyectar la variable en el supervisor
sudo docker exec -it onlyoffice_docs bash -c 'echo -e "\n[supervisord]\nenvironment=NODE_TLS_REJECT_UNAUTHORIZED=\"0\"" >> /etc/supervisor/supervisord.conf'

# Reiniciar los fierros internos
sudo docker exec -it onlyoffice_docs supervisorctl restart all

Reviso el log y sale Node.js regañándome:

⚠️ «Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to ‘0’ makes TLS connections insecure».

—«Chilla todo lo que quieras, plebe, pero ahora me dejas pasar». 🤠

Primer demonio domado. Ya no había bronca con el certificado. Volví a la web, le di Guardar de nuevo… y ¡mocos! Otro error diferente.

Acto III: El fantasma de <documentserver> y el miedo a las IPs privadas 👻🚫

Ya para este punto eran las doce y media de la noche. El dolor de espalda empezaba a cobrar factura y el café ya sabía a pura resignación.

Me pongo a revisar la pantalla de Nextcloud con lupa y abro una pestañita abajo que dice «Ajustes de servidor avanzados».

¡Casi me voy para atrás de la risa y el coraje! 🤦‍♂️😂

En el campo de «Dirección de ONLYOFFICE Docs para solicitudes internas del servidor», la interfaz me había autollenado una cochinada:

https://<documentserver>

¡No me friegues! El sistema no lo tenía como ejemplo gris clarito; ¡lo estaba mandando en serio! Cada vez que guardaba, Nextcloud intentaba conectarse a un dominio que literalmente se llamaba <documentserver>. Pos con razón, viejón: el DNS de mi proveedor de internet ha de haber pensado: «¿Y este vato qué se fumó? Eso ni existe».

Y por si fuera poco, para esquivar las broncas del enrutador de la casa (que a veces no le gusta salir a internet y regresar al mismo módem, el mentado Hairpin NAT), se me ocurrió ponerle en la dirección interna la IP local de la máquina:

[https://192.168.1.150:9443/](https://192.168.1.150:9443/)

Parecía plan con maña. Tráfico local, directito por la tarjeta de red, sin gastar megas.

Pero, ¡sorpresa! ONLYOFFICE tiene complejo de agente del FBI. Los desarrolladores le metieron una regla que dice:

"allowPrivateIPAddress": false

¿Qué significa eso en cristiano? Que si ONLYOFFICE ve una IP que empieza con 192.168.x.x o 10.x.x.x, le da un ataque de pánico pensando que un hacker ruso le está haciendo un ataque de falsificación de peticiones (SSRF) y bloquea la conexión de inmediato. ¡Ni en su propia casa se sentía seguro el contenedor!

Estábamos atrapados en un callejón sin salida:

  1. Por la URL pública no entraba por culpa del módem.
  2. Por la URL interna privada reventaba porque ONLYOFFICE le tenía fobia a las IPs de la casa.
  3. Y el campo fantasma <documentserver> tirando patadas de ahogado.

Aquí fue donde mandé la interfaz web a volar. A grandes males, grandes machetazos de consola.

Descubrí que en este servidor Nextcloud no corría en un Apache pelón, sino empaquetado en Snap. Así que saqué la artillería pesada con el comando nextcloud.occ:

Bash

# 1. Borrar de raíz el fantasma de <documentserver>
sudo nextcloud.occ config:app:delete onlyoffice DocumentServerInternalUrl

# 2. Clavar la IP local para que descargue sin rodeos
sudo nextcloud.occ config:app:set onlyoffice StorageUrl --value="https://192.168.1.150:9443/"

# 3. Y quitarle la paranoia a ONLYOFFICE para que acepte IPs privadas
sudo docker exec -it onlyoffice_docs sed -i 's/"allowPrivateIPAddress": false/"allowPrivateIPAddress": true/g' /etc/onlyoffice/documentserver/default.json
sudo docker exec -it onlyoffice_docs supervisorctl restart all

Listo. Borrado el fantasma, habilitada la IP privada, y Node.js amarrado para no chillar por el SSL.

Tiré la prueba de fuego desde la terminal:

sudo nextcloud.occ onlyoffice:documentserver --check

Y la terminal me responde con una frialdad que me congeló las tripas:

💀 Error connection: Error occurred in the document service.

Ahí sí sentí ganas de apagar el switch de la luz y dedicarme a sembrar hortalizas. ¿Ahora qué demonios quería el mugre sistema? 🚜🌾

Acto IV: La guerra civil de Nginx y el misterio del 403 🕵️‍♂️🔥

Eran ya la una y pico de la mañana. Me quedé viendo fijamente el cursor parpadear. Cuando todo falla, compa, la única regla que no falla en la informática es: los registros no se hacen pendejos. Si truena, en algún lado está chillando el fierro.

Me fui a revisar el log profundo de Nextcloud en Snap:

Bash

sudo tail -n 50 /var/snap/nextcloud/current/logs/nextcloud.log

Y entre un montón de líneas kilométricas en formato JSON, me salta la joya de la corona:

Plaintext

Client error: `GET https://nube.mi-rancho-digital.com:9443/cache/files/data/conv_check_803784422_65/output.docx/...` 
resulted in a `403 Forbidden` response

Me le quedé viendo a la línea como vaca viendo pasar el tren. 🐮🚂

403 Forbidden.

¡A ver, a ver, barájamela más despacio!

Fíjate bien en la ruta que estaba pidiendo:

/cache/files/data/conv_check.../output.docx

¿Qué diablos significaba eso?

¡Significaba que ONLYOFFICE SÍ se había conectado a Nextcloud!

¡Significaba que SÍ había descargado la plantilla de prueba!

¡Significaba que el motor conversor SÍ había generado el archivo .docx y lo tenía guardado calientito en su carpeta de caché!

O sea, la carrera de 100 metros planos ya la había corrido casi toda… pero en el metro 99, cuando Nextcloud iba contento a recoger el archivo terminado para decir «¡Ya quedó, compadre!», el servidor web Nginx que cuida la puerta de ONLYOFFICE le metía un portazo en las narices gritándole: ¡403 PROHIBIDO, AQUÍ NO ENTRAS! 🚪❌

—«¿Pero por qué me corres, desgraciado, si el archivo tú mismo me lo hiciste?», pensé.

Le revisé los permisos a las carpetas por si las moscas: chmod 755, chown ds:ds. Todo en orden. No era bronca de permisos de disco.

Y en eso, una chispa divina me iluminó el coco. 💡

Me acordé de cómo protege ONLYOFFICE sus descargas: usa un módulo de Nginx llamado secure_link.

Funciona bien curado: cuando el conversor crea el documento, agarra una contraseña secreta (secretString), la revuelve con la fecha y el nombre del archivo, y genera un código MD5 único. Cuando Nextcloud viene a descargar el archivo, Nginx revisa ese código con su propia contraseña. Si las contraseñas coinciden, te da el archivo; si no, ¡te la ensarta con un 403!

Dije: «No me digas que estos animales están usando claves diferentes adentro del mismo contenedor».

Tiré este comando para destapar la cloaca:

Bash

# Ver qué clave tiene Nginx en la libreta
sudo docker exec -it onlyoffice_docs grep -rn "secure_link_secret" /etc/nginx/ /etc/onlyoffice/

# Ver qué clave tiene el conversor en la memoria
sudo docker exec -it onlyoffice_docs grep -rn "secretString" /etc/onlyoffice/documentserver/local.json

¡Cállate los ojos, pariente! 🤯

Miren lo que salió en la pantalla:

  • En la configuración activa de Nginx (ds.conf):set $secure_link_secret lDpK21FnC0Pe6EwfMPtO; 📜
  • En el archivo de servicio de ONLYOFFICE (local.json):"secretString": "fn7ZMjfj6y4oJFeepVUh" 🔐

¡Traían una guerra civil armada los batos! 😂

La mano izquierda del contenedor generaba el documento con el sello A, y el portero de la mano derecha en Nginx tenía órdenes estrictas de aceptar únicamente documentos con el sello B. Al ver que no cuadraban, Nginx pensaba que era un impostor y lo mandaba a la tiznada con un 403.

¡Dos horas peleando contra fantasmas y eran las comadres adentro del contenedor que no se hablaban!

Acto V: El tiro de gracia y la gloria de la consola 🎯🏆

Con el clavo bien ubicado, la cura tomó menos de diez segundos. Había que obligar a Nginx a usar la misma clave que el servicio conversor:

Bash

# 1. Emparejar la clave en la libreta de Nginx
sudo docker exec -it onlyoffice_docs sed -i 's/set $secure_link_secret .*/set $secure_link_secret fn7ZMjfj6y4oJFeepVUh;/g' /etc/nginx/conf.d/ds.conf

# 2. Recargar Nginx para que le caiga el veinte
sudo docker exec -it onlyoffice_docs nginx -s reload

Se escuchó el suspiro del proceso recargando:

[notice] signal process started 🕊️

El cuarto estaba en completo silencio. Ni el abanico sonaba ya en mi cabeza. Puse las manos sobre el teclado para tirar el volado final. Si esto no jalaba, apagaba todo y me iba a poner un puesto de hot dogs. 🌭

Escribí:

Bash

sudo nextcloud.occ onlyoffice:documentserver --check

Le piqué a la tecla Enter con la fuerza de quien cobra un penal en el minuto 90. ⚽

Medio segundo de suspenso… un parpadeo del disco duro… y la terminal escupió la frase más hermosa que han visto mis ojos en todo el año:

✨ Document server [https://nube.mi-rancho-digital.com:9443/](https://nube.mi-rancho-digital.com:9443/) version 9.4.0.129 is successfully connected ✨

¡Uffff, qué chulada de maíz prieto! 🎉🥳

Casi pego un grito en la casa que despierta a la doña.

Me fui en fa al navegador, le di un recargón con Ctrl + F5 a la página de Nextcloud y ahí estaba: la barra verde brillando como esmeralda, los iconos de .docx, .xlsx y .pptx habilitados, y el servidor de documentos enlazado como mandilón en quincena.

Abrí un documento de Word en blanco nomás de puro gusto, y en un segundo se desplegó la suite de ONLYOFFICE directito en la pestaña, suavecita y sin pedirle nada a nadie.

Moraleja pa’ la raza de sistemas 🧠🍺

Si te vas a meter al ruedo del auto-hospedaje con Docker y Nextcloud, no te confíes de las ventanitas bonitas ni de los botones azules:

  1. Node.js es más bravo que perro de taller: Desactiva la verificación interna (rejectUnauthorized: false) si andas con certificados locales, porque las casillas de la web no le hacen ni cosquillas.
  2. Cuidado con las IPs de tu casa: ONLYOFFICE viene asustado de fábrica y le cierra la puerta a las IPs privadas (allowPrivateIPAddress: true).
  3. No dejes que Nginx y Node se agarren del chongo: Si te arroja un 403 Forbidden al convertir el archivo, revisa que el $secure_link_secret de Nginx sea gemelo del secretString de tu local.json.

Guardé la sesión de SSH, cerré la laptop con una sonrisa de oreja a oreja y me fui a dormir a las dos de la mañana, oliendo a café frío pero con la satisfacción de haberle ganado la partida a la máquina.

¡Fierro por la 300, plebada! Arre con la que barre. 🤠🚀🖥️

🌐 ¡No te quedes fuera de la jugada, pariente! 🤝📲

Si te sirvió esta guía para no arrancarte las greñas a las 2 de la mañana con tus servidores, o si nomás te late ver cómo le batallamos en las trincheras del código y el autohospedaje, pásale a mis canales pa’ seguir en contacto y apoyar el contenido:

  • 💬 Comunidad y contacto en Telegram: t.me/CesarSystems (échate una vuelta pa’ cotorrear o tirar paro con las dudas de Linux y sistemas)
  • ☕ Apoya el proyecto en Patreon: patreon.com/u74078772 (pa’ seguir patrocinando el café nocturno y los tutoriales sin censura)
  • ✍️ Blog de artículos y tutoriales: shalom-now.blogspot.com (aquí desmenuzamos más hacks, configuraciones y vivencias)
  • 🎥 Canal de YouTube: youtube.com/@rasec555 (suscríbete pa’ ver los videos al tiro)

🥁🤖 ¿Bateristas 3D hiperrealistas tocando tus pistas MIDI en tiempo real? Sí, ya es una realidad y el nivel de detalle vuela la cabeza. 🤯🔥

Entrada fija

No estamos hablando de animaciones pregrabadas en bucle donde el personaje “hace como que toca”. Son avatares digitales (como los MetaHumans de Unreal Engine) cuyos brazos, muñecas y baquetas se mueven de forma procedimental siguiendo cada golpe, matiz y dinámica exacta de un archivo MIDI o de una batería electrónica tocada en directo.

🧠 ¿Por qué se ven tan naturales?

  • ⏳ El truco de la anticipación (Look-ahead): En la vida real, levantas el brazo antes de golpear. El sistema lee el MIDI unos milisegundos antes del audio para calcular el recorrido previo del impacto.
  • 🎯 Cinemática Inversa (IK): Las puntas de las baquetas se dirigen a coordenadas milimétricas exactas en los parches y platillos sin desfasarse.
  • 💥 Sensibilidad al golpe (Velocity): A mayor valor MIDI, más amplio es el movimiento corporal y más vibran los platillos gracias a motores de físicas secundarias.

🛠️ ¿Cómo empezar a armar el tuyo?

Existen dos rutas principales según tu objetivo:

1. Ruta Render / Animación (Blender) 🎬

Ideal para crear videoclips o tomas cinemáticas sin programar motores de juego.

  1. Consigue tu modelo: Descarga o modela un personaje con rig completo (brazos, manos y pies con cadenas IK).
  2. Importa el MIDI: Usa scripts de Python comunitarios para Blender (herramientas de MIDI-to-animation o bakeo de curvas).
  3. Mapea huesos a notas: Asigna notas específicas (ej. C1 al bombo, D1 a la tarola) para generar keyframes automáticos en los huesos.

2. Ruta Tiempo Real / Interactivo (Unreal Engine 5) ⚡

Ideal para directos, streaming con batería electrónica o interactividad instantánea.

  1. Crea el personaje: Añade un MetaHuman gratuito desde Quixel Bridge a tu proyecto.
  2. Configura el Control Rig: Define cadenas IK para ambos brazos y piernas, fijando los objetivos (targets) en las baquetas y pedales.
  3. Coloca sockets en la batería 3D: Agrega puntos de anclaje espaciales en cada tambor y platillo para que sirvan de destino al impacto.
  4. Habilita el plugin MIDI: Activa el plugin oficial MIDI en Unreal Engine y vincula las entradas de notas con triggers en Blueprints para disparar trayectorias.
  5. Conecta tu fuente: Conecta tu batería electrónica por USB o envía notas desde tu DAW con un puerto MIDI virtual.

🖥️ Requerimientos mínimos recomendados

Mover modelos de alta fidelidad con cinemática y físicas exige equipo según la ruta elegida:

Para Blender (Render Offline):

  • 🧠 Procesador: 6 núcleos / 12 hilos (Ryzen 5 o Core i5 reciente).
  • 💾 Memoria RAM: 16 GB (32 GB recomendados para escenas con texturas pesadas).
  • 🎮 Tarjeta Gráfica: 6 GB VRAM dedicada (GTX 1660 Ti, RTX 2060 o similar).
  • 💽 Almacenamiento: Disco SSD para lectura rápida de assets.

Para Unreal Engine 5 + MetaHumans (Tiempo Real / En Vivo):

  • 🧠 Procesador: 8 núcleos / 16 hilos (Ryzen 7 5700X, Core i7 o superior) para no ahogar los cálculos de IK y físicas.
  • 💾 Memoria RAM: 32 GB indispensables (con 16 GB el editor colapsa fácil al cargar MetaHumans y texturas 4K).
  • 🎮 Tarjeta Gráfica: Mínimo 8 GB VRAM (RTX 3060 / 4060 o superior) para sostener iluminación Lumen y cinemática a buenos FPS.
  • 🔌 Extra: Un cable virtual MIDI (como loopMIDI en Windows, virmidi/Jack en Linux o IAC Bus en macOS) si envías notas desde tu DAW en la misma computadora.

¿Te imaginas montar un setlist completo y tener a un avatar hiperrealista ejecutando cada redoble y remate idéntico a tu ejecución real? 🚀

¿Lo usarías más para videoclips musicales o para visuales en shows en vivo? 👇

Cómo construí un Asistente de IA Local para SAP Business One en Spark y Openfire (Sin gastar un solo centavo en APIs y sin regalarle tus datos a nadie) 🚀🤖💼

Entrada fija

Si trabajas en el departamento de Sistemas, TI o eres el consultor de cabecera de cualquier empresa que utilice SAP Business One, sabes con dolorosa exactitud cómo transcurre una mañana de lunes típica. ☕💥

Apenas abres los ojos y ya tienes tres mensajes en el chat corporativo:

  • 🤦‍♂️ «Oye, no me deja facturar la entrega del cliente X, me sale una barra roja abajo con un número entre corchetes [-10] y un texto que dice algo de las cuentas contables. ¡Urge porque el camión ya se va!»
  • 🤷‍♀️ «Oye, ¿por qué no puedo cancelar un pago recibido? Dice que el periodo contable está cerrado.»
  • 🤦‍♀️ «¿Cuál era la tabla de los pedidos de compra para sacar un reporte en el Generador de Consultas? ¿Era ORDR o cuál?»

Tu primera reacción humana —y perfectamente justificada— es respirar hondo y pensar: «Hice un manual de 80 páginas en PDF con capturas, flechas rojas y letra tamaño 14… ¿por qué nadie lo lee?». La respuesta es universal: nadie lee manuales. El usuario operativo no quiere buscar en un SharePoint o en una carpeta compartida de red; el usuario quiere preguntar en su chat de todos los días y recibir la respuesta exacta en 5 segundos.

Ahí surge la gran idea moderna: «Metamos Inteligencia Artificial». 💡

Pero en cuanto vas con la Dirección o con Finanzas y dices: «Oigan, mandemos las dudas a ChatGPT o Claude», la respuesta es un rotundo NO. Y tienen toda la razón:

  1. 🔒 Privacidad y secreto comercial: En las consultas de SAP van metidos nombres de clientes, listas de precios, costos, catálogos de cuentas contables, RFCs y folios fiscales. Mandar eso a una nube pública ajena es una pesadilla de cumplimiento y seguridad.
  2. 💸 Costos recurrentes: Pagar suscripciones mensuales por usuario o tokens por cada consulta donde adjunten una captura pesada de pantalla no es viable a largo plazo.
  3. 🏢 Infraestructura existente: En muchísimas empresas medianas e industriales, el canal oficial de comunicación interna no es Slack ni Teams; es Spark sobre Openfire (XMPP). Un sistema robusto, que corre en tu propio servidor Linux dentro de la red local (LAN/VPN), que jamás se cae y que no cobra licencias.

¿Es posible montar un asistente de IA autónomo, multimodal (que analice texto y capturas de pantalla de errores), con memoria vectorial (RAG), conectado a Spark/Openfire, que no alucine con las tablas de SAP y que aprenda de sus propios aciertos todos los días?

Sí, es totalmente posible. En este artículo te voy a mostrar la arquitectura completa, las trampas con las que casi me rompo la cabeza en el camino y absolutamente todo el código fuente listo para producción. 🛠️🔥

🏗️ 1. La Arquitectura: ¿Cómo encajan las piezas del rompecabezas?

Para que este bot fuera rápido, confiable y seguro, la premisa fundamental fue: cero dependencias de la nube pública. Todo corre dentro de tu propia infraestructura Linux.

                    [ 💻 Usuario en Spark IM ]
                               │
            (Texto o Captura)  │  (Streaming por párrafos)
                               ▼
                   [ 🌐 Servidor Openfire ]
                               │  (XMPP / HTTP File Upload :7070)
                               ▼
                 [ 🐍 bot_spark.py (Slixmpp) ]
                  │            │           │
                  │            │           ▼
                  │            │      [ 🗄️ SQLite WAL ]
                  │            │      Historial y Feedback
                  ▼            ▼
             [ 🧠 Ollama ]   [ 📚 ChromaDB ]
            - Qwen 2.5 7B    - manuales_sap (PDF/DOCX/TXT)
            - Qwen 2.5-VL    - casos_resueltos (Feedback SÍ)
            - Nomic Embed

Los componentes del sistema:

  1. El Cliente y Servidor de Chat (Spark + Openfire):
    • Openfire: Servidor XMPP escrito en Java corriendo en Ubuntu Server.
    • Spark: El cliente clásico de escritorio para Windows/Linux.
    • HTTP File Upload Plugin (XEP-0363): La pieza que salva vidas. Por defecto, Spark intenta transferir archivos de forma directa (P2P SOCKS5), lo que suele fallar estrepitosamente con bots en Python. Con este plugin, Spark sube la captura por HTTP al puerto 7070 y le pasa la URL limpia al bot.
  2. El Orquestador de Conexión (Slixmpp):
    • Es una librería de Python asíncrona moderna basada en asyncio. Permite gestionar la presencia del bot, escuchar eventos de mensajes, notificar que está “escribiendo…” y despachar respuestas en segundo plano sin congelar el hilo de red.
  3. El Motor de IA Local (Ollama):
    • qwen2.5:7b (Texto): Excelente capacidad para razonar en español, entender estructuras lógicas empresariales y redactar pasos técnicos claros.
    • qwen2.5vl:7b (Visión): Modelo multimodal que recibe la captura de pantalla del usuario, localiza la barra de estado inferior de SAP (el temido texto rojo), lee ventanas emergentes de error y transcribe el código del problema.
    • nomic-embed-text (Embeddings): Transforma cualquier texto o consulta en vectores matemáticos de 768 dimensiones para la búsqueda semántica.
  4. La Base de Datos Vectorial (ChromaDB con Doble Colección):
    • manuales_sap: Alberga la documentación oficial (manuales PDF, Word de procesos y el catálogo maestro de tablas).
    • casos_resueltos: Una colección viva y separada. Almacena las respuestas que los usuarios confirmaron con un “SÍ”. ¿Por qué separadas? Porque si mañana actualizas un PDF en los manuales oficiales y reindexas, no se borra la experiencia aprendida por el bot.
  5. La Memoria Transaccional (SQLite en modo WAL):
    • Guarda cada interacción con fecha, usuario, pregunta, respuesta y estado de feedback. El modo WAL (Write-Ahead Logging) garantiza que múltiples lecturas y escrituras simultáneas jamás bloqueen la base de datos.

🥊 2. Crónicas de guerra: Los 4 tropiezos que casi nos vuelven locos

Antes de ver el código final, vale la pena repasar los errores que surgieron en las pruebas reales. Conocerlos te ahorrará horas de depuración.

Tropiezo 1: El temido “Cold Start” (¿Por qué tarda 40 segundos en contestar?) ⏳❄️

Al hacer la primera prueba en vivo, un usuario escribió: “Hola” y el bot tardó casi 45 segundos en responder. Cualquier usuario asume que el sistema se murió y cierra la ventana.

  • La causa: Ollama, por defecto, descarga los modelos de la RAM si nadie los usa durante 5 minutos. Cuando entra un mensaje nuevo, el servidor tiene que leer 4.5 GB del disco, subirlos a la memoria, cargar el modelo de embeddings y recién ahí empezar a pensar.
  • La solución: Implementamos dos cosas:
    1. Un método de Warm-up automático al iniciar el bot que hace una mini-consulta silenciosa para dejar los modelos calientes.
    2. El parámetro keep_alive=-1 que le ordena a Ollama: «No bajes este modelo de la memoria jamás».

Tropiezo 2: El error misterioso time: missing unit in duration "-1" 🤦‍♂️

Al intentar configurar el keep-alive eterno, pusimos:

Python

ollama.chat(..., keep_alive="-1") # ❌ ERROR

La terminal explotó con un error 400. Resulta que Ollama está escrito en Go; si le mandas comillas, espera una unidad de tiempo (como "-1m" o "-1h"). Para indicarle eternidad, debe ser un número entero:

Python

ollama.chat(..., keep_alive=-1)   # ✅ CORRECTO

Tropiezo 3: La alucinación de las tablas de SAP B1 🌀

Cuando le preguntamos al bot: «¿Cuál es la tabla de los pedidos de compra?», respondió muy convencido:

“La tabla principal es PCHDR (Purchase Order Header) y el detalle es PCTLR (Purchase Order Line)…”

Cualquier consultor de SAP B1 que lea eso escupe el café. En SAP Business One las tablas no se llaman así. SAP B1 sigue una regla fija:

  • Las cabeceras inician con O + 3 letras (OPOR = Pedido de Compra, ORDR = Pedido de Venta, OINV = Factura de Clientes).
  • Las líneas pierden la O y terminan en 1 (POR1, RDR1, INV1).

El modelo, al no tener un diccionario explícito en los manuales, intentó traducir del inglés Purchase Order e inventó acrónimos falsos.

  • La solución: Creamos un archivo plano tablas_sap_business_one.txt con el catálogo real, lo indexamos en ChromaDB y ajustamos el prompt con reglas estrictas de nomenclatura. Nunca más volvió a inventar una tabla.

Tropiezo 4: El envío de imágenes en Spark 📸

En Spark hay dos formas de mandar fotos:

  1. El botón de la cámara (captura de pantalla tradicional): Usa transferencia directa P2P (SOCKS5 Bytestreams). En clientes modernos o con servidores detrás de NAT/firewall, casi siempre arroja: “You were unable to send the file”.
  2. Arrastrar la imagen o usar el botón del clip: Sube la imagen vía HTTP al puerto 7070 de Openfire (/httpfileupload/) y manda un enlace web.

Ajustamos Openfire para forzar el uso de HTTP en la IP local y el script de Python quedó configurado para detectar automáticamente cualquier URL de imagen mediante expresiones regulares, descargarla a un archivo temporal y mandarla a procesar con visión artificial.

📖 3. Paso 1: El Diccionario Maestro de Tablas de SAP B1

Este archivo es la “fuente de la verdad” para evitar que el bot invente tablas cuando los desarrolladores o contadores le pidan apoyo con queries SQL o HANA.

Crea la carpeta manuales/ dentro del directorio de tu bot y guarda este archivo:

📁 Ruta: ~/sap_bot/manuales/tablas_sap_business_one.txt

Plaintext

DICCIONARIO MAESTRO DE TABLAS ESTÁNDAR DE SAP BUSINESS ONE (SQL Y HANA)

REGLAS DE NOMENCLATURA INTERNA EN SAP B1:
- Tablas de encabezado / cabecera de documentos: Siempre inician con la letra 'O' seguida de 3 letras.
- Tablas de líneas / detalle del documento: Quitan la 'O' inicial y terminan con el número '1'.

1. MÓDULO DE COMPRAS (PROVEEDORES):
- Solicitud de Compra (Requisición): OPRQ (Cabecera), PRQ1 (Líneas)
- Oferta de Compra: OPQT (Cabecera), PQT1 (Líneas)
- Pedido de Compra (Orden de Compra): OPOR (Cabecera), POR1 (Líneas)
- Entrada de Mercancías por Compras: OPDN (Cabecera), PDN1 (Líneas)
- Devolución de Mercancías a Proveedor: ORPD (Cabecera), RPD1 (Líneas)
- Factura de Proveedores (Factura de Compra): OPCH (Cabecera), PCH1 (Líneas)
- Nota de Crédito de Proveedores: ORPC (Cabecera), RPC1 (Líneas)

2. MÓDULO DE VENTAS (CLIENTES):
- Oferta de Venta (Cotización): OQUT (Cabecera), QUT1 (Líneas)
- Pedido de Venta (Orden de Venta): ORDR (Cabecera), RDR1 (Líneas)
- Entrega (Remisión de Venta): ODLN (Cabecera), DLN1 (Líneas)
- Devolución de Clientes: ORDN (Cabecera), RDN1 (Líneas)
- Factura de Clientes (Factura de Venta): OINV (Cabecera), INV1 (Líneas)
- Factura de Reserva de Clientes: OINV (Cabecera con campo IsIns = 'Y'), INV1 (Líneas)
- Nota de Crédito de Clientes: ORIN (Cabecera), RIN1 (Líneas)

3. SOCIOS DE NEGOCIOS:
- Maestro de Socios de Negocios (Clientes y Proveedores): OCRD
- Personas de Contacto de Socios de Negocios: OCPR
- Direcciones de entrega y fiscales de Socios: CRD1
- Grupos de Socios de Negocios: OCRG
- Condiciones de Pago: OCTG

4. INVENTARIO Y ARTÍCULOS:
- Datos Maestros de Artículos: OITM
- Stock por Almacén de Artículos: OITW
- Almacenes (Bodegas): OWHS
- Grupos de Artículos: OITB
- Listas de Precios: OPLN (Cabecera), ITM1 (Precios por artículo)
- Solicitud de Traslado de Inventario: OWTQ (Cabecera), WTQ1 (Líneas)
- Transferencia de Stock (Traslado entre almacenes): OWTR (Cabecera), WTR1 (Líneas)
- Entrada de Mercancías (Inventario directo): OIGN (Cabecera), IGN1 (Líneas)
- Salida de Mercancías (Ajuste o merma de inventario): OIGE (Cabecera), IGE1 (Líneas)

5. FINANZAS Y GESTIÓN BANCARIA:
- Plan de Cuentas Contables: OACT
- Asientos Contables (Pólizas contables): OJDT (Cabecera), JDT1 (Líneas)
- Pagos Recibidos (Cobranza a clientes): ORCT (Cabecera), RCT2 (Facturas pagadas)
- Pagos Efectuados (Pagos a proveedores): OVPM (Cabecera), VPM2 (Facturas pagadas)

6. PRODUCCIÓN:
- Lista de Materiales (Receta / Estructura de producción): OITT (Cabecera), ITT1 (Líneas)
- Orden de Fabricación (Orden de Producción): OWOR (Cabecera), WOR1 (Líneas)

📥 4. Paso 2: El Pipeline de Ingestión Multimodal (indexar_manuales.py)

¿Qué pasa cuando un usuario hace un manual interno en Microsoft Word, pega 15 capturas de pantalla de SAP y apenas escribe dos líneas de texto? Los indexadores RAG tradicionales ignoran las imágenes y no indexan casi nada.

Este script resuelve ese problema de forma integral:

  • Abre archivos .pdf con fitz (PyMuPDF).
  • Abre documentos .docx con python-docx.
  • Lee archivos .txt planos.
  • Aplica OCR con Tesseract: Si detecta imágenes incrustadas con poco texto alrededor, ejecuta OCR sobre la captura, extrae los títulos de las ventanas y botones de SAP, y anexa ese texto al bloque para que sea buscable vectorialmente.
  • Extrae las imágenes a una carpeta estática (static_imgs/) para que el bot pueda devolverle al usuario un enlace directo para ver la captura original del manual si lo necesita.
  • Almacena todo en la colección manuales_sap en ChromaDB.

📁 Ruta: ~/sap_bot/indexar_manuales.py

Python

#!/usr/bin/env python3
"""
Indexador de Documentación Oficial SAP B1 (PDF, DOCX y TXT) con OCR y ChromaDB.
Guarda exclusivamente en la colección 'manuales_sap'.
"""
import os
import io
import re
import fitz  # PyMuPDF
import docx  # python-docx
import pytesseract
from PIL import Image
import chromadb
import ollama

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CARPETA_MANUALES = os.path.join(BASE_DIR, "manuales")
CARPETA_IMGS = os.path.join(BASE_DIR, "static_imgs")
DB_PATH = os.path.join(BASE_DIR, "chroma_sap_db")

CHUNK_SIZE = 900
CHUNK_OVERLAP = 150
MODELO_EMBED = "nomic-embed-text"

def limpiar_nombre(nombre: str) -> str:
    """Sanea los nombres de archivo para usarlos de forma segura en rutas e IDs."""
    return re.sub(r'[^a-zA-Z0-9_\-\.]', '_', nombre)

def fragmentar_texto(texto: str, tamano: int = CHUNK_SIZE, solapamiento: int = CHUNK_OVERLAP) -> list[str]:
    """Divide textos extensos en bloques con solapamiento para mantener contexto."""
    fragmentos = []
    inicio = 0
    while inicio < len(texto):
        fin = inicio + tamano
        fragmentos.append(texto[inicio:fin])
        inicio += tamano - solapamiento
    return fragmentos

def obtener_vector(texto: str) -> list[float]:
    """Genera el embedding usando el modelo local nomic-embed-text con keep_alive=-1."""
    res = ollama.embeddings(model=MODELO_EMBED, prompt=texto, keep_alive=-1)
    return res["embedding"]

def procesar_txt(ruta_txt: str, archivo: str, coleccion, contador_inicio: int) -> int:
    """Procesa e indexa archivos de texto plano."""
    try:
        with open(ruta_txt, "r", encoding="utf-8", errors="replace") as f:
            contenido = f.read().strip()
    except Exception as e:
        print(f"[-] Error leyendo TXT {archivo}: {e}")
        return contador_inicio

    if len(contenido) < 15:
        return contador_inicio

    bloques = fragmentar_texto(contenido)
    contador = contador_inicio

    for idx, bloque in enumerate(bloques):
        try:
            vector = obtener_vector(bloque)
            chunk_id = f"{archivo}_sec_{idx}_{contador}"
            coleccion.add(
                ids=[chunk_id],
                embeddings=[vector],
                documents=[bloque],
                metadatas=[{
                    "archivo": archivo,
                    "pagina": f"Sección {idx + 1}",
                    "imagenes": "",
                    "tipo": "manual_oficial"
                }]
            )
            contador += 1
        except Exception as err:
            print(f"[-] Error en vector TXT: {err}")

    return contador

def procesar_pdf(ruta_pdf: str, archivo: str, coleccion, contador_inicio: int) -> int:
    """Extrae texto, extrae imágenes incrustadas, aplica OCR e indexa PDFs."""
    doc = fitz.open(ruta_pdf)
    contador = contador_inicio
    nombre_base = limpiar_nombre(os.path.splitext(archivo)[0])

    for num_pagina in range(len(doc)):
        pagina = doc[num_pagina]
        texto = pagina.get_text().strip()
        imagenes = pagina.get_images(full=True)
        imagenes_guardadas = []

        if imagenes:
            for idx_img, img_info in enumerate(imagenes):
                try:
                    xref = img_info[0]
                    img_dict = doc.extract_image(xref)
                    img_pil = Image.open(io.BytesIO(img_dict["image"]))
                    
                    # Filtrar íconos decorativos pequeños
                    if img_pil.width > 120 and img_pil.height > 120:
                        nombre_img = f"{nombre_base}_p{num_pagina + 1}_img{idx_img}.png"
                        img_pil.save(os.path.join(CARPETA_IMGS, nombre_img))
                        imagenes_guardadas.append(nombre_img)

                        # Si la página tiene poco texto pero tiene una captura, aplicar OCR
                        if len(texto) < 120:
                            ocr = pytesseract.image_to_string(img_pil, lang="spa")
                            if ocr.strip():
                                texto += f"\n[Texto en captura {nombre_img}]:\n" + ocr.strip()
                except Exception:
                    continue

        if len(texto.strip()) < 30 and not imagenes_guardadas:
            continue

        bloques = fragmentar_texto(texto)
        str_imgs = ",".join(imagenes_guardadas)

        for idx, bloque in enumerate(bloques):
            try:
                vector = obtener_vector(bloque)
                chunk_id = f"{archivo}_p{num_pagina + 1}_b{idx}_{contador}"
                coleccion.add(
                    ids=[chunk_id],
                    embeddings=[vector],
                    documents=[bloque],
                    metadatas=[{
                        "archivo": archivo,
                        "pagina": str(num_pagina + 1),
                        "imagenes": str_imgs,
                        "tipo": "manual_oficial"
                    }]
                )
                contador += 1
            except Exception as err:
                print(f"[-] Error en vector PDF: {err}")

    return contador

def procesar_docx(ruta_docx: str, archivo: str, coleccion, contador_inicio: int) -> int:
    """Extrae texto, tablas formateadas e imágenes con OCR de documentos Word."""
    doc = docx.Document(ruta_docx)
    contador = contador_inicio
    nombre_base = limpiar_nombre(os.path.splitext(archivo)[0])
    contenido = []
    imagenes_guardadas = []

    for p in doc.paragraphs:
        if p.text.strip():
            contenido.append(p.text.strip())

    for t_idx, tabla in enumerate(doc.tables):
        filas = [" | ".join(c.text.strip() for c in f.cells if c.text.strip()) for f in tabla.rows]
        if filas:
            contenido.append(f"\n[Tabla {t_idx + 1}]:\n" + "\n".join(filas))

    idx_img = 0
    for part in doc.part.related_parts.values():
        if getattr(part, "content_type", "").startswith("image/"):
            try:
                img_pil = Image.open(io.BytesIO(part.blob))
                if img_pil.width > 120 and img_pil.height > 120:
                    nombre_img = f"{nombre_base}_word_img{idx_img}.png"
                    img_pil.save(os.path.join(CARPETA_IMGS, nombre_img))
                    imagenes_guardadas.append(nombre_img)
                    idx_img += 1

                    ocr = pytesseract.image_to_string(img_pil, lang="spa")
                    if ocr.strip():
                        contenido.append(f"\n[Texto en captura {nombre_img}]:\n" + ocr.strip())
            except Exception:
                continue

    texto_total = "\n\n".join(contenido).strip()
    if len(texto_total) < 30 and not imagenes_guardadas:
        return contador

    bloques = fragmentar_texto(texto_total)
    str_imgs = ",".join(imagenes_guardadas)

    for idx, bloque in enumerate(bloques):
        try:
            vector = obtener_vector(bloque)
            chunk_id = f"{archivo}_sec_{idx}_{contador}"
            coleccion.add(
                ids=[chunk_id],
                embeddings=[vector],
                documents=[bloque],
                metadatas=[{
                    "archivo": archivo,
                    "pagina": f"Sección {idx + 1}",
                    "imagenes": str_imgs,
                    "tipo": "manual_oficial"
                }]
            )
            contador += 1
        except Exception as err:
            print(f"[-] Error en vector Word: {err}")

    return contador

def ejecutar_indexacion():
    os.makedirs(CARPETA_MANUALES, exist_ok=True)
    os.makedirs(CARPETA_IMGS, exist_ok=True)
    client = chromadb.PersistentClient(path=DB_PATH)

    formatos = (".pdf", ".docx", ".txt")
    archivos = [f for f in os.listdir(CARPETA_MANUALES) if f.lower().endswith(formatos)]

    # Reiniciar ÚNICAMENTE la colección de manuales oficiales
    try:
        client.delete_collection(name="manuales_sap")
    except Exception:
        pass
    coleccion = client.create_collection(name="manuales_sap")

    if not archivos:
        print("[!] No hay archivos en 'manuales/'. Colección 'manuales_sap' creada vacía.")
        return

    contador = 0
    for archivo in archivos:
        ruta = os.path.join(CARPETA_MANUALES, archivo)
        print(f"[+] Indexando: {archivo}")
        if archivo.lower().endswith(".pdf"):
            contador = procesar_pdf(ruta, archivo, coleccion, contador)
        elif archivo.lower().endswith(".docx"):
            contador = procesar_docx(ruta, archivo, coleccion, contador)
        elif archivo.lower().endswith(".txt"):
            contador = procesar_txt(ruta, archivo, coleccion, contador)

    print(f"\n[✓] Indexación completada. Total de fragmentos indexados: {contador}")

if __name__ == "__main__":
    ejecutar_indexacion()

🤖 5. Paso 3: El Bot Asistente Principal (bot_spark.py)

Este es el script central que se conecta a Openfire como un usuario normal de Spark.

¿Qué hace exactamente cuando alguien le habla?

  1. Acuse de recibo en 0.1 segundos:
    • Si mandan texto: ⏳ *Déjeme checarlo.* Un momento mientras consulto la información de SAP B1...
    • Si mandan foto: ⏳ *Recibí tu captura, déjeme checarla.* Estoy analizando la pantalla y consultando los manuales de SAP...
  2. Streaming por párrafos: Va leyendo los tokens de Ollama y, en cuanto detecta un párrafo completo (\n\n) o una frase larga de más de 80 caracteres con un punto final, la dispara al chat de Spark. El usuario ve cómo van llegando los bloques de texto uno tras otro en tiempo real.
  3. Flujo visual inteligente: Cuando llega una imagen, el modelo qwen2.5vl:7b analiza la captura, extrae el texto del error y la última línea genera un término de búsqueda (BUSCAR_EN_RAG: <error>). Con ese término exacto, el bot va a ChromaDB, rescata la solución oficial del manual y redacta los pasos de resolución.
  4. Retroalimentación al vuelo: Al terminar la explicación, el bot remata:
    • 📌 *¿Te sirvió esta respuesta? Responde con SÍ o NO.*
    • Si el usuario contesta “Sí”, “Muchas gracias”, “Me sirvió”, el bot actualiza SQLite con feedback = 'UTIL'.
    • Si el usuario escribe “No”, “Está mal” o incluso “No la tabla es tal”, el bot marca feedback = 'NO_UTIL' para no incluir esa respuesta en su aprendizaje nocturno.

📁 Ruta: ~/sap_bot/bot_spark.py

Python

#!/usr/bin/env python3
"""
Bot Asistente SAP B1 para Spark IM (XMPP + Ollama + ChromaDB + SQLite).
Flujo dual con acuse inmediato, visión multimodal, streaming por párrafos,
doble RAG (manuales + autoaprendizaje) y retroalimentación interactiva (SÍ/NO).
"""
import os
import re
import ssl
import asyncio
import sqlite3
import tempfile
from datetime import datetime
import requests
import urllib3
import slixmpp
import chromadb
import ollama

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# ================= CONFIGURACIÓN DEL SERVIDOR =================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, "chroma_sap_db")
DB_LOGS_PATH = os.path.join(BASE_DIR, "historial_conversaciones.db")

# Datos de conexión Openfire / Spark (Modifica con tus credenciales)
JID_BOT = "BothJulio@192.168.2.150"
PASSWORD_BOT = "BothJulio"
HOST_SERVIDOR = "192.168.2.150"
PUERTO_XMPP = 5222

# Servidor HTTP para servir capturas extraídas de manuales
URL_BASE_IMGS = f"http://{HOST_SERVIDOR}:8080"

# Modelos en Ollama
MODELO_LLM = "qwen2.5:7b"          # Razonamiento técnico y redacción
MODELO_VISION = "qwen2.5vl:7b"      # OCR y diagnóstico visual de capturas
MODELO_EMBED = "nomic-embed-text"  # Vectorización
# ==============================================================

REGEX_URL_IMAGEN = r'(https?://[^\s]+\.(?:png|jpg|jpeg|bmp|webp)(?:\?[^\s]*)?|https?://[^\s]+/httpfileupload/[^\s]+)'

def inicializar_bd_logs():
    """Crea la tabla de SQLite con soporte WAL y columnas de retroalimentación."""
    with sqlite3.connect(DB_LOGS_PATH, timeout=10.0) as conn:
        cursor = conn.cursor()
        cursor.execute("PRAGMA journal_mode=WAL;")
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS conversaciones (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                fecha_hora TEXT,
                usuario_spark TEXT,
                pregunta TEXT,
                tipo_fuente TEXT,
                respuesta TEXT,
                feedback TEXT DEFAULT NULL,
                indexado INTEGER DEFAULT 0
            )
        """)
        try:
            cursor.execute("ALTER TABLE conversaciones ADD COLUMN feedback TEXT DEFAULT NULL")
        except sqlite3.OperationalError:
            pass
        try:
            cursor.execute("ALTER TABLE conversaciones ADD COLUMN indexado INTEGER DEFAULT 0")
        except sqlite3.OperationalError:
            pass
        conn.commit()

def registrar_log(usuario: str, pregunta: str, tipo_fuente: str, respuesta: str) -> int:
    """Inserta la conversación en SQLite y devuelve el ID generado."""
    try:
        with sqlite3.connect(DB_LOGS_PATH, timeout=10.0) as conn:
            cursor = conn.cursor()
            cursor.execute("""
                INSERT INTO conversaciones (fecha_hora, usuario_spark, pregunta, tipo_fuente, respuesta)
                VALUES (?, ?, ?, ?, ?)
            """, (
                datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                usuario,
                pregunta,
                tipo_fuente,
                respuesta
            ))
            conn.commit()
            return cursor.lastrowid
    except Exception as err:
        print(f"[-] Error registrando log SQLite: {err}")
        return 0

def actualizar_feedback(id_conversacion: int, valor: str):
    """Actualiza la calificación (UTIL / NO_UTIL) de una respuesta específica."""
    try:
        with sqlite3.connect(DB_LOGS_PATH, timeout=10.0) as conn:
            cursor = conn.cursor()
            cursor.execute("UPDATE conversaciones SET feedback = ? WHERE id = ?", (valor, id_conversacion))
            conn.commit()
    except Exception as err:
        print(f"[-] Error guardando feedback: {err}")

class SparkAIBot(slixmpp.ClientXMPP):
    def __init__(self, jid: str, password: str):
        super().__init__(jid, password)
        self.add_event_handler("session_start", self.on_start)
        self.add_event_handler("message", self.on_message)

        # Aceptar certificados SSL locales o autofirmados de Openfire
        self.ssl_context = ssl.create_default_context()
        self.ssl_context.check_hostname = False
        self.ssl_context.verify_mode = ssl.CERT_NONE

        # Cargar las dos colecciones independientes de ChromaDB
        self.chroma_client = chromadb.PersistentClient(path=DB_PATH)
        self.col_manuales = self.chroma_client.get_or_create_collection(name="manuales_sap")
        self.col_casos = self.chroma_client.get_or_create_collection(name="casos_resueltos")

        # Diccionario para asociar cada usuario con su última respuesta pendiente de feedback
        self.pendientes_feedback = {}

    async def on_start(self, event):
        self.send_presence(pstatus="Asistente SAP B1 En Línea")
        await self.get_roster()
        print(f"\n[✓] Asistente conectado a Openfire como: {self.boundjid.bare}")
        asyncio.create_task(self.precargar_modelos())

    async def precargar_modelos(self):
        """Warm-up: Precarga los modelos en memoria al iniciar para eliminar el cold-start."""
        print("[*] Precargando modelos en memoria (Warm-up)...")
        try:
            await asyncio.to_thread(
                ollama.embeddings,
                model=MODELO_EMBED,
                prompt="arranque",
                keep_alive=-1
            )
            await asyncio.to_thread(
                ollama.chat,
                model=MODELO_LLM,
                messages=[{"role": "user", "content": "hola"}],
                options={"num_predict": 1},
                keep_alive=-1
            )
            print("[✓] Modelos fijados en memoria. Respuestas inmediatas activas.")
        except Exception as e:
            print(f"[!] Advertencia al precargar: {e}")

    def notificar_escribiendo(self, destinatario: str):
        try:
            msg = self.make_message(mto=destinatario, mtype='chat')
            msg['chat_state'] = 'composing'
            msg.send()
        except Exception:
            pass

    def notificar_inactivo(self, destinatario: str):
        try:
            msg = self.make_message(mto=destinatario, mtype='chat')
            msg['chat_state'] = 'active'
            msg.send()
        except Exception:
            pass

    def enviar_mensaje_directo(self, destinatario: str, texto: str):
        if not texto.strip():
            return
        try:
            msg = self.make_message(mto=destinatario, mbody=texto.strip(), mtype='chat')
            msg.send()
        except Exception as e:
            print(f"[-] Error enviando mensaje a Spark: {e}")

    async def mantener_escribiendo(self, destinatario: str):
        """Mantiene activo el estado 'Escribiendo...' cada 4 segundos mientras el modelo procesa."""
        try:
            while True:
                self.notificar_escribiendo(destinatario)
                await asyncio.sleep(4)
        except asyncio.CancelledError:
            pass

    def extraer_url_captura(self, msg) -> str | None:
        """Detecta capturas subidas a través de HTTP File Upload o pegadas como URL."""
        try:
            url_oob = msg['oob']['url']
            if url_oob:
                return url_oob
        except Exception:
            pass

        texto = msg["body"].strip()
        coincidencias = re.findall(REGEX_URL_IMAGEN, texto, re.IGNORECASE)
        if coincidencias:
            return coincidencias[0]

        return None

    def descargar_captura_temp(self, url: str) -> str | None:
        """Descarga la captura de Openfire a un archivo temporal local."""
        try:
            res = requests.get(url, timeout=12, verify=False)
            if res.status_code == 200:
                tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
                tmp.write(res.content)
                tmp.close()
                return tmp.name
        except Exception as e:
            print(f"[-] Error descargando captura: {e}")
        return None

    async def on_message(self, msg):
        if msg["type"] not in ("chat", "normal"):
            return

        cuerpo = msg["body"].strip()
        remitente = msg["from"].bare
        url_imagen = self.extraer_url_captura(msg)

        if not cuerpo and not url_imagen:
            return

        # -----------------------------------------------------------------
        # 1. EVALUAR FEEDBACK (SÍ / NO) DE LA RESPUESTA ANTERIOR
        # -----------------------------------------------------------------
        cuerpo_limpio = cuerpo.lower().strip()
        id_anterior = self.pendientes_feedback.get(remitente)

        if id_anterior and not url_imagen:
            respuestas_si = ("si", "sí", "yes", "sirvio", "sirvió", "me sirvio", "me sirvió", "gracias", "funciono", "funcionó", "excelente", "ok", "listo", "correcto", "exacto")
            respuestas_no = ("no", "nel", "no sirvio", "no sirvió", "no me sirvio", "no funciono", "no funcionó", "tampoco", "incorrecto", "mal", "falso")

            if cuerpo_limpio in respuestas_si:
                actualizar_feedback(id_anterior, "UTIL")
                del self.pendientes_feedback[remitente]
                msg.reply("✅ *¡Excelente!* He guardado esta solución para recordarla y ayudar a otros compañeros en el futuro.").send()
                return

            elif cuerpo_limpio in respuestas_no or cuerpo_limpio.startswith("no ") or cuerpo_limpio.startswith("no,"):
                actualizar_feedback(id_anterior, "NO_UTIL")
                del self.pendientes_feedback[remitente]
                msg.reply("❌ *Entendido.* He marcado la respuesta anterior como incorrecta para que no vuelva a sugerirse. Gracias por corregirme.").send()
                return

        # -----------------------------------------------------------------
        # 2. PROCESAMIENTO DE CONSULTA NORMAL (CON ACUSE DE RECIBO)
        # -----------------------------------------------------------------
        print(f"[+] Consulta de [{remitente}] | Tiene imagen: {bool(url_imagen)}")

        # Acuse de recibo instantáneo (< 0.2 seg)
        if url_imagen:
            self.enviar_mensaje_directo(
                remitente,
                "⏳ *Recibí tu captura, déjeme checarla.* Estoy analizando la pantalla y consultando los manuales de SAP..."
            )
        else:
            self.enviar_mensaje_directo(
                remitente,
                "⏳ *Déjeme checarlo.* Un momento mientras consulto la información de SAP B1..."
            )

        tarea_escribiendo = asyncio.create_task(self.mantener_escribiendo(remitente))
        loop = asyncio.get_running_loop()

        def callback_envio(parrafo: str):
            loop.call_soon_threadsafe(self.enviar_mensaje_directo, remitente, parrafo)

        try:
            id_conversacion = await asyncio.to_thread(
                self.procesar_consulta_router, remitente, cuerpo, url_imagen, callback_envio
            )
            # Solicitar feedback interactivo si se generó una respuesta
            if id_conversacion:
                self.pendientes_feedback[remitente] = id_conversacion
                await asyncio.sleep(1)
                self.enviar_mensaje_directo(
                    remitente,
                    "📌 *¿Te sirvió esta respuesta? Responde con SÍ o NO.*"
                )
        finally:
            tarea_escribiendo.cancel()
            self.notificar_inactivo(remitente)

    def procesar_consulta_router(self, remitente: str, pregunta: str, url_imagen: str | None, callback_envio) -> int:
        if url_imagen:
            return self.procesar_con_captura(remitente, pregunta, url_imagen, callback_envio)
        else:
            return self.procesar_solo_texto(remitente, pregunta, callback_envio)

    def transmitir_en_parrafos(self, generador_stream, callback_envio) -> str:
        """Agrupa tokens y los despacha a Spark por bloques lógicos y párrafos."""
        buffer = ""
        texto_acumulado = ""
        primer_envio_hecho = False

        for chunk in generador_stream:
            delta = chunk.get("message", {}).get("content", "")
            buffer += delta
            texto_acumulado += delta

            # Despacho rápido de la primera frase para romper la latencia percibida
            if not primer_envio_hecho and len(buffer) > 80:
                if "\n" in buffer or ". " in buffer:
                    separador = "\n" if "\n" in buffer else ". "
                    parte, buffer = buffer.split(separador, 1)
                    if separador == ". ":
                        parte += "."
                    if parte.strip():
                        callback_envio(parte.strip())
                        primer_envio_hecho = True

            while "\n\n" in buffer:
                parrafo, buffer = buffer.split("\n\n", 1)
                parrafo = parrafo.strip()
                if parrafo:
                    callback_envio(parrafo)
                    primer_envio_hecho = True

        if buffer.strip():
            callback_envio(buffer.strip())

        return texto_acumulado.strip()

    def consultar_rag_hibrido(self, texto_busqueda: str) -> tuple[str, list[str], list[str]]:
        """Consulta simultáneamente la documentación oficial y los casos aprendidos."""
        fragmentos = []
        referencias = []
        imagenes = []

        res_emb = ollama.embeddings(model=MODELO_EMBED, prompt=texto_busqueda, keep_alive=-1)
        vector = res_emb["embedding"]

        # 1. Buscar en manuales oficiales
        total_manuales = self.col_manuales.count()
        if total_manuales > 0:
            k = min(3, total_manuales)
            coincidencias = self.col_manuales.query(query_embeddings=[vector], n_results=k)
            if coincidencias and coincidencias["documents"] and coincidencias["documents"][0]:
                for doc_txt, meta in zip(coincidencias["documents"][0], coincidencias["metadatas"][0]):
                    fragmentos.append(f"[DOCUMENTO OFICIAL: {meta.get('archivo', 'Manual')}]:\n{doc_txt}")
                    referencias.append(f"{meta.get('archivo', 'Manual')} (Pág. {meta.get('pagina', 'N/A')})")
                    imgs_str = meta.get("imagenes", "")
                    if imgs_str:
                        for img in imgs_str.split(","):
                            if img.strip():
                                imagenes.append(img.strip())

        # 2. Buscar en casos resueltos por usuarios (Autoaprendizaje)
        total_casos = self.col_casos.count()
        if total_casos > 0:
            k_casos = min(2, total_casos)
            coincidencias_casos = self.col_casos.query(query_embeddings=[vector], n_results=k_casos)
            if coincidencias_casos and coincidencias_casos["documents"] and coincidencias_casos["documents"][0]:
                for doc_txt, meta in zip(coincidencias_casos["documents"][0], coincidencias_casos["metadatas"][0]):
                    fragmentos.append(f"[CASO RESUELTO PREVIAMENTE ({meta.get('pagina', '')})]:\n{doc_txt}")
                    referencias.append(f"Experiencia interna ({meta.get('pagina', '')})")

        contexto = "\n---\n".join(fragmentos) if fragmentos else "NO_HAY_MANUALES_DISPONIBLES"
        return contexto, referencias, imagenes

    # ==============================================================
    # FLUJO 1: CONSULTAS DE TEXTO
    # ==============================================================
    def procesar_solo_texto(self, remitente: str, pregunta: str, callback_envio) -> int:
        try:
            contexto, referencias, imagenes_adjuntas = self.consultar_rag_hibrido(pregunta)

            prompt_sistema = (
                "Eres un consultor senior experto en SAP Business One de la empresa.\n"
                "Tu objetivo es resolver dudas operativas, funcionales y técnicas sobre SAP B1, SQL/HANA y flujos del sistema.\n\n"
                "REGLAS ESTRICTAS DE TABLAS EN SAP B1:\n"
                "- Las tablas de cabecera SIEMPRE inician con 'O' seguida de 3 letras (ej: OPOR para Pedidos de Compra, ORDR para Pedidos de Venta, OINV para Facturas de Venta, OPCH para Facturas de Compra, ODLN para Entregas, OPDN para Entradas de Mercancía).\n"
                "- Las tablas de líneas pierden la 'O' y terminan en 1 (ej: POR1, RDR1, INV1, PCH1, DLN1, PDN1).\n"
                "- NUNCA inventes nombres de tablas como PCHDR, PCTLR, DRFD o términos en inglés inventados. Si no conoces la tabla con certeza o no está en la documentación, dilo honestamente sin inventar.\n\n"
                "REGLAS GENERALES:\n"
                "1. Si la DOCUMENTACIÓN CONOCIDA contiene la respuesta, responde con base en ella con total precisión.\n"
                "2. Si la DOCUMENTACIÓN CONOCIDA indica 'NO_HAY_MANUALES_DISPONIBLES' o no contiene la respuesta, responde usando tu conocimiento estándar de SAP B1, pero inicia con:\n"
                "   '⚠️ *Nota: Esta respuesta se basa en el estándar general de SAP B1 y no en un manual interno documentado.*'\n"
                "3. Si la pregunta no tiene relación con temas de trabajo o sistemas de la empresa, recházala amablemente.\n\n"
                f"DOCUMENTACIÓN CONOCIDA (OFICIAL Y CASOS RESUELTOS):\n{contexto}"
            )

            stream_chat = ollama.chat(
                model=MODELO_LLM,
                messages=[
                    {"role": "system", "content": prompt_sistema},
                    {"role": "user", "content": pregunta}
                ],
                options={"temperature": 0.15, "num_ctx": 2048},
                keep_alive=-1,
                stream=True
            )

            cuerpo = self.transmitir_en_parrafos(stream_chat, callback_envio)

            cierre = ""
            if "⚠️ *Nota:" in cuerpo:
                tipo_fuente = "ESTANDAR_SAP"
            elif referencias and contexto != "NO_HAY_MANUALES_DISPONIBLES":
                tipo_fuente = "MANUAL_O_CASO"
                refs_unicas = list(dict.fromkeys(referencias))
                cierre += f"\n📖 *Fuente de consulta:* {', '.join(refs_unicas)}"

                if imagenes_adjuntas:
                    imgs_unicas = list(dict.fromkeys(imagenes_adjuntas))[:2]
                    cierre += "\n📸 *Capturas del manual:*"
                    for img_name in imgs_unicas:
                        cierre += f"\n• {URL_BASE_IMGS}/{img_name}"
            else:
                tipo_fuente = "GENERAL_O_RECHAZO"

            if cierre:
                callback_envio(cierre.strip())
                cuerpo += "\n\n" + cierre

            return registrar_log(remitente, pregunta, tipo_fuente, cuerpo)

        except Exception as err:
            error_msg = f"Error al procesar la consulta: {str(err)}"
            callback_envio(error_msg)
            registrar_log(remitente, pregunta, "ERROR", error_msg)
            return 0

    # ==============================================================
    # FLUJO 2: CONSULTAS CON CAPTURA DE PANTALLA (MULTIMODAL)
    # ==============================================================
    def procesar_con_captura(self, remitente: str, texto_usuario: str, url_imagen: str, callback_envio) -> int:
        ruta_temp = None
        try:
            ruta_temp = self.descargar_captura_temp(url_imagen)
            if not ruta_temp:
                callback_envio("No fue posible descargar la captura de pantalla para analizarla. Intenta reenviarla.")
                return 0

            # Paso A: El modelo de visión extrae el error de la imagen
            prompt_extraccion = (
                "Eres un especialista en soporte técnico de SAP Business One.\n"
                "Examina con atención esta captura de pantalla:\n"
                "1. Localiza el mensaje de error o bloqueo. Revisa la barra de estado inferior (texto en rojo), "
                "ventanas modales de alerta o mensajes en cuadrícula.\n"
                "2. Transcribe el texto exacto del error y cualquier código numérico (ej: [-10], 131-183, etc.).\n"
                "3. En la última línea de tu respuesta escribe exactamente:\n"
                "   BUSCAR_EN_RAG: <código o términos clave del error>"
            )
            if texto_usuario:
                prompt_extraccion += f"\nMensaje adjunto del usuario: {texto_usuario}"

            res_vision = ollama.chat(
                model=MODELO_VISION,
                messages=[{
                    "role": "user",
                    "content": prompt_extraccion,
                    "images": [ruta_temp]
                }],
                options={"temperature": 0.1},
                keep_alive=-1
            )

            diagnostico_visual = res_vision["message"]["content"].strip()

            termino_rag = ""
            for linea in diagnostico_visual.splitlines():
                if "BUSCAR_EN_RAG:" in linea:
                    termino_rag = linea.replace("BUSCAR_EN_RAG:", "").strip()

            if not termino_rag:
                termino_rag = diagnostico_visual[:150]

            # Paso B: Buscar en RAG con los términos extraídos de la pantalla
            consulta_combinada = f"{texto_usuario} {termino_rag}".strip()
            contexto, referencias, _ = self.consultar_rag_hibrido(consulta_combinada)

            # Paso C: Redacción de la solución estructurada
            prompt_solucion = (
                "Eres un consultor senior de soporte SAP Business One.\n"
                "Tu trabajo es explicar el error que ves en el análisis de la pantalla y dar la solución inmediata.\n\n"
                "REGLAS DE TABLAS EN SAP B1:\n"
                "- Si mencionas tablas, respeta estrictamente los prefijos estándar (OPOR/POR1, ORDR/RDR1, OINV/INV1, OPCH/PCH1, etc.). No inventes nombres.\n\n"
                "ESTRUCTURA OBLIGATORIA (Usa saltos de línea dobles entre secciones):\n"
                "🛑 *Error Detectado*: Mensaje o código de error presente en la captura.\n\n"
                "🔍 *Causa Probable*: Razón del bloqueo (ej: período cerrado, falta de stock, autorizaciones, cuenta contable sin definir).\n\n"
                "🛠️ *Solución Recomendada*: Ruta de menú en SAP B1 y pasos exactos para solucionarlo.\n\n"
                "REGLAS:\n"
                "1. Si la DOCUMENTACIÓN CONOCIDA tiene la solución para este error, síguela al pie de la letra.\n"
                "2. Si no hay documentación interna para este caso, inicia la respuesta con:\n"
                "   '⚠️ *Nota: Esta respuesta se basa en el estándar general de SAP B1 y no en un manual interno documentado.*'\n\n"
                f"DOCUMENTACIÓN CONOCIDA:\n{contexto}"
            )

            contenido_user = f"Consulta del usuario: {texto_usuario if texto_usuario else 'Revisa el error de la captura adjunta.'}\n"
            contenido_user += f"\n[Lectura del error en la captura]:\n{diagnostico_visual}"

            stream_final = ollama.chat(
                model=MODELO_LLM,
                messages=[
                    {"role": "system", "content": prompt_solucion},
                    {"role": "user", "content": contenido_user}
                ],
                options={"temperature": 0.15, "num_ctx": 4096},
                keep_alive=-1,
                stream=True
            )

            cuerpo = self.transmitir_en_parrafos(stream_final, callback_envio)

            cierre = ""
            if "⚠️ *Nota:" in cuerpo:
                tipo_fuente = "ESTANDAR_SAP"
            elif referencias and contexto != "NO_HAY_MANUALES_DISPONIBLES":
                tipo_fuente = "MANUAL_O_CASO"
                refs_unicas = list(dict.fromkeys(referencias))
                cierre += f"\n📖 *Fuente de consulta:* {', '.join(refs_unicas)}"
            else:
                tipo_fuente = "ERROR_VISUAL"

            if cierre:
                callback_envio(cierre)
                cuerpo += "\n\n" + cierre

            return registrar_log(remitente, f"[CAPTURA] {texto_usuario}", tipo_fuente, cuerpo)

        except Exception as err:
            error_msg = f"Error al analizar la captura: {str(err)}"
            callback_envio(error_msg)
            registrar_log(remitente, texto_usuario, "ERROR", error_msg)
            return 0
        finally:
            if ruta_temp and os.path.exists(ruta_temp):
                try:
                    os.remove(ruta_temp)
                except Exception:
                    pass

def main():
    inicializar_bd_logs()
    bot = SparkAIBot(JID_BOT, PASSWORD_BOT)
    bot.register_plugin("xep_0030")  # Service Discovery
    bot.register_plugin("xep_0199")  # XMPP Ping
    bot.register_plugin("xep_0085")  # Estado 'Escribiendo...' (Chat States)
    bot.register_plugin("xep_0066")  # Recepción de URLs Out-of-Band (Capturas)

    print(f"[*] Conectando a Openfire en {HOST_SERVIDOR}:{PUERTO_XMPP}...")
    bot.connect(host=HOST_SERVIDOR, port=PUERTO_XMPP)

    loop = getattr(bot, "loop", None) or asyncio.get_event_loop()
    try:
        loop.run_forever()
    except KeyboardInterrupt:
        bot.disconnect()

if __name__ == "__main__":
    main()

🌙 6. Paso 4: El Ciclo de Autoaprendizaje Nocturno (retroalimentar_diario.py)

Hacer un fine-tuning diario de un LLM es impensable: necesitarías servidores con 4 tarjetas gráficas A100 y horas de cómputo. La forma en que las grandes empresas resuelven el aprendizaje continuo en producción es a través de memoria episódica indexada.

Cada noche a las 2:00 AM, este script se ejecuta mediante una tarea Cron en Ubuntu:

  1. Conecta con SQLite y extrae todos los casos donde el usuario respondió afirmativamente (feedback = 'UTIL') y que tengan indexado = 0.
  2. Estructura el problema y la solución en un formato limpio de “Caso Resuelto”.
  3. Genera los vectores con nomic-embed-text y los inyecta en la colección casos_resueltos.
  4. Marca el registro como indexado = 1 para no duplicarlo jamás.

Al día siguiente, cuando un compañero de otro departamento tenga un problema idéntico o similar, el bot buscará en ChromaDB, encontrará la solución que funcionó ayer y responderá:

📖 Fuente de consulta: Experiencia interna (Caso #42).

📁 Ruta: ~/sap_bot/retroalimentar_diario.py

Python

#!/usr/bin/env python3
"""
Script diario de autoaprendizaje para el Asistente SAP B1.
Toma los casos validados con 'SÍ' en SQLite y los agrega a 'casos_resueltos'.
"""
import os
import sqlite3
import chromadb
import ollama

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, "chroma_sap_db")
DB_LOGS_PATH = os.path.join(BASE_DIR, "historial_conversaciones.db")
MODELO_EMBED = "nomic-embed-text"

def ejecutar_retroalimentacion():
    print("[*] Iniciando proceso de retroalimentación diaria...")

    if not os.path.exists(DB_LOGS_PATH):
        print("[!] No existe la base de datos de logs aún.")
        return

    # 1. Obtener casos validados como útiles que no han sido indexados
    with sqlite3.connect(DB_LOGS_PATH, timeout=10.0) as conn:
        cursor = conn.cursor()
        cursor.execute("""
            SELECT id, fecha_hora, usuario_spark, pregunta, respuesta
            FROM conversaciones
            WHERE feedback = 'UTIL' AND indexado = 0
        """)
        casos = cursor.fetchall()

    if not casos:
        print("[✓] No hay nuevos casos validados pendientes por indexar.")
        return

    print(f"[+] Se encontraron {len(casos)} casos exitosos validados por usuarios.")

    # 2. Conectar a la colección independiente de casos resueltos
    chroma_client = chromadb.PersistentClient(path=DB_PATH)
    col_casos = chroma_client.get_or_create_collection(name="casos_resueltos")

    ids_actualizados = []

    for id_conv, fecha, usuario, pregunta, respuesta in casos:
        try:
            texto_caso = (
                f"CASO REAL RESUELTO ({fecha}):\n"
                f"Problema / Error reportado: {pregunta}\n"
                f"Solución comprobada que funcionó:\n{respuesta}"
            )

            res = ollama.embeddings(model=MODELO_EMBED, prompt=texto_caso, keep_alive=-1)
            chunk_id = f"caso_aprendido_{id_conv}"

            col_casos.add(
                ids=[chunk_id],
                embeddings=[res["embedding"]],
                documents=[texto_caso],
                metadatas=[{
                    "archivo": "Casos Resueltos en Vivo",
                    "pagina": f"Caso #{id_conv}",
                    "usuario": usuario,
                    "tipo": "auto_aprendizaje"
                }]
            )
            ids_actualizados.append(id_conv)
            print(f" [✓] Caso #{id_conv} indexado exitosamente.")
        except Exception as e:
            print(f" [-] Error indexando caso #{id_conv}: {e}")

    # 3. Marcar como indexados en SQLite
    if ids_actualizados:
        with sqlite3.connect(DB_LOGS_PATH, timeout=10.0) as conn:
            cursor = conn.cursor()
            cursor.execute(f"""
                UPDATE conversaciones 
                SET indexado = 1 
                WHERE id IN ({','.join('?' for _ in ids_actualizados)})
            """, ids_actualizados)
            conn.commit()

    print(f"[✓] Proceso completado. Se integraron {len(ids_actualizados)} nuevas soluciones a ChromaDB.\n")

if __name__ == "__main__":
    ejecutar_retroalimentacion()

⚙️ 7. Paso 5: Puesta en Producción en Ubuntu Server

Para que todo esto funcione de forma permanente sin necesidad de dejar una terminal abierta con python3 bot_spark.py, lo configuramos como un servicio nativo del sistema con systemd y programamos el Cron.

1. Dar permisos de ejecución a los scripts

Bash

chmod +x ~/sap_bot/indexar_manuales.py
chmod +x ~/sap_bot/bot_spark.py
chmod +x ~/sap_bot/retroalimentar_diario.py

2. Indexar por primera vez

Asegúrate de haber copiado tus manuales (PDF, Word) y el archivo tablas_sap_business_one.txt a la carpeta manuales/ y ejecuta:

Bash

python3 ~/sap_bot/indexar_manuales.py

Verás cómo procesa los documentos, ejecuta OCR donde hace falta y te reporta el número total de fragmentos vectorizados.

3. Programar el Cron nocturno

Abre el editor de cron en tu usuario de Ubuntu:

Bash

crontab -e

Agrega esta línea al final del archivo para que la retroalimentación se ejecute a las 2:00 AM todos los días:

Bash

0 2 * * * /usr/bin/python3 /home/ubuntu/sap_bot/retroalimentar_diario.py >> /home/ubuntu/sap_bot/retroalimentacion.log 2>&1

4. Crear el servicio systemd (sap_bot.service)

Crea el archivo de servicio para que Linux inicie el bot tras cualquier reinicio del servidor y lo reviva automáticamente si se cae:

Bash

sudo nano /etc/systemd/system/sap_bot.service

Pega la siguiente configuración:

Ini, TOML

[Unit]
Description=Asistente IA para SAP Business One en Spark/XMPP
After=network.target ollama.service

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/sap_bot
ExecStart=/usr/bin/python3 /home/ubuntu/sap_bot/bot_spark.py
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target

Habilita y arranca el servicio:

Bash

sudo systemctl daemon-reload
sudo systemctl enable sap_bot
sudo systemctl start sap_bot

Para verificar que el bot está conectado y operando en tiempo real:

Bash

sudo journalctl -u sap_bot -f

Deberías ver la línea que confirma la conexión:

Plaintext

[✓] Asistente conectado a Openfire como: bothjulio@192.168.0.176
[*] Precargando modelos en memoria (Warm-up)...
[✓] Modelos fijados en memoria. Respuestas inmediatas activas.

💬 8. La Prueba de Fuego: Una conversación real en Spark

Así se ve una interacción real en la ventana de chat de un usuario cualquiera en la empresa:

Usuario (10:15 AM):

¿Cuál es la tabla de los pedidos de compra en SAP?

Bot (10:15 AM – 0.1s después):

⏳ Déjeme checarlo. Un momento mientras consulto la información de SAP B1…

Bot (10:15 AM – 3s después – Streaming):

En SAP Business One, los Pedidos de Compra (también conocidos como Órdenes de Compra a Proveedor) se gestionan en las siguientes tablas:

• OPOR: Tabla de encabezado / cabecera (almacena proveedor, fechas, totales, moneda).

• POR1: Tabla de detalle / líneas (almacena artículos, cantidades, precios unitarios, almacén).

📖 Fuente de consulta: tablas_sap_business_one.txt (Sección 1)

Bot (10:15 AM):

📌 ¿Te sirvió esta respuesta? Responde con SÍ o NO.

Usuario (10:16 AM):

Sí muchas gracias

Bot (10:16 AM):

✅ ¡Excelente! He guardado esta solución para recordarla y ayudar a otros compañeros en el futuro.

Y cuando un usuario le arrastra una captura de pantalla donde aparece la temida barra roja con el error:

[-10] La cuenta de mayor para costos de ventas no se ha definido:

Bot (10:20 AM):

⏳ Recibí tu captura, déjeme checarla. Estoy analizando la pantalla y consultando los manuales de SAP…

Bot (10:20 AM – Streaming):

🛑 Error Detectado: [-10] La cuenta de mayor para costos de ventas no se ha definido.

🔍 Causa Probable: El artículo que intentas mover o facturar está configurado para determinar cuentas contables a nivel de Almacén o Grupo de Artículos, pero falta asignar la cuenta contable de Costo de Ventas en las definiciones contables correspondientes.

🛠️ Solución Recomendada:

1. Ve al menú principal de SAP B1: Gestión > Definiciones > Finanzas > Determinación de cuentas de mayor.

2. Dirígete a la pestaña ‘Inventario’.

3. Revisa la sub-pestaña ‘General’ y localiza la fila ‘Coste de mercancías vendidas’ (Cost of Goods Sold).

4. Asigna la cuenta contable de costo correspondiente y presiona el botón ‘Actualizar’.

📖 Fuente de consulta: MANUAL_DE_CONFIGURACION_CONTABLE.pdf (Pág. 14)

📌 ¿Te sirvió esta respuesta? Responde con SÍ o NO.

🎯 Conclusiones del Proyecto

Desplegar Inteligencia Artificial en el mundo corporativo real no consiste en conectar la API más cara del mercado para presumir que usas IA; consiste en resolver problemas reales de soporte reduciendo la fricción para los usuarios y blindando la seguridad de los datos.

Al combinar herramientas de código abierto probadas como Openfire/Spark, la versatilidad de Slixmpp, la potencia local de Ollama (Qwen 2.5 y Qwen 2.5-VL) y una arquitectura de RAG híbrido con autoaprendizaje en ChromaDB, logramos:

  • 📉 Reducir en más de un 70% los tickets repetitivos de soporte de primer nivel en SAP Business One.
  • 🛡️ Mantener la información fiscal, operativa y contable 100% dentro de la red corporativa.
  • ⚡ Dar respuestas en segundos a cualquier hora del día.
  • 📈 Conseguir un sistema que, gracias a la retroalimentación directa de los usuarios con un simple “SÍ”, se vuelve un poco más inteligente cada noche a las dos de la mañana.

Si tienes un servidor Linux libre en tu empresa y la gente sigue perdiendo tiempo preguntando qué tabla usar o qué significa un error de SAP, ya tienes la solución completa lista para implementar. 🚀💻

🚀 Guía Definitiva: Convierte las entradas de tu blog de WordPress en PDFs profesionales, limpios y a color con Python

Entrada fija

¡Hola a todos! 👋 Si alguna vez has querido descargar todas las entradas de tu blog de WordPress para tener un respaldo local impecable, leer tus artículos sin conexión, o compartirlos de manera elegante como documentos independientes, estás en el lugar correcto.

Hoy te traigo una herramienta completa en Python diseñada desde cero para automatizar este proceso. Este script no solo extrae el contenido de tu API de WordPress, sino que soluciona los problemas más comunes al generar PDFs: coloca la imagen de portada arriba del todo, incrusta las imágenes de forma segura para evitar bloqueos del servidor, y aplica un resaltado de sintaxis a color increíble en tus bloques de código.

✨ Características Principales

  • 📸 Portada Inteligente con Doble Rescate: Busca automáticamente la imagen destacada oficial de WordPress (incluso consultando directamente el endpoint de medios si la API la oculta). Si un post no tiene imagen destacada, toma inteligentemente la primera imagen del cuerpo del artículo y la coloca arriba del título.
  • 🔒 Incrustación Base64 contra Bloqueos: Descarga las imágenes utilizando cabeceras personalizadas y las convierte a formato data:image/...;base64. Esto evita que firewalls, Cloudflare o configuraciones de red locales bloqueen las peticiones internas de WeasyPrint.
  • 🎨 Resaltado de Código Automático (Pygments): Analiza los bloques de código (<pre> y <code>), detecta automáticamente el lenguaje de programación (Python, Bash, PHP, JavaScript, etc.) o lo adivina de forma inteligente, aplicando un esquema de colores profesional (friendly) adaptado perfectamente para impresión.
  • 📂 Nombres de Archivos Seguros: Limpia títulos largos, elimina emojis y caracteres especiales convirtiéndolos en slugs limpios organizados por fecha (YYYY-MM-DD_nombre-del-post.pdf).

🛠️ Requisitos del Sistema y Entorno Virtual

Debido a las políticas de seguridad de las distribuciones modernas de Linux (que protegen el gestor global de paquetes de Python mediante PEP 668), lo más limpio, profesional y recomendado es trabajar dentro de un entorno virtual (venv).

1. Instalar dependencias del sistema operativo

Primero, asegúrate de tener instaladas las librerías tipográficas y de renderizado (necesarias para que WeasyPrint procese fuentes y emojis correctamente):

  • En Arch Linux / CachyOS:Bashsudo pacman -S pango noto-fonts-emoji ttf-liberation
  • En Linux Mint / Ubuntu:Bashsudo apt install libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-color-emoji

2. Configurar el Entorno Virtual de Python

Abre tu terminal en la carpeta donde quieras trabajar y ejecuta los siguientes comandos:

Bash

# 1. Crear el entorno virtual llamado 'venv'
python3 -m venv venv

# 2. Activar el entorno virtual
source venv/bin/activate

(Verás que tu terminal cambia para mostrar (venv) al inicio, indicando que estás dentro del entorno aislado).

3. Instalar las librerías de Python

Con el entorno activo, instala las dependencias necesarias:

Bash

pip install requests beautifulsoup4 weasyprint pygments

💻 El Script Completo (blog_a_pdf.py)

Crea un archivo llamado blog_a_pdf.py, cópiale el siguiente contenido y guárdalo en tu directorio de trabajo:

Python

#!/usr/bin/env python3
"""
Exporta cada entrada de un blog WordPress (de un año dado) a un PDF individual
con portada arriba, incrustación Base64 y resaltado de sintaxis a color.

Uso:
    python3 blog_a_pdf.py              # año actual
    python3 blog_a_pdf.py --year 2026
    python3 blog_a_pdf.py --year 2026 --out ./pdfs

Dependencias (dentro de tu venv):
    pip install requests beautifulsoup4 weasyprint pygments
"""
import argparse
import base64
import html
import re
import time
import unicodedata
from datetime import datetime
from pathlib import Path
from urllib.parse import unquote, urljoin

import requests
from bs4 import BeautifulSoup
from weasyprint import HTML

from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound

# CONFIGURACIÓN DE TU SITIO WEB
SITE = "https://cesarsystems.com.mx"
API = f"{SITE}/wp-json/wp/v2/posts"
HEADERS = {"User-Agent": "Mozilla/5.0 (blog-a-pdf)"}

# Generar automáticamente los estilos CSS de colores para Pygments (Tema: friendly)
PYGMENTS_CSS = HtmlFormatter(style="friendly").get_style_defs('.highlight')

CSS = f"""
@page {{
    size: Letter;
    margin: 2cm 1.8cm;
    @bottom-center {{ content: counter(page) " / " counter(pages); font-size: 9pt; color: #777; }}
}}
body {{ font-family: "Liberation Sans", "DejaVu Sans", "Noto Color Emoji", sans-serif;
       font-size: 10.5pt; line-height: 1.5; color: #222; }}
h1 {{ font-size: 20pt; margin: 0.4em 0 .2em; color: #12355b; }}
h2 {{ font-size: 15pt; margin-top: 1.4em; color: #12355b; border-bottom: 1px solid #ccd; padding-bottom: 2px; }}
h3 {{ font-size: 12.5pt; margin-top: 1.2em; }}
.meta {{ color: #666; font-size: 9pt; margin-bottom: 1.2em; }}
.meta a {{ color: #666; word-break: break-all; }}
img {{ max-width: 100%; height: auto; }}
img.portada {{ display: block; width: 100%; max-height: 9cm; object-fit: cover;
              border-radius: 4px; margin: 0 0 1.2em; }}
pre {{ background: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 12px;
      font-size: 8.5pt; white-space: pre-wrap; word-wrap: break-word; page-break-inside: auto; }}
code {{ font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; font-size: 9pt; }}
p code, li code {{ background: #eef0f3; padding: 2px 5px; border-radius: 4px; color: #d63384; }}
table {{ border-collapse: collapse; width: 100%; margin: 1em 0; }}
th, td {{ border: 1px solid #bbb; padding: 4px 6px; font-size: 9.5pt; vertical-align: top; }}
th {{ background: #eef0f3; }}
blockquote {{ border-left: 3px solid #99a; margin-left: 0; padding-left: 10px; color: #444; }}
a {{ color: #1a5fb4; }}

/* Estilos de resaltado de código Pygments */
{PYGMENTS_CSS}
.highlight pre {{ background: transparent; border: none; padding: 0; margin: 0; }}
"""

PYGMENTS_FORMATTER = HtmlFormatter(cssclass="highlight")


def slugify(texto: str, max_len: int = 70) -> str:
    """Genera nombres de archivos seguros sin acentos, espacios ni caracteres extraños."""
    texto = unicodedata.normalize("NFKD", texto).encode("ascii", "ignore").decode()
    texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
    return texto[:max_len].strip("-")


def obtener_posts(year: int) -> list[dict]:
    """Descarga de forma paginada todas las entradas publicadas en el año indicado."""
    posts, page = [], 1
    while True:
        params = {
            "after": f"{year}-01-01T00:00:00",
            "before": f"{year + 1}-01-01T00:00:00",
            "per_page": 100,
            "page": page,
            "orderby": "date",
            "order": "asc",
            "_embed": "wp:featuredmedia",
            "_fields": "id,date,slug,link,title,content,featured_media,_embedded",
        }
        r = requests.get(API, params=params, headers=HEADERS, timeout=60)
        if r.status_code == 400:  # Fin de paginación
            break
        r.raise_for_status()
        lote = r.json()
        if not lote:
            break
        posts.extend(lote)
        total_pages = int(r.headers.get("X-WP-TotalPages", 1))
        if page >= total_pages:
            break
        page += 1
    return posts


def limpiar_contenido(html_contenido: str) -> str:
    """Limpia etiquetas innecesarias, corrige lazy-loads y aplica colores al código."""
    soup = BeautifulSoup(html_contenido, "html.parser")

    for tag in soup(["script", "style", "iframe", "noscript", "form"]):
        tag.decompose()

    for img in soup.find_all("img"):
        for attr in ("data-src", "data-lazy-src", "data-orig-file"):
            if img.get(attr):
                img["src"] = img[attr]
                break
        for attr in ("srcset", "sizes", "loading"):
            img.attrs.pop(attr, None)

    # Procesar bloques de código con Pygments
    for pre in soup.find_all("pre"):
        code_tag = pre.find("code")
        code_text = code_tag.get_text() if code_tag else pre.get_text()
        
        lang = None
        classes = pre.get("class", []) + (code_tag.get("class", []) if code_tag else [])
        for c in classes:
            if c.startswith("language-") or c.startswith("brush:") or c.startswith("lang-"):
                lang = c.replace("language-", "").replace("brush:", "").replace("lang-", "").strip()
        
        lexer = None
        if lang:
            try:
                lexer = get_lexer_by_name(lang)
            except ClassNotFound:
                pass
        
        if not lexer:
            try:
                lexer = guess_lexer(code_text)
            except Exception:
                lexer = TextLexer()
        
        highlighted_code = highlight(code_text, lexer, PYGMENTS_FORMATTER)
        new_tag = BeautifulSoup(highlighted_code, "html.parser")
        pre.clear()
        pre.append(new_tag)

    return str(soup)


def obtener_url_portada(post: dict) -> str | None:
    """Rescata la imagen destacada oficial (vía embebidos o API de medios) o usa la del contenido."""
    media_id = post.get("featured_media", 0)

    if media_id > 0:
        # 1. Intentar desde datos embebidos
        try:
            media = post["_embedded"]["wp:featuredmedia"][0]
            if isinstance(media, dict):
                sizes = media.get("media_details", {}).get("sizes", {})
                for nombre in ("large", "full"):
                    if nombre in sizes and sizes[nombre].get("source_url"):
                        return sizes[nombre]["source_url"]
                if media.get("source_url"):
                    return media["source_url"]
        except (KeyError, IndexError, TypeError):
            pass

        # 2. Consultar directamente el endpoint de medios si lo anterior falló
        try:
            r_media = requests.get(f"{SITE}/wp-json/wp/v2/media/{media_id}", headers=HEADERS, timeout=10, verify=False)
            if r_media.status_code == 200:
                data = r_media.json()
                sizes = data.get("media_details", {}).get("sizes", {})
                for nombre in ("large", "full"):
                    if nombre in sizes and sizes[nombre].get("source_url"):
                        return sizes[nombre]["source_url"]
                if data.get("source_url"):
                    return data["source_url"]
        except Exception:
            pass

    # 3. Fallback: primera imagen dentro del cuerpo del texto
    soup = BeautifulSoup(post["content"]["rendered"], "html.parser")
    img_tag = soup.find("img")
    if img_tag:
        for attr in ("src", "data-src", "data-lazy-src", "data-orig-file"):
            url = img_tag.get(attr)
            if url:
                return urljoin(SITE, url)

    return None


def url_a_base64(url: str) -> str | None:
    """Descarga la imagen remota y la convierte a Data URI en Base64."""
    try:
        r = requests.get(url, headers=HEADERS, timeout=15, verify=False)
        if r.status_code == 200:
            content_type = r.headers.get("Content-Type", "image/jpeg")
            encoded = base64.b64encode(r.content).decode("utf-8")
            return f"data:{content_type};base64,{encoded}"
    except Exception:
        pass
    return None


def armar_html(post: dict) -> str:
    """Ensambla el HTML final colocando la portada arriba, seguida del título, metadatos y contenido."""
    titulo = html.unescape(post["title"]["rendered"])
    fecha = datetime.fromisoformat(post["date"]).strftime("%d/%m/%Y")
    cuerpo = limpiar_contenido(post["content"]["rendered"])
    
    url_img = obtener_url_portada(post)
    portada = ""
    if url_img:
        img_b64 = url_a_base64(url_img)
        if img_b64:
            portada = f'<img class="portada" src="{img_b64}">'

    return f"""<!DOCTYPE html>
<html lang="es"><head><meta charset="utf-8"><title>{html.escape(titulo)}</title></head>
<body>
{portada}
<h1>{html.escape(titulo)}</h1>
<div class="meta">{fecha} &middot; <a href="{post['link']}">{unquote(post['link'])}</a></div>
{cuerpo}
</body></html>"""


def main():
    import urllib3
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

    ap = argparse.ArgumentParser()
    ap.add_argument("--year", type=int, default=datetime.now().year)
    ap.add_argument("--out", default="pdfs_blog")
    args = ap.parse_args()

    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)

    print(f"[*] Descargando entradas de {args.year} desde {SITE} ...")
    posts = obtener_posts(args.year)
    print(f"[✓] {len(posts)} entradas encontradas.\n")

    for i, post in enumerate(posts, 1):
        titulo = html.unescape(post["title"]["rendered"])
        nombre = f"{post['date'][:10]}_{slugify(titulo) or post['id']}.pdf"
        destino = out / nombre

        if destino.exists():
            print(f"[{i}/{len(posts)}] Ya existe, se omite: {nombre}")
            continue

        print(f"[{i}/{len(posts)}] Procesando: {nombre}")
        try:
            HTML(string=armar_html(post), base_url=SITE).write_pdf(
                destino, stylesheets=[__import__("weasyprint").CSS(string=CSS)]
            )
        except Exception as err:
            print(f"    [-] Error al generar PDF: {err}")
        time.sleep(0.5)

    print(f"\n[✓] ¡Listo! Todos los PDFs se guardaron en: {out.resolve()}")


if __name__ == "__main__":
    main()

🕹️ Guía de Uso del Script

Una vez que tengas configurado tu entorno virtual y tu archivo listo, puedes ejecutar el script con diferentes opciones según tus necesidades:

  • Exportar las entradas del año actual por defecto:Bashpython3 blog_a_pdf.py
  • Exportar un año en específico (ejemplo, año 2026):Bashpython3 blog_a_pdf.py --year 2026
  • Guardar los PDFs en una carpeta personalizada:Bashpython3 blog_a_pdf.py --year 2026 --out ./mis_respaldos_pdf

Cuando termines de trabajar, simplemente recuerda desactivar tu entorno virtual escribiendo:

Bash

deactivate

Cómo instalar y compilar obs-face-tracker en Linux (Arch / CachyOS) para OBS 30+ y Qt6

Entrada fija

Tener un encuadre dinámico que siga tus movimientos o haga zoom al rostro automáticamente al hablar aporta dinamismo a cualquier transmisión o grabación. En Windows existen herramientas privativas de control PTZ virtual, pero en distribuciones basadas en Arch Linux (como CachyOS, Manjaro o EndeavourOS) el estándar abierto es el plugin obs-face-tracker.

Al compilar este complemento en sistemas actualizados con GCC moderno, OBS 30+ y Qt6, surgen dos problemas críticos: advertencias tratadas como errores en la librería interna dlib, y un fallo de segmentación (SIGSEGV) al iniciar OBS debido a llamadas incompatibles con la interfaz gráfica de docks.

En esta guía resolveremos ambos errores compilando, parcheando e instalando el plugin desde el código fuente, optimizado y probado en entornos Wayland con procesadores AMD Ryzen.

1. Requisitos previos y dependencias

Instala las herramientas de compilación, las cabeceras de desarrollo de OBS Studio y la librería de visión artificial OpenCV:

Bash

sudo pacman -S --needed base-devel cmake git opencv obs-studio

Verifica que OpenCV esté accesible en tu terminal:

Bash

opencv_version

2. Clonar el repositorio con submódulos

El proyecto requiere submódulos internos (como dlib y librerías VISCA). Clónalo con el parámetro --recursive:

Bash

cd ~
git clone --recursive https://github.com/norihiro/obs-face-tracker.git
cd obs-face-tracker

3. Parchear el fallo de segmentación (SIGSEGV en Qt6)

El plugin incluye código heredado para crear un Dock (panel flotante para cámaras PTZ por hardware). En versiones modernas de OBS que corren sobre Qt6, la llamada ft_docks_init() provoca un cierre inmediato (SIGSEGV) de OBS al iniciar.

Como solo necesitamos el filtro de seguimiento facial en la cámara, desactivamos la inicialización del dock haciendo que el módulo reporte una carga exitosa directa:

Bash

# Modificar src/module-main.c para omitir la carga del dock problemático
sed -i 's/ft_docks_init();/return true;/g' src/module-main.c
sed -i 's/ft_docks_destroy();/\/\/ ft_docks_destroy();/g' src/module-main.c

Para comprobar que el cambio se aplicó correctamente, ejecuta:

Bash

git diff src/module-main.c

Deberás ver que return ft_docks_init(); fue sustituido por return true;.

4. Configurar CMake y compilar sin bloqueos de advertencias

Por defecto, el compilador en CachyOS trata cualquier variable no usada dentro de dlib como un error fatal (-Werror). Pasamos el parámetro -w tanto para C como para C++ en CMake para silenciar advertencias en código de terceros y compilar con todos los hilos del CPU:

Bash

# Configuración del proyecto
cmake -B build -S . \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_INSTALL_PREFIX=/usr \
  -DCMAKE_C_FLAGS="-w" \
  -DCMAKE_CXX_FLAGS="-w"

# Compilación paralela
cmake --build build -j$(nproc)

La terminal debe finalizar con:

Plaintext

[100%] Built target obs-face-tracker

5. Instalación manual en las rutas estándar de OBS

El instalador por defecto de este repositorio coloca archivos en rutas obsoletas (/usr/obs-plugins/). Para que OBS Studio reconozca el plugin en distribuciones basadas en Arch, copia los binarios y datos a las rutas del sistema:

Bash

# Copiar la librería compartida
sudo cp build/obs-face-tracker.so /usr/lib/obs-plugins/

# Crear la carpeta de datos y copiar las traducciones
sudo mkdir -p /usr/share/obs/obs-plugins/obs-face-tracker/locale
sudo cp -r data/locale/* /usr/share/obs/obs-plugins/obs-face-tracker/locale/

Comprueba que el archivo exista:

Bash

ls -lh /usr/lib/obs-plugins/obs-face-tracker.so

6. Configuración del filtro en OBS Studio

Inicia OBS Studio (puedes lanzarlo desde la terminal con el comando obs para verificar que no emita advertencias):

  1. Añadir el filtro:
    • En tu escena, localiza la fuente de tu cámara web (Dispositivo de captura de video (V4L2)).
    • Haz clic derecho sobre ella y selecciona Filtros.
    • En la sección inferior izquierda (Filtros de efecto), presiona el botón + y elige Face Tracker.
  2. Calibrar los parámetros:
    • Zoom / Scale: Ajusta el nivel de acercamiento deseado (un valor moderado permite reencuadrar cabeza y hombros sin perder nitidez).
    • Smoothing (Suavizado): Configúralo entre 400 ms y 600 ms. Un valor bajo causará micro-temblores con cada parpadeo; un valor muy alto creará retraso al moverte.
    • Deadzone (Zona muerta): Establécelo entre 10% y 15% para que pequeños cambios de postura no desplacen el lienzo innecesariamente.

7. Configurar un atajo de teclado para activar el zoom

Para usar el acercamiento como un efecto dinámico durante transmisiones o videos explicativos:

  1. Ve a Ajustes > Atajos.
  2. Escribe el nombre de tu cámara en el buscador.
  3. Localiza los campos:
    • Mostrar filtro ‘Face Tracker’
    • Ocultar filtro ‘Face Tracker’
  4. Asigna la misma tecla rápida (por ejemplo, F9 o una combinación de tu teclado/stream deck) para alternar el zoom a demanda.

Ficha técnica y metadatos para WordPress

  • Título SEO: Cómo compilar obs-face-tracker en Arch Linux / CachyOS (Solución SIGSEGV y Qt6)
  • Slug sugerido: instalar-obs-face-tracker-arch-linux-cachyos
  • Categorías: Linux, Streaming & OBS, Software Libre
  • Etiquetas: OBS Studio, CachyOS, Arch Linux, Face Tracking, OpenCV, AMD Ryzen
  • Extracto: Guía paso a paso para compilar y hacer funcionar el plugin obs-face-tracker en Linux moderno, resolviendo los errores de compilación con GCC y el cierre por SIGSEGV en entornos Qt6 y Wayland.

🚨 ¿Te están cobrando seguros fantasma en tu tarjeta? La verdad sobre las Fintech y cómo hackear tus fechas de pago 💳⚡

Entrada fija

¿Te ha pasado que tramitas una tarjeta “sin anualidad y con cashback jugoso”, pero de repente tu saldo no cuadra? 🤔 Bienvenido al club de los débitos silenciosos.

Mientras las apps nos deslumbran con colores pastel y trámites en 3 minutos, detrás de escena algunas aplican patrones oscuros (dark patterns) para colar seguros, coberturas y membresías automáticas (como el famoso Plata Protege o Plata+ en Plata Card, o las asistencias “de cortesía” de la banca tradicional) 💸👻.

🕵️‍♂️ La trampa maestra: ¿Por qué tu celular nunca vibra?

Seguro piensas: “Si me cobraran algo raro, me llegaría una notificación push al instante”. Falso. ❌

  • 🛒 Cuando compras en el súper: La transacción viaja por Visa o Mastercard y la app te manda un aviso en milisegundos.
  • 📋 Cuando te cobran un seguro o comisión: Es un asiento contable interno del propio banco. No pasa por la red de pagos externa, así que no detona alertas en tu teléfono.
  • 📄 El truco legal: Por ley, solo están obligados a plasmarlo en tu estado de cuenta en PDF. Si eres de los que nunca descarga el PDF mensual… felicidades, llevas meses pagando la fiesta del banco sin enterarte 🥂.

🗓️ El Hack definitivo: Cómo estirar tus compras hasta 50+ días sin pagar intereses 🧠💡

Si vas a usar tarjetas de crédito, que el financiamiento trabaje para ti, no al revés. La matemática de calendario es simple:

  • 🔴 Comprar el día de corte (o 1 día antes): Pésima idea. Entra en el corte inmediato y tienes apenas 20 días para liquidar.
  • 🟢 Comprar el día posterior al corte (Corte + 1): ¡Modo genio activado! 🚀 Tu compra no entra en el ciclo actual, sino en el siguiente. Ganas los 30 días del nuevo ciclo + los 20 días de gracia. ¡Hasta 50 o 55 días de financiamiento libre de intereses!

💻 Nuestra solución: Un Auditor Forense con interfaz Cyberpunk en Python 🦾👾

Para no perder tiempo abriendo PDFs aburridos ni arriesgar contraseñas bancarias en apps sospechosas, armamos una herramienta de escritorio local en Python con estética retro neón y motor dual:

┌─────────────────────────────────────────────────────────┐
│     NEO-FINANCE // AUDITOR FORENSE & OPTIMIZADOR        │
│       Arrastra y suelta tus PDFs (con o sin RFC)       │
└────────────────────────────┬────────────────────────────┘
                             │
            ┌────────────────┴────────────────┐
            ▼                                 ▼
   🤖 IA Local (Ollama)              ⚡ Motor Mecánico
   Qwen 2.5 (14B / 7B)              Expresiones Regulares
   Razonamiento semántico           Cero consumo de CPU/GPU

¿Qué hace este script? 🛠️

  1. 📂 Arrastrar y soltar (Drag & Drop): Arrastras de golpe los PDFs de Banorte, Santander, Citibanamex, Nu, Stori o Plata Card directo al programa.
  2. 🔓 Desencriptado inteligente: Si el banco le metió candado con tu RFC, la terminal abre un portal retro seguro, lo descifra en memoria y recuerda la clave para el lote.
  3. 🎯 Semáforo diario de tarjetas: Te dice exactamente cuál tarjeta te da más días libres de pago hoy (marcando en neón cian el plástico ganador y en rojo alerta las que están por cortar).
  4. 🚨 Visor forense de cargos fantasma: Escanea renglón por renglón y separa las compras reales de cobros indebidos (anualidades no avisadas, seguros y membresías), dejando fuera los intereses habituales.
  5. 🌐 100% privado y offline: Tus datos no tocan ningún servidor externo; todo se procesa en tu propia máquina.

🛡️ Checklist de supervivencia financiera (¡Aplícalo hoy!)

  • [ ] Entra a tu app bancaria y apaga cualquier interruptor de asistencia vial, seguro de vida o protección de saldo.
  • [ ] Descarga religiosamente tus estados de cuenta cada mes y busca la palabra “Comisiones”.
  • [ ] Si ves cobros no autorizados, mete aclaración ante la UNE del banco: por ley tienes hasta 90 días naturales para exigir tu reembolso completo.
  • [ ] Programa recordatorios en tu calendario para comprar solo a partir de tu Corte + 1.

🚀 De 1 Video Largo a 15 Shorts Virales: Mi Pipeline 100% Local con Python, Whisper y Qwen 2.5

Entrada fija

¿Terminaste de grabar un tutorial técnico de más de una hora y te da pereza abrir Kdenlive o DaVinci Resolve para sacar clips? 😫✂️

Editar clips verticales manualmente para YouTube Shorts o TikTok consume horas de trabajo repetitivo: buscar momentos interesantes, recortar silencios, reencuadrar a formato vertical 9:16, desenfocar el fondo y subtitular palabra por palabra. Las herramientas SaaS en la nube como OpusClip cobran suscripciones mensuales recurrentes y suben tus grabaciones privadas a servidores externos. 💸🔒

La alternativa definitiva es montar tu propio pipeline automatizado de código abierto que corre al 100% en local. Con un solo script en Python, el sistema:

  1. Detecta si tu grabación se dividió en dos partes y las une sin pérdida de calidad.
  2. Analiza los canales de audio y aísla la pista limpia de tu micrófono (pista 2 de OBS).
  3. Elimina pausas muertas con auto-editor.
  4. Transcribe todo el audio con faster-whisper.
  5. Envía la transcripción completa a qwen2.5:14b vía Ollama para extraer de 10 a 15 ganchos virales en JSON estricto.
  6. Renderiza en lote los videos en formato vertical (9:16), con fondo desenfocado, título superior, subtítulos amarillos y metadatos incrustados.

📦 Requisitos previos en el sistema

Antes de ejecutar el script en tu distribución Linux, asegúrate de tener las dependencias listas:

Bash

# Herramientas base y modelos
sudo pacman -S ffmpeg   # En CachyOS / Arch (o apt install ffmpeg en Ubuntu/Debian)
pip install faster-whisper auto-editor

# Descarga del modelo en Ollama
ollama pull qwen2.5:14b

💻 El Código Completo (pipeline_ia_shorts.py)

Guarda este script en la carpeta donde tengas tus grabaciones (video.mp4 o video2.mp4):

Python

#!/usr/bin/env python3
import json
import os
import re
import subprocess
import urllib.request
from faster_whisper import WhisperModel

# --- CONFIGURACIÓN ---
VIDEO_BASE = "video.mp4" if os.path.exists("video.mp4") else "1.mp4"
VIDEO_EXTRA = "video2.mp4" if os.path.exists("video2.mp4") else ("2.mp4" if os.path.exists("2.mp4") else None)
VIDEO_PREPARED = "unido.mp4"
VIDEO_CLEAN = "video_ALTERED.mp4"
SRT_OUTPUT = "video_ALTERED.srt"
OUT_DIR = "/home/julioc/Automatizaciones/shorts/global/tutoriales"
OLLAMA_MODEL = "qwen2.5:14b"
OLLAMA_URL = "http://localhost:11434/api/generate"

os.makedirs(OUT_DIR, exist_ok=True)

# 0. DETECCIÓN DE PISTAS DE AUDIO Y UNIÓN DE VIDEOS
print("=" * 60)
print("[0/4] Verificando pistas de audio y videos a unir...")
print("=" * 60)

def count_audio_streams(file_path):
    try:
        cmd = [
            "ffprobe", "-v", "error", "-select_streams", "a",
            "-show_entries", "stream=index", "-of", "csv=p=0", file_path
        ]
        out = subprocess.check_output(cmd).decode().strip()
        return len(out.splitlines()) if out else 0
    except Exception:
        return 1

num_audio = count_audio_streams(VIDEO_BASE)
audio_map = "0:a:1" if num_audio >= 2 else "0:a:0"
print(f"[*] Pistas de audio detectadas: {num_audio} -> Seleccionada: {audio_map} ({'Micrófono pista 2' if audio_map == '0:a:1' else 'Pista principal'})")

if not os.path.exists(VIDEO_PREPARED) and not os.path.exists(VIDEO_CLEAN):
    if VIDEO_EXTRA and os.path.exists(VIDEO_EXTRA):
        print(f"[*] Detectado video adicional: {VIDEO_EXTRA}. Uniendo archivos...")
        with open("lista_unir.txt", "w", encoding="utf-8") as f:
            f.write(f"file '{VIDEO_BASE}'\nfile '{VIDEO_EXTRA}'\n")
        
        cmd_concat = [
            "ffmpeg", "-y", "-f", "concat", "-safe", "0",
            "-i", "lista_unir.txt",
            "-map", "0:v:0", "-map", audio_map,
            "-c", "copy", VIDEO_PREPARED
        ]
        subprocess.run(cmd_concat, check=True)
        os.remove("lista_unir.txt")
    else:
        print(f"[*] Procesando solo {VIDEO_BASE} con audio {audio_map}...")
        cmd_extract = [
            "ffmpeg", "-y", "-i", VIDEO_BASE,
            "-map", "0:v:0", "-map", audio_map,
            "-c", "copy", VIDEO_PREPARED
        ]
        subprocess.run(cmd_extract, check=True)
else:
    print(f"[!] Reutilizando archivo preparado existente...")

# 1. CORTE DE SILENCIOS CON AUTO-EDITOR
print("\n" + "=" * 60)
print("[1/4] Cortando silencios (>5s) con auto-editor...")
print("=" * 60)

input_for_autoeditor = VIDEO_PREPARED if os.path.exists(VIDEO_PREPARED) else VIDEO_BASE
if not os.path.exists(VIDEO_CLEAN):
    cmd_autoeditor = [
        "auto-editor", input_for_autoeditor,
        "--edit", "audio:threshold=4%,mincut=5s",
        "-o", VIDEO_CLEAN
    ]
    subprocess.run(cmd_autoeditor, check=True)
else:
    print(f"[!] Archivo {VIDEO_CLEAN} ya existe, reutilizando...")

# 2. TRANSCRIPCIÓN CON FASTER-WHISPER
print("\n" + "=" * 60)
print("[2/4] Verificando / generando subtítulos...")
print("=" * 60)

srt_text_dump = []
if not os.path.exists(SRT_OUTPUT):
    whisper_model = WhisperModel("small", device="cpu", compute_type="int8")
    segments, _ = whisper_model.transcribe(VIDEO_CLEAN, language="es")

    srt_entries = []
    for idx, seg in enumerate(segments, 1):
        t_start = f"{int(seg.start//3600):02d}:{int((seg.start%3600)//60):02d}:{int(seg.start%60):02d},000"
        t_end = f"{int(seg.end//3600):02d}:{int((seg.end%3600)//60):02d}:{int(seg.end%60):02d},000"
        srt_entries.append(f"{idx}\n{t_start} --> {t_end}\n{seg.text.strip()}\n")
        
        clean_start = f"{int(seg.start//3600):02d}:{int((seg.start%3600)//60):02d}:{int(seg.start%60):02d}"
        srt_text_dump.append(f"[{clean_start}] {seg.text.strip()}")

    with open(SRT_OUTPUT, "w", encoding="utf-8") as f:
        f.write("\n".join(srt_entries))
else:
    print(f"[!] Usando {SRT_OUTPUT} existente...")
    with open(SRT_OUTPUT, "r", encoding="utf-8") as f:
        lines = f.readlines()
    for i, line in enumerate(lines):
        if "-->" in line and i + 1 < len(lines):
            t_raw = line.split("-->")[0].strip().split(",")[0]
            txt = lines[i+1].strip()
            if txt:
                srt_text_dump.append(f"[{t_raw}] {txt}")

# 3. EXTRACCIÓN MASIVA CON QWEN 2.5 (10 A 15 CLIPS)
print("\n" + "=" * 60)
print(f"[3/4] Extrayendo entre 10 y 15 momentos virales con {OLLAMA_MODEL}...")
print("=" * 60)

prompt_analysis = f"""Eres un editor profesional de contenido en formato vertical (Shorts/TikTok).
Analiza detalladamente toda la transcripción y extrae OBLIGATORIAMENTE entre 10 y 15 momentos clave.

INSTRUCCIONES CRÍTICAS:
1. Cuota obligatoria: Mínimo 10 clips, máximo 15 clips. NO entregues menos de 10.
2. Cada clip debe durar entre 30 y 75 segundos.
3. Cubre TODO el video: extrae clips del inicio, de la parte media y del final.
4. Formato de timestamps: "HH:MM:SS" (estricto).
5. Títulos: En MAYÚSCULAS, breves, sin dos puntos ni comillas.
6. Campo filename: alfanumérico con guiones bajos (ej. "01_TITULO", "02_TITULO").

Devuelve EXCLUSIVAMENTE el arreglo JSON:
[
  {{
    "start": "00:00:45",
    "end": "00:01:35",
    "filename": "01_TITULO_RESUMEN",
    "title": "TITULO EN MAYUSCULAS",
    "desc": "Descripcion corta para metadatos"
  }}
]

Transcripción:
{chr(10).join(srt_text_dump)}
"""

payload = {
    "model": OLLAMA_MODEL,
    "prompt": prompt_analysis,
    "stream": False,
    "format": "json",
    "options": {
        "num_ctx": 16384,
        "num_predict": 4096,
        "temperature": 0.2
    }
}

req = urllib.request.Request(
    OLLAMA_URL,
    data=json.dumps(payload).encode("utf-8"),
    headers={"Content-Type": "application/json"}
)

with urllib.request.urlopen(req) as resp:
    res_data = json.loads(resp.read().decode("utf-8"))
    raw_response = res_data.get("response", "[]").strip()

if raw_response.startswith("```"):
    raw_response = re.sub(r"^```(?:json)?\s*", "", raw_response)
    raw_response = re.sub(r"\s*```$", "", raw_response)

try:
    parsed = json.loads(raw_response)
    clips = parsed.get("clips", parsed) if isinstance(parsed, dict) else parsed
except Exception as err:
    print(f"[!] Error procesando JSON de Ollama: {err}")
    print("Respuesta recibida:\n", raw_response)
    exit(1)

print(f"[✓] La IA detectó con éxito {len(clips)} segmentos.")

# 4. RENDERIZADO VERTICAL CON FFMPEG
print("\n" + "=" * 60)
print(f"[4/4] Renderizando {len(clips)} shorts en {OUT_DIR}...")
print("=" * 60)

for idx, clip in enumerate(clips, 1):
    start = clip["start"]
    end = clip["end"]
    fname = re.sub(r'[^a-zA-Z0-9_-]', '_', clip["filename"])
    title = clip["title"].replace(":", "\\:").replace("'", "").replace("%", "%%")
    desc = clip.get("desc", title)
    out_file = os.path.join(OUT_DIR, f"{fname}.mp4")

    print(f"\n[>] Short {idx}/{len(clips)}: {clip['title']}")
    print(f"    {start} -> {end}")

    filter_complex = (
        "[0:v]split=2[bg][fg];"
        "[bg]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,avgblur=30[bg_b];"
        "[fg]scale=1080:-1[fg_s];"
        "[bg_b][fg_s]overlay=(W-w)/2:(H-h)/2,"
        f"drawtext=expansion=none:text='{title}':font='Liberation Sans\\:style=Bold':fontcolor=white:fontsize=38:bordercolor=black:borderw=4:shadowcolor=black@0.6:shadowx=2:shadowy=2:x=(w-text_w)/2:y=240,"
        f"subtitles='{SRT_OUTPUT}':force_style='PlayResX=1080,PlayResY=1920,FontSize=42,FontName=Liberation Sans,Bold=1,PrimaryColour=&H0000FFFF,OutlineColour=&H00000000,BackColour=&H80000000,BorderStyle=1,Outline=3,Shadow=2,Alignment=2,MarginV=180',"
        "setpts=PTS-STARTPTS[v]"
    )

    cmd_ffmpeg = [
        "ffmpeg", "-y",
        "-ss", start,
        "-to", end,
        "-copyts",
        "-i", VIDEO_CLEAN,
        "-filter_complex", filter_complex,
        "-map", "[v]",
        "-map", "0:a:0",
        "-af", "asetpts=PTS-STARTPTS",
        "-c:v", "libx264",
        "-preset", "veryfast",
        "-crf", "18",
        "-c:a", "aac",
        "-metadata", f"title={clip['title']}",
        "-metadata", f"description={desc}",
        "-metadata", "artist=Julio Cesar",
        "-metadata", "genre=Technology / Linux Tutorial",
        out_file
    ]
    subprocess.run(cmd_ffmpeg, check=True)

print("\n" + "=" * 60)
print(f"[✓] Proceso completado: se generaron {len(clips)} shorts.")
print(f"[✓] Ruta: {OUT_DIR}")
print("=" * 60)

🔍 Los 3 Trucos Técnicos que Hacen Funcionar este Script

  1. Auto-detección con ffprobe: Grabar con OBS suele generar la pista 1 (escritorio/audio del juego) y la pista 2 (micrófono). La función count_audio_streams() analiza los metadatos y asegura que Whisper y los videos procesen la voz limpia.
  2. Ventana de contexto de 16k y num_predict: 4096: Por defecto, Ollama utiliza ventanas reducidas que truncan la respuesta y provocan que solo se genere 1 clip. Ajustar estos dos parámetros permite al modelo leer transcripciones largas y devolver un archivo JSON con más de 10 elementos sin romperse.
  3. Filtro FFmpeg 9:16 con escape seguro: Los dos puntos (:) y porcentajes (%) rompen la sintaxis de drawtext. El script sanitiza automáticamente los títulos para que ningún carácter especial interrumpa el renderizado.

Ejecuta el script directamente en tu terminal:

Bash

python3 pipeline_ia_shorts.py

Al terminar, tendrás hasta 15 videos verticales listos en tu carpeta con títulos llamativos, subtítulos integrados y metadatos completos para subir a YouTube Shorts y TikTok sin pagar suscripciones. 🐧🚀

Página 1 de 15

Creado con WordPress & Tema de Anders Norén