diff --git a/gfd/__init__.py b/gfd/__init__.py
index a533607..0e0b578 100644
--- a/gfd/__init__.py
+++ b/gfd/__init__.py
@@ -1,23 +1,108 @@
from PySide6 import QtWidgets, QtCore
import gfd.i18n as i18n
+from gfd import sfd
+
class GFDWidget(QtWidgets.QWidget):
def __init__(self, tr):
super().__init__()
self.tr = tr
self.setWindowTitle(self.tr("window_title"))
- self.resize(300, 100)
+ self.resize(400, 220)
+
+ self.label = QtWidgets.QLabel(
+ self.tr("hello_world"),
+ alignment=QtCore.Qt.AlignmentFlag.AlignCenter,
+ )
+
+ self.combo = QtWidgets.QComboBox()
+ self.combo.addItem(self.tr("loading_installers") + "…")
+
+ self.refresh_btn = QtWidgets.QPushButton("⟳ " + self.tr("refresh"))
+ self.refresh_btn.clicked.connect(self.reload_installers)
+
+ combo_layout = QtWidgets.QHBoxLayout()
+ combo_layout.addWidget(self.combo)
+ combo_layout.addWidget(self.refresh_btn)
+
+ self.md5_label = QtWidgets.QLabel(
+ "", alignment=QtCore.Qt.AlignmentFlag.AlignCenter
+ )
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(self.label)
+ layout.addLayout(combo_layout)
+ layout.addWidget(self.md5_label)
+
+ self.combo.currentIndexChanged.connect(self.on_selection_changed)
+
+ QtCore.QTimer.singleShot(0, self.load_installers_async)
+
+ def reload_installers(self):
+ """Triggered by the refresh button."""
+ self.refresh_btn.setEnabled(False)
+ self.combo.clear()
+ self.combo.addItem(self.tr("loading_installers") + "…")
+ self.load_installers_async()
+
+ def load_installers_async(self):
+ """Fetch installer options without freezing the UI."""
+ self.thread = QtCore.QThread()
+ self.worker = SFDWorker()
+ self.worker.moveToThread(self.thread)
+
+ self.thread.started.connect(self.worker.run)
+ self.worker.finished.connect(self.thread.quit)
+ self.worker.finished.connect(self.worker.deleteLater)
+ self.thread.finished.connect(self.thread.deleteLater)
+ self.worker.result_ready.connect(self.populate_installers)
+
+ self.thread.start()
+
+ @QtCore.Slot(list)
+ def populate_installers(self, installers):
+ self.refresh_btn.setEnabled(True)
+ self.combo.clear()
+
+ if not installers:
+ self.combo.addItem(self.tr("no_installers_found"))
+ self.md5_label.setText("")
+ return
+
+ self.installers = installers
+ for item in installers:
+ self.combo.addItem(item["name"], item["md5"])
+
+ self.combo.setCurrentIndex(0)
+ self.on_selection_changed(0)
+
+ @QtCore.Slot(int)
+ def on_selection_changed(self, index):
+ if not hasattr(self, "installers") or not self.installers:
+ return
+ md5 = self.combo.currentData()
+ name = self.combo.currentText()
+ self.md5_label.setText(f"{name}
MD5: {md5 or 'N/A'}")
+
+
+class SFDWorker(QtCore.QObject):
+ """Background worker to fetch installers safely."""
+
+ finished = QtCore.Signal()
+ result_ready = QtCore.Signal(list)
+
+ @QtCore.Slot()
+ def run(self):
+ data = sfd.fetchInstallerOptions()
+ self.result_ready.emit(data)
+ self.finished.emit()
- label = QtWidgets.QLabel(self.tr("hello_world"), alignment=QtCore.Qt.AlignCenter)
- lay = QtWidgets.QVBoxLayout(self)
- lay.addWidget(label)
def run(lang="es"):
import sys
- app = QtWidgets.QApplication(sys.argv)
+ app = QtWidgets.QApplication(sys.argv)
tr = i18n.Translator(lang)
w = GFDWidget(tr)
w.show()
-
sys.exit(app.exec())
diff --git a/gfd/i18n/en.py b/gfd/i18n/en.py
index 3017bc8..9ae74e5 100644
--- a/gfd/i18n/en.py
+++ b/gfd/i18n/en.py
@@ -1,4 +1,7 @@
translations = {
"hello_world": "Hello, World!",
"window_title": "Digital Signature Manager",
+ "no_installers_found": "Could not update installer list",
+ "loading_installers": "Loading Installers",
+ "refresh": "Refresh",
}
diff --git a/gfd/i18n/es.py b/gfd/i18n/es.py
index e57ed7a..20d5330 100644
--- a/gfd/i18n/es.py
+++ b/gfd/i18n/es.py
@@ -1,4 +1,7 @@
translations = {
"hello_world": "¡Hola, Mundo!",
"window_title": "Gestor de Firma Digital",
+ "no_installers_found": "No se pudo actualizar la lista de instaladores",
+ "loading_installers": "Cargando instaladores",
+ "refresh": "Buscar",
}
diff --git a/gfd/sfd.py b/gfd/sfd.py
new file mode 100644
index 0000000..c082ccb
--- /dev/null
+++ b/gfd/sfd.py
@@ -0,0 +1,68 @@
+import re
+import unicodedata
+import requests
+from bs4 import BeautifulSoup
+
+SFD_URL = "https://soportefirmadigital.com/sfdj/dl.aspx"
+
+
+def _normalize_text(text: str) -> str:
+ """Internal: remove accents, extra spaces, and lowercase."""
+ text = unicodedata.normalize("NFKD", text)
+ text = "".join(c for c in text if not unicodedata.combining(c))
+ return re.sub(r"\s+", " ", text).strip().lower()
+
+
+def fetchInstallerOptions(url: str = SFD_URL):
+ """
+ Fetch the list of installer options from the given Soporte Firma Digital page.
+
+ Args:
+ url (str, optional): Page URL to fetch. Defaults to SFD_URL.
+
+ Returns:
+ list[dict]: Each item is {'name': , 'md5': }
+ """
+ try:
+ r = requests.get(url, timeout=8)
+ r.raise_for_status()
+ except requests.RequestException:
+ return []
+
+ soup = BeautifulSoup(r.text, "html.parser")
+
+ installers = [
+ opt.text.strip()
+ for opt in soup.select("#ctl00_certContents_ddlInstaladores option")
+ ]
+ if not installers:
+ return []
+
+ # Extract valid MD5 hashes from embedded JavaScript
+ md5s = {}
+ for name, md5 in re.findall(
+ r"text\s*==\s*'([^']+)'.*?MD5\s*[:=]\s*([A-Za-z0-9]+)",
+ r.text,
+ re.DOTALL,
+ ):
+ md5 = md5.lower()
+ if re.fullmatch(r"[0-9a-f]{32}", md5): # only valid 32-char hex hashes
+ md5s[_normalize_text(name)] = md5
+
+ results = []
+ for inst in installers:
+ n = _normalize_text(inst)
+ md5 = md5s.get(n)
+ if not md5:
+ for key, val in md5s.items():
+ if key in n or n in key:
+ md5 = val
+ break
+ results.append({"name": inst, "md5": md5})
+
+ return results
+
+
+if __name__ == "__main__":
+ for item in fetchInstallerOptions():
+ print(f"{item['name']:<70} MD5={item['md5'] or 'N/A'}")
diff --git a/requirements.txt b/requirements.txt
index 91c79fa..87cbcfe 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,12 @@
+beautifulsoup4==4.14.2
+certifi==2025.10.5
+charset-normalizer==3.4.3
+idna==3.10
PySide6==6.9.2
PySide6_Addons==6.9.2
PySide6_Essentials==6.9.2
+requests==2.32.5
shiboken6==6.9.2
+soupsieve==2.8
+typing_extensions==4.15.0
+urllib3==2.5.0