jueves, 24 de septiembre de 2026

La aventura del compresor de ficheros de Firmadoc de Aytos (2). Versión definitiva

 0. Introducción

Esto parece una película. Primeramente busqué en Google "Algoritmo de Huffman en Visual Basic" y me hace referencia a esta URL de jrubi  que ha encontrado el algoritmo de Huffman realizado por James Vincent Carnicelli en agosto del 2000, y le pasé el código a Claude para que me lo convirtiera a Python.

Como Claude tenía ya muestras de ficheros originales y comprimidos, me contesta que el algoritmo casi daba resultados correctos pero que era una versión antigua, en vez de darle cabecera "HF3" daba "HF2", y me propone buscar el algoritmo más reciente en internet y le digo que si.

Entonces encuentra esta URL de post.bytes  que dice que es :"Es un hilo de 2005 (espejo de un antiguo grupo de noticias de Microsoft sobre VB.NET) donde un usuario llamado "Crouchie1998" responde a una pregunta pegando el código VB6 completo de la clase clsHuffman, atribuido originalmente a David Midkiff. Ese mismo mensaje también enlaza a dos copias del programa VB6 ya compilado (Planet Source Code y a1vbcode.com), aunque esos enlaces concretos ya no funcionan hoy en día — el texto del código en sí es lo que pude usar."

Y primeramente prueba el algoritmo en VB.NET sobre los ficheros que tenía y me suelta "BINGO!" y me genera el algoritmo en Python completamente funcional.

Con esto conseguimos no utilizar código que puede ser propiedad intelectual de Aytos como compress.dll (aunque el algoritmo subyacente no sea de su propiedad) y poder trabajar perfectamente en Linux para obtener estos ficheros y tener un histórico documental


1. Algoritmo de Huffman de compresión y decompresión

Veamos pues el preciado algoritmo pero teniendo en cuenta que:
  1. En la función main_edu() hay que darle la relació de ficheros a descomprimir
  2. Los nombres de ficheros serán del tipo "12345.EXT.bin" donde "12345" representa el campo IDX_ID de la tabla BIB_AY.dbo.IDX  que es numérico; "EXT" represnta la extensión por ejemplo "DOC", "PDF"  o la que sea, y "bin" es constante y significa que está comprido. Ejemplos son "345123.PDF.bin", "24512.DOC.bin"
  3. Al final genera el fichero con el mismo nombre al que se le ha añadido la extensiónb correspondiente, por ejemplo en los dos casos anteriores generaría "345123.PDF.bin.PDF", "24512.DOC.bin.DOC"

#!/usr/bin/env python3
"""
decode_he3.py
--------------
Codificador/decodificador puro-Python del formato "HE3" usado por
compress.dll (clase clsHuffman, de David Midkiff). Reimplementa
exactamente el algoritmo Huffman original (encontrado en su código
fuente VB6), sin depender de Windows, Wine ni la DLL original.

Verificado byte a byte contra ficheros reales: tanto DecodeFile como
EncodeFile producen un resultado IDÉNTICO al de la DLL original
(incluido el checksum interno del propio formato).

USO:
  Descomprimir un fichero:
      python3 decode_he3.py decode origen.bin destino.pdf

  Comprimir un fichero:
      python3 decode_he3.py encode origen.pdf destino.bin

  Toda una carpeta (descomprimir todos los *.bin):
      python3 decode_he3.py decode --batch carpeta_origen carpeta_destino

  Toda una carpeta (comprimir):
      python3 decode_he3.py encode --batch carpeta_origen carpeta_destino --ext .pdf --suffix .aytos

FORMATO DEL FICHERO (para referencia):
  offset 0-3   : magia "HE3" + CR (0x48 0x45 0x33 0x0D)
                 (si el 3er byte es '0'=0x30, el fichero no se llegó a
                  comprimir y el resto son los datos originales tal cual)
  offset 4     : checksum (XOR de todos los bytes originales)
  offset 5-8   : Long (LE32) = tamaño del fichero original
  offset 9-10  : Integer (LE16) = número de símbolos (valores de byte)
                 distintos usados en el fichero
  luego, por cada símbolo (en orden ascendente de valor de byte):
      1 byte  = valor del símbolo (0-255)
      1 byte  = longitud en bits de su código Huffman
  luego, en el mismo orden ascendente de símbolo:
      los propios patrones de bits de cada código, empaquetados LSB-first,
      concatenados y sin relleno entre símbolos (solo relleno al final,
      hasta completar el byte)
  a partir de ahí, alineado a byte: los datos reales comprimidos,
  empaquetados también LSB-first, decodificables recorriendo el árbol
  binario reconstruido (bit 0 = rama izquierda, bit 1 = rama derecha)
  hasta producir tantos bytes como indica el tamaño original.
"""

import argparse
import os
import struct
import sys


class HE3Error(Exception):
    pass


class _BitReader:
    """Lee bits LSB-first de un buffer, empezando en un byte concreto."""
    __slots__ = ('data', 'pos', 'bitpos')

    def __init__(self, data: bytes, start_byte: int = 0):
        self.data = data
        self.pos = start_byte
        self.bitpos = 0

    def read_bit(self) -> int:
        byte = self.data[self.pos]
        bit = (byte >> self.bitpos) & 1
        self.bitpos += 1
        if self.bitpos == 8:
            self.bitpos = 0
            self.pos += 1
        return bit

    def align_byte(self) -> None:
        if self.bitpos != 0:
            self.bitpos = 0
            self.pos += 1


class _Node:
    __slots__ = ('left', 'right', 'value')

    def __init__(self):
        self.left = None
        self.right = None
        self.value = -1


def decode_he3(data: bytes) -> bytes:
    """Decodifica el contenido binario (formato HE3) y devuelve los bytes
    originales. Lanza HE3Error si la cabecera o el checksum no son validos."""

    if len(data) < 4 or data[0] != 72 or data[1] != 69 or data[3] != 13:
        raise HE3Error("Cabecera invalida: no es un fichero HE0/HE3")

    if data[2] == 48:  # '0' -> el fichero se guardo sin comprimir
        return data[4:]

    if data[2] != 51:  # '3'
        raise HE3Error(f"Version de formato no soportada (byte2={data[2]})")

    checksum = data[4]
    orig_size = struct.unpack('<I', data[5:9])[0]
    if orig_size == 0:
        return b''

    count = struct.unpack('<H', data[9:11])[0]

    pos = 11
    symbols = []
    lengths = {}
    for _ in range(count):
        sym = data[pos]; pos += 1
        length = data[pos]; pos += 1
        symbols.append(sym)
        lengths[sym] = length

    # Leer los patrones de bits de cada codigo (orden raiz->hoja)
    br = _BitReader(data, pos)
    codes = {}
    for sym in symbols:
        bits = [br.read_bit() for _ in range(lengths[sym])]
        codes[sym] = bits
    br.align_byte()

    # Reconstruir el arbol binario: 0 = izquierda, 1 = derecha
    root = _Node()
    for sym, bits in codes.items():
        node = root
        for b in bits:
            if b == 0:
                if node.left is None:
                    node.left = _Node()
                node = node.left
            else:
                if node.right is None:
                    node.right = _Node()
                node = node.right
        node.value = sym

    # Decodificar el resto del fichero recorriendo el arbol
    out = bytearray()
    node = root
    for byte in data[br.pos:]:
        for bitpos in range(8):
            bit = (byte >> bitpos) & 1
            node = node.right if bit else node.left
            if node is None:
                raise HE3Error("Arbol Huffman corrupto durante la decodificacion")
            if node.value > -1:
                out.append(node.value)
                if len(out) == orig_size:
                    chk = 0
                    for b in out:
                        chk ^= b
                    if chk != checksum:
                        raise HE3Error(
                            f"Checksum no coincide (esperado {checksum}, "
                            f"calculado {chk}) - el fichero podria estar corrupto"
                        )
                    return bytes(out)
                node = root
    raise HE3Error("Fin del fichero antes de completar la decodificacion")


def encode_he3(data: bytes) -> bytes:
    """Comprime bytes al formato HE3. Devuelve el mismo resultado, byte a
    byte, que produciria clsHuffman.EncodeFile()."""
    byte_len = len(data)

    if byte_len == 0:
        return bytes([72, 69, 48, 13])  # "HE0" + CR, fichero vacio

    counts = [0] * 256
    for b in data:
        counts[b] += 1

    class _ENode:
        __slots__ = ('weight', 'left', 'right', 'parent', 'value')

        def __init__(self, weight, value=-1, left=-1, right=-1):
            self.weight = weight
            self.value = value
            self.left = left
            self.right = right
            self.parent = -1

    nodes = [_ENode(counts[i], value=i) for i in range(256) if counts[i] > 0]
    nodes_count = len(nodes)
    nodes.extend([None] * (nodes_count - 1))  # espacio para nodos internos

    n = nodes_count
    while n > 1:
        lnode1 = lnode2 = -1
        lweight1 = lweight2 = 0
        # Replica exacta del algoritmo O(n^2) de "dos mas pequenos" del
        # codigo VB original, incluido su criterio de desempate.
        for i, node in enumerate(nodes):
            if node is None or node.parent != -1:
                continue
            if lnode1 == -1:
                lweight1 = node.weight; lnode1 = i
            elif lnode2 == -1:
                lweight2 = node.weight; lnode2 = i
            elif node.weight < lweight1:
                if node.weight < lweight2:
                    if lweight1 < lweight2:
                        lweight2 = node.weight; lnode2 = i
                    else:
                        lweight1 = node.weight; lnode1 = i
                else:
                    lweight1 = node.weight; lnode1 = i
            elif node.weight < lweight2:
                lweight2 = node.weight; lnode2 = i

        new_index = next(i for i, node in enumerate(nodes) if node is None)
        nodes[new_index] = _ENode(lweight1 + lweight2, left=lnode1, right=lnode2)
        nodes[lnode1].parent = new_index
        nodes[lnode2].parent = new_index
        n -= 1

    root_index = next(i for i, node in enumerate(nodes) if node is not None and node.parent == -1)

    codes = {}

    def walk(idx, bits):
        node = nodes[idx]
        if node.value > -1:
            codes[node.value] = bits
            return
        if node.left != -1:
            walk(node.left, bits + [0])
        if node.right != -1:
            walk(node.right, bits + [1])

    walk(root_index, [])
    used_symbols = sorted(codes.keys())

    total_bits = sum(len(codes[i]) * counts[i] for i in used_symbols)
    l_length = (total_bits + 7) // 8

    if l_length == 0 or l_length > byte_len:
        return bytes([72, 69, 48, 13]) + data  # fallback: sin comprimir

    checksum = 0
    for b in data:
        checksum ^= b

    out = bytearray()
    out += bytes([72, 69, 51, 13])
    out.append(checksum)
    out += struct.pack('<I', byte_len)
    out += struct.pack('<H', len(used_symbols))
    for sym in used_symbols:
        out.append(sym)
        out.append(len(codes[sym]))

    bitbuf = 0
    bitcount = 0

    def push_bit(bit):
        nonlocal bitbuf, bitcount
        if bit:
            bitbuf |= (1 << bitcount)
        bitcount += 1
        if bitcount == 8:
            out.append(bitbuf)
            bitbuf = 0
            bitcount = 0

    for sym in used_symbols:
        for bit in codes[sym]:
            push_bit(bit)
    if bitcount > 0:
        out.append(bitbuf)
        bitbuf = 0
        bitcount = 0

    for b in data:
        for bit in codes[b]:
            push_bit(bit)
    if bitcount > 0:
        out.append(bitbuf)

    return bytes(out)


def process_file(src: str, dst: str, mode: str) -> None:
    with open(src, 'rb') as f:
        data = f.read()
    result = decode_he3(data) if mode == 'decode' else encode_he3(data)
    out_dir = os.path.dirname(os.path.abspath(dst))
    if out_dir:
        os.makedirs(out_dir, exist_ok=True)
    with open(dst, 'wb') as f:
        f.write(result)


def main_edu() -> None:
    files=[
        "/home/user/kk/DOCUMENTS_AYTOS/238476.PDF.bin",
        "/home/user/kk/DOCUMENTS_AYTOS/233831.DOC.bin",
    ]
    fail=0
    ok=0
    for fname in files:
        parts=fname.split(".")
        f_out=fname + "." + parts[1]
        try:
            process_file(fname, f_out, "decode")
            print(f"[OK]    {fname} -> {os.path.basename(f_out)}")
            ok += 1
        except Exception as exc:
            print(f"[ERROR] {fname}: {exc}")
            fail += 1
    print(f"\nHecho. {ok} correctos, {fail} con error.")
    

if __name__ == "__main__":
    main_edu()

2. Descargar los ficheros desde BD a disco


Veamos ahora el módulo completo para descargar los ficheros descomprimidos o descifrados a disco

Aquí ya no escribimos el fichero encriptado/comprimido a disco sinó que escribimos el fichero drirectamente a disco con su nombre y extensión correspondiente.
En mi caso he guardado el fichero anterior en la misma carpeta y lo he llamado xmhuffman.py Por tanto la tercera línea se debe de ajustar al nombre que se le haya dado a dicho fichero. En la función main hay que darle los parámetros de acceso a la BD y también la rudta donde guardar los ficheros.


from sqlalchemy import create_engine, text
from urllib.parse import quote_plus
from xmhuffman import decode_he3


# --------------------------------------------------------------------------
# Utilidades de configuración
# --------------------------------------------------------------------------

def _connection_string(c: dict) -> str:
    usuario = quote_plus(str(c["dbUser"]))
    password = quote_plus(str(c["dbPwd"]))
    host = f"{c['dbHost']}:{c['dbPort']}" if c["dbPort"] else c["dbHost"]

    url = f"{c['dbType']}://{usuario}:{password}@{host}/{c['dbName']}"

    params = {}
    if c["dbDriver"]:
        params["driver"] = c["dbDriver"]
    if c["dbTrustServerCertificate"]:
        params["TrustServerCertificate"] = c["dbTrustServerCertificate"]
    if params:
        url += "?" + "&".join(f"{k}={quote_plus(str(v))}" for k, v in params.items())
    return url


def _create_sync_engine(conf: dict):
    """Engine síncrono, para SQL Server (pyodbc)."""
    return create_engine(_connection_string(conf))





def copy_all_blobs2files(
    query_ms: str,
    conf_ms: dict,
    ruta_salida: str,
):
    engine_ms = _create_sync_engine(conf_ms)
    archivos_creados = 0
    
    try:
        with engine_ms.connect() as conn:
            # Usamos fetchall() para traer todos los registros de la consulta
            resultados = conn.execute(text(query_ms)).fetchall()
            
            if not resultados:
                raise ValueError(f"No se encontraron filas para la consulta: {query_ms}")

            i=0
            for fila in resultados:
                if i==2000: 
                    break
                # fila[0] debe ser el ID (o nombre del archivo) y fila[1] el BLOB
                id_registro = fila[0]
                extension = fila[1]
                contenido = fila[2]
                nombre_archivo=f"{id_registro}.{extension}"
                print(nombre_archivo)
                
                # Omitimos registros si el BLOB está vacío
                if contenido is None:
                    print(f"Advertencia: El campo BLOB está vacío para el ID {id_registro}. Saltando...")
                    continue
                
                # Construimos la ruta completa del archivo
                nombre_archivo = f"{ruta_salida}{nombre_archivo}"
                
                result = decode_he3(contenido) 
                with open(nombre_archivo, 'wb') as f:
                    f.write(result)                
                archivos_creados += 1
                i+=1
                
            return archivos_creados
            
    finally:
        engine_ms.dispose()


if __name__ == "__main__":
    conf_ms = {
        "dbType": "mssql+pyodbc",
        "dbUser": "myuser",
        "dbPwd": "mypassword",
        "dbHost": "192.168.xxx.xxx",
        "dbPort": 1433,
        "dbName": "BIB_AY",
        "dbDriver": "ODBC Driver 18 for SQL Server",
        "dbTrustServerCertificate": "yes",
    }

    
    query_ms_all = """
        SELECT B.IDX_ID, B.IDX_EXT, ISNULL(d1.DOC_CNT,d2.ORI_CNT) as doc_blob,B.IDX_ALI, d1.DOC_TAM, d2.ORI_TAM,
            10000*YEAR(B.IDX_FCH)+100*MONTH(B.IDX_FCH)+DAY(B.IDX_FCH) as FOLDER
          FROM BIB_AY.dbo.IDX B
          LEFT JOIN BIB_AY.dbo.DOC d1 ON d1.DOC_ID=B.IDX_ID
          LEFT JOIN BIB_AY.dbo.DOC_ORI d2 ON d2.ORI_ID=B.IDX_ID
          WHERE d1.DOC_TAM>0 or d2.ORI_TAM>0;
        """

    
    resultado =  copy_all_blobs2files(
        query_ms=query_ms_all,
        conf_ms=conf_ms,
        ruta_salida="/home/user/kk/DOCUMENTS_AYTOS/",
    )        
    

miércoles, 23 de septiembre de 2026

La aventura del compresor de ficheros de Firmadoc de Aytos (1). Versión desesperada

0.Introducción

Se me requiere que pase los documentos de Firmadoc a un archivo documental, pero hay documentos guardados en BD que estan comprimidos y no se sabe el formato de compresión.

En las tablas BIB_AY.dbo.DOC y BIB_AY.dbo.DOC_ORI hay un campo llamado DOC_CNT y ORI_CNT respectivamente que contien el binario de un documento comprimido por el algoritmo de Huffman. Pero dicho algoritmo tiene muchas variantes y es parametrizable con lo que no hay manera de saber como descomprimirlo.

Para el resto de ficheros se puede consultar una capeta que Firmadoc tiene en el sistema que está organizada en carpetas diarias. Las carpetas se construen con la siguient función SQL server atacando a la tabla BIB_AY.dbo.IDX, y el nombre de los ficheros junto con la extensión se obtienen

SELECT
   B.IDX_ID as FILE_NAME, B.IDX_EXT as EXTENSION,
  10000*YEAR(B.IDX_FCH)+100*MONTH(B.IDX_FCH)+DAY(B.IDX_FCH) as FOLDER
FROM BIB_AY.dbo.IDX B;


1. Ficheros involucrados

Tras preguntar a la Claude AI y pasarle los ejecutables y DLLs de Firmadoc me indica que parece ser que todo el meollo está en el fichero compress.dll

A continuación le paso este fichero a gemini i me dice:

Este archivo es una Librería de Enlace Dinámico (DLL) de 32 bits compilada nativamente en Visual Basic 5.0 / 6.0.
A continuación, te detallo toda la información técnica extraída directamente de su estructura binaria y los pasos exactos para llevar la lógica de la clase clsHuffman a Python.

📑 1. Información General de la DLL
  • Nombre Original del Archivo: compress.dll [1]
  • Nombre del Producto: compress (Versión 1.01) [1]
  • Desarrollador / Compañía: Ramcor [1]
  • Lenguaje y Entorno: Compilado con el motor de ejecución MSVBVM50.DLL (Visual Basic 5.0), lo que significa que utiliza las funciones clásicas de manejo de memoria, cadenas y arrays de Microsoft VB.
  • Ruta de Desarrollo Original (Hardcoded):
    A:\SICALWIN\Desarrollo\Mi_Codigo_Comun\compress\compress.vbp [1]
  • Funciones de Exportación COM de la DLL: Al ser una DLL de Visual Basic, expone las funciones estándar para registrarse en el sistema operativo:
    • DllCanUnloadNow [1]
    • DllGetClassObject [1]
    • DllRegisterServer [1]
    • DllUnregisterServer [1]

🧩 2. Estructura de Componentes Internos
El binario revela que el proyecto está compuesto por los siguientes módulos y clases autodeclarados:
  1. Module1: Módulo estándar (suele contener el punto de entrada Sub Main).
  2. clsHuffman: Clase principal que expone los algoritmos de compresión y descompresión basados en el algoritmo de Huffman.
Métodos expuestos en la clase clsHuffman:
Examinando las tablas de símbolos y cadenas internas del binario, la clase cuenta con los siguientes métodos públicos (funciones) y parámetros asociados:
  • EncodeFile(SourceFile, DestFile): Comprime un archivo físico y genera un archivo de salida.
  • DecodeFile(SourceFile, DestFile): Descomprime un archivo comprimido previamente en un nuevo destino.
  • EncodeByte(ByteArray, ByteLen): Comprime datos directamente desde un array de bytes en memoria.
  • DecodeByte(ByteArray, ByteLen): Descomprime datos hacia un array de bytes en memoria.
  • EncodeString(Text): Comprime una cadena de texto.
  • DecodeString(Text): Descomprime una cadena de texto.
  • Progress(Procent): Un evento o propiedad interna utilizada para reportar el porcentaje de avance del proceso (0 a 100%).
Firmas del Formato y Errores identificados:
El descompilador de strings revela cómo opera el formato interno de compresión de esta DLL específica:
  • Firma de cabecera: Los archivos comprimidos por esta DLL inician con la firma mágica HE3.
  • Mensaje de error 1: "The data either was not compressed with HE3 or is corrupt (identification string not found)". Esto confirma que al descompilar, el código busca primero estos 3 bytes iniciales.
  • Mensaje de error 2: "The data might be corrupted (checksum did not match expected value)". Confirma que el algoritmo implementa una validación de integridad mediante suma de verificación (checksum) al final o en la cabecera del archivo comprimido.


2. Utilizar la lógica para comprimir y descomprimir ficheros

Y a la vista de esto, vuelvo a consultar a Claude AI y me dice que:
  1. Consiga una máquina Windows por ejm plo Windows 11
  2. Que registre las librerias compress.dll y msvbvm50.dll con l"cmd" en modo administrador:

    C:\Windows\SysWOW64\regsvr32.exe /s "C:\Ruta\compress.dll"
    C:\Windows\SysWOW64\regsvr32.exe /s "C:\Ruta\msvbvm50.dll
    "

  3. Descargar los campos blob (DOC_CNT y ORI_CNT ) de sus tablas correspondientes en ficheros individuales mediante un script de Python dentro de una carpeta
  4. Mediante un script ".vbs" descomprimir los ficheros conservando las extensiones

 
3. Script de python para descargar los ficheros


Para ello hay que atacar a SQL SERVER

y genera los ficheros en la carpeta indicada y le añade la extension que tendria que tener al nombre. Comprobar al final del script (en la función main) usuario, contraseña y url y otros datos de la BD y la carpeta donde descargar los ficheros. Nota este script es para linux. Seguramente se tendrá que cambiar alguno de los parámetros que estan remarcados en fondo amarillo

from sqlalchemy import create_engine, text
from urllib.parse import quote_plus


# --------------------------------------------------------------------------
# Utilidades de configuración
# --------------------------------------------------------------------------
def _leer_conf(conf: dict) -> dict:
    """
    Normaliza el diccionario de configuración aceptando nombres de clave
    alternativos (dbType/type, dbHost/host, etc.). Lanza KeyError claro si
    falta algo obligatorio.
    """
    campos = {
        "dbtype": None, "host": None, "database": None,
        "user": None, "pwd": None, "port": None,
        "driver": None, "schema": None, "trust_cert": None,
    }
    alias = {
        "dbtype": ("dbtype", "type"),
        "host": ("host", "dbhost"),
        "database": ("database", "dbname"),
        "user": ("user", "dbuser"),
        "pwd": ("password", "dbpwd"),
        "port": ("port", "dbport"),
        "driver": ("driver", "dbdriver"),
        "schema": ("schema", "dbschema"),
        "trust_cert": ("trustservercertificate", "dbtrustservercertificate"),
    }
    conf_lower = {k.lower(): v for k, v in conf.items()}
    for campo, nombres in alias.items():
        for nombre in nombres:
            if nombre in conf_lower:
                campos[campo] = conf_lower[nombre]
                break

    obligatorios = ("dbtype", "host", "database", "user", "pwd")
    faltantes = [c for c in obligatorios if not campos[c]]
    if faltantes:
        raise KeyError(f"Faltan claves obligatorias en la configuración: {faltantes}")

    return campos


def _connection_string(conf: dict) -> str:
    c = _leer_conf(conf)
    usuario = quote_plus(str(c["user"]))
    password = quote_plus(str(c["pwd"]))
    host = f"{c['host']}:{c['port']}" if c["port"] else c["host"]

    url = f"{c['dbtype']}://{usuario}:{password}@{host}/{c['database']}"

    params = {}
    if c["driver"]:
        params["driver"] = c["driver"]
    if c["trust_cert"]:
        params["TrustServerCertificate"] = c["trust_cert"]
    if params:
        url += "?" + "&".join(f"{k}={quote_plus(str(v))}" for k, v in params.items())
    return url


def _create_sync_engine(conf: dict):
    """Engine síncrono, para SQL Server (pyodbc)."""
    return create_engine(_connection_string(conf))





def copy_all_blobs2files(
    query_ms: str,
    conf_ms: dict,
    ruta_salida: str,
):
    engine_ms = _create_sync_engine(conf_ms)
    archivos_creados = 0
    
    try:
        with engine_ms.connect() as conn:
            # Usamos fetchall() para traer todos los registros de la consulta
            resultados = conn.execute(text(query_ms)).fetchall()
            
            if not resultados:
                raise ValueError(f"No se encontraron filas para la consulta: {query_ms}")

            i=0
            for fila in resultados:
                if i==2000: 
                    break
                # fila[0] debe ser el ID (o nombre del archivo) y fila[1] el BLOB
                id_registro = fila[0]
                extension = fila[1]
                contenido = fila[2]
                nombre_archivo=f"{id_registro}.{extension}.bin"
                print(nombre_archivo)
                
                # Omitimos registros si el BLOB está vacío
                if contenido is None:
                    print(f"Advertencia: El campo BLOB está vacío para el ID {id_registro}. Saltando...")
                    continue
                
                # Construimos la ruta completa del archivo
                nombre_archivo = f"{ruta_salida}{nombre_archivo}"
                
                with open(nombre_archivo, "wb") as f:
                    f.write(contenido)
                
                archivos_creados += 1
                i+=1
                
            return archivos_creados
            
    finally:
        engine_ms.dispose()


if __name__ == "__main__":
    conf_ms = {
        "dbType": "mssql+pyodbc",
        "dbUser": "myuser",
        "dbPwd": "mypwd",
        "dbHost": "192.168.xxx.xxx",
        "dbPort": 1433,
        "dbName": "BIB_AY",
        "dbDriver": "ODBC Driver 18 for SQL Server",
        "dbTrustServerCertificate": "yes",
    }

    
    query_ms_all = """
        SELECT B.IDX_ID, B.IDX_EXT, ISNULL(d1.DOC_CNT,d2.ORI_CNT) as doc_blob,B.IDX_ALI, d1.DOC_TAM, d2.ORI_TAM,
            10000*YEAR(B.IDX_FCH)+100*MONTH(B.IDX_FCH)+DAY(B.IDX_FCH) as FOLDER
          FROM BIB_AY.dbo.IDX B
          LEFT JOIN BIB_AY.dbo.DOC d1 ON d1.DOC_ID=B.IDX_ID
          LEFT JOIN BIB_AY.dbo.DOC_ORI d2 ON d2.ORI_ID=B.IDX_ID
          WHERE d1.DOC_TAM>0 or d2.ORI_TAM>0;
        """

    
    resultado =  copy_all_blobs2files(
        query_ms=query_ms_all,
        conf_ms=conf_ms,
        ruta_salida="/home/myuser/kk/DOCUMENTS_AYTOS",
    )        
    

4. Script vbs para descomprimir los ficheros y asignarles su extensión original en Windows 11

Ahora a¡hay que retocar el scrip para que lea de la carpeta correspondiente los ficheros terminados en ".bin". 
Ojo la Verificar la carpeta de origen que debe ser la misma que la del script anterior. El problema es que antes utilice Linux y ahora Windows 11. Por tanto e3s importante que ambos scripts apunten a la miosma carpeta

Option Explicit

'==================== AJUSTA ESTAS RUTAS ====================
Dim carpeta_origen, carpeta_destino
Dim sufijo_origen, sufijo_destino

carpeta_origen  = "Z:\Compatida|DOCUMENTS_AYTOS\"
carpeta_destino = "Z:\Compatida|DOCUMENTS_AYTOS\"
sufijo_origen = ".bin" ' terminación que buscamos en los ficheros de entrada sufijo_destino = ".bin.desc" ' extensión con la que se guarda ya descomprimido '============================================================== Dim huff, fso Set fso = CreateObject("Scripting.FileSystemObject") ' Comprobar que la carpeta de origen existe If Not fso.FolderExists(carpeta_origen) Then WScript.Echo "ERROR: no existe la carpeta de origen:" & vbCrLf & carpeta_origen WScript.Quit 1 End If ' Crear la carpeta de destino si no existe If Not fso.FolderExists(carpeta_destino) Then fso.CreateFolder(carpeta_destino) End If ' Crear el objeto de la DLL una sola vez para toda la carpeta On Error Resume Next Set huff = CreateObject("Compress.clsHuffman") If Err.Number <> 0 Then WScript.Echo "ERROR al crear el objeto Compress.clsHuffman: " & Err.Description WScript.Quit 1 End If On Error Goto 0 Dim carpeta, fichero, nombre, nombreBase, file_ent, file_sal, fragmentos, extension Dim total, ok, fallos total = 0 ok = 0 fallos = 0 Set carpeta = fso.GetFolder(carpeta_origen) For Each fichero In carpeta.Files nombre = fichero.Name ' ¿Termina el nombre en el sufijo que buscamos? (sin distinguir mayúsculas/minúsculas) If Len(nombre) >= Len(sufijo_origen) Then If LCase(Right(nombre, Len(sufijo_origen))) = LCase(sufijo_origen) Then total = total + 1 nombreBase = Left(nombre, Len(nombre) - Len(sufijo_origen)) WScript.Echo nombreBase 'Edu fragmentos = Split(nombreBase, ".") extension ="unkown" If UBound(fragmentos) >= 1 Then extension = fragmentos(1)     End If 'Fi edu file_ent = fichero.Path file_sal = carpeta_destino & nombreBase & sufijo_destino & "." & extension 'WScript.Echo file_sal 'WScript.Quit On Error Resume Next Err.Clear huff.DecodeFile CStr(file_ent), CStr(file_sal) If Err.Number <> 0 Then WScript.Echo "[ERROR] " & nombre & " -> " & Err.Description & " (0x" & Hex(Err.Number) & ")" fallos = fallos + 1 Else If fso.FileExists(file_sal) Then 'WScript.Echo "[OK] " & nombre & " -> " & file_sal ok = ok + 1 resto = ok Mod 100 If resto = 0 Then WScript.Echo "[OK] " & CStr(ok) & " -> " & file_sal End If Else WScript.Echo "[AVISO] " & nombre & " -> DecodeFile no dio error, pero no se generó la salida" fallos = fallos + 1 End If End If On Error Goto 0 End If End If Next WScript.Echo "" WScript.Echo "Hecho. " & total & " ficheros encontrados, " & ok & " correctos, " & fallos & " con error." Set huff = Nothing Set fso = Nothing


Y con esto ya se pueden obtener los ficheros originales,