Skip re-downloading site MP3s; reuse existing Gooya audio on PVC.
build-and-deploy / deploy (push) Successful in 2m34s

Crawl only ayah text/notes/messages; API falls back to episode audio.
This commit is contained in:
2026-10-10 01:11:14 +03:30
parent d85813f529
commit 6acfb0b9d0
3 changed files with 53 additions and 28 deletions
+2 -1
View File
@@ -24,7 +24,8 @@ spec:
set -euo pipefail set -euo pipefail
apt-get update -qq apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ca-certificates >/dev/null DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ca-certificates >/dev/null
python3 /scripts/crawl-gharaati.py /data/audio --skip-existing-json --delay 0.25 # Content only — PVC already has ~2997 Gooya MP3s; skip site audio re-download.
python3 /scripts/crawl-gharaati.py /data/audio --skip-existing-json --delay 0.15
volumeMounts: volumeMounts:
- name: audio - name: audio
mountPath: /data/audio mountPath: /data/audio
+25 -26
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Crawl Tafsir Noor from gharaati.ir into AUDIO_DIR/gharaati + content JSON.""" """Crawl Tafsir Noor text/notes/messages from gharaati.ir (JSON only by default).
Existing PVC audio (Tafsir Gooya RARs) is kept; do not re-download site MP3s
unless --with-audio is passed.
"""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -8,7 +12,6 @@ import os
import re import re
import sys import sys
import time import time
import urllib.error
import urllib.request import urllib.request
from html import unescape from html import unescape
from pathlib import Path from pathlib import Path
@@ -38,20 +41,15 @@ def strip_tags(html: str) -> str:
def section_after_title(html: str, title: str) -> str | None: def section_after_title(html: str, title: str) -> str | None:
# Match heading text then first content cell with class tafsir or tafsir_ayeh
pattern = ( pattern = (
rf'<span class="post-title h-text">\s*{re.escape(title)}\s*</span>.*?' rf'<span class="post-title h-text">\s*{re.escape(title)}\s*</span>.*?'
rf'<td[^>]*class=[\'"]?(?:tafsir_ayeh|tafsir)[\'"]?[^>]*>(.*?)</td>' rf'<td[^>]*class=[\'"]?(?:tafsir_ayeh|tafsir)[\'"]?[^>]*>(.*?)</td>'
) )
m = re.search(pattern, html, flags=re.I | re.S) m = re.search(pattern, html, flags=re.I | re.S)
if not m: return m.group(1).strip() if m else None
# alternate spellings
return None
return m.group(1).strip()
def parse_show3(html: str) -> dict: def parse_show3(html: str) -> dict:
# Normalize common title variants
ayah_html = section_after_title(html, "آيه") or section_after_title(html, "آیه") ayah_html = section_after_title(html, "آيه") or section_after_title(html, "آیه")
tarjome = section_after_title(html, "ترجمه") tarjome = section_after_title(html, "ترجمه")
nokteh = section_after_title(html, "نکته ها") or section_after_title(html, "نكته ها") nokteh = section_after_title(html, "نکته ها") or section_after_title(html, "نكته ها")
@@ -98,18 +96,18 @@ def download_mp3(surah: int, ayah: int, dest: Path) -> bool:
return False return False
def crawl_one(surah: int, ayah: int, audio_dir: Path, delay: float) -> dict: def crawl_one(surah: int, ayah: int, audio_dir: Path, delay: float, with_audio: bool) -> dict:
html = fetch(f"{BASE}/show3.php?page=tafsir3&numsooreh={surah}&numayeh={ayah}").decode( html = fetch(f"{BASE}/show3.php?page=tafsir3&numsooreh={surah}&numayeh={ayah}").decode(
"utf-8", "replace" "utf-8", "replace"
) )
parsed = parse_show3(html) parsed = parse_show3(html)
rel = f"gharaati/{surah}/{ayah}.mp3" parsed["audioPath"] = None
mp3_path = audio_dir / rel if with_audio:
download_mp3(surah, ayah, mp3_path) rel = f"gharaati/{surah}/{ayah}.mp3"
if mp3_path.exists(): mp3_path = audio_dir / rel
parsed["audioPath"] = rel download_mp3(surah, ayah, mp3_path)
else: if mp3_path.exists():
parsed["audioPath"] = None parsed["audioPath"] = rel
parsed["surah"] = surah parsed["surah"] = surah
parsed["ayah"] = ayah parsed["ayah"] = ayah
content_path = audio_dir / "gharaati" / "content" / str(surah) / f"{ayah}.json" content_path = audio_dir / "gharaati" / "content" / str(surah) / f"{ayah}.json"
@@ -124,8 +122,13 @@ def main() -> int:
ap.add_argument("audio_dir", nargs="?", default=os.environ.get("AUDIO_DIR", "/data/audio")) ap.add_argument("audio_dir", nargs="?", default=os.environ.get("AUDIO_DIR", "/data/audio"))
ap.add_argument("--surah-from", type=int, default=1) ap.add_argument("--surah-from", type=int, default=1)
ap.add_argument("--surah-to", type=int, default=114) ap.add_argument("--surah-to", type=int, default=114)
ap.add_argument("--delay", type=float, default=0.35) ap.add_argument("--delay", type=float, default=0.2)
ap.add_argument("--skip-existing-json", action="store_true") ap.add_argument("--skip-existing-json", action="store_true")
ap.add_argument(
"--with-audio",
action="store_true",
help="Also download archive/tafsir/{s}/{a}.mp3 (usually unnecessary; PVC already has Gooya audio)",
)
args = ap.parse_args() args = ap.parse_args()
audio_dir = Path(args.audio_dir) audio_dir = Path(args.audio_dir)
audio_dir.mkdir(parents=True, exist_ok=True) audio_dir.mkdir(parents=True, exist_ok=True)
@@ -136,24 +139,20 @@ def main() -> int:
if not ayahs: if not ayahs:
print(f"surah {s}: no ayah list", file=sys.stderr) print(f"surah {s}: no ayah list", file=sys.stderr)
continue continue
print(f"surah {s}: {len(ayahs)} ayahs") print(f"surah {s}: {len(ayahs)} ayahs", flush=True)
for a in ayahs: for a in ayahs:
jpath = audio_dir / "gharaati" / "content" / str(s) / f"{a}.json" jpath = audio_dir / "gharaati" / "content" / str(s) / f"{a}.json"
if args.skip_existing_json and jpath.exists(): if args.skip_existing_json and jpath.exists():
# still try mp3 if missing
mp3 = audio_dir / "gharaati" / str(s) / f"{a}.mp3"
if not mp3.exists():
download_mp3(s, a, mp3)
continue continue
try: try:
crawl_one(s, a, audio_dir, args.delay) crawl_one(s, a, audio_dir, args.delay, args.with_audio)
total += 1 total += 1
if total % 25 == 0: if total % 50 == 0:
print(f" crawled {total} ayahs (at {s}:{a})") print(f" crawled {total} ayahs (at {s}:{a})", flush=True)
except Exception as ex: # noqa: BLE001 except Exception as ex: # noqa: BLE001
print(f"FAIL {s}:{a} {ex}", file=sys.stderr) print(f"FAIL {s}:{a} {ex}", file=sys.stderr)
time.sleep(2) time.sleep(2)
print(f"done. crawled={total}") print(f"done. crawled={total}", flush=True)
return 0 return 0
@@ -97,18 +97,26 @@ public static class TafsirEndpoints
}).ToList(); }).ToList();
} }
var episodeAyahs = (await db.Episodes.AsNoTracking()
.Where(e => e.TafsirId == tafsir.Id && e.SurahId == surah.Id)
.Select(e => e.AyahNumber)
.Distinct()
.ToListAsync(ct)).ToHashSet();
var list = ayahs.Select(a => var list = ayahs.Select(a =>
{ {
contents.TryGetValue(a.Number, out var c); contents.TryGetValue(a.Number, out var c);
var preview = a.TextFa; var preview = a.TextFa;
if (!string.IsNullOrWhiteSpace(preview) && preview.Length > 80) if (!string.IsNullOrWhiteSpace(preview) && preview.Length > 80)
preview = preview[..80] + "…"; preview = preview[..80] + "…";
var hasAudio = !string.IsNullOrWhiteSpace(c?.AudioPath) || episodeAyahs.Contains(a.Number)
|| episodeAyahs.Count > 0;
return new AyahListItemDto( return new AyahListItemDto(
surah.Number, surah.Number,
surah.NameFa, surah.NameFa,
a.Number, a.Number,
preview, preview,
!string.IsNullOrWhiteSpace(c?.AudioPath), hasAudio,
bookmarks.Contains(a.Number), bookmarks.Contains(a.Number),
favorites.Contains(a.Number)); favorites.Contains(a.Number));
}).ToList(); }).ToList();
@@ -156,7 +164,24 @@ public static class TafsirEndpoints
string? audioUrl = null; string? audioUrl = null;
if (!string.IsNullOrWhiteSpace(content?.AudioPath)) if (!string.IsNullOrWhiteSpace(content?.AudioPath))
{
audioUrl = $"/api/audio/ayah/{surahNumber}/{ayahNumber}?tafsirId={tafsir.Id}"; audioUrl = $"/api/audio/ayah/{surahNumber}/{ayahNumber}?tafsirId={tafsir.Id}";
}
else
{
// Fall back to existing Gooya episode audio already on PVC (best-effort ayah match).
var episode = await db.Episodes.AsNoTracking()
.Where(e => e.TafsirId == tafsir.Id && e.SurahId == surah.Id && e.AyahNumber == ayahNumber)
.OrderBy(e => e.SortOrder)
.FirstOrDefaultAsync(ct);
episode ??= await db.Episodes.AsNoTracking()
.Where(e => e.TafsirId == tafsir.Id && e.SurahId == surah.Id)
.OrderBy(e => Math.Abs(e.AyahNumber - ayahNumber))
.ThenBy(e => e.SortOrder)
.FirstOrDefaultAsync(ct);
if (episode is not null)
audioUrl = $"/api/audio/{episode.Id}";
}
return Results.Ok(new AyahDetailDto( return Results.Ok(new AyahDetailDto(
tafsir.Id, tafsir.Id,