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,25 @@
1
+ """Modelo de persona física."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from csf_scraper.date_parser import DateField
6
+ from csf_scraper.persona import Persona
7
+
8
+
9
+ class PersonaFisica(Persona):
10
+ """Datos fiscales de una persona física."""
11
+
12
+ curp: str = ""
13
+ nombre: str = ""
14
+ apellido_paterno: str = ""
15
+ apellido_materno: str = ""
16
+ fecha_nacimiento: DateField = None
17
+
18
+ field_titles: ClassVar[dict[str, str]] = {
19
+ "curp": "CURP:",
20
+ "nombre": "Nombre:",
21
+ "apellido_paterno": "Apellido Paterno:",
22
+ "apellido_materno": "Apellido Materno:",
23
+ "fecha_nacimiento": "Fecha Nacimiento:",
24
+ **Persona.field_titles,
25
+ }
@@ -0,0 +1,21 @@
1
+ """Modelo de persona moral."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from csf_scraper.date_parser import DateField
6
+ from csf_scraper.persona import Persona
7
+
8
+
9
+ class PersonaMoral(Persona):
10
+ """Datos fiscales de una persona moral."""
11
+
12
+ razon_social: str = ""
13
+ regimen_de_capital: str = ""
14
+ fecha_constitucion: DateField = None
15
+
16
+ field_titles: ClassVar[dict[str, str]] = {
17
+ "razon_social": "Denominación o Razón Social:",
18
+ "regimen_de_capital": "Régimen de capital:",
19
+ "fecha_constitucion": "Fecha de constitución:",
20
+ **Persona.field_titles,
21
+ }
@@ -0,0 +1,5 @@
1
+ """Puertos (interfaces) de la librería."""
2
+
3
+ from csf_scraper.ports.rfc_cif_extractor_port import RfcCifExtractorPort
4
+
5
+ __all__ = ["RfcCifExtractorPort"]
@@ -0,0 +1,40 @@
1
+ """Puerto que unifica la extracción de RFC e idCIF de una CSF desde una ruta."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from csf_scraper.rfc_cif_data import RfcCifData
6
+
7
+
8
+ class RfcCifExtractorPort(ABC):
9
+ """Extrae el :class:`RfcCifData` de una Constancia de Situación Fiscal.
10
+
11
+ Contrato público de los adaptadores que leen una CSF *desde una ruta*
12
+ (PDF, imagen o cualquier formato futuro). ``Scraper`` solo depende de este
13
+ puerto, así que todos los adaptadores son intercambiables entre sí.
14
+
15
+ Reglas del contrato:
16
+
17
+ - La ruta se recibe en :meth:`extract`, **no** en el constructor: los
18
+ adaptadores son sin estado y reutilizables entre archivos.
19
+ - El constructor se reserva a la configuración del motor (credenciales,
20
+ modelo, binarios o dependencias inyectadas).
21
+ - El adaptador valida y devuelve :class:`RfcCifData` con los value objects
22
+ ya construidos (``rfc: Rfc``, ``id_cif: IdCif``); nunca cadenas crudas.
23
+ - Los adaptadores de la librería reportan sus fallos con subclases de
24
+ :class:`~csf_scraper.exceptions.CsfScraperException`. Las dependencias
25
+ inyectadas (por ejemplo, un ``ImageReaderPort`` externo) pueden propagar
26
+ sus propias excepciones.
27
+
28
+ Implementaciones: :class:`~csf_scraper.pdf_reader.CsfPdfExtractor` (texto
29
+ del PDF) y :class:`~csf_scraper.image_readers.CsfImageExtractor` (cualquier
30
+ :class:`~csf_scraper.image_readers.ports.ImageReaderPort`).
31
+
32
+ El contrato es **asíncrono**: ``extract`` es una corrutina para que las
33
+ implementaciones puedan delegar E/S (procesos, disco, red) sin bloquear el
34
+ bucle de eventos. ``Scraper`` la consume con ``await``.
35
+ """
36
+
37
+ @abstractmethod
38
+ async def extract(self, path: str) -> RfcCifData:
39
+ """Devuelve el RFC y el idCIF leídos del archivo en ``path``."""
40
+ ...
csf_scraper/py.typed ADDED
File without changes
csf_scraper/regimen.py ADDED
@@ -0,0 +1,62 @@
1
+ """Modelo de un régimen fiscal."""
2
+
3
+ import re
4
+
5
+ from pydantic import BaseModel, ConfigDict, computed_field
6
+
7
+ from csf_scraper.date_parser import DateField
8
+
9
+ _REGIMEN_CATALOG: dict[str, str] = {
10
+ "601": "General de Ley Personas Morales",
11
+ "603": "Personas Morales con Fines no Lucrativos",
12
+ "605": "Sueldos y Salarios e Ingresos Asimilados a Salarios",
13
+ "606": "Arrendamiento",
14
+ "607": "Enajenación o Adquisición de Bienes",
15
+ "608": "Demás ingresos",
16
+ "610": "Residentes en el Extranjero sin Establecimiento Permanente en México",
17
+ "611": "Ingresos por Dividendos (socios y accionistas)",
18
+ "612": "Personas Físicas con Actividades Empresariales y Profesionales",
19
+ "614": "Ingresos por intereses",
20
+ "615": "ingresos por obtención de premios",
21
+ "616": "Sin obligaciones fiscales",
22
+ "620": "Sociedades Cooperativas de Producción que optan por diferir sus ingresos",
23
+ "621": "Incorporación Fiscal",
24
+ "622": "Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras",
25
+ "623": "Opcional para Grupos de Sociedades",
26
+ "624": "Coordinados",
27
+ "625": "Actividades Empresariales con ingresos a través de Plataformas Tecnológicas",
28
+ "626": "Simplificado de Confianza",
29
+ }
30
+
31
+ _REGIMEN_BY_TEXT: dict[str, str] = {
32
+ text.upper(): regimen_id for regimen_id, text in _REGIMEN_CATALOG.items()
33
+ }
34
+
35
+ _PREFIX = re.compile(r"^Régimen (?:de (?:las |los )?)?")
36
+ _SUFFIX_PM = re.compile(r" PM$")
37
+ _SUFFIX_DOT = re.compile(r"\.$")
38
+
39
+
40
+ def _normalize_text(regimen: str) -> str:
41
+ text = _PREFIX.sub("", regimen)
42
+ text = _SUFFIX_PM.sub("", text)
43
+ return _SUFFIX_DOT.sub("", text)
44
+
45
+
46
+ def _search_regimen_id(regimen: str) -> str:
47
+ return _REGIMEN_BY_TEXT.get(_normalize_text(regimen).upper(), "")
48
+
49
+
50
+ class Regimen(BaseModel):
51
+ """Un régimen fiscal con su id derivado del texto y su fecha de alta."""
52
+
53
+ model_config = ConfigDict(validate_assignment=True)
54
+
55
+ regimen: str = ""
56
+ fecha_alta: DateField = None
57
+
58
+ @computed_field # type: ignore[prop-decorator]
59
+ @property
60
+ def regimen_id(self) -> str:
61
+ """Id del catálogo que corresponde al texto de :attr:`regimen`."""
62
+ return _search_regimen_id(self.regimen)
@@ -0,0 +1,15 @@
1
+ """DTO con el RFC y el idCIF extraídos de una CSF (PDF o imagen)."""
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from csf_scraper.value_objects.id_cif import IdCif
6
+ from csf_scraper.value_objects.rfc import Rfc
7
+
8
+
9
+ class RfcCifData(BaseModel):
10
+ """Par validado de RFC e idCIF obtenido por cualquier extractor."""
11
+
12
+ model_config = ConfigDict(validate_assignment=True)
13
+
14
+ rfc: Rfc
15
+ id_cif: IdCif
csf_scraper/scraper.py ADDED
@@ -0,0 +1,117 @@
1
+ """Cliente para consultar los datos fiscales en el validador del SAT."""
2
+
3
+ import ssl
4
+ from types import TracebackType
5
+ from typing import ClassVar, Self
6
+
7
+ import httpx
8
+
9
+ from csf_scraper.data_extractor import DataExtractor
10
+ from csf_scraper.exceptions import CifDownloadException
11
+ from csf_scraper.exceptions.image_reader import ImageExtractorNotConfiguredException
12
+ from csf_scraper.pdf_reader import CsfPdfExtractor
13
+ from csf_scraper.persona_fisica import PersonaFisica
14
+ from csf_scraper.persona_moral import PersonaMoral
15
+ from csf_scraper.ports import RfcCifExtractorPort
16
+ from csf_scraper.value_objects.id_cif import IdCif
17
+ from csf_scraper.value_objects.rfc import Rfc
18
+
19
+
20
+ def _create_ssl_context() -> ssl.SSLContext:
21
+ context = ssl.create_default_context()
22
+ context.set_ciphers("DEFAULT@SECLEVEL=1")
23
+ return context
24
+
25
+
26
+ class Scraper:
27
+ """Obtiene una :class:`PersonaMoral` o :class:`PersonaFisica` desde el sitio del SAT.
28
+
29
+ Todo el flujo es asíncrono: la descarga usa un
30
+ :class:`httpx.AsyncClient` y la extracción de datos se espera con
31
+ ``await``. El cliente se puede cerrar con :meth:`aclose` o usando el
32
+ scraper como *async context manager*::
33
+
34
+ async with Scraper.create() as scraper:
35
+ person = await scraper.obtain_from_rfc_and_cif(rfc, id_cif)
36
+ """
37
+
38
+ url: ClassVar[str] = (
39
+ "https://siat.sat.gob.mx/app/qr/faces/pages/mobile/validadorqr.jsf?D1=10&D2=1&D3=%s_%s"
40
+ )
41
+
42
+ def __init__(
43
+ self,
44
+ client: httpx.AsyncClient,
45
+ *,
46
+ pdf_extractor: RfcCifExtractorPort | None = None,
47
+ image_extractor: RfcCifExtractorPort | None = None,
48
+ ) -> None:
49
+ self.client = client
50
+ #: Extractor de CSF en PDF; por omisión usa ``pdftotext``.
51
+ self.pdf_extractor: RfcCifExtractorPort = pdf_extractor or CsfPdfExtractor()
52
+ #: Extractor de CSF escaneada (LLM/OCR); opcional.
53
+ self.image_extractor = image_extractor
54
+
55
+ @classmethod
56
+ def create(
57
+ cls,
58
+ *,
59
+ pdf_extractor: RfcCifExtractorPort | None = None,
60
+ image_extractor: RfcCifExtractorPort | None = None,
61
+ ) -> Self:
62
+ """Crea un cliente HTTP asíncrono listo para usar contra el SAT."""
63
+ return cls(
64
+ httpx.AsyncClient(follow_redirects=True, timeout=None, verify=_create_ssl_context()),
65
+ pdf_extractor=pdf_extractor,
66
+ image_extractor=image_extractor,
67
+ )
68
+
69
+ async def __aenter__(self) -> Self:
70
+ """Permite usar el scraper en un bloque ``async with``."""
71
+ return self
72
+
73
+ async def __aexit__(
74
+ self,
75
+ exc_type: type[BaseException] | None,
76
+ exc_value: BaseException | None,
77
+ traceback: TracebackType | None,
78
+ ) -> None:
79
+ """Cierra el cliente HTTP al salir del bloque ``async with``."""
80
+ await self.aclose()
81
+
82
+ async def aclose(self) -> None:
83
+ """Cierra el cliente HTTP subyacente."""
84
+ await self.client.aclose()
85
+
86
+ async def obtain_from_rfc_and_cif(self, rfc: str, id_cif: str) -> PersonaMoral | PersonaFisica:
87
+ """Obtiene la persona a partir de su RFC y su idCIF."""
88
+ parsed_rfc = Rfc(rfc)
89
+ parsed_cif = IdCif(id_cif)
90
+
91
+ uri = type(self).url % (parsed_cif, parsed_rfc)
92
+ html = await self._obtain_html(uri)
93
+ person = DataExtractor(html).extract(is_fisica=parsed_rfc.is_fisica())
94
+
95
+ person.rfc = parsed_rfc
96
+ person.id_cif = parsed_cif
97
+ return person
98
+
99
+ async def obtain_from_pdf_path(self, path: str) -> PersonaMoral | PersonaFisica:
100
+ """Obtiene la persona leyendo su RFC e idCIF de una CSF en PDF."""
101
+ data = await self.pdf_extractor.extract(path)
102
+ return await self.obtain_from_rfc_and_cif(data.rfc, data.id_cif)
103
+
104
+ async def obtain_from_image_path(self, path: str) -> PersonaMoral | PersonaFisica:
105
+ """Obtiene la persona leyendo su RFC e idCIF de una CSF escaneada (JPEG/PNG)."""
106
+ if self.image_extractor is None:
107
+ raise ImageExtractorNotConfiguredException()
108
+ data = await self.image_extractor.extract(path)
109
+ return await self.obtain_from_rfc_and_cif(data.rfc, data.id_cif)
110
+
111
+ async def _obtain_html(self, uri: str) -> str:
112
+ try:
113
+ response = await self.client.get(uri)
114
+ response.raise_for_status()
115
+ except httpx.HTTPError as exception:
116
+ raise CifDownloadException(uri) from exception
117
+ return response.text
@@ -0,0 +1,7 @@
1
+ """Value objects del dominio de la CSF."""
2
+
3
+ from csf_scraper.value_objects.csf_image import CsfImage
4
+ from csf_scraper.value_objects.id_cif import IdCif
5
+ from csf_scraper.value_objects.rfc import Rfc
6
+
7
+ __all__ = ["CsfImage", "IdCif", "Rfc"]
@@ -0,0 +1,68 @@
1
+ """Value object de una CSF escaneada como imagen (JPEG o PNG)."""
2
+
3
+ import base64
4
+ from pathlib import Path
5
+ from typing import Self
6
+
7
+ from csf_scraper.exceptions.image_reader import (
8
+ InvalidImageException,
9
+ InvalidImageFormatException,
10
+ )
11
+
12
+ #: Firmas (magic bytes) de los formatos de imagen soportados.
13
+ _MIME_TYPES: dict[bytes, str] = {
14
+ b"\xff\xd8\xff": "image/jpeg",
15
+ b"\x89PNG\r\n\x1a\n": "image/png",
16
+ }
17
+
18
+
19
+ class CsfImage:
20
+ """Imagen de una Constancia de Situación Fiscal en formato JPEG o PNG.
21
+
22
+ Se construye desde una ruta (:meth:`new_from_path`) o desde bytes
23
+ (:meth:`new_from_contents`); en ambos casos se valida el formato por sus
24
+ *magic bytes* y se determina su tipo MIME.
25
+ """
26
+
27
+ __slots__ = ("_contents", "_mime_type")
28
+
29
+ def __init__(self, contents: bytes, mime_type: str) -> None:
30
+ self._contents = contents
31
+ self._mime_type = mime_type
32
+
33
+ @classmethod
34
+ def new_from_path(cls, path: str) -> Self:
35
+ """Lee y valida la imagen en ``path``; lanza :class:`InvalidImageException`."""
36
+ try:
37
+ contents = Path(path).read_bytes()
38
+ except OSError as exception:
39
+ raise InvalidImageException(f"Cannot read image from path: {path}") from exception
40
+ return cls.new_from_contents(contents)
41
+
42
+ @classmethod
43
+ def new_from_contents(cls, contents: bytes) -> Self:
44
+ """Valida ``contents`` como imagen soportada y detecta su tipo MIME."""
45
+ if not contents:
46
+ raise InvalidImageException("The image is empty.")
47
+ return cls(contents, cls._detect_mime_type(contents))
48
+
49
+ @staticmethod
50
+ def _detect_mime_type(contents: bytes) -> str:
51
+ for signature, mime_type in _MIME_TYPES.items():
52
+ if contents.startswith(signature):
53
+ return mime_type
54
+ raise InvalidImageFormatException(
55
+ "The given file is not a supported image (expected JPEG or PNG).",
56
+ )
57
+
58
+ def as_bytes(self) -> bytes:
59
+ """Contenido binario de la imagen."""
60
+ return self._contents
61
+
62
+ def as_base64(self) -> str:
63
+ """Contenido de la imagen codificado en base64."""
64
+ return base64.b64encode(self._contents).decode("ascii")
65
+
66
+ def get_mime_type(self) -> str:
67
+ """Tipo MIME detectado (``image/jpeg`` o ``image/png``)."""
68
+ return self._mime_type
@@ -0,0 +1,48 @@
1
+ """Value object del idCIF (identificador del CIF del SAT)."""
2
+
3
+ import re
4
+ from typing import Any, Self
5
+
6
+ from pydantic_core import CoreSchema, core_schema
7
+
8
+ from csf_scraper.exceptions.value_objects import InvalidIdCifFormatException
9
+
10
+ #: Entre 8 y 13 dígitos (los idCIF observados van de 8 a 13 posiciones).
11
+ _PATTERN = re.compile(r"\d{8,13}\Z")
12
+
13
+
14
+ class IdCif(str):
15
+ """idCIF validado (de 8 a 13 dígitos).
16
+
17
+ Se construye con ``IdCif("1904014102123")``; lanza
18
+ :class:`~csf_scraper.exceptions.value_objects.InvalidIdCifFormatException` si el
19
+ valor no cumple el formato.
20
+ """
21
+
22
+ __slots__ = ()
23
+
24
+ def __new__(cls, value: Any) -> Self:
25
+ if isinstance(value, cls):
26
+ return value
27
+ text = value.strip() if isinstance(value, str) else value
28
+ if not isinstance(text, str) or _PATTERN.fullmatch(text) is None:
29
+ raise InvalidIdCifFormatException(f"Invalid idCIF format: {value!r}")
30
+ return super().__new__(cls, text)
31
+
32
+ @classmethod
33
+ def ensure(cls, value: Any) -> Self:
34
+ """Convierte ``value`` a :class:`IdCif` validando su formato."""
35
+ return cls(value)
36
+
37
+ @property
38
+ def value(self) -> str:
39
+ """idCIF como ``str`` plano (sin la subclase :class:`IdCif`)."""
40
+ return str(self)
41
+
42
+ @classmethod
43
+ def __get_pydantic_core_schema__(cls, source: Any, handler: Any) -> CoreSchema:
44
+ return core_schema.no_info_after_validator_function(
45
+ cls,
46
+ core_schema.str_schema(),
47
+ serialization=core_schema.to_string_ser_schema(),
48
+ )
@@ -0,0 +1,56 @@
1
+ """Value object del RFC (Registro Federal de Contribuyentes)."""
2
+
3
+ import re
4
+ from typing import Any, Self
5
+
6
+ from pydantic_core import CoreSchema, core_schema
7
+
8
+ from csf_scraper.exceptions.value_objects import InvalidRfcFormatException
9
+
10
+ #: Alfanumérico: de 3 a 4 letras (incluye ``Ñ`` y ``&``), 6 dígitos de fecha y 3 de homoclave.
11
+ _PATTERN = re.compile(r"[A-ZÑ&]{3,4}\d{6}[A-Z\d]{3}\Z")
12
+
13
+
14
+ class Rfc(str):
15
+ """RFC validado de una persona física (13 caracteres) o moral (12 caracteres).
16
+
17
+ Se construye con ``Rfc("DIM8701081LA")``; lanza
18
+ :class:`~csf_scraper.exceptions.value_objects.InvalidRfcFormatException` si el
19
+ valor no cumple el formato. Normaliza espacios y mayúsculas.
20
+ """
21
+
22
+ __slots__ = ()
23
+
24
+ def __new__(cls, value: Any) -> Self:
25
+ if isinstance(value, cls):
26
+ return value
27
+ text = value.strip().upper() if isinstance(value, str) else value
28
+ if not isinstance(text, str) or _PATTERN.fullmatch(text) is None:
29
+ raise InvalidRfcFormatException(f"Invalid RFC format: {value!r}")
30
+ return super().__new__(cls, text)
31
+
32
+ @classmethod
33
+ def ensure(cls, value: Any) -> Self:
34
+ """Convierte ``value`` a :class:`Rfc` validando (y normalizando) su formato."""
35
+ return cls(value)
36
+
37
+ @property
38
+ def value(self) -> str:
39
+ """RFC como ``str`` plano (sin la subclase :class:`Rfc`)."""
40
+ return str(self)
41
+
42
+ def is_fisica(self) -> bool:
43
+ """Indica si el RFC corresponde a una persona física (13 caracteres)."""
44
+ return len(self) == 13
45
+
46
+ def is_moral(self) -> bool:
47
+ """Indica si el RFC corresponde a una persona moral (12 caracteres)."""
48
+ return not self.is_fisica()
49
+
50
+ @classmethod
51
+ def __get_pydantic_core_schema__(cls, source: Any, handler: Any) -> CoreSchema:
52
+ return core_schema.no_info_after_validator_function(
53
+ cls,
54
+ core_schema.str_schema(),
55
+ serialization=core_schema.to_string_ser_schema(),
56
+ )