compraspublica-shared 0.9.0__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.
- compraspublica_shared/__init__.py +37 -0
- compraspublica_shared/_config.py +144 -0
- compraspublica_shared/_log.py +39 -0
- compraspublica_shared/amm_diario/__init__.py +42 -0
- compraspublica_shared/amm_diario/amm.py +128 -0
- compraspublica_shared/analise_risco/__init__.py +60 -0
- compraspublica_shared/analise_risco/_canon.py +168 -0
- compraspublica_shared/analise_risco/_util.py +52 -0
- compraspublica_shared/analise_risco/aditivos.py +58 -0
- compraspublica_shared/analise_risco/benford.py +63 -0
- compraspublica_shared/analise_risco/direcionamento.py +63 -0
- compraspublica_shared/analise_risco/fracionamento.py +99 -0
- compraspublica_shared/analise_risco/grafo.py +110 -0
- compraspublica_shared/analise_risco/laranjas.py +105 -0
- compraspublica_shared/analise_risco/models.py +120 -0
- compraspublica_shared/analise_risco/nepotismo.py +140 -0
- compraspublica_shared/analise_risco/ordem_pagamento.py +54 -0
- compraspublica_shared/analise_risco/pagamentos_duplicados.py +133 -0
- compraspublica_shared/analise_risco/regras.py +88 -0
- compraspublica_shared/analise_risco/restos_a_pagar.py +77 -0
- compraspublica_shared/analise_risco/score.py +228 -0
- compraspublica_shared/api/__init__.py +4 -0
- compraspublica_shared/api/main.py +252 -0
- compraspublica_shared/brasilapi/__init__.py +41 -0
- compraspublica_shared/brasilapi/_minha_receita.py +37 -0
- compraspublica_shared/brasilapi/cli.py +131 -0
- compraspublica_shared/brasilapi/cliente.py +277 -0
- compraspublica_shared/cadprev/__init__.py +60 -0
- compraspublica_shared/cadprev/api.py +346 -0
- compraspublica_shared/cnes/__init__.py +44 -0
- compraspublica_shared/cnes/datasus.py +176 -0
- compraspublica_shared/emendas/__init__.py +28 -0
- compraspublica_shared/emendas/cgu_api.py +65 -0
- compraspublica_shared/erros.py +34 -0
- compraspublica_shared/geocode/__init__.py +47 -0
- compraspublica_shared/geocode/nominatim.py +229 -0
- compraspublica_shared/geocode/scoring.py +194 -0
- compraspublica_shared/http/__init__.py +40 -0
- compraspublica_shared/http/escalonador.py +262 -0
- compraspublica_shared/portal_transparencia/__init__.py +38 -0
- compraspublica_shared/portal_transparencia/cliente.py +220 -0
- compraspublica_shared/precos_cascata/__init__.py +60 -0
- compraspublica_shared/precos_cascata/cascata.py +308 -0
- compraspublica_shared/precos_cascata/data/baseline_precos.json +179 -0
- compraspublica_shared/precos_cascata/data/catmat_map.json +19 -0
- compraspublica_shared/py.typed +1 -0
- compraspublica_shared/sancoes/__init__.py +37 -0
- compraspublica_shared/sancoes/base_local.py +332 -0
- compraspublica_shared/sancoes/cli.py +129 -0
- compraspublica_shared/tse/__init__.py +46 -0
- compraspublica_shared/tse/cruzamento.py +267 -0
- compraspublica_shared/tse/parquet_loader.py +117 -0
- compraspublica_shared-0.9.0.dist-info/METADATA +231 -0
- compraspublica_shared-0.9.0.dist-info/RECORD +58 -0
- compraspublica_shared-0.9.0.dist-info/WHEEL +5 -0
- compraspublica_shared-0.9.0.dist-info/entry_points.txt +3 -0
- compraspublica_shared-0.9.0.dist-info/licenses/LICENSE +21 -0
- compraspublica_shared-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""compraspublica-shared — forense pública de contratações.
|
|
2
|
+
|
|
3
|
+
Camada compartilhada entre o projeto de auditoria independente
|
|
4
|
+
[`EnemyDisclosure`](https://github.com/renato0503/EnemyDisclosure) e o painel
|
|
5
|
+
de gestão de contratações
|
|
6
|
+
[`CompraPublica.AI`](https://github.com/renato0503/compraspublica.ai).
|
|
7
|
+
|
|
8
|
+
Roadmap completo no `README.md` e em
|
|
9
|
+
`https://github.com/renato0503/EnemyDisclosure/blob/main/sprint.md` (PARTE E).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.9.0"
|
|
13
|
+
__author__ = "Renato Rosa"
|
|
14
|
+
__email__ = "renato0503@gmail.com"
|
|
15
|
+
__license__ = "MIT"
|
|
16
|
+
|
|
17
|
+
from . import _config # noqa: F401 (configures env vars on import)
|
|
18
|
+
from . import brasilapi, erros, _log, sancoes, tse
|
|
19
|
+
|
|
20
|
+
logger = _log.logger
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__",
|
|
24
|
+
"__author__",
|
|
25
|
+
"__email__",
|
|
26
|
+
"__license__",
|
|
27
|
+
"brasilapi",
|
|
28
|
+
"erros",
|
|
29
|
+
"logger",
|
|
30
|
+
"sancoes",
|
|
31
|
+
"tse",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Configuração central do pacote `compraspublica-shared`.
|
|
2
|
+
|
|
3
|
+
Todas as variáveis são lidas de env vars (ou de defaults seguros). Não
|
|
4
|
+
persistimos configuração fora do ambiente — o diretório de cache é
|
|
5
|
+
determinado uma vez no import.
|
|
6
|
+
|
|
7
|
+
Variáveis reconhecidas:
|
|
8
|
+
- `COMPPUBL_CACHE_DIR` — diretório raiz de cache (default `~/.compraspubl/cache`)
|
|
9
|
+
- `COMPPUBL_HTTP_UA` — User-Agent para chamadas HTTP
|
|
10
|
+
- `COMPPUBL_RATE_LIMIT` — segundos entre chamadas a APIs externas
|
|
11
|
+
- `COMPPUBL_LOG_LEVEL` — nível do logger (DEBUG/INFO/WARNING/ERROR)
|
|
12
|
+
- `COMPPUBL_SANCOES_DIR` — path do cache de CEIS/CNEP/CEPIM/Leniência
|
|
13
|
+
- `COMPPUBL_BRASILAPI_CACHE` — path do cache de BrasilAPI
|
|
14
|
+
- `COMPPUBL_USE_SCRAPFLY` — habilita fallback SaaS (precisa `SCRAPFLY_KEY`)
|
|
15
|
+
- `COMPPUBL_USE_ZENROWS` — habilita fallback SaaS alternativo
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from urllib.parse import urlparse
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _env_or(name: str, default: str) -> str:
|
|
25
|
+
val = os.environ.get(name)
|
|
26
|
+
if val is None or val == "":
|
|
27
|
+
return default
|
|
28
|
+
return val
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _env_float(name: str, default: float) -> float:
|
|
32
|
+
try:
|
|
33
|
+
return float(_env_or(name, str(default)))
|
|
34
|
+
except ValueError:
|
|
35
|
+
return default
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
39
|
+
return _env_or(name, "1" if default else "0").lower() in ("1", "true", "yes", "on")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- cache dir ---
|
|
43
|
+
CACHE_DIR: Path = Path(
|
|
44
|
+
_env_or(
|
|
45
|
+
"COMPPUBL_CACHE_DIR",
|
|
46
|
+
str(Path.home() / ".compraspubl" / "cache"),
|
|
47
|
+
)
|
|
48
|
+
).expanduser().resolve()
|
|
49
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --- HTTP ---
|
|
53
|
+
USER_AGENT: str = _env_or(
|
|
54
|
+
"COMPPUBL_HTTP_UA",
|
|
55
|
+
"compraspublica-bot/1.0 (auditoria publica; contato: dev@compraspublica.ai)",
|
|
56
|
+
)
|
|
57
|
+
RATE_LIMIT: float = _env_float("COMPPUBL_RATE_LIMIT", 0.35)
|
|
58
|
+
TIMEOUT: int = int(_env_float("COMPPUBL_TIMEOUT", 30.0))
|
|
59
|
+
RETRIES: int = int(_env_float("COMPPUBL_RETRIES", 3.0))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- log ---
|
|
63
|
+
LOG_LEVEL: str = _env_or("COMPPUBL_LOG_LEVEL", "INFO").upper()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- paths específicos ---
|
|
67
|
+
SANCOES_DIR: Path = Path(
|
|
68
|
+
_env_or("COMPPUBL_SANCOES_DIR", str(CACHE_DIR / "sancoes"))
|
|
69
|
+
).expanduser().resolve()
|
|
70
|
+
BRASILAPI_CACHE: Path = Path(
|
|
71
|
+
_env_or("COMPPUBL_BRASILAPI_CACHE", str(CACHE_DIR / "brasilapi.json"))
|
|
72
|
+
).expanduser().resolve()
|
|
73
|
+
PNCP_CACHE_DIR: Path = Path(
|
|
74
|
+
_env_or("COMPPUBL_PNCP_CACHE_DIR", str(CACHE_DIR / "pncp"))
|
|
75
|
+
).expanduser().resolve()
|
|
76
|
+
RADARTCE_CACHE: Path = Path(
|
|
77
|
+
_env_or("COMPPUBL_RADARTCE_CACHE", str(CACHE_DIR / "radartce.db"))
|
|
78
|
+
).expanduser().resolve()
|
|
79
|
+
TSE_CACHE_DIR: Path = Path(
|
|
80
|
+
_env_or("COMPPUBL_TSE_CACHE_DIR", str(CACHE_DIR / "tse"))
|
|
81
|
+
).expanduser().resolve()
|
|
82
|
+
CNES_CACHE_DIR: Path = Path(
|
|
83
|
+
_env_or("COMPPUBL_CNES_CACHE_DIR", str(CACHE_DIR / "cnes"))
|
|
84
|
+
).expanduser().resolve()
|
|
85
|
+
HTTP_CACHE_DIR: Path = Path(
|
|
86
|
+
_env_or("COMPPUBL_HTTP_CACHE_DIR", str(CACHE_DIR / "http"))
|
|
87
|
+
).expanduser().resolve()
|
|
88
|
+
GEOCODE_CACHE: Path = Path(
|
|
89
|
+
_env_or("COMPPUBL_GEOCODE_CACHE", str(CACHE_DIR / "geocode.json"))
|
|
90
|
+
).expanduser().resolve()
|
|
91
|
+
AMM_CACHE_DIR: Path = Path(
|
|
92
|
+
_env_or("COMPPUBL_AMM_DIR", str(CACHE_DIR / "amm_diario"))
|
|
93
|
+
).expanduser().resolve()
|
|
94
|
+
PRECO_CACHE_DIR: Path = Path(
|
|
95
|
+
_env_or("COMPPUBL_PRECO_CACHE_DIR", str(CACHE_DIR / "precos"))
|
|
96
|
+
).expanduser().resolve()
|
|
97
|
+
|
|
98
|
+
# --- APIs externas (URLs) ---
|
|
99
|
+
BRASILAPI_BASE: str = "https://brasilapi.com.br/api/cnpj/v1/"
|
|
100
|
+
MINHARECEITA_BASE: str = "https://minhareceita.org/{cnpj}"
|
|
101
|
+
CEIS_BASE: str = "https://portaldatransparencia.gov.br/download-de-dados"
|
|
102
|
+
TSE_BASE: str = "https://dadosabertos.tse.jus.br"
|
|
103
|
+
TSE_BASE_OLD: str = "https://cdn.tse.jus.br/estatistica/sead/odsele"
|
|
104
|
+
PNCP_BASE: str = "https://pncp.gov.br/api"
|
|
105
|
+
RADARTCE_BASE: str = "https://radardeprecos.tce.mt.gov.br"
|
|
106
|
+
PORTAL_TRANSPARENCIA_BASE: str = "https://api.portaldatransparencia.gov.br/api-de-dados"
|
|
107
|
+
CADPREV_BASE: str = "https://apicadprev.trabalho.gov.br"
|
|
108
|
+
DATASUS_FTP: str = "ftp.datasus.gov.br"
|
|
109
|
+
DATASUS_CNES_BASE: str = "/dissemin/publicos/CNES/200508_/Dados"
|
|
110
|
+
|
|
111
|
+
# cria diretórios lazy na primeira escrita — ver helpers abaixo
|
|
112
|
+
for _p in (SANCOES_DIR, PNCP_CACHE_DIR, CNES_CACHE_DIR, TSE_CACHE_DIR,
|
|
113
|
+
HTTP_CACHE_DIR, PRECO_CACHE_DIR, AMM_CACHE_DIR):
|
|
114
|
+
_p.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- fallbacks SaaS ---
|
|
118
|
+
USE_SCRAPFLY: bool = _env_bool("COMPPUBL_USE_SCRAPFLY", False)
|
|
119
|
+
USE_ZENROWS: bool = _env_bool("COMPPUBL_USE_ZENROWS", False)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- validação ---
|
|
123
|
+
def _validate_url(url: str, env_name: str) -> None:
|
|
124
|
+
try:
|
|
125
|
+
u = urlparse(url)
|
|
126
|
+
if u.scheme not in ("http", "https"):
|
|
127
|
+
raise ValueError(f"scheme inválido: {u.scheme!r}")
|
|
128
|
+
except Exception as e: # noqa: BLE001
|
|
129
|
+
from .erros import ConfiguracaoInvalida
|
|
130
|
+
raise ConfiguracaoInvalida(
|
|
131
|
+
f"{env_name}={url!r} não é uma URL válida ({e})"
|
|
132
|
+
) from e
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
for _name, _val in [
|
|
136
|
+
("BRASILAPI_BASE", BRASILAPI_BASE),
|
|
137
|
+
("CEIS_BASE", CEIS_BASE),
|
|
138
|
+
("PNCP_BASE", PNCP_BASE),
|
|
139
|
+
("RADARTCE_BASE", RADARTCE_BASE),
|
|
140
|
+
("TSE_BASE_OLD", TSE_BASE_OLD),
|
|
141
|
+
("PORTAL_TRANSPARENCIA_BASE", PORTAL_TRANSPARENCIA_BASE),
|
|
142
|
+
("CADPREV_BASE", CADPREV_BASE),
|
|
143
|
+
]:
|
|
144
|
+
_validate_url(_val, _name)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Logger central do pacote `compraspublica-shared`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import _config
|
|
8
|
+
|
|
9
|
+
_LOGGER_NAME = "compraspublica_shared"
|
|
10
|
+
|
|
11
|
+
_LEVELS = {
|
|
12
|
+
"DEBUG": logging.DEBUG,
|
|
13
|
+
"INFO": logging.INFO,
|
|
14
|
+
"WARNING": logging.WARNING,
|
|
15
|
+
"WARN": logging.WARNING,
|
|
16
|
+
"ERROR": logging.ERROR,
|
|
17
|
+
"CRITICAL": logging.CRITICAL,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_logger() -> logging.Logger:
|
|
22
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
23
|
+
if logger.handlers:
|
|
24
|
+
return logger # já configurado
|
|
25
|
+
|
|
26
|
+
logger.setLevel(_LEVELS.get(_config.LOG_LEVEL, logging.INFO))
|
|
27
|
+
|
|
28
|
+
# Handler que escreve no stderr (sem dependência de root logger)
|
|
29
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
30
|
+
handler.setFormatter(logging.Formatter(
|
|
31
|
+
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
32
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
33
|
+
))
|
|
34
|
+
logger.addHandler(handler)
|
|
35
|
+
logger.propagate = False # não propaga para root
|
|
36
|
+
return logger
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
logger = _build_logger()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""AMM — Diário Oficial dos Municípios de Mato Grosso (amm.diariomunicipal.org).
|
|
2
|
+
|
|
3
|
+
Publica um PDF por edição (todos os municípios juntos). Serve para extrair:
|
|
4
|
+
- **portarias de nomeação** de cargos comissionados e contratos temporários
|
|
5
|
+
(para cruzar com o QSA dos fornecedores — servidor/comissionado que é sócio de
|
|
6
|
+
contratada = conflito de interesse);
|
|
7
|
+
- aditivos contratuais, atas de registro de preços, avisos de dispensa.
|
|
8
|
+
|
|
9
|
+
Não há API pública: raspa-se `/edicoes/?p=N` (paginado) e baixa-se o PDF do CDN.
|
|
10
|
+
Texto via `pdfplumber` (cacheado em `.txt` ao lado do PDF — a extração de ~500
|
|
11
|
+
páginas leva minutos, então é um **job em lote**, não interativo).
|
|
12
|
+
Varredura por regex de CPF num raio de ~250 caracteres das palavras-gatilho.
|
|
13
|
+
|
|
14
|
+
Obs.: o AMM agrega TODOS os municípios de MT numa edição. Várzea Grande também
|
|
15
|
+
mantém diário próprio em `diario.varzeagrande.mt.gov.br` (fora do ar em nuvem no
|
|
16
|
+
teste de 01/09) — se voltar, é a fonte mais direta para as portarias de VG.
|
|
17
|
+
|
|
18
|
+
>>> from compraspublica_shared import amm_diario as amm
|
|
19
|
+
>>> for ed in amm.editions(ate_pagina=3):
|
|
20
|
+
... pdf = amm.baixar(ed["url"])
|
|
21
|
+
... for n in amm.nomeacoes(pdf, municipio="VARZEA GRANDE"):
|
|
22
|
+
... ... # {cpf, nome, tipo, trecho, edicao}
|
|
23
|
+
"""
|
|
24
|
+
from .amm import (
|
|
25
|
+
BASE,
|
|
26
|
+
CACHE,
|
|
27
|
+
UA,
|
|
28
|
+
editions,
|
|
29
|
+
baixar,
|
|
30
|
+
nomeacoes,
|
|
31
|
+
cruzar_com_socios,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"BASE",
|
|
36
|
+
"CACHE",
|
|
37
|
+
"UA",
|
|
38
|
+
"editions",
|
|
39
|
+
"baixar",
|
|
40
|
+
"nomeacoes",
|
|
41
|
+
"cruzar_com_socios",
|
|
42
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Cliente AMM — Diário Oficial dos Municípios de MT.
|
|
2
|
+
|
|
3
|
+
Refatorado de `D:\\Dev\\EnemyDisclosure\\core\\fontes\\amm_diario.py`.
|
|
4
|
+
Independente do pacote Enemy. Sem rede nas importações (urllib/request só é
|
|
5
|
+
importado nas funções que precisam).
|
|
6
|
+
|
|
7
|
+
Dependência opcional: `pdfplumber` (instale com `pip install
|
|
8
|
+
compraspublica-shared[amm]`).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
BASE = "https://amm.diariomunicipal.org"
|
|
21
|
+
# path local pode ser sobrescrito via env `COMPPUBL_AMM_DIR` (via _config)
|
|
22
|
+
from .. import _config
|
|
23
|
+
CACHE = _config.AMM_CACHE_DIR
|
|
24
|
+
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
|
|
25
|
+
|
|
26
|
+
_GATILHOS = re.compile(
|
|
27
|
+
r"(nomear|nomea[çc][ãa]o|design(?:ar|a[çc][ãa]o)|contrata[çc][ãa]o tempor[áa]ria|"
|
|
28
|
+
r"contrato tempor[áa]rio|cargo em comiss[ãa]o|comissionad[oa]|admitir|admiss[ãa]o)",
|
|
29
|
+
re.I)
|
|
30
|
+
_CPF = re.compile(r"\b(\d{3}\.?\d{3}\.?\d{3}-?\d{2})\b")
|
|
31
|
+
_CNPJ = re.compile(r"\b(\d{2}\.?\d{3}\.?\d{3}/?\d{4}-?\d{2})\b")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get(url: str, timeout: int = 60) -> str:
|
|
35
|
+
"""GET simples via urllib (sem deps)."""
|
|
36
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers=UA),
|
|
37
|
+
timeout=timeout) as r:
|
|
38
|
+
return r.read().decode("utf-8", "replace")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def editions(ate_pagina: int = 1, desde: dt.date | None = None) -> list[dict]:
|
|
42
|
+
"""Lista edições recentes: [{numero, data, url}]."""
|
|
43
|
+
out, vistos = [], set()
|
|
44
|
+
for p in range(1, ate_pagina + 1):
|
|
45
|
+
html = _get(f"{BASE}/edicoes/?p={p}")
|
|
46
|
+
for m in re.finditer(
|
|
47
|
+
r"(https://cdn-amm\.diariomunicipal\.org/edicoes/(\d{4})/(\d{2})/(\d{2})/"
|
|
48
|
+
r"edicao-(\d+)-[\w-]+\.pdf)", html):
|
|
49
|
+
url, y, mo, d, num = m.groups()
|
|
50
|
+
data = dt.date(int(y), int(mo), int(d))
|
|
51
|
+
if desde and data < desde:
|
|
52
|
+
return out
|
|
53
|
+
key = (num, url)
|
|
54
|
+
if key in vistos:
|
|
55
|
+
continue
|
|
56
|
+
vistos.add(key)
|
|
57
|
+
out.append({"numero": num, "data": data.isoformat(), "url": url})
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def baixar(url: str) -> Path:
|
|
62
|
+
"""Baixa o PDF (com cache local)."""
|
|
63
|
+
CACHE.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
destino = CACHE / url.rsplit("/", 1)[-1]
|
|
65
|
+
if not destino.exists():
|
|
66
|
+
logger.info("AMM: baixando %s", destino.name)
|
|
67
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers=UA),
|
|
68
|
+
timeout=180) as r:
|
|
69
|
+
destino.write_bytes(r.read())
|
|
70
|
+
return destino
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _texto(pdf: Path) -> str:
|
|
74
|
+
"""Texto do PDF, com cache em .txt ao lado (a extração é lenta)."""
|
|
75
|
+
cache = pdf.with_suffix(".txt")
|
|
76
|
+
if cache.exists():
|
|
77
|
+
return cache.read_text(encoding="utf-8")
|
|
78
|
+
try:
|
|
79
|
+
import pdfplumber # type: ignore
|
|
80
|
+
except ImportError as e:
|
|
81
|
+
raise ImportError(
|
|
82
|
+
"amm_diario precisa de `pdfplumber`. Rode: "
|
|
83
|
+
"`pip install compraspublica-shared[amm]`"
|
|
84
|
+
) from e
|
|
85
|
+
with pdfplumber.open(str(pdf)) as doc:
|
|
86
|
+
t = "\n\f\n".join((pg.extract_text() or "") for pg in doc.pages)
|
|
87
|
+
cache.write_text(t, encoding="utf-8")
|
|
88
|
+
return t
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def nomeacoes(pdf: Path, municipio: str | None = None) -> list[dict]:
|
|
92
|
+
"""Trechos com palavra-gatilho de nomeação/contratação + um CPF por perto."""
|
|
93
|
+
txt = _texto(pdf)
|
|
94
|
+
if municipio:
|
|
95
|
+
# restringe às páginas/blocos que citam o município
|
|
96
|
+
mun = municipio.upper()
|
|
97
|
+
blocos = [b for b in re.split(r"\f|\n{2,}", txt) if mun in b.upper()]
|
|
98
|
+
txt = "\n\n".join(blocos)
|
|
99
|
+
achados = []
|
|
100
|
+
for g in _GATILHOS.finditer(txt):
|
|
101
|
+
janela = txt[max(0, g.start() - 250): g.end() + 250]
|
|
102
|
+
for c in _CPF.finditer(janela):
|
|
103
|
+
achados.append({
|
|
104
|
+
"cpf": re.sub(r"\D", "", c.group(1)),
|
|
105
|
+
"tipo": g.group(1).lower(),
|
|
106
|
+
"trecho": re.sub(r"\s+", " ", janela).strip()[:300],
|
|
107
|
+
})
|
|
108
|
+
# dedup por (cpf, tipo)
|
|
109
|
+
vistos, uniq = set(), []
|
|
110
|
+
for a in achados:
|
|
111
|
+
k = (a["cpf"], a["tipo"])
|
|
112
|
+
if k not in vistos:
|
|
113
|
+
vistos.add(k)
|
|
114
|
+
uniq.append(a)
|
|
115
|
+
return uniq
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cruzar_com_socios(nomeados: list[dict], socios_por_cpf: dict) -> list[dict]:
|
|
119
|
+
"""Cruza nomeados (com CPF) × QSA das contratadas.
|
|
120
|
+
|
|
121
|
+
`socios_por_cpf`: dict cpf → [(cnpj, empresa, nome), ...].
|
|
122
|
+
"""
|
|
123
|
+
hits = []
|
|
124
|
+
for n in nomeados:
|
|
125
|
+
for cnpj, empresa, nome in socios_por_cpf.get(n["cpf"], []):
|
|
126
|
+
hits.append({**n, "cnpj_empresa": cnpj,
|
|
127
|
+
"empresa": empresa, "socio": nome})
|
|
128
|
+
return hits
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Motor de análise de risco de fornecedor — B-suite empacotada.
|
|
2
|
+
|
|
3
|
+
Empacota os 9 módulos B forenses relevantes para o CompraPublica.AI F61
|
|
4
|
+
(due diligence do vencedor) e para auditoria:
|
|
5
|
+
|
|
6
|
+
B2 fracionamento de dispensa
|
|
7
|
+
B3 direcionamento de edital
|
|
8
|
+
B5 empresas de fachada (score)
|
|
9
|
+
B6 nepotismo / servidor-sócio
|
|
10
|
+
B9 aditivos contratuais >25%
|
|
11
|
+
B11 restos a pagar como caixa 2
|
|
12
|
+
B17 Lei de Benford
|
|
13
|
+
B18 ordem cronológica de pagamento
|
|
14
|
+
B19 pagamentos duplicados
|
|
15
|
+
|
|
16
|
+
`run_b_suite(cnpj, valor_adjudicado, objeto, uf, data_disputa)` combina os
|
|
17
|
+
módulos + regras (`regras.py`) + fontes (sancoes, brasilapi, cnes, tse) num
|
|
18
|
+
`score_fornecedor` consolidado (0-100).
|
|
19
|
+
|
|
20
|
+
Uso:
|
|
21
|
+
>>> from compraspublica_shared import analise_risco
|
|
22
|
+
>>> r = analise_risco.run_b_suite(
|
|
23
|
+
... cnpj="27814736000150", valor_adjudicado=5_000_000,
|
|
24
|
+
... objeto="Limpeza urbana", uf="MT")
|
|
25
|
+
>>> r.score, r.categoria
|
|
26
|
+
(87, 'alto')
|
|
27
|
+
"""
|
|
28
|
+
from . import _util, _canon, benford, aditivos, ordem_pagamento
|
|
29
|
+
from . import pagamentos_duplicados, restos_a_pagar, direcionamento
|
|
30
|
+
from . import fracionamento, laranjas, nepotismo, regras, grafo, models
|
|
31
|
+
from .score import (
|
|
32
|
+
FlagRisco as _FlagRiscoDC,
|
|
33
|
+
RiscoFornecedor as _RiscoFornecedorDC,
|
|
34
|
+
run_b_suite,
|
|
35
|
+
score_fornecedor,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# re-exporta dataclasses (compat) + Pydantic models
|
|
39
|
+
from .score import FlagRisco, RiscoFornecedor
|
|
40
|
+
from .models import (
|
|
41
|
+
RequestAnalise,
|
|
42
|
+
PrecoReferencia,
|
|
43
|
+
EnderecoGeocodificado,
|
|
44
|
+
SnapshotOrgao,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"_util", "_canon",
|
|
49
|
+
"benford", "aditivos", "ordem_pagamento",
|
|
50
|
+
"pagamentos_duplicados", "restos_a_pagar", "direcionamento",
|
|
51
|
+
"fracionamento", "laranjas", "nepotismo", "regras", "grafo", "models",
|
|
52
|
+
"FlagRisco",
|
|
53
|
+
"RiscoFornecedor",
|
|
54
|
+
"RequestAnalise",
|
|
55
|
+
"PrecoReferencia",
|
|
56
|
+
"EnderecoGeocodificado",
|
|
57
|
+
"SnapshotOrgao",
|
|
58
|
+
"run_b_suite",
|
|
59
|
+
"score_fornecedor",
|
|
60
|
+
]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# core/analises/_canon.py
|
|
2
|
+
"""Camada de normalização: schema bruto de cada portal → modelo canônico.
|
|
3
|
+
|
|
4
|
+
Cada portal (Fiorilli, Elotech, GeneXus-Cuiabá, PNCP...) nomeia os campos de
|
|
5
|
+
um jeito. Aqui um registro bruto vira um dict com chaves canônicas
|
|
6
|
+
(ver PARTE 0 do sprint.md). Os módulos de análise trabalham só no canônico.
|
|
7
|
+
|
|
8
|
+
e = canon("empenho", registro_bruto)
|
|
9
|
+
e["valor_empenhado"], e["credor_doc"], e["data"]
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from ._util import data as _data
|
|
17
|
+
from ._util import doc_limpo, norm, num
|
|
18
|
+
|
|
19
|
+
# canônico → lista de nomes possíveis no bruto (1º que existir vence)
|
|
20
|
+
MAPAS = {
|
|
21
|
+
"empenho": {
|
|
22
|
+
"numero": ("CODIGO", "EmpenhoNumero", "Empenho", "numeroEmpenho", "numero", "empenho",
|
|
23
|
+
"numeroExercicioEmpenho", "numeroAno", "Empenho Numero", "Número/Ano"),
|
|
24
|
+
"data": ("DATAE", "EmpenhoData", "dataEmpenho", "data", "Data"),
|
|
25
|
+
"credor_doc": ("CPFFORMATADO", "EmpenhoCredorDoc", "cnpjCpf", "cpfCnpj",
|
|
26
|
+
"niFornecedor", "documentoFornecedor", "CPFCNPJ",
|
|
27
|
+
"cpfcnpjFavorecido", "cpfCnpjCredor", "Empenho Credor CPFCNPJ"),
|
|
28
|
+
"credor_nome": ("NOMEFOR", "EmpenhoCredorNome", "EmpenhoCredorRazao",
|
|
29
|
+
"NOMEEMPRESA", "nomeFornecedor", "fornecedor", "credor",
|
|
30
|
+
"nomeRazaoSocialFornecedor", "nomeRazaoSocialCredor", "nomeFavorecido",
|
|
31
|
+
"razao_social", "Empenho Credor Nome"),
|
|
32
|
+
"valor_empenhado": ("EMPENHADO", "EmpenhoValor", "valor_empenhado",
|
|
33
|
+
"valorEmpenhado", "valor", "valorEmpenho",
|
|
34
|
+
"valorTotalEmpenhado", "valorEmpenho", "Valor"),
|
|
35
|
+
"valor_liquidado": ("LIQUIDADO", "EmpenhoValorLiq", "EmpenhoLiquidacaoValorLiq",
|
|
36
|
+
"valor_liquidado", "valorLiquidado", "valorTotalLiquidado",
|
|
37
|
+
"Valor Liquidado"),
|
|
38
|
+
"valor_pago": ("PAGO", "EmpenhoPagamentoValorLiq", "valor_pago", "valorPago",
|
|
39
|
+
"valorTotalPago", "valorPagoSemAnulacao", "Valor Pago"),
|
|
40
|
+
"elemento": ("ELEMENTO", "EmpenhoElementoNome", "elementoDespesa",
|
|
41
|
+
"descricaoElemento", "NATUREZA", "dotacao",
|
|
42
|
+
"dota_o_fonte_de_recurso"),
|
|
43
|
+
"funcao": ("FUNCAONOME", "EmpenhoFuncaoNome", "FUNCAO", "funcao",
|
|
44
|
+
"Empenho Orgao Nome"),
|
|
45
|
+
# ATENÇÃO: modalidade de LICITAÇÃO (não a modalidade de aplicação da
|
|
46
|
+
# despesa — em Cuiabá `EmpenhoModalidadeNome`="APLICACOES DIRETAS" é
|
|
47
|
+
# categoria contábil, não licitatória).
|
|
48
|
+
"modalidade": ("MODALIDADE", "EmpenhoLicModalidadeNome", "EmpenhoModLicitacao",
|
|
49
|
+
"modalidadeLicitacao", "modalidade", "MODALI", "modalidadeNome"),
|
|
50
|
+
"processo": ("PROC", "EmpenhoProcesso", "EmpenhoNumeroProcessoCompra",
|
|
51
|
+
"processo", "PROCLIC"),
|
|
52
|
+
"licitacao": ("NUMLICIT", "EmpenhoContratoNumero", "LICIT", "licitacao",
|
|
53
|
+
"numeroLicitacao"),
|
|
54
|
+
"descricao": ("descricao_item", "_item_descricao", "EmpenhoDsc",
|
|
55
|
+
"DESCLICIT_DETALHESEMPENHO", "descricao", "objeto", "historico", "motivo",
|
|
56
|
+
"Histórico"),
|
|
57
|
+
"valor_unitario": ("_item_valor_unitario", "valorUnitario", "valorUnitarioEstimado"),
|
|
58
|
+
},
|
|
59
|
+
"contrato": {
|
|
60
|
+
"numero": ("CONTRATONUM", "ContratoNumero", "Contrato", "numeroContrato",
|
|
61
|
+
"numero", "contrato", "CODIGO"),
|
|
62
|
+
"fornecedor_doc": ("CPFCNPJ", "ContratoFornecedorDoc", "cnpjCpf",
|
|
63
|
+
"niFornecedor", "documentoFornecedor"),
|
|
64
|
+
"fornecedor_nome": ("FORNECEDOR", "ContratoFornecedorNome", "nome",
|
|
65
|
+
"nomeRazaoSocialFornecedor", "fornecedor_nome", "razaoSocial"),
|
|
66
|
+
"objeto": ("OBJETO", "OBJETO_COMPLETO", "ContratoDsc", "objeto", "objetoContrato"),
|
|
67
|
+
"valor_inicial": ("VALCON", "ContratoValor", "valorContratado", "valorInicial", "valor"),
|
|
68
|
+
"valor_atual": ("ContratoValorAtual", "valorGlobal", "valorAtualizado",
|
|
69
|
+
"valor_atual", "valorAcumulado"),
|
|
70
|
+
"valor_aditivos": ("valorAditivo", "valorAditivos"),
|
|
71
|
+
"assinatura": ("DTASSI", "ContratoDataIni", "dataAssinatura", "assinatura"),
|
|
72
|
+
"vigencia_inicio": ("VIGENI", "inicioVigencia", "dataVigenciaInicio", "vigencia_inicio"),
|
|
73
|
+
"vigencia_fim": ("VIGENF", "terminoVigencia", "dataVigenciaFim", "vigencia_fim"),
|
|
74
|
+
"modalidade": ("MODALI", "descricaoTipoLicitacao", "modalidade"),
|
|
75
|
+
"aditado_flag": ("ADITADO", "aditado"),
|
|
76
|
+
},
|
|
77
|
+
"servidor": {
|
|
78
|
+
"nome": ("NOME", "nome", "nomeServidor", "nomeRazaoSocial", "FolhaNome"),
|
|
79
|
+
"cpf": ("CPF", "cpf", "documento", "cpfFormatado", "FolhaCPF"),
|
|
80
|
+
"cargo": ("CARGO", "cargo", "descricaoCargo", "DESCCARGO", "FolhaCargoPriNome",
|
|
81
|
+
"FolhaCargo"),
|
|
82
|
+
"lotacao": ("LOTACAO", "descricaoLotacao", "localTrabalho", "lotacao", "SETOR",
|
|
83
|
+
"estrutAdministrativa", "secretaria", "departamento",
|
|
84
|
+
"FolhaLotacaoNome", "FolhaOrgaoNome"),
|
|
85
|
+
"vinculo": ("VINCULO", "vinculo", "formaInvestidura", "TIPOVINCULO", "situacao",
|
|
86
|
+
"formaIngressoFunc", "tipoSituacaoVincTrab", "FolhaSituacaoFuncDsc",
|
|
87
|
+
"FolhaTipoIngressoDsc"),
|
|
88
|
+
"admissao": ("ADMISSAO", "dataAdmissao", "DATA_ADMISSAO", "dataAdmissaoAtivo"),
|
|
89
|
+
"demissao": ("DESLIGAMENTO", "dataDemissao", "dataFimContrato", "DATA_DESLIGAMENTO",
|
|
90
|
+
"dataExoneracao", "FolhaExoneracaoData"),
|
|
91
|
+
"remuneracao": ("PROVENTOS", "remuneracao", "salarioBase", "REMUNERACAO",
|
|
92
|
+
"salario_base", "salarioBruto", "FolhaVlrBruta"),
|
|
93
|
+
"descontos": ("DESCONTOS", "descontos", "FolhaVlrDescTotal"),
|
|
94
|
+
"competencia": ("FolhaCompetencia", "competencia", "COMPETENCIA"),
|
|
95
|
+
"folha_tipo": ("FolhaTipoDsc",),
|
|
96
|
+
},
|
|
97
|
+
"pagamento": {
|
|
98
|
+
"credor": ("NOMEFOR", "credor", "fornecedor", "nomeCredor", "PagamentoEmpCredorNome"),
|
|
99
|
+
"credor_doc": ("CPFCNPJ", "cnpjCpf", "documentoCredor", "PagamentoEmpCredorCPFCNPJ"),
|
|
100
|
+
"data_liquidacao": ("DATA_LIQUIDACAO", "dataLiquidacao", "data", "LiquidacaoData"),
|
|
101
|
+
"data_pagamento": ("DATA_PAGAMENTO", "dataPagamento", "PagamentoData"),
|
|
102
|
+
"data_vencimento": ("DATA_VENCIMENTO", "dataVencimento"),
|
|
103
|
+
"valor": ("VALOR", "valor", "valorPago", "PagamentoValor"),
|
|
104
|
+
"fonte": ("FONTE", "fonteRecurso", "descFonteRecurso"),
|
|
105
|
+
"empenho": ("EMPENHO", "empenho", "numeroEmpenho", "EmpenhoNumero"),
|
|
106
|
+
},
|
|
107
|
+
"licitacao": {
|
|
108
|
+
"numero": ("NLICITACAO", "LicitacaoNumero", "Licitacao", "NUMLIC",
|
|
109
|
+
"numeroLicitacao", "numero", "LICITACAO", "numeroAnoLicitacao"),
|
|
110
|
+
"ano": ("ANO", "LicitacaoAno", "exercicio", "anoLicitacao"),
|
|
111
|
+
"modalidade": ("DISCR", "LicitacaoModalidadeNome", "modalidadeNome",
|
|
112
|
+
"descricaoTipoLicitacao", "modalidade", "ESPECIETCE",
|
|
113
|
+
"descricaoModalidade"),
|
|
114
|
+
"objeto": ("DISCR10", "LicitacaoObjeto", "objeto", "objetoLicitacao", "OBJETO",
|
|
115
|
+
"descricaoObjeto"),
|
|
116
|
+
"data_publicacao": ("DTENV", "LicitacaoData", "dataPublicacao", "DATAPUBLICACAO"),
|
|
117
|
+
"data_abertura": ("DATAE", "LicitacaoDataSessao", "dataAbertura",
|
|
118
|
+
"DTPROPOSTAINI", "dataSessao"),
|
|
119
|
+
"data_encerramento": ("DTENC", "DTPROPOSTAFIM", "dataEncerramento"),
|
|
120
|
+
"valor_estimado": ("VALOR", "valorEstimado", "valor_estimado"),
|
|
121
|
+
"valor_homologado": ("VALOR1", "valorHomologado", "valor_homologado"),
|
|
122
|
+
"situacao": ("SITUACAO", "LicitacaoSituacao", "situacao", "descricaoSituacao"),
|
|
123
|
+
"n_participantes": ("PARTICIPANTES", "numeroParticipantes", "qtdParticipantes"),
|
|
124
|
+
"vencedor_doc": ("CPFCNPJ", "cnpjVencedor", "documentoVencedor"),
|
|
125
|
+
"registro_preco": ("REGISTROPRECO", "REGISTRO_PRECO", "srp", "registroPreco",
|
|
126
|
+
"flGeraRegistroPrecosExibicao"),
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_DATAS = {"data", "assinatura", "vigencia_inicio", "vigencia_fim", "admissao",
|
|
131
|
+
"demissao", "data_liquidacao", "data_pagamento", "data_vencimento",
|
|
132
|
+
"data_publicacao", "data_abertura", "data_encerramento"}
|
|
133
|
+
_VALORES = {"valor_empenhado", "valor_liquidado", "valor_pago", "valor_unitario",
|
|
134
|
+
"valor_inicial", "valor_atual", "valor_aditivos", "remuneracao",
|
|
135
|
+
"descontos", "valor", "valor_estimado", "valor_homologado"}
|
|
136
|
+
_DOCS = {"credor_doc", "fornecedor_doc", "cpf", "vencedor_doc"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _primeiro(rec: dict, nomes) -> Optional[object]:
|
|
140
|
+
for n in nomes:
|
|
141
|
+
if n in rec and rec[n] not in (None, "", "null"):
|
|
142
|
+
return rec[n]
|
|
143
|
+
# busca case-insensitive
|
|
144
|
+
for k in rec:
|
|
145
|
+
if k.lower() == n.lower() and rec[k] not in (None, "", "null"):
|
|
146
|
+
return rec[k]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def canon(tipo: str, rec: dict) -> dict:
|
|
151
|
+
m = MAPAS[tipo]
|
|
152
|
+
out = {"_raw": rec}
|
|
153
|
+
for campo, nomes in m.items():
|
|
154
|
+
v = _primeiro(rec, nomes)
|
|
155
|
+
if campo in _DATAS:
|
|
156
|
+
out[campo] = _data(v)
|
|
157
|
+
elif campo in _VALORES:
|
|
158
|
+
out[campo] = num(v)
|
|
159
|
+
elif campo in _DOCS:
|
|
160
|
+
out[campo] = doc_limpo(v)
|
|
161
|
+
else:
|
|
162
|
+
out[campo] = v
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def canon_lista(tipo: str, regs: List[dict]) -> List[dict]:
|
|
167
|
+
return [canon(tipo, r) for r in (regs or [])]
|
|
168
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Helpers comuns às análises B (refatorados de `core/analises/_util.py`)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def num(v) -> float:
|
|
11
|
+
"""'1.234,56' | '1234.56' | 1234 → float."""
|
|
12
|
+
if v is None or v == "":
|
|
13
|
+
return 0.0
|
|
14
|
+
if isinstance(v, (int, float)):
|
|
15
|
+
return float(v)
|
|
16
|
+
s = str(v).strip().replace("R$", "").replace(" ", "")
|
|
17
|
+
if "," in s and "." in s:
|
|
18
|
+
s = s.replace(".", "").replace(",", ".")
|
|
19
|
+
elif "," in s:
|
|
20
|
+
s = s.replace(",", ".")
|
|
21
|
+
try:
|
|
22
|
+
return float(re.sub(r"[^\d.\-]", "", s) or 0)
|
|
23
|
+
except ValueError:
|
|
24
|
+
return 0.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def data(v) -> Optional[datetime]:
|
|
28
|
+
if not v:
|
|
29
|
+
return None
|
|
30
|
+
s = str(v)[:10]
|
|
31
|
+
for f in ("%Y-%m-%d", "%d/%m/%Y", "%Y%m%d"):
|
|
32
|
+
try:
|
|
33
|
+
return datetime.strptime(s, f)
|
|
34
|
+
except ValueError:
|
|
35
|
+
pass
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def norm(s: str) -> str:
|
|
40
|
+
s = unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode()
|
|
41
|
+
return re.sub(r"\s+", " ", s).strip().upper()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def doc_limpo(v) -> str:
|
|
45
|
+
return re.sub(r"\D", "", str(v or ""))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sobrenomes(nome: str, min_len: int = 4) -> set:
|
|
49
|
+
"""Sobrenomes 'úteis' (ignora conectivos e nomes curtos)."""
|
|
50
|
+
stop = {"DE", "DA", "DO", "DActionResult", "DOS", "DAS", "E"}
|
|
51
|
+
toks = norm(nome).split()
|
|
52
|
+
return {t for t in toks[1:] if len(t) >= min_len and t not in stop}
|