feat(lecturas): lote masivo de lecturas bíblicas vía índice del leccionario

build_lectionary_index.py construye un índice por referencia bíblica
descargando un ciclo litúrgico de evangelizo.ws (combina idiomas por
reading_code, robusto a fiestas trasladadas por país). lecturas_apply.py
casa las lecturas ES por referencia y apply_lecturas_wp.php crea+asocia
las traducciones en Polylang. 440/501 lecturas cubiertas y publicadas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 11:38:45 -04:00
parent 0da0c0eb59
commit 0bc58bfa96
4 changed files with 313 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
<?php
/**
* apply_lecturas_wp.php — Crea las traducciones de lecturas bíblicas casadas por
* referencia (lecturas_apply.py) y las asocia en Polylang. Idempotente.
*
* Ejecutar dentro del contenedor:
* docker cp /tmp/lecturas_creadas.json wordpress-web:/tmp/
* docker exec wordpress-web wp eval-file /tmp/apply_lecturas_wp.php [publish]
*/
$status = (isset($argv[1]) && $argv[1] === 'publish') ? 'publish' : 'draft';
$data = json_decode(file_get_contents('/tmp/lecturas_creadas.json'), true);
$created = 0; $posts_done = 0; $skipped = 0;
foreach ($data as $row) {
$es_id = (int) $row['es_id'];
$es = get_post($es_id);
if (!$es) { $skipped++; continue; }
$existing = function_exists('pll_get_post_translations') ? pll_get_post_translations($es_id) : ['es' => $es_id];
$group = $existing;
$es_cats = wp_get_post_categories($es_id);
foreach (['en', 'fr', 'it', 'pt'] as $L) {
if (!empty($existing[$L])) { $group[$L] = $existing[$L]; continue; }
if (empty($row['langs'][$L])) continue;
$id = wp_insert_post([
'post_title' => $es->post_title, // referencia bíblica (igual en todos)
'post_content' => $row['langs'][$L],
'post_status' => $status,
'post_type' => 'post',
'comment_status' => 'closed',
], true);
if (is_wp_error($id)) continue;
pll_set_post_language($id, $L);
$cats = [];
foreach ($es_cats as $c) { $t = pll_get_term($c, $L); if ($t) $cats[] = $t; }
if ($cats) wp_set_post_categories($id, $cats);
$group[$L] = $id;
$created++;
}
if (function_exists('pll_save_post_translations')) pll_save_post_translations($group);
$posts_done++;
}
echo "posts ES procesados: $posts_done | traducciones creadas: $created | status=$status | skip=$skipped\n";
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
build_lectionary_index.py — Descarga el leccionario de evangelizo.ws para un rango de
fechas (un ciclo litúrgico completo cubre todas las lecturas) en es/en/fr/it/pt y
construye un índice POR REFERENCIA bíblica, para casar lecturas sin depender de fechas.
Salida: /tmp/lectionary_index.json { "LIBRO|cap|vers": {es,en,fr,it,pt: html} }
Cache por día/idioma en /tmp/evangelizo_cache (resumible).
Uso: python3 build_lectionary_index.py 2023-01-01 2025-12-31
"""
import sys, os, re, json, time, html, unicodedata, urllib.request
from datetime import date, timedelta
LANGS = {"SP": "es", "AM": "en", "FR": "fr", "IT": "it", "PT": "pt"}
CACHE = "/tmp/evangelizo_cache"
os.makedirs(CACHE, exist_ok=True)
INDEX = "/tmp/lectionary_index.json"
def norm_book(full_title):
# "Libro de Jeremías" / "Carta de san Pablo a los Romanos" -> "JEREMIAS"/"ROMANOS"
s = unicodedata.normalize("NFKD", full_title).encode("ascii", "ignore").decode().upper()
s = re.sub(r"[^A-Z0-9 ]", " ", s)
toks = [t for t in s.split() if t]
return toks[-1] if toks else ""
def clean(raw):
raw = html.unescape(raw or "")
raw = re.sub(r"\[\[[^\]]+\]\]", "", raw)
paras = [p.strip() for p in raw.split("\n") if p.strip()]
return "".join(f"<p>{p}</p>\n" for p in paras)
def fetch(date_s, lang_code):
fp = os.path.join(CACHE, f"{date_s}_{lang_code}.json")
if os.path.exists(fp):
try:
return json.load(open(fp))
except Exception:
pass
url = f"https://publication.evangelizo.ws/{lang_code}/days/{date_s}"
for a in range(3):
try:
req = urllib.request.Request(url, headers={"User-Agent": "fea-lect/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
data = json.load(r)
out = []
for rd in data.get("data", {}).get("readings", []) or []:
out.append({
"code": rd.get("reading_code", ""),
"ref": (rd.get("reference_displayed") or "").strip().rstrip("."),
"book": (rd.get("book") or {}).get("full_title", ""),
"text": clean(rd.get("text", "")),
})
json.dump(out, open(fp, "w"), ensure_ascii=False)
time.sleep(0.15)
return out
except Exception:
if a == 2:
json.dump([], open(fp, "w"))
return []
time.sleep(1.0)
def key_from(book_full, ref):
m = re.match(r"(\d{1,3})\s*,\s*(\d{1,3})", ref)
if not m:
return None
return f"{norm_book(book_full)}|{int(m.group(1))}|{int(m.group(2))}"
def main():
d0 = date.fromisoformat(sys.argv[1])
d1 = date.fromisoformat(sys.argv[2])
days = (d1 - d0).days + 1
# Las fiestas trasladadas caen en fechas distintas por país/idioma → NO se puede
# casar dentro del mismo día. Indexamos por reading_code (estable entre idiomas)
# acumulando el texto de cada idioma desde CUALQUIER día donde aparezca.
code_text = {wl: {} for wl in LANGS.values()} # lang -> {code: text}
code_book = {} # code -> norm_book (del SP)
cur, n = d0, 0
while cur <= d1:
ds = cur.isoformat()
for lc, wl in LANGS.items():
for rd in fetch(ds, lc):
code = rd["code"]
if not code:
continue
if rd["text"] and code not in code_text[wl]:
code_text[wl][code] = rd["text"]
if wl == "es" and code not in code_book:
nb = norm_book(rd["book"])
m = re.search(r"(\d{1,3})\s*,\s*(\d{1,3})", code)
if nb and m:
code_book[code] = f"{nb}|{int(m.group(1))}|{int(m.group(2))}"
n += 1
if n % 60 == 0:
print(f" {n}/{days} días codes_es={len(code_text['es'])}", flush=True)
cur += timedelta(days=1)
# combinar: para cada code con clave-ES y texto en los 4 idiomas
index = {}
for code, key in code_book.items():
if key in index:
continue
entry = {}
ok = True
for wl in ("es", "en", "fr", "it", "pt"):
t = code_text[wl].get(code)
if not t:
ok = (wl == "es") and ok # es siempre presente; faltar otro descarta
if wl != "es":
ok = False
break
entry[wl] = t
if ok and all(l in entry for l in ("en", "fr", "it", "pt")):
index[key] = entry
json.dump(index, open(INDEX, "w"), ensure_ascii=False)
print(f"FIN. {n} días. codes_es={len(code_book)} → índice {len(index)} referencias en {INDEX}")
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
lecturas_apply.py — Casa las lecturas ES sin traducir contra el índice del leccionario
(build_lectionary_index.py) POR REFERENCIA bíblica y vuelca las traducciones a crear.
Entrada: /tmp/lectionary_index.json , /tmp/lecturas_todo.json
Salida: /tmp/lecturas_creadas.json (para que un wp eval cree+asocie+publique)
/tmp/lecturas_skip.json
Uso: python3 lecturas_apply.py [--limit N]
"""
import sys, re, json, unicodedata
from collections import Counter
# Alias de nombre de libro: feadulta -> token usado por evangelizo (último token full_title ES)
ALIAS = {
"HECHOS": "APOSTOLES", "HCH": "APOSTOLES",
"CANTAR": "CANTARES",
"APOC": "APOCALIPSIS", "AP": "APOCALIPSIS",
"QOHELET": "ECLESIASTES",
# abreviaturas litúrgicas
"MT": "MATEO", "MC": "MARCOS", "LC": "LUCAS", "JN": "JUAN",
"RM": "ROMANOS", "GA": "GALATAS", "EF": "EFESIOS", "FLP": "FILIPENSES",
"COL": "COLOSENSES", "HB": "HEBREOS", "ST": "SANTIAGO",
"IS": "ISAIAS", "JR": "JEREMIAS", "EZ": "EZEQUIEL", "GN": "GENESIS",
"EX": "EXODO", "DT": "DEUTERONOMIO", "SAL": "SALMOS", "PR": "PROVERBIOS",
}
def norm(s):
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode().upper()
return re.sub(r"[^A-Z]", "", s) # solo letras → descarta el número del libro
def title_keys(title):
keys = []
for part in re.split(r"\s*/\s*", title):
m = re.search(r"([0-9]?\s*[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ.\s]+?)\s+(\d{1,3})\s*,\s*(\d{1,3})", part)
if not m:
return None # parte no parseable → no casar el post entero
book = norm(m.group(1))
book = ALIAS.get(book, book)
keys.append(f"{book}|{int(m.group(2))}|{int(m.group(3))}")
return keys or None
def main():
limit = 0
if "--limit" in sys.argv:
limit = int(sys.argv[sys.argv.index("--limit") + 1])
idx = json.load(open("/tmp/lectionary_index.json"))
todo = json.load(open("/tmp/lecturas_todo.json"))
if limit:
todo = todo[:limit]
creadas, skip = [], []
for t in todo:
keys = title_keys(t["title"])
if not keys:
skip.append({**t, "why": "título no parseable"})
continue
if not all(k in idx for k in keys):
missing = [k for k in keys if k not in idx]
skip.append({**t, "why": "ref no en índice", "missing": missing})
continue
langs = {}
for wl in ("en", "fr", "it", "pt"):
langs[wl] = "".join(idx[k][wl] for k in keys)
creadas.append({"es_id": t["id"], "title": t["title"], "langs": langs})
json.dump(creadas, open("/tmp/lecturas_creadas.json", "w"), ensure_ascii=False)
json.dump(skip, open("/tmp/lecturas_skip.json", "w"), ensure_ascii=False)
print(f"CASADAS: {len(creadas)} / {len(todo)} SKIP: {len(skip)}")
print("motivos skip:", dict(Counter(s["why"] for s in skip)))
# muestra de refs que faltan (para ampliar alias/rango)
missing = Counter()
for s in skip:
for k in s.get("missing", []):
missing[k.split("|")[0]] += 1
print("libros con más misses:", dict(missing.most_common(12)))
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
# Lecturas sin traducir tras lote evangelizo (61) — ref no hallada en ciclo 2023-2025
1199 MATEO 27, 11-54 ref no en índice
52 JUAN 18, 1-40 / JUAN 19, 1-42 ref no en índice
286 DEUTERONOMIO, 2-3 y 14-16 / 1 CORINTIOS 10, 16-17 título no parseable
2988 JUAN 18, 28-40 ref no en índice
3478 LUCAS 19, 28-44 ref no en índice
2687 MATEO 21, 1-11 ref no en índice
5532 SANTIAGO 2, 1-9 ref no en índice
5539 PUNTOS DE REFLEXIÓN (Mt 23, 8-24) ref no en índice
5778 HECHOS 1, 12-14 ref no en índice
5842 1 PEDRO 1, 8 ref no en índice
5866 HECHOS 10, 37-39 ref no en índice
6000 GÁLATAS 5, 19-20, 22-23 ref no en índice
6015 ROMANOS 8, 15-17 ref no en índice
6087 SABIDURÍA 11, 23-26 y 12, 1-2 ref no en índice
57 MARCOS 16, 1-7 ref no en índice
4843 ÉXODO 15, 22 a 16, 35 ref no en índice
4846 HECHOS 2, 14-36 ref no en índice
6163 1 JUAN 4, 20-21 y 3, 16-18 ref no en índice
7661 1 REYES 17, 17-24 ref no en índice
7662 GÁLATAS 1, 11-19 ref no en índice
7704 ZACARÍAS 12, 10-11 y 13, 1 ref no en índice
7705 GÁLATAS 3, 26-29 ref no en índice
8152 MALAQUÍAS 4, 1-2 ref no en índice
4675 ISAÍAS 49, 14-15 ref no en índice
10527 SAMUEL 27, 4-7 ref no en índice
13022 HECHOS 4,33;5,12.27-33;12,2 ref no en índice
13289 DEUTERONOMIO 6, 1-9 ref no en índice
2217 LUCAS 3, 1-6 ref no en índice
7070 BARUC 5, 1-9 ref no en índice
7071 FILIPENSES 1, 3-11 ref no en índice
1996 LUCAS 4, 21-30 ref no en índice
7266 JEREMÍAS 1, 4-19 ref no en índice
7958 TIMOTEO 1, 12-17 ref no en índice
8105 SABIDURÍA 11, 23 - 12, 2 ref no en índice
8106 2 TESALONICENSES 1, 11 - 2, 2 ref no en índice
8131 2 TESALONICENSES 2, 15 - 3, 5 ref no en índice
11260 MALAQUIAS 3,19-20a ref no en índice
8195 ROMANOS 13, 11-14 ref no en índice
4701 GÉNESIS 2, 7-9 y 3, 1-7 ref no en índice
4932 1 PEDRO 2, 20-25 ref no en índice
5218 ROMANOS 8, 35-39 ref no en índice
9039 ECLESIÁSTICO 27, 33 a 28, 9 ref no en índice
5549 SABIDURÍA 6, 13-17 ref no en índice
5550 1 TESALONICENSES 4, 12-17 ref no en índice
5640 ISAÍAS 63, 16 a 64, 8 ref no en índice
5657 2 PEDRO 3, 8-14 ref no en índice
5954 LEVÍTICO 13. 1-2, 44-46 título no parseable
71 MARCOS 15, 1-39 ref no en índice
15823 SEGUNDO ANUNCIO PASCUAL: LO FEMENINO DEL ALMA DISPONE A LA EXPERIENCIA PASCUAL LC 24, 6-10 ref no en índice
6302 HECHOS 10, 25-48 ref no en índice
6644 1 REYES 19, 4-8 ref no en índice
10026 SANTIAGO 2, 1-5 ref no en índice
6818 NÚMEROS 11, 25-29 ref no en índice
6896 ISAÍAS 53, 10-11 ref no en índice
2849 MARCOS 10, 46-52 ref no en índice
16393 DEUTERONOMIO 6, 2-6 ref no en índice
6970 1 REYES 17, 10-16 ref no en índice
2001 LUCAS 1, 1-4 / LUCAS 4, 14-21 ref no en índice
7746 ISAÍAS 66, 10-14 ref no en índice
1648 LUCAS 17, 5-10 ref no en índice
17511 “NO NOS DEJES CAER EN LA TENTACIÓN” (MT 6, 13) ref no en índice