csf-scraper-python 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- csf_scraper/__init__.py +119 -0
- csf_scraper/data_extractor.py +74 -0
- csf_scraper/date_parser.py +26 -0
- csf_scraper/exceptions/__init__.py +17 -0
- csf_scraper/exceptions/image_reader.py +106 -0
- csf_scraper/exceptions/pdf_reader.py +33 -0
- csf_scraper/exceptions/value_objects.py +11 -0
- csf_scraper/image_readers/__init__.py +52 -0
- csf_scraper/image_readers/adapters/__init__.py +18 -0
- csf_scraper/image_readers/adapters/llms/__init__.py +33 -0
- csf_scraper/image_readers/adapters/llms/gemini_image_reader.py +157 -0
- csf_scraper/image_readers/adapters/llms/prompts/__init__.py +8 -0
- csf_scraper/image_readers/adapters/llms/prompts/llm_image_reader_prompt.py +17 -0
- csf_scraper/image_readers/csf_image_extractor.py +29 -0
- csf_scraper/image_readers/ports/__init__.py +5 -0
- csf_scraper/image_readers/ports/image_reader_port.py +37 -0
- csf_scraper/image_readers/schemas/__init__.py +5 -0
- csf_scraper/image_readers/schemas/rfc_cif_schema.py +42 -0
- csf_scraper/image_readers/services/__init__.py +18 -0
- csf_scraper/image_readers/services/llm_image_reader_service.py +328 -0
- csf_scraper/pdf_reader/__init__.py +7 -0
- csf_scraper/pdf_reader/csf_pdf_extractor.py +33 -0
- csf_scraper/pdf_reader/pdf_csf_text_parser.py +54 -0
- csf_scraper/pdf_reader/pdf_to_text.py +36 -0
- csf_scraper/persona.py +64 -0
- csf_scraper/persona_fisica.py +25 -0
- csf_scraper/persona_moral.py +21 -0
- csf_scraper/ports/__init__.py +5 -0
- csf_scraper/ports/rfc_cif_extractor_port.py +40 -0
- csf_scraper/py.typed +0 -0
- csf_scraper/regimen.py +62 -0
- csf_scraper/rfc_cif_data.py +15 -0
- csf_scraper/scraper.py +117 -0
- csf_scraper/value_objects/__init__.py +7 -0
- csf_scraper/value_objects/csf_image.py +68 -0
- csf_scraper/value_objects/id_cif.py +48 -0
- csf_scraper/value_objects/rfc.py +56 -0
- csf_scraper_python-0.1.1.dist-info/METADATA +390 -0
- csf_scraper_python-0.1.1.dist-info/RECORD +42 -0
- csf_scraper_python-0.1.1.dist-info/WHEEL +4 -0
- csf_scraper_python-0.1.1.dist-info/licenses/LICENSE +21 -0
- csf_scraper_python-0.1.1.dist-info/licenses/NOTICE.md +16 -0
csf_scraper/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Obtiene los datos fiscales actuales de una persona moral o física del SAT.
|
|
2
|
+
|
|
3
|
+
El núcleo de la librería no depende de ningún cliente de LLM. Los adaptadores
|
|
4
|
+
que sí lo requieren (por ahora :class:`GeminiImageReader`, que necesita el
|
|
5
|
+
extra ``llm-gemini``) se resuelven de forma perezosa (:pep:`562`), de modo que
|
|
6
|
+
``import csf_scraper`` funciona con las dependencias base únicamente.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from csf_scraper.data_extractor import DataExtractor
|
|
12
|
+
from csf_scraper.exceptions import (
|
|
13
|
+
CifDownloadException,
|
|
14
|
+
CifNotFoundException,
|
|
15
|
+
CsfScraperException,
|
|
16
|
+
)
|
|
17
|
+
from csf_scraper.exceptions.image_reader import (
|
|
18
|
+
CifFromImageNotFoundException,
|
|
19
|
+
ImageExtractorNotConfiguredException,
|
|
20
|
+
ImageReaderApiException,
|
|
21
|
+
ImageReaderAuthenticationException,
|
|
22
|
+
ImageReaderUnavailableException,
|
|
23
|
+
ImageReadingException,
|
|
24
|
+
InvalidImageException,
|
|
25
|
+
InvalidImageFormatException,
|
|
26
|
+
RfcFromImageNotFoundException,
|
|
27
|
+
)
|
|
28
|
+
from csf_scraper.exceptions.pdf_reader import (
|
|
29
|
+
CifFromPdfNotFoundException,
|
|
30
|
+
EmptyPdfContentException,
|
|
31
|
+
PdfToTextConvertException,
|
|
32
|
+
RfcFromPdfNotFoundException,
|
|
33
|
+
)
|
|
34
|
+
from csf_scraper.exceptions.value_objects import (
|
|
35
|
+
InvalidIdCifFormatException,
|
|
36
|
+
InvalidRfcFormatException,
|
|
37
|
+
)
|
|
38
|
+
from csf_scraper.image_readers import (
|
|
39
|
+
CSF_PROMPT,
|
|
40
|
+
MAX_RETRIES,
|
|
41
|
+
MAX_RETRY_BACKOFF,
|
|
42
|
+
RETRY_BACKOFF,
|
|
43
|
+
RETRY_PROMPT,
|
|
44
|
+
CsfImageExtractor,
|
|
45
|
+
ImageReaderPort,
|
|
46
|
+
LlmImageReaderService,
|
|
47
|
+
RfcCifSchema,
|
|
48
|
+
)
|
|
49
|
+
from csf_scraper.pdf_reader import CsfPdfExtractor, PdfCsfTextParser, PdfTextProvider
|
|
50
|
+
from csf_scraper.persona import Persona
|
|
51
|
+
from csf_scraper.persona_fisica import PersonaFisica
|
|
52
|
+
from csf_scraper.persona_moral import PersonaMoral
|
|
53
|
+
from csf_scraper.ports import RfcCifExtractorPort
|
|
54
|
+
from csf_scraper.regimen import Regimen
|
|
55
|
+
from csf_scraper.rfc_cif_data import RfcCifData
|
|
56
|
+
from csf_scraper.scraper import Scraper
|
|
57
|
+
from csf_scraper.value_objects import CsfImage, IdCif, Rfc
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
from csf_scraper.image_readers.adapters.llms import GeminiImageReader
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"CSF_PROMPT",
|
|
64
|
+
"MAX_RETRIES",
|
|
65
|
+
"MAX_RETRY_BACKOFF",
|
|
66
|
+
"RETRY_BACKOFF",
|
|
67
|
+
"RETRY_PROMPT",
|
|
68
|
+
"CifDownloadException",
|
|
69
|
+
"CifFromImageNotFoundException",
|
|
70
|
+
"CifFromPdfNotFoundException",
|
|
71
|
+
"CifNotFoundException",
|
|
72
|
+
"CsfImage",
|
|
73
|
+
"CsfImageExtractor",
|
|
74
|
+
"CsfPdfExtractor",
|
|
75
|
+
"CsfScraperException",
|
|
76
|
+
"DataExtractor",
|
|
77
|
+
"EmptyPdfContentException",
|
|
78
|
+
"GeminiImageReader",
|
|
79
|
+
"IdCif",
|
|
80
|
+
"ImageExtractorNotConfiguredException",
|
|
81
|
+
"ImageReaderApiException",
|
|
82
|
+
"ImageReaderAuthenticationException",
|
|
83
|
+
"ImageReaderPort",
|
|
84
|
+
"ImageReaderUnavailableException",
|
|
85
|
+
"ImageReadingException",
|
|
86
|
+
"InvalidIdCifFormatException",
|
|
87
|
+
"InvalidImageException",
|
|
88
|
+
"InvalidImageFormatException",
|
|
89
|
+
"InvalidRfcFormatException",
|
|
90
|
+
"LlmImageReaderService",
|
|
91
|
+
"PdfCsfTextParser",
|
|
92
|
+
"PdfTextProvider",
|
|
93
|
+
"PdfToTextConvertException",
|
|
94
|
+
"Persona",
|
|
95
|
+
"PersonaFisica",
|
|
96
|
+
"PersonaMoral",
|
|
97
|
+
"Regimen",
|
|
98
|
+
"Rfc",
|
|
99
|
+
"RfcCifData",
|
|
100
|
+
"RfcCifExtractorPort",
|
|
101
|
+
"RfcCifSchema",
|
|
102
|
+
"RfcFromImageNotFoundException",
|
|
103
|
+
"RfcFromPdfNotFoundException",
|
|
104
|
+
"Scraper",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def __getattr__(name: str) -> object:
|
|
109
|
+
"""Resuelve los adaptadores opcionales de forma perezosa.
|
|
110
|
+
|
|
111
|
+
Así ``from csf_scraper import GeminiImageReader`` sigue funcionando, pero
|
|
112
|
+
solo importa el SDK de Google cuando alguien realmente lo pide.
|
|
113
|
+
"""
|
|
114
|
+
if name == "GeminiImageReader":
|
|
115
|
+
from csf_scraper.image_readers.adapters.llms import GeminiImageReader
|
|
116
|
+
|
|
117
|
+
return GeminiImageReader
|
|
118
|
+
|
|
119
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Extracción de datos fiscales a partir del HTML de la consulta del CSF."""
|
|
2
|
+
|
|
3
|
+
from bs4 import BeautifulSoup, Tag
|
|
4
|
+
|
|
5
|
+
from csf_scraper.exceptions import CifNotFoundException
|
|
6
|
+
from csf_scraper.persona_fisica import PersonaFisica
|
|
7
|
+
from csf_scraper.persona_moral import PersonaMoral
|
|
8
|
+
from csf_scraper.regimen import Regimen
|
|
9
|
+
|
|
10
|
+
_XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8" ?>'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _normalize_whitespace(value: str) -> str:
|
|
14
|
+
"""Colapsa cualquier espacio en blanco en un único espacio y recorta los extremos."""
|
|
15
|
+
return " ".join(value.split())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DataExtractor:
|
|
19
|
+
"""Extrae los datos de una persona desde el HTML de la consulta del CSF."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, html: str) -> None:
|
|
22
|
+
self.html = html
|
|
23
|
+
|
|
24
|
+
def extract(self, is_fisica: bool) -> PersonaMoral | PersonaFisica:
|
|
25
|
+
"""Construye la persona correspondiente; lanza :class:`CifNotFoundException`."""
|
|
26
|
+
person: PersonaMoral | PersonaFisica = PersonaFisica() if is_fisica else PersonaMoral()
|
|
27
|
+
soup = BeautifulSoup(self._clear_html(self.html), "lxml")
|
|
28
|
+
|
|
29
|
+
if soup.select("span[class=ui-messages-info-detail]"):
|
|
30
|
+
raise CifNotFoundException("Failed to found CIF info.")
|
|
31
|
+
|
|
32
|
+
fields = type(person).model_fields
|
|
33
|
+
for field, title in person.field_titles.items():
|
|
34
|
+
value = self._find_value(soup, title)
|
|
35
|
+
if field in fields:
|
|
36
|
+
setattr(person, field, value)
|
|
37
|
+
else:
|
|
38
|
+
person.extra_data[field] = value
|
|
39
|
+
|
|
40
|
+
person.regimenes = self._get_regimenes(soup)
|
|
41
|
+
return person
|
|
42
|
+
|
|
43
|
+
def _find_value(self, soup: BeautifulSoup, title: str) -> str:
|
|
44
|
+
for element in soup.select('td[role="gridcell"] > span'):
|
|
45
|
+
if title not in _normalize_whitespace(element.get_text()):
|
|
46
|
+
continue
|
|
47
|
+
row = element.find_parent("tr")
|
|
48
|
+
text = _normalize_whitespace(row.get_text()) if isinstance(row, Tag) else ""
|
|
49
|
+
return text.replace(title, " ").strip()
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _clear_html(html: str) -> str:
|
|
54
|
+
return html.replace(_XML_DECLARATION, "")
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _get_regimenes(soup: BeautifulSoup) -> list[Regimen]:
|
|
58
|
+
tables = soup.select('tbody[class="ui-datatable-data ui-widget-content"]')
|
|
59
|
+
if len(tables) < 5:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
values = [
|
|
63
|
+
_normalize_whitespace(cell.get_text())
|
|
64
|
+
for cell in tables[4].select('td[role="gridcell"]')
|
|
65
|
+
if not cell.select("span")
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
regimenes: list[Regimen] = []
|
|
69
|
+
for index in range(0, len(values), 2):
|
|
70
|
+
regimen = Regimen(regimen=values[index])
|
|
71
|
+
if index + 1 < len(values):
|
|
72
|
+
regimen.fecha_alta = values[index + 1] # type: ignore[assignment]
|
|
73
|
+
regimenes.append(regimen)
|
|
74
|
+
return regimenes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Conversión de fechas ``dd-mm-yyyy`` a :class:`datetime.datetime`."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BeforeValidator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_date(value: str) -> datetime | None:
|
|
10
|
+
"""Convierte una fecha ``dd-mm-yyyy`` a la medianoche; ``None`` si es inválida."""
|
|
11
|
+
try:
|
|
12
|
+
return datetime.strptime(value, "%d-%m-%Y")
|
|
13
|
+
except (TypeError, ValueError):
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _coerce_date(value: Any) -> datetime | None:
|
|
18
|
+
if value is None or isinstance(value, datetime):
|
|
19
|
+
return value
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
return parse_date(value)
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#: Campo de fecha opcional que acepta ``datetime``, ``None`` o una cadena ``dd-mm-yyyy``.
|
|
26
|
+
DateField = Annotated[datetime | None, BeforeValidator(_coerce_date)]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Excepciones de la librería."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CsfScraperException(Exception):
|
|
5
|
+
"""Tipo base de todas las excepciones de la librería."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CifDownloadException(CsfScraperException):
|
|
9
|
+
"""La petición HTTP para descargar la información del CIF falló."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, url: str) -> None:
|
|
12
|
+
super().__init__("The request has failed")
|
|
13
|
+
self.url = url
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CifNotFoundException(CsfScraperException):
|
|
17
|
+
"""No se encontró la información del CIF en la respuesta del SAT."""
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Excepciones del lector de imágenes (CSF escaneada)."""
|
|
2
|
+
|
|
3
|
+
from csf_scraper.exceptions import CsfScraperException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InvalidImageException(CsfScraperException):
|
|
7
|
+
"""La ruta de la imagen no existe o no puede leerse."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidImageFormatException(CsfScraperException):
|
|
11
|
+
"""El archivo recibido no es una imagen soportada (JPEG o PNG)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ImageExtractorNotConfiguredException(CsfScraperException):
|
|
15
|
+
"""No se inyectó un extractor de imágenes (motor LLM/OCR)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
super().__init__(
|
|
19
|
+
"An image extractor is required to obtain the data from an image; "
|
|
20
|
+
"inject one via Scraper(..., image_extractor=...).",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImageReadingException(CsfScraperException):
|
|
25
|
+
"""El motor de lectura de imágenes (LLM/OCR) falló al procesar la CSF.
|
|
26
|
+
|
|
27
|
+
``provider`` y ``model`` identifican el motor que falló, de modo que un
|
|
28
|
+
adaptador pueda reportar el origen del error sin exponer credenciales.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
message: str = "The image reader failed to process the CSF.",
|
|
34
|
+
*,
|
|
35
|
+
provider: str | None = None,
|
|
36
|
+
model: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.provider = provider
|
|
40
|
+
self.model = model
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ImageReaderApiException(ImageReadingException):
|
|
44
|
+
"""La API del proveedor del modelo respondió con un error.
|
|
45
|
+
|
|
46
|
+
Es la traducción propia de las excepciones de cada proveedor: el resto de
|
|
47
|
+
la librería no importa ni conoce las librerías de Gemini, OpenAI, etc.
|
|
48
|
+
|
|
49
|
+
``status_code`` es el código HTTP devuelto por el proveedor (``None`` si no
|
|
50
|
+
hubo respuesta, por ejemplo en un fallo de red) y ``retryable`` indica si
|
|
51
|
+
vale la pena volver a intentarlo: los fallos temporales (saturación,
|
|
52
|
+
indisponibilidad, límite de peticiones, red) sí lo son; los de credenciales
|
|
53
|
+
o de petición mal formada, no.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
message: str = "The model provider API returned an error.",
|
|
59
|
+
*,
|
|
60
|
+
provider: str | None = None,
|
|
61
|
+
model: str | None = None,
|
|
62
|
+
status_code: int | None = None,
|
|
63
|
+
retryable: bool = False,
|
|
64
|
+
) -> None:
|
|
65
|
+
super().__init__(message, provider=provider, model=model)
|
|
66
|
+
self.status_code = status_code
|
|
67
|
+
self.retryable = retryable
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ImageReaderUnavailableException(ImageReaderApiException):
|
|
71
|
+
"""El servicio del proveedor está saturado, caído o no es alcanzable.
|
|
72
|
+
|
|
73
|
+
Siempre es reintentable: la misma imagen y las mismas credenciales pueden
|
|
74
|
+
funcionar una vez que el proveedor se recupere.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
message: str = "The model provider is temporarily unavailable.",
|
|
80
|
+
*,
|
|
81
|
+
provider: str | None = None,
|
|
82
|
+
model: str | None = None,
|
|
83
|
+
status_code: int | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
super().__init__(
|
|
86
|
+
message,
|
|
87
|
+
provider=provider,
|
|
88
|
+
model=model,
|
|
89
|
+
status_code=status_code,
|
|
90
|
+
retryable=True,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ImageReaderAuthenticationException(ImageReaderApiException):
|
|
95
|
+
"""El proveedor rechazó las credenciales (API key inválida, vencida o sin permiso).
|
|
96
|
+
|
|
97
|
+
No es reintentable: repetir la petición con la misma clave fallará igual.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RfcFromImageNotFoundException(CsfScraperException):
|
|
102
|
+
"""No se pudo obtener el RFC de la imagen procesada."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class CifFromImageNotFoundException(CsfScraperException):
|
|
106
|
+
"""No se pudo obtener el idCIF de la imagen procesada."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Excepciones del lector de PDF."""
|
|
2
|
+
|
|
3
|
+
from csf_scraper.exceptions import CsfScraperException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CifFromPdfNotFoundException(CsfScraperException):
|
|
7
|
+
"""No se pudo obtener el idCIF del PDF procesado."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EmptyPdfContentException(CsfScraperException):
|
|
11
|
+
"""El contenido del PDF está vacío."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RfcFromPdfNotFoundException(CsfScraperException):
|
|
15
|
+
"""No se pudo obtener el RFC del PDF procesado."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PdfToTextConvertException(CsfScraperException):
|
|
19
|
+
"""La ejecución de ``pdftotext`` falló."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
command: list[str],
|
|
25
|
+
exit_code: int,
|
|
26
|
+
output: str,
|
|
27
|
+
error: str,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.command = command
|
|
31
|
+
self.exit_code = exit_code
|
|
32
|
+
self.output = output
|
|
33
|
+
self.error = error
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Excepciones de los value objects del dominio."""
|
|
2
|
+
|
|
3
|
+
from csf_scraper.exceptions import CsfScraperException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InvalidRfcFormatException(CsfScraperException, ValueError):
|
|
7
|
+
"""El valor recibido no corresponde al formato de un RFC."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidIdCifFormatException(CsfScraperException, ValueError):
|
|
11
|
+
"""El valor recibido no corresponde al formato de un idCIF."""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Lectura de imágenes de la Constancia de Situación Fiscal.
|
|
2
|
+
|
|
3
|
+
El paquete base solo expone el puerto, el adaptador de ruta y la base de los
|
|
4
|
+
lectores LLM. :class:`GeminiImageReader` vive en
|
|
5
|
+
``csf_scraper.image_readers.adapters`` y requiere el extra ``llm-gemini``, por
|
|
6
|
+
eso se resuelve de forma perezosa (:pep:`562`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from csf_scraper.image_readers.csf_image_extractor import CsfImageExtractor
|
|
12
|
+
from csf_scraper.image_readers.ports import ImageReaderPort
|
|
13
|
+
from csf_scraper.image_readers.schemas import RfcCifSchema
|
|
14
|
+
from csf_scraper.image_readers.services import (
|
|
15
|
+
CSF_PROMPT,
|
|
16
|
+
MAX_RETRIES,
|
|
17
|
+
MAX_RETRY_BACKOFF,
|
|
18
|
+
RETRY_BACKOFF,
|
|
19
|
+
RETRY_PROMPT,
|
|
20
|
+
LlmImageReaderService,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from csf_scraper.image_readers.adapters.llms import GeminiImageReader
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CSF_PROMPT",
|
|
28
|
+
"MAX_RETRIES",
|
|
29
|
+
"MAX_RETRY_BACKOFF",
|
|
30
|
+
"RETRY_BACKOFF",
|
|
31
|
+
"RETRY_PROMPT",
|
|
32
|
+
"CsfImageExtractor",
|
|
33
|
+
"GeminiImageReader",
|
|
34
|
+
"ImageReaderPort",
|
|
35
|
+
"LlmImageReaderService",
|
|
36
|
+
"RfcCifSchema",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def __getattr__(name: str) -> object:
|
|
41
|
+
"""Resuelve :class:`GeminiImageReader` de forma perezosa.
|
|
42
|
+
|
|
43
|
+
Mantiene el import base libre de ``google-genai`` y de
|
|
44
|
+
``llama-index-llms-google-genai``; si se pide sin el extra ``llm-gemini``
|
|
45
|
+
se propaga el ``ModuleNotFoundError`` del paquete faltante.
|
|
46
|
+
"""
|
|
47
|
+
if name == "GeminiImageReader":
|
|
48
|
+
from csf_scraper.image_readers.adapters.llms import GeminiImageReader
|
|
49
|
+
|
|
50
|
+
return GeminiImageReader
|
|
51
|
+
|
|
52
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Adaptadores de los lectores de imagen."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from csf_scraper.image_readers.adapters.llms import GeminiImageReader
|
|
7
|
+
|
|
8
|
+
__all__ = ["GeminiImageReader"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __getattr__(name: str) -> object:
|
|
12
|
+
"""Reexporta los adaptadores concretos de forma perezosa."""
|
|
13
|
+
if name == "GeminiImageReader":
|
|
14
|
+
from csf_scraper.image_readers.adapters.llms import GeminiImageReader
|
|
15
|
+
|
|
16
|
+
return GeminiImageReader
|
|
17
|
+
|
|
18
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Adaptadores de lectura de imágenes basados en LLMs multimodales.
|
|
2
|
+
|
|
3
|
+
Este paquete es el único lugar de la librería que conoce el SDK de un
|
|
4
|
+
proveedor concreto. Por eso **no** se importa ningún adaptador al cargar el
|
|
5
|
+
paquete: :class:`GeminiImageReader` requiere el extra ``llm-gemini`` y debe
|
|
6
|
+
resolverse solo cuando se usa (:pep:`562`), para que ``import csf_scraper``
|
|
7
|
+
funcione sin instalar extras.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from csf_scraper.image_readers.adapters.llms.gemini_image_reader import (
|
|
14
|
+
GeminiImageReader,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = ["GeminiImageReader"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name: str) -> object:
|
|
21
|
+
"""Resuelve :class:`GeminiImageReader` de forma perezosa.
|
|
22
|
+
|
|
23
|
+
Levanta ``ModuleNotFoundError`` (del paquete ``llama-index-llms-google-genai``)
|
|
24
|
+
si se pide el adaptador sin haber instalado el extra ``llm-gemini``.
|
|
25
|
+
"""
|
|
26
|
+
if name == "GeminiImageReader":
|
|
27
|
+
from csf_scraper.image_readers.adapters.llms.gemini_image_reader import (
|
|
28
|
+
GeminiImageReader,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return GeminiImageReader
|
|
32
|
+
|
|
33
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Adaptador del lector de imágenes basado en Gemini (Google GenAI).
|
|
2
|
+
|
|
3
|
+
Aquí sí se importan las librerías de LlamaIndex y del SDK de Google, porque el
|
|
4
|
+
transporte hacia el proveedor es responsabilidad exclusiva del adaptador::
|
|
5
|
+
|
|
6
|
+
from llama_index.llms.google_genai import GoogleGenAI
|
|
7
|
+
|
|
8
|
+
reader = GeminiImageReader(api_key="…", model_name="gemini-2.5-flash")
|
|
9
|
+
|
|
10
|
+
El paquete ``llama-index-llms-google-genai`` es una dependencia opcional
|
|
11
|
+
(``pip install "csf-scraper-python[llm-gemini]"``), junto con el SDK
|
|
12
|
+
``google-genai`` que este adaptador importa directamente.
|
|
13
|
+
|
|
14
|
+
Las excepciones de ``google.genai.errors`` se traducen en
|
|
15
|
+
:meth:`GeminiImageReader._translate_api_exception` a excepciones de la librería,
|
|
16
|
+
de modo que fuera de este archivo nadie necesita conocer el SDK de Google.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from google.genai import errors
|
|
22
|
+
from llama_index.llms.google_genai import GoogleGenAI
|
|
23
|
+
|
|
24
|
+
from csf_scraper.exceptions.image_reader import (
|
|
25
|
+
ImageReaderApiException,
|
|
26
|
+
ImageReaderAuthenticationException,
|
|
27
|
+
ImageReaderUnavailableException,
|
|
28
|
+
)
|
|
29
|
+
from csf_scraper.image_readers.ports.image_reader_port import ImageReaderPort
|
|
30
|
+
from csf_scraper.image_readers.services.llm_image_reader_service import (
|
|
31
|
+
MAX_RETRIES,
|
|
32
|
+
RETRY_BACKOFF,
|
|
33
|
+
LlmImageReaderService,
|
|
34
|
+
)
|
|
35
|
+
from csf_scraper.rfc_cif_data import RfcCifData
|
|
36
|
+
from csf_scraper.value_objects.csf_image import CsfImage
|
|
37
|
+
|
|
38
|
+
#: Códigos HTTP de Google que indican una falla temporal del servicio: el
|
|
39
|
+
#: límite de peticiones (429), los errores del servidor (5xx) y los timeouts de
|
|
40
|
+
#: la propia API (408).
|
|
41
|
+
_RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
|
|
42
|
+
|
|
43
|
+
#: Estados de Google que también indican una falla temporal, por si la
|
|
44
|
+
#: respuesta llega sin un código HTTP reconocible.
|
|
45
|
+
_RETRYABLE_STATUSES = frozenset(
|
|
46
|
+
{
|
|
47
|
+
"RESOURCE_EXHAUSTED",
|
|
48
|
+
"INTERNAL",
|
|
49
|
+
"UNAVAILABLE",
|
|
50
|
+
"DEADLINE_EXCEEDED",
|
|
51
|
+
"ABORTED",
|
|
52
|
+
"UNKNOWN",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
#: Estados que indican que las credenciales no sirven: reintentar con la misma
|
|
57
|
+
#: clave daría exactamente el mismo error.
|
|
58
|
+
_AUTHENTICATION_STATUSES = frozenset({"UNAUTHENTICATED", "PERMISSION_DENIED"})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GeminiImageReader(LlmImageReaderService, ImageReaderPort):
|
|
62
|
+
"""Lee el RFC y el idCIF de una imagen de CSF usando Gemini.
|
|
63
|
+
|
|
64
|
+
Hereda de :class:`~csf_scraper.image_readers.services.LlmImageReaderService`
|
|
65
|
+
el prompt, el armado de la imagen, los reintentos, la ejecución del
|
|
66
|
+
programa multimodal y el mapeo a
|
|
67
|
+
:class:`~csf_scraper.rfc_cif_data.RfcCifData`. Lo propio de este adaptador
|
|
68
|
+
es crear el cliente ``GoogleGenAI``, traducir los errores del SDK y exponer
|
|
69
|
+
``read()`` delegando en el método plantilla de la clase base.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
api_key: Clave de la API de Google GenAI.
|
|
73
|
+
model_name: Modelo de Gemini a utilizar.
|
|
74
|
+
max_retries: Intentos totales antes de rendirse.
|
|
75
|
+
retry_backoff: Espera inicial, en segundos, entre reintentos por errores
|
|
76
|
+
de API; se duplica en cada intento.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
api_key: str,
|
|
82
|
+
model_name: str,
|
|
83
|
+
*,
|
|
84
|
+
max_retries: int = MAX_RETRIES,
|
|
85
|
+
retry_backoff: float = RETRY_BACKOFF,
|
|
86
|
+
) -> None:
|
|
87
|
+
if not api_key:
|
|
88
|
+
raise ValueError("The Gemini image reader requires an API key.")
|
|
89
|
+
|
|
90
|
+
if not model_name:
|
|
91
|
+
raise ValueError("The Gemini image reader requires an Model Name.")
|
|
92
|
+
super().__init__(
|
|
93
|
+
model=model_name,
|
|
94
|
+
max_retries=max_retries,
|
|
95
|
+
retry_backoff=retry_backoff,
|
|
96
|
+
)
|
|
97
|
+
self._llm_provider = GoogleGenAI(api_key=api_key, model=model_name)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def provider(self) -> str:
|
|
101
|
+
"""Identificador del proveedor."""
|
|
102
|
+
return "gemini"
|
|
103
|
+
|
|
104
|
+
async def read(self, image: CsfImage) -> RfcCifData:
|
|
105
|
+
"""Delega el flujo de reintentos y mapeo en el método plantilla."""
|
|
106
|
+
return await self._run_program(image=image, llm_provider=self._llm_provider)
|
|
107
|
+
|
|
108
|
+
def _translate_api_exception(
|
|
109
|
+
self, exception: Exception
|
|
110
|
+
) -> ImageReaderApiException | None:
|
|
111
|
+
"""Traduce ``google.genai.errors`` a excepciones de la librería.
|
|
112
|
+
|
|
113
|
+
- Credenciales rechazadas (401/403) →
|
|
114
|
+
:class:`ImageReaderAuthenticationException`, **sin** reintentar.
|
|
115
|
+
- Saturación, caída o timeout (429, 408, 5xx) →
|
|
116
|
+
:class:`ImageReaderUnavailableException`, **con** reintento.
|
|
117
|
+
- Cualquier otro error de la API →
|
|
118
|
+
:class:`ImageReaderApiException` con ``retryable=False``.
|
|
119
|
+
|
|
120
|
+
Los errores de transporte genéricos (``httpx``, timeouts) los resuelve
|
|
121
|
+
la implementación base.
|
|
122
|
+
"""
|
|
123
|
+
translated = super()._translate_api_exception(exception)
|
|
124
|
+
if translated is not None:
|
|
125
|
+
return translated
|
|
126
|
+
|
|
127
|
+
if not isinstance(exception, errors.APIError):
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
status_code = exception.code or None
|
|
131
|
+
status = (exception.status or "").upper()
|
|
132
|
+
|
|
133
|
+
if status_code in {401, 403} or status in _AUTHENTICATION_STATUSES:
|
|
134
|
+
return ImageReaderAuthenticationException(
|
|
135
|
+
f"The {self.provider} API rejected the credentials: "
|
|
136
|
+
f"{exception.message or status_code}",
|
|
137
|
+
provider=self.provider,
|
|
138
|
+
model=self.model,
|
|
139
|
+
status_code=status_code,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if status_code in _RETRYABLE_STATUS_CODES or status in _RETRYABLE_STATUSES:
|
|
143
|
+
return ImageReaderUnavailableException(
|
|
144
|
+
f"The {self.provider} API is temporarily unavailable: "
|
|
145
|
+
f"{exception.message or status_code}",
|
|
146
|
+
provider=self.provider,
|
|
147
|
+
model=self.model,
|
|
148
|
+
status_code=status_code,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return ImageReaderApiException(
|
|
152
|
+
f"The {self.provider} API returned an error: {exception.message or status_code}",
|
|
153
|
+
provider=self.provider,
|
|
154
|
+
model=self.model,
|
|
155
|
+
status_code=status_code,
|
|
156
|
+
retryable=False,
|
|
157
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
CSF_PROMPT = (
|
|
2
|
+
"Analiza la imagen adjunta, que es una Constancia de Situación Fiscal "
|
|
3
|
+
"emitida por el SAT, y extrae el RFC y el idCIF que aparecen en el "
|
|
4
|
+
"encabezado del documento. Transcribe ambos valores exactamente como están "
|
|
5
|
+
"impresos: conserva las mayúsculas, el signo & y la Ñ si aparecen, y no "
|
|
6
|
+
"elimines los ceros a la izquierda del idCIF. No inventes ni completes "
|
|
7
|
+
"valores: si un dato no se distingue en la imagen, devuélvelo como cadena "
|
|
8
|
+
"vacía."
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
#: Sufijo que se agrega al prompt cuando el intento anterior fue rechazado.
|
|
12
|
+
RETRY_PROMPT = (
|
|
13
|
+
"\n\nATENCIÓN: el intento #{previous_attempt} fue descartado por este "
|
|
14
|
+
"motivo:\n{error}\n\n"
|
|
15
|
+
"Vuelve a revisar la imagen con cuidado, carácter por carácter, y corrige "
|
|
16
|
+
"exactamente eso antes de responder."
|
|
17
|
+
)
|