feat: initial scaffold of osint_db (DuckDB source-of-truth service)
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"""Parseo ligero de vCard y de iCalendar para el ingest DAV.
|
||||
|
||||
Lógica propia de la app (glue específico del dominio): el registry baja las
|
||||
colecciones en bruto con dav_get_collection y aquí se extraen los campos
|
||||
mínimos que las tablas maestras contacts y events necesitan. Mismo enfoque de
|
||||
regex con unfold que projects/osint/tools/sync_dav_to_osint.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def _unfold(text: str) -> str:
|
||||
"""Deshace el folding de líneas (continuación con espacio o tab)."""
|
||||
return re.sub(r"\r?\n[ \t]", "", text)
|
||||
|
||||
|
||||
def _values(text: str, prop: str) -> list:
|
||||
"""Devuelve todos los valores de una propiedad (TEL, UID, DTSTART, ...).
|
||||
|
||||
Acepta PROP;PARAMS:valor y PROP:valor (con prefijo itemN. opcional) y
|
||||
decodifica los escapes simples (\\n, \\,, \\;, \\\\).
|
||||
"""
|
||||
vals = []
|
||||
for line in text.splitlines():
|
||||
m = re.match(rf"^(?:item\d+\.)?{prop}(?:;[^:]*)?:(.*)$", line, re.IGNORECASE)
|
||||
if m:
|
||||
v = m.group(1).strip()
|
||||
v = (
|
||||
v.replace("\\n", "\n")
|
||||
.replace("\\,", ",")
|
||||
.replace("\\;", ";")
|
||||
.replace("\\\\", "\\")
|
||||
).strip()
|
||||
if v:
|
||||
vals.append(v)
|
||||
return vals
|
||||
|
||||
|
||||
def _prop_line(text: str, prop: str) -> str:
|
||||
"""Devuelve la primera línea completa de una propiedad (con sus params)."""
|
||||
for line in text.splitlines():
|
||||
if re.match(rf"^(?:item\d+\.)?{prop}(?:;|:)", line, re.IGNORECASE):
|
||||
return line
|
||||
return ""
|
||||
|
||||
|
||||
def parse_vcard(vcard_text: str) -> dict:
|
||||
"""Extrae los campos mínimos de un vCard para la tabla contacts.
|
||||
|
||||
Devuelve {uid, fn, tels, emails}. tels y emails son listas deduplicadas
|
||||
preservando el orden.
|
||||
"""
|
||||
txt = _unfold(vcard_text)
|
||||
return {
|
||||
"uid": (_values(txt, "UID") or [""])[0],
|
||||
"fn": (_values(txt, "FN") or [""])[0],
|
||||
"tels": _dedup(_values(txt, "TEL")),
|
||||
"emails": _dedup(_values(txt, "EMAIL")),
|
||||
}
|
||||
|
||||
|
||||
def parse_ical_events(ical_text: str) -> list:
|
||||
"""Extrae los VEVENT de un VCALENDAR para la tabla events.
|
||||
|
||||
Devuelve una lista de dicts {uid, dtstart, dtend, all_day, summary,
|
||||
location, rrule, raw}. dtstart/dtend se normalizan a ISO básico
|
||||
(YYYY-MM-DD o YYYY-MM-DDTHH:MM:SS, conservando la Z de UTC si la trae).
|
||||
all_day es True cuando DTSTART es VALUE=DATE (sin componente de hora).
|
||||
"""
|
||||
events = []
|
||||
txt = _unfold(ical_text)
|
||||
for block in re.findall(
|
||||
r"BEGIN:VEVENT(.*?)END:VEVENT", txt, re.DOTALL | re.IGNORECASE
|
||||
):
|
||||
dtstart_line = _prop_line(block, "DTSTART")
|
||||
dtstart_raw = (_values(block, "DTSTART") or [""])[0]
|
||||
all_day = bool(
|
||||
re.search(r";VALUE=DATE(?:;|:)", dtstart_line, re.IGNORECASE)
|
||||
) or bool(re.fullmatch(r"\d{8}", dtstart_raw))
|
||||
events.append(
|
||||
{
|
||||
"uid": (_values(block, "UID") or [""])[0],
|
||||
"dtstart": _norm_dt(dtstart_raw),
|
||||
"dtend": _norm_dt((_values(block, "DTEND") or [""])[0]),
|
||||
"all_day": all_day,
|
||||
"summary": (_values(block, "SUMMARY") or [""])[0],
|
||||
"location": (_values(block, "LOCATION") or [None])[0],
|
||||
"rrule": (_values(block, "RRULE") or [None])[0],
|
||||
"raw": "BEGIN:VEVENT" + block + "END:VEVENT",
|
||||
}
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _norm_dt(value: str) -> str:
|
||||
"""Normaliza un DATE/DATE-TIME de iCalendar a ISO legible.
|
||||
|
||||
20260115 -> 2026-01-15; 20260115T093000Z -> 2026-01-15T09:30:00Z. Valores
|
||||
ya ISO o vacíos se devuelven tal cual.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", value)
|
||||
if m:
|
||||
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)", value)
|
||||
if m:
|
||||
return (
|
||||
f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||
f"T{m.group(4)}:{m.group(5)}:{m.group(6)}{m.group(7)}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _dedup(items: list) -> list:
|
||||
"""Deduplica una lista de strings preservando el orden (case-insensitive)."""
|
||||
seen, out = set(), []
|
||||
for it in items:
|
||||
key = str(it).strip().lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(str(it).strip())
|
||||
return out
|
||||
|
||||
|
||||
def norm_phone(p) -> str:
|
||||
"""Normaliza un teléfono a sus últimos 9 dígitos (número nacional ES)."""
|
||||
if not p:
|
||||
return ""
|
||||
d = re.sub(r"\D", "", str(p))
|
||||
return d[-9:] if len(d) >= 9 else d
|
||||
Reference in New Issue
Block a user