csf-scraper-python 0.1.1__tar.gz

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 (97) hide show
  1. csf_scraper_python-0.1.1/.gitignore +21 -0
  2. csf_scraper_python-0.1.1/LICENSE +21 -0
  3. csf_scraper_python-0.1.1/NOTICE.md +16 -0
  4. csf_scraper_python-0.1.1/PKG-INFO +390 -0
  5. csf_scraper_python-0.1.1/README.md +351 -0
  6. csf_scraper_python-0.1.1/docs/differences.md +152 -0
  7. csf_scraper_python-0.1.1/docs/migration-map.md +187 -0
  8. csf_scraper_python-0.1.1/pyproject.toml +79 -0
  9. csf_scraper_python-0.1.1/src/csf_scraper/__init__.py +119 -0
  10. csf_scraper_python-0.1.1/src/csf_scraper/data_extractor.py +74 -0
  11. csf_scraper_python-0.1.1/src/csf_scraper/date_parser.py +26 -0
  12. csf_scraper_python-0.1.1/src/csf_scraper/exceptions/__init__.py +17 -0
  13. csf_scraper_python-0.1.1/src/csf_scraper/exceptions/image_reader.py +106 -0
  14. csf_scraper_python-0.1.1/src/csf_scraper/exceptions/pdf_reader.py +33 -0
  15. csf_scraper_python-0.1.1/src/csf_scraper/exceptions/value_objects.py +11 -0
  16. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/__init__.py +52 -0
  17. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/adapters/__init__.py +18 -0
  18. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/adapters/llms/__init__.py +33 -0
  19. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/adapters/llms/gemini_image_reader.py +157 -0
  20. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/adapters/llms/prompts/__init__.py +8 -0
  21. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/adapters/llms/prompts/llm_image_reader_prompt.py +17 -0
  22. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/csf_image_extractor.py +29 -0
  23. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/ports/__init__.py +5 -0
  24. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/ports/image_reader_port.py +37 -0
  25. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/schemas/__init__.py +5 -0
  26. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/schemas/rfc_cif_schema.py +42 -0
  27. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/services/__init__.py +18 -0
  28. csf_scraper_python-0.1.1/src/csf_scraper/image_readers/services/llm_image_reader_service.py +328 -0
  29. csf_scraper_python-0.1.1/src/csf_scraper/pdf_reader/__init__.py +7 -0
  30. csf_scraper_python-0.1.1/src/csf_scraper/pdf_reader/csf_pdf_extractor.py +33 -0
  31. csf_scraper_python-0.1.1/src/csf_scraper/pdf_reader/pdf_csf_text_parser.py +54 -0
  32. csf_scraper_python-0.1.1/src/csf_scraper/pdf_reader/pdf_to_text.py +36 -0
  33. csf_scraper_python-0.1.1/src/csf_scraper/persona.py +64 -0
  34. csf_scraper_python-0.1.1/src/csf_scraper/persona_fisica.py +25 -0
  35. csf_scraper_python-0.1.1/src/csf_scraper/persona_moral.py +21 -0
  36. csf_scraper_python-0.1.1/src/csf_scraper/ports/__init__.py +5 -0
  37. csf_scraper_python-0.1.1/src/csf_scraper/ports/rfc_cif_extractor_port.py +40 -0
  38. csf_scraper_python-0.1.1/src/csf_scraper/py.typed +0 -0
  39. csf_scraper_python-0.1.1/src/csf_scraper/regimen.py +62 -0
  40. csf_scraper_python-0.1.1/src/csf_scraper/rfc_cif_data.py +15 -0
  41. csf_scraper_python-0.1.1/src/csf_scraper/scraper.py +117 -0
  42. csf_scraper_python-0.1.1/src/csf_scraper/value_objects/__init__.py +7 -0
  43. csf_scraper_python-0.1.1/src/csf_scraper/value_objects/csf_image.py +68 -0
  44. csf_scraper_python-0.1.1/src/csf_scraper/value_objects/id_cif.py +48 -0
  45. csf_scraper_python-0.1.1/src/csf_scraper/value_objects/rfc.py +56 -0
  46. csf_scraper_python-0.1.1/tests/__init__.py +0 -0
  47. csf_scraper_python-0.1.1/tests/_files/csf-bad-content.txt +5 -0
  48. csf_scraper_python-0.1.1/tests/_files/csf-content.txt +73 -0
  49. csf_scraper_python-0.1.1/tests/_files/csf-correct-but-invalid.pdf +0 -0
  50. csf_scraper_python-0.1.1/tests/_files/csf-without-cif.pdf +0 -0
  51. csf_scraper_python-0.1.1/tests/_files/csf-without-rfc.pdf +0 -0
  52. csf_scraper_python-0.1.1/tests/_files/error.html +239 -0
  53. csf_scraper_python-0.1.1/tests/_files/scrap_fisica.html +448 -0
  54. csf_scraper_python-0.1.1/tests/_files/scrap_moral.html +437 -0
  55. csf_scraper_python-0.1.1/tests/_files/scrap_moral_without_colonia.html +366 -0
  56. csf_scraper_python-0.1.1/tests/_files/scrap_regimenes.html +879 -0
  57. csf_scraper_python-0.1.1/tests/helpers.py +15 -0
  58. csf_scraper_python-0.1.1/tests/integration/__init__.py +0 -0
  59. csf_scraper_python-0.1.1/tests/integration/helpers.py +25 -0
  60. csf_scraper_python-0.1.1/tests/integration/test_scrap_csf_from_rfc_and_cif.py +224 -0
  61. csf_scraper_python-0.1.1/tests/integration/test_scrap_from_csf_file.py +119 -0
  62. csf_scraper_python-0.1.1/tests/unit/__init__.py +0 -0
  63. csf_scraper_python-0.1.1/tests/unit/exceptions/__init__.py +0 -0
  64. csf_scraper_python-0.1.1/tests/unit/exceptions/pdf_reader/__init__.py +0 -0
  65. csf_scraper_python-0.1.1/tests/unit/exceptions/pdf_reader/test_cif_from_pdf_not_found_exception.py +5 -0
  66. csf_scraper_python-0.1.1/tests/unit/exceptions/pdf_reader/test_empty_pdf_content_exception.py +5 -0
  67. csf_scraper_python-0.1.1/tests/unit/exceptions/pdf_reader/test_pdf_to_text_convert_exception.py +23 -0
  68. csf_scraper_python-0.1.1/tests/unit/exceptions/pdf_reader/test_rfc_from_pdf_not_found_exception.py +5 -0
  69. csf_scraper_python-0.1.1/tests/unit/exceptions/test_cif_download_exception.py +21 -0
  70. csf_scraper_python-0.1.1/tests/unit/exceptions/test_cif_not_found_exception.py +5 -0
  71. csf_scraper_python-0.1.1/tests/unit/image_readers/__init__.py +0 -0
  72. csf_scraper_python-0.1.1/tests/unit/image_readers/test_csf_image_extractor.py +61 -0
  73. csf_scraper_python-0.1.1/tests/unit/image_readers/test_llm_image_reader.py +293 -0
  74. csf_scraper_python-0.1.1/tests/unit/pdf_reader/__init__.py +0 -0
  75. csf_scraper_python-0.1.1/tests/unit/pdf_reader/pdf_csf_text_extractor/__init__.py +0 -0
  76. csf_scraper_python-0.1.1/tests/unit/pdf_reader/pdf_csf_text_extractor/test_extract.py +55 -0
  77. csf_scraper_python-0.1.1/tests/unit/pdf_reader/pdf_csf_text_extractor/test_get_cif_id.py +12 -0
  78. csf_scraper_python-0.1.1/tests/unit/pdf_reader/pdf_csf_text_extractor/test_get_rfc.py +26 -0
  79. csf_scraper_python-0.1.1/tests/unit/pdf_reader/pdf_to_text/__init__.py +0 -0
  80. csf_scraper_python-0.1.1/tests/unit/pdf_reader/pdf_to_text/test_pdf_to_text_constructor.py +21 -0
  81. csf_scraper_python-0.1.1/tests/unit/persona/__init__.py +0 -0
  82. csf_scraper_python-0.1.1/tests/unit/persona/test_common_persona_properties.py +75 -0
  83. csf_scraper_python-0.1.1/tests/unit/persona/test_persona_fisica_properties.py +51 -0
  84. csf_scraper_python-0.1.1/tests/unit/persona/test_persona_json_serialize.py +63 -0
  85. csf_scraper_python-0.1.1/tests/unit/persona/test_persona_moral_properties.py +49 -0
  86. csf_scraper_python-0.1.1/tests/unit/persona/traits/__init__.py +0 -0
  87. csf_scraper_python-0.1.1/tests/unit/persona/traits/persona_trait.py +27 -0
  88. csf_scraper_python-0.1.1/tests/unit/regimen/__init__.py +0 -0
  89. csf_scraper_python-0.1.1/tests/unit/regimen/test_regimen_json_serialize.py +15 -0
  90. csf_scraper_python-0.1.1/tests/unit/regimen/test_regimenes_json_serialize.py +24 -0
  91. csf_scraper_python-0.1.1/tests/unit/regimen/test_set_regimen.py +87 -0
  92. csf_scraper_python-0.1.1/tests/unit/test_regimenes.py +13 -0
  93. csf_scraper_python-0.1.1/tests/unit/test_scraper.py +72 -0
  94. csf_scraper_python-0.1.1/tests/unit/value_objects/__init__.py +0 -0
  95. csf_scraper_python-0.1.1/tests/unit/value_objects/test_csf_image.py +52 -0
  96. csf_scraper_python-0.1.1/tests/unit/value_objects/test_id_cif.py +42 -0
  97. csf_scraper_python-0.1.1/tests/unit/value_objects/test_rfc.py +60 -0
@@ -0,0 +1,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+
10
+ # Tools
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # Editors / OS
18
+ .idea/
19
+ .vscode/
20
+ *.swp
21
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 - 2025 PhpCfdi https://www.phpcfdi.com/
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ # NOTICE
2
+
3
+ Este proyecto es un **port a Python** de la librería PHP
4
+ [`phpcfdi/csf-scraper`](https://github.com/phpcfdi/csf-scraper).
5
+
6
+ - **Proyecto original**: phpcfdi/csf-scraper
7
+ - **Repositorio**: <https://github.com/phpcfdi/csf-scraper>
8
+ - **Tag de origen migrado**: `v0.2.0`
9
+ - **Commit de origen migrado**: `c66aab5fc1997da5de0ace9cea38274b86bfeaaf` (`v0.2.0-1-gc66aab5`)
10
+ - **Licencia original**: MIT ( véase [LICENSE](LICENSE), copyright conservado de los autores originales)
11
+ - **Autoría original**: Cesar Aguilera, Misael Limón, Fernando Isidro y colaboradores de [PhpCfdi](https://www.phpcfdi.com/)
12
+
13
+ Todo el código fuente de este port se deriva del proyecto original; la lógica de
14
+ negocio, los nombres de dominio, los mensajes de las excepciones y los fixtures
15
+ de pruebas provienen de `phpcfdi/csf-scraper` y fueron migrados a Python
16
+ (httpx, BeautifulSoup4 + lxml, pydantic, pytest, respx).
@@ -0,0 +1,390 @@
1
+ Metadata-Version: 2.5
2
+ Name: csf-scraper-python
3
+ Version: 0.1.1
4
+ Summary: Obtiene los datos fiscales actuales de una persona moral o física dado su RFC y su idCIF (port de phpcfdi/csf-scraper)
5
+ Project-URL: Homepage, https://github.com/PJMP4826/csf-scraper-python
6
+ Project-URL: Issues, https://github.com/PJMP4826/csf-scraper-python/issues
7
+ Project-URL: Source, https://github.com/PJMP4826/csf-scraper-python
8
+ Author-email: PJMP4826 <faustojaviermendozaperez.tareas@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ License-File: NOTICE.md
12
+ Keywords: csf,phpcfdi,rfc,sat,scraper
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: beautifulsoup4>=4.12
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: llama-index-core>=0.14
27
+ Requires-Dist: lxml>=5.0
28
+ Requires-Dist: pydantic-core>=2.18
29
+ Requires-Dist: pydantic>=2.7
30
+ Provides-Extra: dev
31
+ Requires-Dist: mypy>=1.11; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Requires-Dist: respx>=0.22; extra == 'dev'
34
+ Requires-Dist: ruff>=0.6; extra == 'dev'
35
+ Provides-Extra: llm-gemini
36
+ Requires-Dist: google-genai>=1.0; extra == 'llm-gemini'
37
+ Requires-Dist: llama-index-llms-google-genai<0.12,>=0.11; extra == 'llm-gemini'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # csf-scraper
41
+
42
+ Port a Python de [phpcfdi/csf-scraper](https://github.com/phpcfdi/csf-scraper):
43
+ obtiene los datos fiscales actuales de una persona moral o física dado su RFC y
44
+ su idCIF (SAT, Constancia de Situación Fiscal).
45
+
46
+ ## Requisitos
47
+
48
+ - Python >= 3.11
49
+ - [`pdftotext`](https://poppler.freedesktop.org/) (paquete del sistema
50
+ `poppler-utils`), necesario **solo** para leer la CSF desde un archivo PDF
51
+ (`obtain_from_pdf_path`).
52
+ - Dependencias: `httpx`, `beautifulsoup4`, `lxml`, `pydantic`, `llama-index-core`.
53
+ Los clientes de cada proveedor LLM son extras opcionales (`llm-gemini`).
54
+
55
+ ## Instalación
56
+
57
+ Desde PyPI (solo lectura de CSF por RFC+idCIF y por PDF):
58
+
59
+ ```bash
60
+ pip install csf-scraper-python
61
+ ```
62
+
63
+ Para leer CSF escaneadas (JPEG/PNG) con Gemini, agrega el extra:
64
+
65
+ ```bash
66
+ pip install "csf-scraper-python[llm-gemini]"
67
+ ```
68
+
69
+ Desde el código fuente:
70
+
71
+ ```bash
72
+ pip install .
73
+ ```
74
+
75
+ ## Ejemplo de uso
76
+
77
+ Todo el flujo es asíncrono: `Scraper` envuelve un `httpx.AsyncClient` y todos
78
+ los métodos de lectura son corrutinas, así que se consumen con `await`.
79
+
80
+ ```python
81
+ import asyncio
82
+
83
+ from csf_scraper import Scraper
84
+
85
+
86
+ async def main() -> None:
87
+ # El scraper es un context manager asíncrono: cierra el AsyncClient al salir
88
+ async with Scraper.create() as scraper:
89
+ # RFC + idCIF directamente (se validan con los value objects Rfc e IdCif)
90
+ person = await scraper.obtain_from_rfc_and_cif("DIM8701081LA", "1904014102123")
91
+
92
+ # También puedes obtener los datos directamente de un archivo local PDF
93
+ # (requiere pdftotext / poppler-utils instalado)
94
+ person = await scraper.obtain_from_pdf_path("ruta/al/csf.pdf")
95
+
96
+ # Acceso a los datos de la persona (moral o física) por atributos
97
+ if person.is_fisica():
98
+ print(person.nombre)
99
+ elif person.is_moral():
100
+ print(person.razon_social)
101
+
102
+ # Regímenes: lista de Regimen, cada uno con su id ya calculado
103
+ for regimen in person.regimenes:
104
+ print(regimen.regimen, regimen.regimen_id, regimen.fecha_alta)
105
+
106
+ # Los títulos del HTML que no tienen un campo propio se guardan aquí
107
+ print(person.extra_data)
108
+
109
+ # Diccionario de datos (envoltura de model_dump) o JSON (ISO-8601 en fechas)
110
+ data = person.to_dict()
111
+ json_text = person.model_dump_json()
112
+
113
+
114
+ if __name__ == "__main__":
115
+ asyncio.run(main())
116
+ ```
117
+
118
+ Para leer una CSF escaneada (JPEG/PNG) se inyecta el motor de lectura de imágenes.
119
+ Requiere el extra `llm-gemini`:
120
+
121
+ ```python
122
+ import asyncio
123
+
124
+ from csf_scraper import CsfImageExtractor, GeminiImageReader, Scraper
125
+
126
+
127
+ async def main() -> None:
128
+ reader = GeminiImageReader(api_key="…", model_name="…")
129
+ async with Scraper.create(image_extractor=CsfImageExtractor(reader)) as scraper:
130
+ person = await scraper.obtain_from_image_path("ruta/al/csf.png")
131
+ print(person.to_dict())
132
+
133
+
134
+ if __name__ == "__main__":
135
+ asyncio.run(main())
136
+ ```
137
+
138
+ Salida de `person.to_dict()` para una persona moral (fechas como `datetime`):
139
+
140
+ ```python
141
+ {
142
+ "rfc": "DIM8701081LA",
143
+ "id_cif": "12345678",
144
+ "fecha_inicio_operaciones": datetime(2019, 2, 21),
145
+ # …
146
+ "regimenes": [
147
+ {
148
+ "regimen": "Régimen General de Ley Personas Morales",
149
+ "fecha_alta": datetime(2019, 2, 21),
150
+ "regimen_id": "601",
151
+ },
152
+ ],
153
+ "extra_data": {"localidad": ""},
154
+ "razon_social": "Mi razón social",
155
+ "regimen_de_capital": "SA DE CV",
156
+ "fecha_constitucion": datetime(2019, 2, 21),
157
+ }
158
+ ```
159
+
160
+ ## Modelos
161
+
162
+ - `Persona` es la base de `PersonaFisica` y `PersonaMoral`; sus campos se
163
+ exponen como atributos en `snake_case` (`rfc`, `id_cif`, `colonia`, …).
164
+ - `rfc: Rfc | None` e `id_cif: IdCif | None` usan los value objects que validan
165
+ el formato tanto al construir como al asignar (pydantic normaliza las cadenas
166
+ automáticamente).
167
+ - `is_fisica()` e `is_moral()` permiten distinguir el tipo de persona sin
168
+ inspeccionar el RFC por fuera de la librería.
169
+ - `Regimen` se construye con el texto del régimen y, opcionalmente, la fecha de
170
+ alta: `Regimen(regimen="Régimen Simplificado de Confianza", fecha_alta="01-02-2001")`.
171
+ El atributo `regimen_id` se calcula a partir del texto y `fecha_alta` acepta
172
+ cadenas `dd-mm-yyyy` o `datetime`.
173
+ - `extra_data` reúne los títulos del HTML que no corresponden a un campo del
174
+ modelo. La correspondencia título → campo está en `Persona.field_titles`.
175
+ - `to_dict()` es una envoltura de `model_dump()`; `model_dump_json()` serializa
176
+ las fechas en ISO-8601.
177
+
178
+ ## Value objects
179
+
180
+ | Value object | Regla |
181
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
182
+ | `Rfc` | `3–4 letras` (incluye `Ñ` y `&`) + `6 dígitos` + `3 alfanuméricos`; normaliza espacios/mayúsculas. `is_fisica()` (13) / `is_moral()` (12) |
183
+ | `IdCif` | solo dígitos, longitud 8–13 |
184
+ | `CsfImage` | imagen JPEG o PNG validada por _magic bytes_; expone `as_bytes()`, `as_base64()` y `get_mime_type()` |
185
+
186
+ Los value objects son subclases de `str`, por lo que se comportan como cadenas,
187
+ se serializan como texto plano y lanzan la excepción personalizada
188
+ correspondiente si el formato no es válido:
189
+
190
+ ```python
191
+ from csf_scraper import InvalidIdCifFormatException, InvalidRfcFormatException, Rfc
192
+
193
+ rfc = Rfc(" dim8701081la ") # → "DIM8701081LA"
194
+ rfc.is_moral() # → True
195
+
196
+ try:
197
+ Rfc("no-es-un-rfc")
198
+ except InvalidRfcFormatException as exception:
199
+ print(exception)
200
+ ```
201
+
202
+ ## Lectura de CSF
203
+
204
+ La librería separa dos responsabilidades que es fácil confundir:
205
+
206
+ - **Origen**: de dónde salen los datos (una ruta de archivo). Contrato público
207
+ `RfcCifExtractorPort.extract(path) -> RfcCifData`.
208
+ - **Motor**: cómo se lee una imagen ya cargada en memoria. Contrato
209
+ `ImageReaderPort.read(image) -> RfcCifData`.
210
+
211
+ `Scraper` solo habla con `RfcCifExtractorPort`, de modo que no distingue un PDF
212
+ (`CsfPdfExtractor`) de una imagen: cualquier extractor que cumpla el contrato
213
+ sirve para ambas rutas.
214
+
215
+ ```mermaid
216
+ flowchart LR
217
+ S[Scraper] -->|obtain_from_pdf_path| P[CsfPdfExtractor]
218
+ S -->|obtain_from_image_path| B[CsfImageExtractor]
219
+ B -->|ImageReaderPort| G[GeminiImageReader]
220
+ G -->|hereda| L[LlmImageReaderService]
221
+ B -.-> T[OCR: OpenCV + Tesseract]
222
+ P --> R[RfcCifData]
223
+ B --> R
224
+ ```
225
+
226
+ ### Contrato de los extractores (`RfcCifExtractorPort`)
227
+
228
+ ```python
229
+ class RfcCifExtractorPort(ABC):
230
+ @abstractmethod
231
+ async def extract(self, path: str) -> RfcCifData: ...
232
+ ```
233
+
234
+ - El contrato es **asíncrono**: `extract` es una corrutina para que cada
235
+ implementación pueda delegar E/S (procesos externos, disco) sin bloquear el
236
+ bucle de eventos; `Scraper` la consume con `await`.
237
+ - La ruta viaja **en el método**, no en el constructor: los extractores son
238
+ sin estado y reutilizables entre archivos.
239
+ - El constructor queda reservado para configuración del motor (API key, modelo,
240
+ binario alternativo…).
241
+ - Devuelve siempre `RfcCifData` con `rfc: Rfc` e `id_cif: IdCif` ya validados.
242
+ - Los adaptadores de la librería reportan sus fallos con `CsfScraperException`;
243
+ las dependencias inyectadas pueden propagar sus propias excepciones.
244
+
245
+ Implementaciones: `CsfPdfExtractor` (texto de `pdftotext` + `PdfCsfTextParser`)
246
+ y `CsfImageExtractor` (puente entre una ruta y cualquier `ImageReaderPort`).
247
+
248
+ ### Contrato de los motores de imagen (`ImageReaderPort`)
249
+
250
+ ```python
251
+ class ImageReaderPort(ABC):
252
+ @abstractmethod
253
+ async def read(self, image: CsfImage) -> RfcCifData: ...
254
+ ```
255
+
256
+ - Recibe una imagen **en memoria**; nunca abre archivos ni conoce rutas.
257
+ - La validación de formato (JPEG/PNG) ocurre al construir `CsfImage`, así que el
258
+ motor puede asumir que la imagen es válida.
259
+
260
+ ### Sub-contrato de los adaptadores basados en LLM
261
+
262
+ Los adaptadores de Gemini, DeepSeek, etc. heredan de `LlmImageReaderService` e
263
+ implementan `ImageReaderPort`. La base aporta el comportamiento común; el
264
+ adaptador crea su cliente de LlamaIndex y expone `read()` delegando en el método
265
+ plantilla.
266
+
267
+ ```python
268
+ class LlmImageReaderService(ABC):
269
+ prompt: ClassVar[str] = CSF_PROMPT
270
+ provider: str # propiedad abstracta
271
+ model: str
272
+
273
+ async def _run_program(
274
+ self, image: CsfImage, llm_provider: LLM
275
+ ) -> RfcCifData: # plantilla
276
+ ...
277
+
278
+ async def _image_to_schema(
279
+ self,
280
+ image_documents: Sequence[ImageBlock],
281
+ retry_prompt: str,
282
+ llm_provider: LLM,
283
+ ) -> RfcCifSchema:
284
+ ...
285
+ ```
286
+
287
+ `LlmImageReaderService` solo tiene cuatro responsabilidades:
288
+
289
+ 1. **Armado del prompt**: `CSF_PROMPT` más la realimentación del intento previo.
290
+ 2. **Armado del mensaje con la imagen**: un único `ImageBlock` con los bytes y
291
+ el MIME type de la `CsfImage`.
292
+ 3. **Reintentos**: cuando `Rfc` o `IdCif` rechazan el formato de negocio, se
293
+ vuelve a interrogar al modelo pasándole el error como contexto (hasta
294
+ `max_retries`, por omisión `MAX_RETRIES = 3`). Los fallos de transporte _no_
295
+ se reintentan: se envuelven en `ImageReadingException`.
296
+ 4. **Parseo al DTO**: un `RfcCifSchema` ya tipado se mapea a `RfcCifData`.
297
+
298
+ | Responsabilidad | Dónde vive |
299
+ | ----------------------- | ----------------------------------------------------------- |
300
+ | Prompt canónico | constante `CSF_PROMPT` (+ `RETRY_PROMPT`) |
301
+ | Lectura del archivo | `CsfImage.new_from_path`, invocado por `CsfImageExtractor` |
302
+ | Forma de la respuesta | `RfcCifSchema` (`Field` con `description` para el modelo) |
303
+ | Transporte al proveedor | el adaptador crea `llm_provider` y lo pasa a `_run_program` |
304
+ | Validación de RFC/idCIF | value objects `Rfc` / `IdCif` |
305
+ | Mapeo de errores | `ImageReadingException` y derivadas |
306
+
307
+ Nota: la imagen se lee con `asyncio.to_thread` dentro de `CsfImageExtractor` y
308
+ `pdftotext` se ejecuta con `asyncio.to_thread` dentro de `PdfToText`, porque
309
+ ambas operaciones son bloqueantes; el resto del flujo (HTTP, LLM) usa APIs
310
+ asíncronas.
311
+
312
+ `RfcCifSchema` es un `BaseModel` cuyos `Field` llevan `description`; esos textos
313
+ son los que el modelo multimodal usa para saber qué aspecto tienen el RFC y el
314
+ idCIF impresos en la Constancia de Situación Fiscal. Pydantic valida la _forma_
315
+ (campos presentes y de tipo `str`) y los value objects validan el _formato de
316
+ negocio_, que es justo lo que dispara los reintentos.
317
+
318
+ El adaptador crea su cliente de LlamaIndex (`GoogleGenAI`) y lo pasa al método
319
+ plantilla; `_image_to_schema` construye y ejecuta el
320
+ `MultiModalLLMCompletionProgram`:
321
+
322
+ ```python
323
+ class GeminiImageReader(LlmImageReaderService, ImageReaderPort):
324
+ def __init__(
325
+ self, api_key: str, model_name: str, *, max_retries: int = MAX_RETRIES
326
+ ) -> None:
327
+ super().__init__(model=model_name, max_retries=max_retries)
328
+ self._llm_provider = GoogleGenAI(api_key=api_key, model=model_name)
329
+
330
+ @property
331
+ def provider(self) -> str:
332
+ return "gemini"
333
+
334
+ async def read(self, image: CsfImage) -> RfcCifSchema:
335
+ return await self._run_program(
336
+ image=image, llm_provider=self._llm_provider
337
+ )
338
+ ```
339
+
340
+ El extra `llm-gemini` instala `llama-index-llms-google-genai`; el resto de la
341
+ librería no depende de ningún proveedor.
342
+
343
+ Por omisión `Scraper` solo sabe leer PDFs; el extractor de imágenes es
344
+ **opcional** y se inyecta con `Scraper.create(image_extractor=…)`. Si se invoca
345
+ `obtain_from_image_path` sin extractor configurado se lanza
346
+ `ImageExtractorNotConfiguredException`.
347
+
348
+ ## Excepciones
349
+
350
+ Todas heredan de `CsfScraperException`:
351
+
352
+ - `CifDownloadException` (la petición HTTP falló; conserva `url`; la excepción
353
+ original queda encadenada en `__cause__`)
354
+ - `CifNotFoundException` (el SAT no devolvió información del CIF)
355
+ - `RfcFromPdfNotFoundException` / `CifFromPdfNotFoundException` (el PDF no
356
+ contiene RFC / idCIF)
357
+ - `EmptyPdfContentException` / `PdfToTextConvertException` (problemas con
358
+ `pdftotext`)
359
+ - `RfcFromImageNotFoundException` / `CifFromImageNotFoundException` (la imagen
360
+ no contiene RFC / idCIF)
361
+ - `ImageExtractorNotConfiguredException` (se pidió leer una imagen sin inyectar
362
+ un extractor)
363
+ - `InvalidImageException` / `InvalidImageFormatException` (imagen vacía, ilegible
364
+ o con formato distinto de JPEG/PNG)
365
+ - `ImageReadingException` (fallo del motor de lectura de imágenes; conserva
366
+ `provider` y `model`)
367
+ - `InvalidRfcFormatException` / `InvalidIdCifFormatException` (formato inválido
368
+ en los value objects `Rfc` / `IdCif`)
369
+
370
+ ## Desarrollo
371
+
372
+ ```bash
373
+ python -m venv .venv
374
+ .venv/bin/pip install -e ".[dev]"
375
+ .venv/bin/ruff check .
376
+ .venv/bin/mypy
377
+ .venv/bin/pytest
378
+ ```
379
+
380
+ Los tests usan fixtures de `tests/_files/` y mockean HTTP con `respx`:
381
+ **ningún test contacta al SAT**.
382
+
383
+ ## Documentación de la migración
384
+
385
+ - `docs/migration-map.md`: correspondencia archivo a archivo PHP → Python.
386
+ - `docs/differences.md`: diferencias de comportamiento deliberadas.
387
+
388
+ ## Licencia
389
+
390
+ MIT — ver `LICENSE` (heredado del original) y `NOTICE.md`.