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.
Files changed (42) hide show
  1. csf_scraper/__init__.py +119 -0
  2. csf_scraper/data_extractor.py +74 -0
  3. csf_scraper/date_parser.py +26 -0
  4. csf_scraper/exceptions/__init__.py +17 -0
  5. csf_scraper/exceptions/image_reader.py +106 -0
  6. csf_scraper/exceptions/pdf_reader.py +33 -0
  7. csf_scraper/exceptions/value_objects.py +11 -0
  8. csf_scraper/image_readers/__init__.py +52 -0
  9. csf_scraper/image_readers/adapters/__init__.py +18 -0
  10. csf_scraper/image_readers/adapters/llms/__init__.py +33 -0
  11. csf_scraper/image_readers/adapters/llms/gemini_image_reader.py +157 -0
  12. csf_scraper/image_readers/adapters/llms/prompts/__init__.py +8 -0
  13. csf_scraper/image_readers/adapters/llms/prompts/llm_image_reader_prompt.py +17 -0
  14. csf_scraper/image_readers/csf_image_extractor.py +29 -0
  15. csf_scraper/image_readers/ports/__init__.py +5 -0
  16. csf_scraper/image_readers/ports/image_reader_port.py +37 -0
  17. csf_scraper/image_readers/schemas/__init__.py +5 -0
  18. csf_scraper/image_readers/schemas/rfc_cif_schema.py +42 -0
  19. csf_scraper/image_readers/services/__init__.py +18 -0
  20. csf_scraper/image_readers/services/llm_image_reader_service.py +328 -0
  21. csf_scraper/pdf_reader/__init__.py +7 -0
  22. csf_scraper/pdf_reader/csf_pdf_extractor.py +33 -0
  23. csf_scraper/pdf_reader/pdf_csf_text_parser.py +54 -0
  24. csf_scraper/pdf_reader/pdf_to_text.py +36 -0
  25. csf_scraper/persona.py +64 -0
  26. csf_scraper/persona_fisica.py +25 -0
  27. csf_scraper/persona_moral.py +21 -0
  28. csf_scraper/ports/__init__.py +5 -0
  29. csf_scraper/ports/rfc_cif_extractor_port.py +40 -0
  30. csf_scraper/py.typed +0 -0
  31. csf_scraper/regimen.py +62 -0
  32. csf_scraper/rfc_cif_data.py +15 -0
  33. csf_scraper/scraper.py +117 -0
  34. csf_scraper/value_objects/__init__.py +7 -0
  35. csf_scraper/value_objects/csf_image.py +68 -0
  36. csf_scraper/value_objects/id_cif.py +48 -0
  37. csf_scraper/value_objects/rfc.py +56 -0
  38. csf_scraper_python-0.1.1.dist-info/METADATA +390 -0
  39. csf_scraper_python-0.1.1.dist-info/RECORD +42 -0
  40. csf_scraper_python-0.1.1.dist-info/WHEEL +4 -0
  41. csf_scraper_python-0.1.1.dist-info/licenses/LICENSE +21 -0
  42. csf_scraper_python-0.1.1.dist-info/licenses/NOTICE.md +16 -0
@@ -0,0 +1,29 @@
1
+ """Puente entre una ruta de archivo y un motor de lectura de imágenes."""
2
+
3
+ import asyncio
4
+
5
+ from csf_scraper.image_readers.ports.image_reader_port import ImageReaderPort
6
+ from csf_scraper.ports.rfc_cif_extractor_port import RfcCifExtractorPort
7
+ from csf_scraper.rfc_cif_data import RfcCifData
8
+ from csf_scraper.value_objects.csf_image import CsfImage
9
+
10
+
11
+ class CsfImageExtractor(RfcCifExtractorPort):
12
+ """Implementa :class:`~csf_scraper.ports.RfcCifExtractorPort` con un motor.
13
+
14
+ Lee la imagen de ``path``, la valida como :class:`CsfImage` y delega en el
15
+ :class:`~csf_scraper.image_readers.ports.ImageReaderPort` inyectado. Sirve
16
+ para cualquier motor (LLM u OCR) y así todos los extractores de imagen
17
+ comparten el mismo contrato público: ``extract(path) -> RfcCifData``.
18
+
19
+ La lectura del archivo se hace con :func:`asyncio.to_thread` para no
20
+ bloquear el bucle de eventos, y el motor se espera con ``await``.
21
+ """
22
+
23
+ def __init__(self, reader: ImageReaderPort) -> None:
24
+ self._reader = reader
25
+
26
+ async def extract(self, path: str) -> RfcCifData:
27
+ """Devuelve el RFC y el idCIF de la imagen de CSF en ``path``."""
28
+ image = await asyncio.to_thread(CsfImage.new_from_path, path)
29
+ return await self._reader.read(image)
@@ -0,0 +1,5 @@
1
+ """Puertos de los lectores de imagen."""
2
+
3
+ from csf_scraper.image_readers.ports.image_reader_port import ImageReaderPort
4
+
5
+ __all__ = ["ImageReaderPort"]
@@ -0,0 +1,37 @@
1
+ """Puerto que abstrae el motor de lectura de imágenes (LLM multimodal u OCR)."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from csf_scraper.rfc_cif_data import RfcCifData
6
+ from csf_scraper.value_objects.csf_image import CsfImage
7
+
8
+
9
+ class ImageReaderPort(ABC):
10
+ """Extrae el :class:`RfcCifData` de la imagen ya materializada de una CSF.
11
+
12
+ Este es el contrato de un **motor**, no de un origen: recibe una
13
+ :class:`~csf_scraper.value_objects.CsfImage` en memoria y nunca toca el
14
+ disco. Para leer desde una ruta se usa
15
+ :class:`~csf_scraper.image_readers.CsfImageExtractor`, que adapta este
16
+ puerto al contrato público
17
+ :class:`~csf_scraper.ports.RfcCifExtractorPort`.
18
+
19
+ Reglas del contrato:
20
+
21
+ - No lee archivos: toda la validación de formato la hace :class:`CsfImage`.
22
+ - Devuelve :class:`RfcCifData` con los value objects ya construidos.
23
+ - Las implementaciones de la librería reportan sus fallos con excepciones
24
+ de la librería; en particular
25
+ :class:`~csf_scraper.exceptions.image_reader.ImageReadingException`. Un
26
+ motor externo puede propagar sus propias excepciones.
27
+
28
+ Implementaciones: :class:`~csf_scraper.image_readers.adapters.llms.GeminiImageReader`
29
+ (hereda de :class:`~csf_scraper.image_readers.LlmImageReaderService` y de
30
+ este puerto; sirve de base para otros proveedores) o un pipeline de OCR
31
+ (OpenCV + Tesseract).
32
+ """
33
+
34
+ @abstractmethod
35
+ async def read(self, image: CsfImage) -> RfcCifData:
36
+ """Devuelve el RFC y el idCIF leídos de ``image``."""
37
+ ...
@@ -0,0 +1,5 @@
1
+ """Esquemas de respuesta que los motores de lectura deben producir."""
2
+
3
+ from csf_scraper.image_readers.schemas.rfc_cif_schema import RfcCifSchema
4
+
5
+ __all__ = ["RfcCifSchema"]
@@ -0,0 +1,42 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class RfcCifSchema(BaseModel):
5
+ """Datos que el LLM debe devolver de una imagen de Constancia de Situación Fiscal (CSF).
6
+
7
+ Se usa como ``output_cls`` del programa multimodal de LlamaIndex, que
8
+ adjunta automáticamente al prompt el JSON Schema de esta clase.
9
+ """
10
+
11
+ rfc: str = Field(
12
+ min_length=1,
13
+ description=(
14
+ "Registro Federal de Contribuyentes (RFC) de la persona física o moral. "
15
+ "Ubicación en la imagen:\n"
16
+ "1. En el renglón 'RFC:' de la sección 'Datos de Identificación del "
17
+ "Contribuyente'.\n"
18
+ "2. Arriba del texto 'Registro Federal de Contribuyentes', a la derecha "
19
+ "del código QR.\n"
20
+ "3. Debajo del código de barras vertical del lado derecho del encabezado.\n"
21
+ "Formato: Cadena alfanumérica en mayúsculas de 12 caracteres (persona moral) o 13 "
22
+ "caracteres (persona física) sin espacios, guiones ni puntos.\n"
23
+ "Ejemplos válidos: 'SME2009297K7', 'MEPF050926N61', 'TME990513J64'."
24
+ ),
25
+ )
26
+ id_cif: str = Field(
27
+ min_length=1,
28
+ description=(
29
+ "Identificador de la Cédula de Identificación Fiscal (idCIF).\n"
30
+ "Ubicación en la imagen:\n"
31
+ "Se encuentra EXCLUSIVAMENTE dentro del recuadro de la 'CÉDULA DE "
32
+ "IDENTIFICACIÓN FISCAL' "
33
+ "(a la derecha del código QR), sobre la leyenda 'VALIDA TU INFORMACIÓN FISCAL', "
34
+ "antecedido por la etiqueta 'idCIF:'.\n"
35
+ "Formato: Extraer ÚNICAMENTE los números (de 8 a 13 dígitos consecutivos), "
36
+ "omitiendo el prefijo 'idCIF:', espacios, guiones o cualquier otro carácter.\n"
37
+ "Ejemplos válidos:\n"
38
+ "- Si dice 'idCIF: 20090374035', extraer únicamente '20090374035'.\n"
39
+ "- Si dice 'idCIF: 25020218873', extraer únicamente '25020218873'.\n"
40
+ "- Si dice 'idCIF: 15080334415', extraer únicamente '15080334415'."
41
+ ),
42
+ )
@@ -0,0 +1,18 @@
1
+ """Servicios compartidos por los adaptadores de lectura de imagen."""
2
+
3
+ from csf_scraper.image_readers.adapters.llms.prompts import CSF_PROMPT, RETRY_PROMPT
4
+ from csf_scraper.image_readers.services.llm_image_reader_service import (
5
+ MAX_RETRIES,
6
+ MAX_RETRY_BACKOFF,
7
+ RETRY_BACKOFF,
8
+ LlmImageReaderService,
9
+ )
10
+
11
+ __all__ = [
12
+ "CSF_PROMPT",
13
+ "MAX_RETRIES",
14
+ "MAX_RETRY_BACKOFF",
15
+ "RETRY_BACKOFF",
16
+ "RETRY_PROMPT",
17
+ "LlmImageReaderService",
18
+ ]
@@ -0,0 +1,328 @@
1
+ """Base común de los adaptadores de imagen basados en un LLM multimodal.
2
+
3
+ :class:`LlmImageReaderService` concentra exactamente cinco responsabilidades y
4
+ nada más:
5
+
6
+ 1. **Armado del prompt** — el texto canónico (:data:`CSF_PROMPT`) más la
7
+ realimentación del intento anterior cuando toca reintentar.
8
+ 2. **Armado de la imagen del mensaje** — la CSF viaja como un único
9
+ :class:`~llama_index.core.base.llms.types.ImageBlock`.
10
+ 3. **Reintentos por respuestas inválidas** — cuando los value objects
11
+ :class:`~csf_scraper.value_objects.Rfc` y
12
+ :class:`~csf_scraper.value_objects.IdCif` rechazan el formato de negocio, se
13
+ vuelve a interrogar al modelo pasándole el error como contexto.
14
+ 4. **Reintentos por errores de API** — cuando el proveedor responde que está
15
+ saturado, caído o inalcanzable, se vuelve a intentar tras una espera
16
+ creciente. El adaptador traduce las excepciones de su proveedor con
17
+ :meth:`LlmImageReaderService._translate_api_exception`, de modo que esta
18
+ clase —y el resto de la librería— nunca importe las excepciones de Gemini,
19
+ OpenAI, etc.
20
+ 5. **Parseo al DTO** — un :class:`~csf_scraper.image_readers.RfcCifSchema` ya
21
+ tipado se mapea a :class:`~csf_scraper.rfc_cif_data.RfcCifData`.
22
+
23
+ Lo que **no** vive aquí es el transporte hacia el proveedor ni el acceso al
24
+ sistema de archivos. Cada adaptador concreto (``GeminiImageReader``, …) crea su
25
+ cliente de LlamaIndex, lo entrega como ``llm_provider`` y expone ``read()``
26
+ delegando en el *método plantilla*
27
+ :meth:`LlmImageReaderService._run_program`. Ese método arma la imagen y el
28
+ prompt de cada intento, ejecuta el ``MultiModalLLMCompletionProgram`` mediante
29
+ :meth:`LlmImageReaderService._image_to_schema` y mapea la respuesta a
30
+ :class:`~csf_scraper.rfc_cif_data.RfcCifData`. La lectura desde una **ruta** la
31
+ hace :class:`~csf_scraper.image_readers.CsfImageExtractor`, que adapta cualquier
32
+ :class:`~csf_scraper.image_readers.ImageReaderPort` al contrato público
33
+ :class:`~csf_scraper.ports.RfcCifExtractorPort`.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import logging
40
+ from abc import ABC, abstractmethod
41
+ from collections.abc import Sequence
42
+ from typing import ClassVar, cast
43
+
44
+ import httpx
45
+ from llama_index.core.base.llms.types import ImageBlock
46
+ from llama_index.core.llms.llm import LLM
47
+ from llama_index.core.program import MultiModalLLMCompletionProgram
48
+
49
+ from csf_scraper.exceptions.image_reader import (
50
+ CifFromImageNotFoundException,
51
+ ImageReaderApiException,
52
+ ImageReaderUnavailableException,
53
+ ImageReadingException,
54
+ RfcFromImageNotFoundException,
55
+ )
56
+ from csf_scraper.exceptions.value_objects import (
57
+ InvalidIdCifFormatException,
58
+ InvalidRfcFormatException,
59
+ )
60
+ from csf_scraper.image_readers.adapters.llms.prompts.llm_image_reader_prompt import (
61
+ CSF_PROMPT,
62
+ RETRY_PROMPT,
63
+ )
64
+ from csf_scraper.image_readers.schemas.rfc_cif_schema import RfcCifSchema
65
+ from csf_scraper.rfc_cif_data import RfcCifData
66
+ from csf_scraper.value_objects.csf_image import CsfImage
67
+ from csf_scraper.value_objects.id_cif import IdCif
68
+ from csf_scraper.value_objects.rfc import Rfc
69
+
70
+ logger = logging.getLogger(__name__)
71
+
72
+ #: Número de envíos al modelo antes de rendirse.
73
+ MAX_RETRIES = 3
74
+
75
+ #: Espera inicial, en segundos, antes de reintentar tras un error de API. Se
76
+ #: duplica en cada intento (1, 2, 4, …) hasta :data:`MAX_RETRY_BACKOFF`.
77
+ RETRY_BACKOFF = 1.0
78
+
79
+ #: Tope de la espera entre reintentos, en segundos.
80
+ MAX_RETRY_BACKOFF = 30.0
81
+
82
+ #: Prompt canónico. Todos los adaptadores LLM parten de este texto.
83
+
84
+
85
+ #: Errores provocados por la *respuesta* del modelo. Se reintentan
86
+ #: realimentando el mensaje de error al prompt.
87
+ _RESPONSE_ERRORS: tuple[type[Exception], ...] = (
88
+ InvalidRfcFormatException,
89
+ InvalidIdCifFormatException,
90
+ RfcFromImageNotFoundException,
91
+ CifFromImageNotFoundException,
92
+ )
93
+
94
+ #: Errores de transporte genéricos. ``TimeoutError``/``ConnectionError`` vienen
95
+ #: de la stdlib y los de ``httpx`` los usan por igual todos los SDK de LLM que
96
+ #: esta librería puede albergar; ninguno es específico de un proveedor.
97
+ _TRANSPORT_ERRORS: tuple[type[Exception], ...] = (
98
+ TimeoutError,
99
+ ConnectionError,
100
+ httpx.TimeoutException,
101
+ httpx.TransportError,
102
+ )
103
+
104
+
105
+ class LlmImageReaderService(ABC):
106
+ """Base de los adaptadores de imagen basados en un LLM multimodal.
107
+
108
+ Aporta el *método plantilla* :meth:`_run_program`, que arma el prompt, arma
109
+ la imagen, ejecuta el programa multimodal del proveedor y mapea el resultado
110
+ a :class:`RfcCifData`, reintentando tanto cuando los value objects rechazan
111
+ el formato de negocio de los datos obtenidos como cuando la API del
112
+ proveedor falla de forma temporal.
113
+
114
+ El adaptador concreto hereda de esta clase e implementa
115
+ :class:`~csf_scraper.image_readers.ports.ImageReaderPort` exponiendo un
116
+ ``read()`` que delega en :meth:`_run_program` con su propio
117
+ ``llm_provider``::
118
+
119
+ class MiImageReader(LlmImageReaderService, ImageReaderPort):
120
+ @property
121
+ def provider(self) -> str:
122
+ return "mi-proveedor"
123
+
124
+ async def read(self, image):
125
+ return await self._run_program(
126
+ image=image, llm_provider=self._llm_provider
127
+ )
128
+ """
129
+
130
+ #: Prompt base. Una subclase puede reescribirlo sin tocar la lógica.
131
+ prompt: ClassVar[str] = CSF_PROMPT
132
+
133
+ def __init__(
134
+ self,
135
+ *,
136
+ model: str,
137
+ max_retries: int = MAX_RETRIES,
138
+ retry_backoff: float = RETRY_BACKOFF,
139
+ ) -> None:
140
+ if max_retries < 1:
141
+ raise ValueError(f"max_retries must be at least 1, got {max_retries}.")
142
+ if retry_backoff < 0:
143
+ raise ValueError(f"retry_backoff must be zero or positive, got {retry_backoff}.")
144
+ self._model = model
145
+ self._max_retries = max_retries
146
+ self._retry_backoff = retry_backoff
147
+
148
+ @property
149
+ def model(self) -> str:
150
+ """Nombre del modelo con el que trabaja el adaptador."""
151
+ return self._model
152
+
153
+ @property
154
+ @abstractmethod
155
+ def provider(self) -> str:
156
+ """Identificador del proveedor (``"gemini"``, …)."""
157
+
158
+ async def _run_program(self, image: CsfImage, llm_provider: LLM) -> RfcCifData:
159
+ """Lee el RFC y el idCIF de ``image``, reintentando si hace falta.
160
+
161
+ Es el *método plantilla*: arma la imagen y el prompt de cada intento,
162
+ delega la ejecución del programa multimodal en :meth:`_image_to_schema`
163
+ y mapea la respuesta a :class:`RfcCifData`.
164
+
165
+ Se reintenta en dos casos:
166
+
167
+ - **Respuesta inválida**: los value objects rechazan el formato de
168
+ negocio. El error viaja de vuelta al prompt para que el modelo se
169
+ corrija.
170
+ - **Error de API reintentable**: el proveedor está saturado, caído o no
171
+ es alcanzable. Se espera un tiempo creciente (ver
172
+ :data:`RETRY_BACKOFF`) y se repite el *mismo* prompt, porque el
173
+ problema no está en la imagen ni en la respuesta.
174
+
175
+ Un error de API no reintentable (credenciales inválidas, petición mal
176
+ formada) o cualquier otra excepción inesperada se propaga de inmediato:
177
+ repetirla daría el mismo resultado.
178
+ """
179
+ image_documents = self._build_image_documents(image)
180
+
181
+ previous_error: str | None = None
182
+ last_error: Exception | None = None
183
+ last_cause: Exception | None = None
184
+
185
+ for attempt in range(1, self._max_retries + 1):
186
+ retry_prompt = self._build_prompt(previous_error=previous_error, attempt=attempt)
187
+
188
+ try:
189
+ schema = await self._image_to_schema(
190
+ image_documents=image_documents,
191
+ retry_prompt=retry_prompt,
192
+ llm_provider=llm_provider,
193
+ )
194
+
195
+ return self._to_rfc_cif_data(schema)
196
+ except _RESPONSE_ERRORS as exception:
197
+ last_error = exception
198
+ previous_error = str(exception)
199
+ self._log_retry(attempt, exception)
200
+ # Sin espera: la corrección viaja en el prompt del reintento.
201
+ continue
202
+ except Exception as exception:
203
+ api_error = self._translate_api_exception(exception)
204
+ if api_error is None:
205
+ raise ImageReadingException(
206
+ f"The {self.provider} engine failed to read the image.",
207
+ provider=self.provider,
208
+ model=self.model,
209
+ ) from exception
210
+ if not api_error.retryable:
211
+ raise api_error from exception
212
+
213
+ last_error = api_error
214
+ last_cause = exception
215
+ # El error no proviene de la respuesta del modelo: se repite el
216
+ # prompt canónico en lugar de realimentar el fallo de la API.
217
+ previous_error = None
218
+ self._log_retry(attempt, api_error)
219
+
220
+ await self._wait_before_retry(attempt)
221
+
222
+ if last_error is not None:
223
+ raise last_error from last_cause
224
+ raise ImageReadingException( # pragma: no cover - max_retries >= 1
225
+ f"The {self.provider} engine did not process the image.",
226
+ provider=self.provider,
227
+ model=self.model,
228
+ )
229
+
230
+ def _translate_api_exception(self, exception: Exception) -> ImageReaderApiException | None:
231
+ """Traduce una excepción del proveedor a una de la librería.
232
+
233
+ Es el punto de extensión de los adaptadores: cada uno mapea los tipos de
234
+ error de su SDK a :class:`ImageReaderApiException` (o a sus derivadas
235
+ :class:`ImageReaderUnavailableException` y
236
+ :class:`ImageReaderAuthenticationException`), marcando ``retryable``.
237
+
238
+ Devolver ``None`` significa «no sé qué es»: la plantilla envuelve la
239
+ excepción original en :class:`ImageReadingException` sin reintentar.
240
+
241
+ La implementación base solo reconoce los errores de transporte de la
242
+ stdlib, que no pertenecen a ningún proveedor en particular.
243
+ """
244
+ if isinstance(exception, _TRANSPORT_ERRORS):
245
+ return ImageReaderUnavailableException(
246
+ f"The {self.provider} service is not reachable.",
247
+ provider=self.provider,
248
+ model=self.model,
249
+ )
250
+ return None
251
+
252
+ async def _wait_before_retry(self, attempt: int) -> None:
253
+ """Espera un tiempo creciente entre intentos (1, 2, 4, … segundos).
254
+
255
+ No espera tras el último intento: ya no queda nada que reintentar.
256
+ """
257
+ if attempt >= self._max_retries:
258
+ return
259
+ delay = self._retry_delay(attempt)
260
+ if delay > 0:
261
+ await asyncio.sleep(delay)
262
+
263
+ def _retry_delay(self, attempt: int) -> float:
264
+ """Segundos de espera antes del intento ``attempt + 1``.
265
+
266
+ Crece exponencialmente desde ``retry_backoff`` y se topa en
267
+ :data:`MAX_RETRY_BACKOFF` para no esperar indefinidamente.
268
+ """
269
+ if self._retry_backoff == 0:
270
+ return 0.0
271
+ return min(self._retry_backoff * float(2 ** (attempt - 1)), MAX_RETRY_BACKOFF)
272
+
273
+ async def _image_to_schema(
274
+ self,
275
+ image_documents: Sequence[ImageBlock],
276
+ retry_prompt: str,
277
+ llm_provider: LLM,
278
+ ) -> RfcCifSchema:
279
+ """Ejecuta el programa multimodal del proveedor para un intento."""
280
+ program = MultiModalLLMCompletionProgram.from_defaults(
281
+ output_cls=RfcCifSchema,
282
+ image_documents=list(image_documents),
283
+ prompt_template_str=retry_prompt,
284
+ multi_modal_llm=llm_provider,
285
+ )
286
+ return cast(RfcCifSchema, await program.acall())
287
+
288
+ def _build_image_documents(self, image: CsfImage) -> list[ImageBlock]:
289
+ """Convierte la CSF en el bloque de imagen que consume el programa."""
290
+ return [
291
+ ImageBlock(
292
+ image=image.as_bytes(),
293
+ image_mimetype=image.get_mime_type(),
294
+ )
295
+ ]
296
+
297
+ def _to_rfc_cif_data(self, schema: RfcCifSchema) -> RfcCifData:
298
+ """Mapea la respuesta tipada del modelo al DTO de la librería.
299
+
300
+ Delega el formato de negocio en :class:`Rfc` y :class:`IdCif`, cuyas
301
+ excepciones disparan el siguiente reintento.
302
+ """
303
+ rfc = schema.rfc.strip()
304
+ id_cif = schema.id_cif.strip()
305
+
306
+ if not rfc:
307
+ raise RfcFromImageNotFoundException("The LLM response does not contain an RFC value.")
308
+ if not id_cif:
309
+ raise CifFromImageNotFoundException("The LLM response does not contain an idCIF value.")
310
+
311
+ return RfcCifData(rfc=Rfc(rfc), id_cif=IdCif(id_cif))
312
+
313
+ def _build_prompt(self, *, previous_error: str | None, attempt: int) -> str:
314
+ """Devuelve el prompt del intento ``attempt``, con realimentación opcional."""
315
+ if previous_error is None:
316
+ return self.prompt
317
+ return self.prompt + RETRY_PROMPT.format(previous_attempt=attempt - 1, error=previous_error)
318
+
319
+ def _log_retry(self, attempt: int, exception: Exception) -> None:
320
+ if attempt >= self._max_retries:
321
+ return
322
+ logger.warning(
323
+ "The %s engine could not read the image (attempt %d/%d): %s",
324
+ self.provider,
325
+ attempt,
326
+ self._max_retries,
327
+ exception,
328
+ )
@@ -0,0 +1,7 @@
1
+ """Lectura de la Constancia de Situación Fiscal en PDF."""
2
+
3
+ from csf_scraper.pdf_reader.csf_pdf_extractor import CsfPdfExtractor, PdfTextProvider
4
+ from csf_scraper.pdf_reader.pdf_csf_text_parser import PdfCsfTextParser
5
+ from csf_scraper.pdf_reader.pdf_to_text import PdfToText
6
+
7
+ __all__ = ["CsfPdfExtractor", "PdfCsfTextParser", "PdfTextProvider", "PdfToText"]
@@ -0,0 +1,33 @@
1
+ """Extracción del RFC y el idCIF a partir de un archivo PDF de CSF."""
2
+
3
+ from typing import Protocol
4
+
5
+ from csf_scraper.pdf_reader.pdf_csf_text_parser import PdfCsfTextParser
6
+ from csf_scraper.pdf_reader.pdf_to_text import PdfToText
7
+ from csf_scraper.ports.rfc_cif_extractor_port import RfcCifExtractorPort
8
+ from csf_scraper.rfc_cif_data import RfcCifData
9
+
10
+
11
+ class PdfTextProvider(Protocol):
12
+ """Fuente del texto de un PDF; permite sustituir ``pdftotext`` en los tests."""
13
+
14
+ async def extract(self, path: str) -> str:
15
+ """Devuelve el texto plano del PDF en ``path``."""
16
+ ...
17
+
18
+
19
+ class CsfPdfExtractor(RfcCifExtractorPort):
20
+ """Implementa :class:`RfcCifExtractorPort` leyendo CSFs en PDF.
21
+
22
+ Convierte el PDF a texto (``pdftotext`` por omisión, de forma asíncrona) y
23
+ delega el *parsing* en :class:`PdfCsfTextParser`. El proveedor de texto se
24
+ puede inyectar para probar el extractor sin invocar el binario.
25
+ """
26
+
27
+ def __init__(self, text_provider: PdfTextProvider | None = None) -> None:
28
+ self._text_provider: PdfTextProvider = text_provider or PdfToText()
29
+
30
+ async def extract(self, path: str) -> RfcCifData:
31
+ """Devuelve el RFC y el idCIF del PDF en ``path``."""
32
+ contents = await self._text_provider.extract(path)
33
+ return PdfCsfTextParser(contents).extract()
@@ -0,0 +1,54 @@
1
+ """Análisis del texto de una CSF para obtener su RFC y su idCIF."""
2
+
3
+ from csf_scraper.exceptions.pdf_reader import (
4
+ CifFromPdfNotFoundException,
5
+ EmptyPdfContentException,
6
+ RfcFromPdfNotFoundException,
7
+ )
8
+ from csf_scraper.rfc_cif_data import RfcCifData
9
+ from csf_scraper.value_objects.id_cif import IdCif
10
+ from csf_scraper.value_objects.rfc import Rfc
11
+
12
+
13
+ class PdfCsfTextParser:
14
+ """Obtiene el RFC y el idCIF de las líneas de texto de una CSF.
15
+
16
+ Es la pieza de *parsing* pura: recibe el texto (salida de ``pdftotext``) y
17
+ no conoce el origen ni el binario. :class:`~csf_scraper.pdf_reader.CsfPdfExtractor`
18
+ lo combina con un proveedor de texto para implementar
19
+ :class:`~csf_scraper.ports.RfcCifExtractorPort`.
20
+ """
21
+
22
+ def __init__(self, contents: str) -> None:
23
+ if not contents:
24
+ raise EmptyPdfContentException("Cannot read pdf contents.")
25
+ self._lines = contents.split("\n")
26
+
27
+ def get_rfc(self) -> str | None:
28
+ """RFC encontrado tras la etiqueta ``RFC:``, o ``None`` si no existe."""
29
+ return self._clean_value(self._first_line_starting_with("RFC:"))
30
+
31
+ def get_cif_id(self) -> str | None:
32
+ """idCIF encontrado tras la etiqueta ``idCIF:``, o ``None`` si no existe."""
33
+ return self._clean_value(self._first_line_starting_with("idCIF:"))
34
+
35
+ def extract(self) -> RfcCifData:
36
+ """Devuelve el RFC y el idCIF validados de la CSF en texto."""
37
+ rfc = self.get_rfc()
38
+ cif = self.get_cif_id()
39
+
40
+ if rfc is None:
41
+ raise RfcFromPdfNotFoundException("Cannot obtain rfc from given PDF")
42
+ if cif is None:
43
+ raise CifFromPdfNotFoundException("Cannot obtain cif from given PDF")
44
+ return RfcCifData(rfc=Rfc(rfc), id_cif=IdCif(cif))
45
+
46
+ def _first_line_starting_with(self, prefix: str) -> str:
47
+ return next((line for line in self._lines if line.startswith(prefix)), "")
48
+
49
+ @staticmethod
50
+ def _clean_value(entry: str) -> str | None:
51
+ parts = entry.split(":")
52
+ if len(parts) < 2:
53
+ return None
54
+ return parts[1].strip()
@@ -0,0 +1,36 @@
1
+ """Conversión de PDF a texto mediante el binario ``pdftotext``."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+
6
+ from csf_scraper.exceptions.pdf_reader import PdfToTextConvertException
7
+
8
+
9
+ class PdfToText:
10
+ """Extrae el contenido de un PDF usando ``pdftotext`` (paquete ``poppler-utils``)."""
11
+
12
+ def __init__(self, executable: str = "pdftotext") -> None:
13
+ self._executable = executable
14
+
15
+ async def extract(self, path: str) -> str:
16
+ """Devuelve el texto del PDF; lanza :class:`PdfToTextConvertException` si falla.
17
+
18
+ ``pdftotext`` es un proceso externo y bloqueante, así que se ejecuta en
19
+ un hilo aparte con :func:`asyncio.to_thread`; el bucle de eventos sigue
20
+ libre mientras el binario convierte el archivo.
21
+ """
22
+ command = self.build_command(path)
23
+ result = await asyncio.to_thread(subprocess.run, command, capture_output=True, check=False)
24
+ if result.returncode != 0:
25
+ raise PdfToTextConvertException(
26
+ f"Running pdftotext exit with error (exit code {result.returncode})",
27
+ command,
28
+ result.returncode,
29
+ result.stdout.decode("utf-8", errors="replace"),
30
+ result.stderr.decode("utf-8", errors="replace"),
31
+ )
32
+ return result.stdout.decode("utf-8", errors="replace")
33
+
34
+ def build_command(self, pdf_file: str) -> list[str]:
35
+ """Argumentos con los que se invoca a ``pdftotext``."""
36
+ return [self._executable, "-eol", "unix", "-raw", "-q", pdf_file, "-"]
csf_scraper/persona.py ADDED
@@ -0,0 +1,64 @@
1
+ """Modelo base para los datos fiscales de una persona."""
2
+
3
+ from typing import Any, ClassVar
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from csf_scraper.date_parser import DateField
8
+ from csf_scraper.regimen import Regimen
9
+ from csf_scraper.value_objects.id_cif import IdCif
10
+ from csf_scraper.value_objects.rfc import Rfc
11
+
12
+
13
+ class Persona(BaseModel):
14
+ """Datos fiscales comunes a una persona moral o física."""
15
+
16
+ model_config = ConfigDict(validate_assignment=True)
17
+
18
+ rfc: Rfc | None = None
19
+ id_cif: IdCif | None = None
20
+ fecha_inicio_operaciones: DateField = None
21
+ situacion_contribuyente: str = ""
22
+ fecha_ultimo_cambio_situacion: DateField = None
23
+ entidad_federativa: str = ""
24
+ municipio_delegacion: str = ""
25
+ colonia: str = ""
26
+ tipo_vialidad: str = ""
27
+ nombre_vialidad: str = ""
28
+ numero_exterior: str = ""
29
+ numero_interior: str = ""
30
+ codigo_postal: str = ""
31
+ correo_electronico: str = ""
32
+ al: str = ""
33
+ regimenes: list[Regimen] = Field(default_factory=list)
34
+ extra_data: dict[str, Any] = Field(default_factory=dict)
35
+
36
+ #: Títulos del HTML que se buscan y el campo (o clave de ``extra_data``) que alimentan.
37
+ field_titles: ClassVar[dict[str, str]] = {
38
+ "fecha_inicio_operaciones": "Fecha de Inicio de operaciones:",
39
+ "situacion_contribuyente": "Situación del contribuyente:",
40
+ "fecha_ultimo_cambio_situacion": "Fecha del último cambio de situación:",
41
+ "entidad_federativa": "Entidad Federativa:",
42
+ "municipio_delegacion": "Municipio o delegación:",
43
+ "colonia": "Colonia:",
44
+ "localidad": "Localidad:",
45
+ "tipo_vialidad": "Tipo de vialidad:",
46
+ "nombre_vialidad": "Nombre de la vialidad:",
47
+ "numero_exterior": "Número exterior:",
48
+ "numero_interior": "Número interior:",
49
+ "codigo_postal": "CP:",
50
+ "correo_electronico": "Correo electrónico:",
51
+ "al": "AL:",
52
+ }
53
+
54
+ def is_fisica(self) -> bool:
55
+ """Indica si la persona es física (RFC de 13 caracteres)."""
56
+ return self.rfc.is_fisica() if self.rfc is not None else False
57
+
58
+ def is_moral(self) -> bool:
59
+ """Indica si la persona es moral (RFC de 12 caracteres)."""
60
+ return self.rfc.is_moral() if self.rfc is not None else False
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ """Devuelve los datos como un diccionario plano (envoltura de :meth:`model_dump`)."""
64
+ return self.model_dump()