From 5159c32f5868868ac5b213c49c622a2150d1217a Mon Sep 17 00:00:00 2001 From: "peter.fong" Date: Fri, 5 Dec 2025 13:26:25 +0100 Subject: [PATCH] parsing fix+progress half working --- bookscraper/README.md | 8 + bookscraper/app.py | 81 +- bookscraper/project.zip | Bin 0 -> 89157 bytes bookscraper/scraper/progress.py | 28 +- bookscraper/scraper/replacements/junk.txt | 22 +- bookscraper/scraper/tasks/audio_tasks.py | 4 + bookscraper/scraper/tasks/download_tasks.py | 11 +- bookscraper/scraper/tasks/parse_tasks.py | 130 +- bookscraper/scraper/tasks/save_tasks.py | 7 +- bookscraper/scraper/utils.py | 1 + bookscraper/static/js/log_view.js | 2 +- bookscraper/text_replacements.txt | 4 +- tmp/stash.patch2 | 2950 +++++++++++++++++++ 13 files changed, 3170 insertions(+), 78 deletions(-) create mode 100644 bookscraper/project.zip create mode 100644 tmp/stash.patch2 diff --git a/bookscraper/README.md b/bookscraper/README.md index 37da684..103eded 100644 --- a/bookscraper/README.md +++ b/bookscraper/README.md @@ -134,3 +134,11 @@ docker compose up docker compose down docker compose build docker compose up + +tar \ + --exclude="**pycache**" \ + --exclude="_/**pycache**/_" \ + --exclude="\*.pyc" \ + --exclude=".venv" \ + --exclude="venv" \ + -czvf project.tar.gz . diff --git a/bookscraper/app.py b/bookscraper/app.py index 45f2e29..8e62f33 100644 --- a/bookscraper/app.py +++ b/bookscraper/app.py @@ -125,6 +125,33 @@ def celery_result(task_id): return jsonify({"ready": False}) +# ===================================================== +# API: book status new model +# ===================================================== +def getStatus(book_id): + + state = r.hgetall(f"book:{book_id}:state") + status = state.get("status") or "unknown" + dl_done = int(state.get("chapters_download_done", 0)) + dl_skipped = int(state.get("chapters_download_skipped", 0)) + dl_total = int(state.get("chapters_total", 0)) + au_done = int(state.get("audio_done") or 0) + title = state.get("title") or book_id + + au_total = dl_total + + return { + "book_id": book_id, + "title": title, + "status": status, + "download_done": dl_done, + "download_skipped": dl_skipped, + "download_total": dl_total, + "audio_done": au_done, + "audio_total": au_total, + } + + # ===================================================== # REDIS BACKEND — BOOK STATE MODEL # ===================================================== @@ -132,36 +159,29 @@ REDIS_URL = os.getenv("REDIS_BROKER", "redis://redis:6379/0") r = redis.Redis.from_url(REDIS_URL, decode_responses=True) -def list_active_books(): +def list_active_booksold(): """Return list of active books from Redis Book State Model.""" - keys = r.keys("book:*:status") + keys = r.keys("book:*:state") books = [] for key in keys: book_id = key.split(":")[1] - status = r.get(f"book:{book_id}:status") or "unknown" - title = r.get(f"book:{book_id}:title") or book_id - - dl_done = int(r.get(f"book:{book_id}:download:done") or 0) - dl_total = int(r.get(f"book:{book_id}:download:total") or 0) - au_done = int(r.get(f"book:{book_id}:audio:done") or 0) - au_total = dl_total - - books.append( - { - "book_id": book_id, - "title": title, - "status": status, - "download_done": dl_done, - "download_total": dl_total, - "audio_done": au_done, - "audio_total": au_total, - } - ) + print(book_id) + books.append(getStatus(book_id)) return books +def list_active_books(): + books = [] + for key in r.scan_iter(match="book:*:state", count=1000): + first = key.find(":") + second = key.find(":", first + 1) + book_id = key[first + 1 : second] + books.append(getStatus(book_id)) + return books + + # ===================================================== # API: list all active books # ===================================================== @@ -170,27 +190,10 @@ def api_books(): return jsonify(list_active_books()) -# ===================================================== -# API: book status -# ===================================================== @app.route("/api/book//status") def api_book_status(book_id): - status = r.get(f"book:{book_id}:status") or "unknown" - dl_done = int(r.get(f"book:{book_id}:download:done") or 0) - dl_total = int(r.get(f"book:{book_id}:download:total") or 0) - au_done = int(r.get(f"book:{book_id}:audio:done") or 0) - au_total = dl_total - return jsonify( - { - "book_id": book_id, - "status": status, - "download_done": dl_done, - "download_total": dl_total, - "audio_done": au_done, - "audio_total": au_total, - } - ) + return jsonify(getStatus(book_id)) # ===================================================== diff --git a/bookscraper/project.zip b/bookscraper/project.zip new file mode 100644 index 0000000000000000000000000000000000000000..6da01aa6c9783043ade1ae2cf96d8d666445d901 GIT binary patch literal 89157 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

1<<<$@n2wtl%h%Yn?; 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 [IMPORT] parse_tasks.py loaded (enhanced parser)") +def extract_piaotia_content(soup): + """ + Extract clean chapter content from Piaotia pages. + Start after the table following

. + End before nav/ads/footer/copyright. + """ + + h1 = soup.find("h1") + if not h1: + return None + + # -------- Find first table after

-------- + table = None + for sib in h1.next_siblings: + if getattr(sib, "name", None) == "table": + table = sib + break + + if not table: + return None + + parts = [] + + # -------- Iterate after table -------- + for sib in table.next_siblings: + + name = getattr(sib, "name", None) + text = None + if hasattr(sib, "get_text"): + text = sib.get_text(strip=True) + + # === STOP CONDITIONS === + + # Comments like + if isinstance(sib, Comment) and ("翻页" in sib): + break + + # Explicit footer blocks + if name == "div": + sid = sib.get("id", "") + cls = sib.get("class", []) + if sid in ("thumb", "tags", "tips", "Commenddiv", "feit2"): + break + + # Copyright block — strongest indicator + if text and ("重要声明" in text or "Copyright" in text): + break + + # Navigation or 推荐阅读 + if text and (text.startswith(("推荐阅读", "目录", "目 录"))): + break + + # Skip scripts, ads, centers + if name in ("script", "style"): + continue + + # Skip JS containers like
+ if name == "center": + continue + + # === ACCUMULATE TEXT === + if isinstance(sib, NavigableString): + s = sib.strip() + if s: + parts.append(s) + + elif hasattr(sib, "get_text"): + t = sib.get_text(separator="\n").strip() + if t: + parts.append(t) + + return "\n".join(parts).strip() + + @celery_app.task(bind=True, queue="parse", ignore_result=False) def parse_chapter(self, download_result: dict): """ @@ -63,32 +139,38 @@ def parse_chapter(self, download_result: dict): node = tmp break - # ------------------------------------------------------------ - # PIAOTIA FALLBACK: - # Extract content between

and the "bottomlink" block. - # ------------------------------------------------------------ raw = None + + # --- STRICT SELECTOR FAILED → Try Piaotia extractor --- if node is None: - h1 = soup.find("h1") - if h1: - content_parts = [] - for sib in h1.next_siblings: - - sib_class = getattr(sib, "get", lambda *_: None)("class") - if sib_class and ( - "bottomlink" in sib_class or sib_class == "bottomlink" - ): - break - - if getattr(sib, "name", None) in ["script", "style", "center"]: - continue - - if hasattr(sib, "get_text"): - content_parts.append(sib.get_text(separator="\n")) - else: - content_parts.append(str(sib)) - - raw = "\n".join(content_parts) + raw = extract_piaotia_content(soup) + + # # ------------------------------------------------------------ + # # PIAOTIA FALLBACK: + # # Extract content between

and the "bottomlink" block. + # # ------------------------------------------------------------ + # raw = None + # if node is None: + # h1 = soup.find("h1") + # if h1: + # content_parts = [] + # for sib in h1.next_siblings: + + # sib_class = getattr(sib, "get", lambda *_: None)("class") + # if sib_class and ( + # "bottomlink" in sib_class or sib_class == "bottomlink" + # ): + # break + + # if getattr(sib, "name", None) in ["script", "style", "center"]: + # continue + + # if hasattr(sib, "get_text"): + # content_parts.append(sib.get_text(separator="\n")) + # else: + # content_parts.append(str(sib)) + + # raw = "\n".join(content_parts) # ------------------------------------------------------------ # FINAL FALLBACK diff --git a/bookscraper/scraper/tasks/save_tasks.py b/bookscraper/scraper/tasks/save_tasks.py index 15b64b9..0999676 100644 --- a/bookscraper/scraper/tasks/save_tasks.py +++ b/bookscraper/scraper/tasks/save_tasks.py @@ -8,12 +8,12 @@ print(">>> [IMPORT] save_tasks.py loaded") from celery import shared_task import os - from scraper.utils import get_save_path from scraper.tasks.download_tasks import log_msg # unified logger from scraper.progress import ( inc_completed, - inc_skipped, + inc_chapter_done, + inc_chapter_download_skipped, ) from scraper.tasks.audio_tasks import generate_audio @@ -54,7 +54,7 @@ def save_chapter(self, parsed: dict): path = parsed.get("path", None) log_msg(book_id, f"[SAVE] SKIP chapter {chapter_num} → {path}") - inc_skipped(book_id) + inc_chapter_download_skipped(book_id) volume_name = os.path.basename(volume_path.rstrip("/")) @@ -103,6 +103,7 @@ def save_chapter(self, parsed: dict): f.write(text) log_msg(book_id, f"[SAVE] Saved chapter {chapter_num} → {path}") + inc_chapter_done(book_id) inc_completed(book_id) # Determine volume name diff --git a/bookscraper/scraper/utils.py b/bookscraper/scraper/utils.py index 0bdd2f9..1a8510c 100644 --- a/bookscraper/scraper/utils.py +++ b/bookscraper/scraper/utils.py @@ -97,6 +97,7 @@ def clean_text(raw: str, repl: dict) -> str: # Apply loaded replacements for key, val in repl.items(): + # print(f"Replacing: {key} → {val}") txt = txt.replace(key, val) # Collapse 3+ blank lines → max 1 diff --git a/bookscraper/static/js/log_view.js b/bookscraper/static/js/log_view.js index c51c633..a65b271 100644 --- a/bookscraper/static/js/log_view.js +++ b/bookscraper/static/js/log_view.js @@ -127,6 +127,6 @@ function pollLogs() { } // Poll every 800 ms -setInterval(pollLogs, 800); +setInterval(pollLogs, 1800); console.log(">>> log_view.js LOADED"); diff --git a/bookscraper/text_replacements.txt b/bookscraper/text_replacements.txt index a2a6525..2884ed5 100644 --- a/bookscraper/text_replacements.txt +++ b/bookscraper/text_replacements.txt @@ -57,7 +57,9 @@ Copyright ©= 本站立场无关= 均由网友发表或上传= 感谢各位书友的支持,您的支持就是我们最大的动力 - +飘天文学www.piaotia.com +感谢各位书友的支持 +您的支持就是我们最大的动力 # ---------- COMMON NOISE ---------- 广告= 广告位= diff --git a/tmp/stash.patch2 b/tmp/stash.patch2 new file mode 100644 index 0000000..bd4227d --- /dev/null +++ b/tmp/stash.patch2 @@ -0,0 +1,2950 @@ +diff --git a/bookscraper/.gitignore b/bookscraper/.gitignore +index 08fedd4..cd78ff3 100644 +--- a/bookscraper/.gitignore ++++ b/bookscraper/.gitignore +@@ -1,4 +1,164 @@ +-output/ ++# ============================================ ++# PYTHON ++# ============================================ ++ ++# Bytecode ++__pycache__/ ++*.pyc ++*.pyo ++*.pyd ++ ++# Virtual environments + venv/ ++env/ ++.venv/ ++ ++# Python build artifacts ++build/ ++dist/ ++*.egg-info/ ++ ++ ++# ============================================ ++# PROJECT-SPECIFIC IGNORE RULES ++# ============================================ ++ ++# Output generated by BookScraper ++output/ ++audio_output/ ++m4b_output/ ++covers/ ++ ++# Logs + *.log +-__pycache__/ +\ No newline at end of file ++logs/ ++log/ ++celerybeat-schedule ++celerybeat.pid ++ ++# Redis dump (if ever created) ++dump.rdb ++ ++# Temporary HTML/debug scrapings ++tmp/ ++temp/ ++*.html.tmp ++*.debug.html ++ ++ ++# ============================================ ++# CELERY / RUNTIME ++# ============================================ ++ ++celerybeat-schedule ++*.pid ++*.worker ++ ++# Celery progress / abort temporary files (if any) ++abort_flags/ ++progress_cache/ ++ ++ ++# ============================================ ++# DOCKER ++# ============================================ ++ ++# Docker build cache ++**/.dockerignore ++**/Dockerfile~ ++docker-compose.override.yml ++docker-compose.local.yml ++docker-compose*.backup ++ ++# Local bind mounts from Docker ++**/.volumes/ ++**/mnt/ ++**/cache/ ++ ++ ++# ============================================ ++# FRONTEND / STATIC FILES ++# ============================================ ++ ++# Node / JS (if ever used) ++node_modules/ ++npm-debug.log ++yarn-debug.log ++yarn-error.log ++dist/ ++.bundle/ ++ ++ ++# ============================================ ++# VS CODE / EDITORS ++# ============================================ ++ ++# VSCode ++.vscode/ ++.history/ ++.code-workspace ++ ++# PyCharm / JetBrains ++.idea/ ++ ++# Editor backups ++*.swp ++*.swo ++*~ ++ ++# Autosave files ++*.bak ++*.tmp ++ ++ ++# ============================================ ++# SYSTEM / OS FILES ++# ============================================ ++ ++# MacOS bullshit ++.DS_Store ++.AppleDouble ++.LSOverride ++Icon? ++.Trashes ++ ++# Windows ++Thumbs.db ++Desktop.ini ++ ++ ++# ============================================ ++# ARCHIVES ++# ============================================ ++ ++*.zip ++*.tar.gz ++*.tgz ++*.7z ++*.rar ++ ++ ++# ============================================ ++# AUDIO / TTS / TEMPORARY ++# ============================================ ++ ++*.wav ++*.mp3 ++*.m4a ++*.m4b ++*.aac ++*.flac ++ ++tts_temp/ ++audio_temp/ ++tts_cache/ ++ ++ ++# ============================================ ++# GIT INTERNAL SAFETY ++# ============================================ ++ ++# Never track your global git config junk ++.gitignore_global ++.gitconfig ++.gitattributes-global +diff --git a/bookscraper/app.py b/bookscraper/app.py +index bf758c8..241bc22 100644 +--- a/bookscraper/app.py ++++ b/bookscraper/app.py +@@ -1,5 +1,5 @@ + # ============================================ +-# File: bookscraper/app.py (ASYNC SCRAPING) ++# File: bookscraper/app.py (ASYNC SCRAPING + DASHBOARD) + # ============================================ + + from dotenv import load_dotenv +@@ -9,34 +9,45 @@ load_dotenv() + print(">>> [WEB] Importing celery_app …") + from celery_app import celery_app + +-from flask import Flask, render_template, request, jsonify ++from flask import ( ++ Flask, ++ render_template, ++ request, ++ jsonify, ++ redirect, ++ send_from_directory, ++) + from scraper.logger import log_debug + +-# Abort + Progress (per book_id) ++# Abort + Progress (legacy) + from scraper.abort import set_abort + from scraper.progress import get_progress + +-# UI LOGS (GLOBAL — no book_id) +-from scraper.ui_log import get_ui_logs, reset_ui_logs # <-- ADDED ++# NEW: Full Redis Book State Model ++from scraper.progress import get_state ++from scraper.progress import r as redis_client + +-from celery.result import AsyncResult ++# NEW: Indexed log fetchers ++from scraper.log_index import fetch_logs, fetch_recent_logs, fetch_global_logs ++ ++# UI LOGS (legacy) ++from scraper.ui_log import get_ui_logs, reset_ui_logs + +-# ⬇⬇⬇ TOEGEVOEGD voor cover-serving +-from flask import send_from_directory ++from celery.result import AsyncResult + import os ++import time ++import re + + app = Flask(__name__) + +- + # ===================================================== +-# STATIC FILE SERVING FOR OUTPUT ← TOEGEVOEGD ++# STATIC FILE SERVING FOR OUTPUT + # ===================================================== + OUTPUT_ROOT = os.getenv("BOOKSCRAPER_OUTPUT_DIR", "output") + + + @app.route("/output/") + def serve_output(filename): +- """Serve output files such as cover.jpg and volumes.""" + return send_from_directory(OUTPUT_ROOT, filename, as_attachment=False) + + +@@ -49,7 +60,7 @@ def index(): + + + # ===================================================== +-# START SCRAPING (async via Celery) ++# START SCRAPING → DIRECT REDIRECT TO DASHBOARD (book_idx native) + # ===================================================== + @app.route("/start", methods=["POST"]) + def start_scraping(): +@@ -58,64 +69,150 @@ def start_scraping(): + if not url: + return render_template("result.html", error="Geen URL opgegeven.") + +- # --------------------------------------------------------- +- # NEW: Clear UI log buffer when starting a new scrape +- # --------------------------------------------------------- + reset_ui_logs() +- + log_debug(f"[WEB] Scraping via Celery: {url}") + +- async_result = celery_app.send_task( ++ # -------------------------------------------- ++ # Extract book_idx from URL ++ # Supports: ++ # - /15/15618.html ++ # - /15618/ ++ # - /15618.html ++ # -------------------------------------------- ++ m = re.search(r"/(\d+)(?:\.html|/)?$", url) ++ if not m: ++ return render_template( ++ "result.html", error="Kan book_idx niet bepalen uit URL." ++ ) ++ ++ book_idx = m.group(1) ++ ++ # -------------------------------------------- ++ # Start async scraping task ++ # -------------------------------------------- ++ celery_app.send_task( + "scraper.tasks.scraping.start_scrape_book", + args=[url], + queue="scraping", + ) + +- return render_template( +- "result.html", +- message="Scraping gestart.", +- scraping_task_id=async_result.id, +- book_title=None, +- ) ++ # -------------------------------------------- ++ # DIRECT redirect — no waiting on Celery ++ # -------------------------------------------- ++ return redirect(f"/book/{book_idx}") + + + # ===================================================== +-# CLEAR UI LOGS MANUALLY (NEW) ++# CLEAR UI LOGS (legacy) + # ===================================================== + @app.route("/clear-logs", methods=["POST"]) + def clear_logs(): + reset_ui_logs() +- return jsonify({"status": "ok", "message": "UI logs cleared"}) ++ return jsonify({"status": "ok"}) ++ ++ ++# ===================================================== ++# ABORT (per book_idx) ++# ===================================================== ++@app.route("/abort/", methods=["POST"]) ++def abort_download(book_idx): ++ log_debug(f"[WEB] Abort requested for book: {book_idx}") ++ set_abort(book_idx) ++ return jsonify({"status": "ok", "aborted": book_idx}) ++ ++ ++# ===================================================== ++# LEGACY PROGRESS ENDPOINT ++# ===================================================== ++@app.route("/progress/", methods=["GET"]) ++def progress(book_idx): ++ return jsonify(get_progress(book_idx)) ++ ++ ++# ===================================================== ++# REDIS STATE ENDPOINT ++# ===================================================== ++@app.route("/state/", methods=["GET"]) ++def full_state(book_idx): ++ return jsonify(get_state(book_idx)) + + + # ===================================================== +-# ABORT (per book_id) ++# LIST ALL BOOKS — METADATA + # ===================================================== +-@app.route("/abort/", methods=["POST"]) +-def abort_download(book_id): +- log_debug(f"[WEB] Abort requested for book: {book_id}") +- set_abort(book_id) +- return jsonify({"status": "ok", "aborted": book_id}) ++@app.route("/books", methods=["GET"]) ++def list_books(): ++ books = sorted(redis_client.smembers("books") or []) ++ result = [] ++ ++ for book_idx in books: ++ meta = redis_client.hgetall(f"book:{book_idx}:meta") or {} ++ state = get_state(book_idx) or {} ++ ++ result.append( ++ { ++ "id": book_idx, ++ "title": meta.get("title", book_idx), ++ "author": meta.get("author"), ++ "url": meta.get("url"), ++ "cover_url": meta.get("cover_url"), ++ "scraped_at": meta.get("scraped_at"), ++ "status": state.get("status"), ++ "last_update": state.get("last_update"), ++ "chapters_total": state.get("chapters_total"), ++ "chapters_done": state.get("chapters_done"), ++ } ++ ) ++ ++ return jsonify(result) + + + # ===================================================== +-# PROGRESS (per book_id) ++# LIBRARY DASHBOARD PAGE + # ===================================================== +-@app.route("/progress/", methods=["GET"]) +-def progress(book_id): +- return jsonify(get_progress(book_id)) ++@app.route("/library", methods=["GET"]) ++def library_page(): ++ return render_template("library.html") + + + # ===================================================== +-# LOGS — GLOBAL UI LOGS ++# BOOK DASHBOARD PAGE — book_idx native + # ===================================================== +-@app.route("/logs", methods=["GET"]) +-def logs(): +- return jsonify({"logs": get_ui_logs()}) ++@app.route("/book/", methods=["GET"]) ++def book_dashboard(book_idx): ++ return render_template( ++ "book_dashboard.html", ++ book_id=book_idx, # for template backward compatibility ++ book_idx=book_idx, ++ ) + + + # ===================================================== +-# CELERY RESULT → return book_id when scraping finishes ++# INDEXED LOG API — book_idx direct ++# ===================================================== ++@app.route("/api/book//logs", methods=["GET"]) ++def api_book_logs(book_idx): ++ cursor = int(request.args.get("cursor", "0")) ++ logs, new_cursor = fetch_logs(book_idx, cursor) ++ return jsonify({"logs": logs, "cursor": new_cursor}) ++ ++ ++@app.route("/api/book//logs/recent", methods=["GET"]) ++def api_book_logs_recent(book_idx): ++ limit = int(request.args.get("limit", "200")) ++ logs = fetch_recent_logs(book_idx, limit) ++ return jsonify({"logs": logs}) ++ ++ ++@app.route("/api/logs/global", methods=["GET"]) ++def api_global_logs(): ++ cursor = int(request.args.get("cursor", "0")) ++ logs, new_cursor = fetch_global_logs(cursor) ++ return jsonify({"logs": logs, "cursor": new_cursor}) ++ ++ ++# ===================================================== ++# CELERY RESULT + # ===================================================== + @app.route("/celery-result/", methods=["GET"]) + def celery_result(task_id): +@@ -123,10 +220,8 @@ def celery_result(task_id): + + if result.successful(): + return jsonify({"ready": True, "result": result.get()}) +- + if result.failed(): + return jsonify({"ready": True, "error": "failed"}) +- + return jsonify({"ready": False}) + + +diff --git a/bookscraper/logbus/publisher.py b/bookscraper/logbus/publisher.py +index 9a597db..5476475 100644 +--- a/bookscraper/logbus/publisher.py ++++ b/bookscraper/logbus/publisher.py +@@ -1,4 +1,11 @@ +-# logbus/publisher.py ++# ============================================================ ++# File: logbus/publisher.py ++# Purpose: ++# Centralized logger: ++# - console logging ++# - UI legacy log echo ++# - NEW: indexed Redis log ingest (non-blocking) ++# ============================================================ + + import logging + +@@ -10,20 +17,45 @@ def log(message: str): + Dumb logger: + - skip lege messages + - stuur message 1:1 door +- - geen prefixes ++ - geen prefixes wijzigen + - geen mutaties + """ + + if not message or not message.strip(): + return + +- # console ++ # ============================================================ ++ # SAFETY FIX (C&U): ++ # voorkom infinite loop: messages die uit log_index komen ++ # beginnen met "[IDX]" en mogen NIET opnieuw via de pipeline. ++ # ============================================================ ++ if message.startswith("[IDX]"): ++ logger.warning(message) ++ return ++ ++ # --------------------------------------- ++ # Console log ++ # --------------------------------------- + logger.warning(message) + +- # UI-echo ++ # --------------------------------------- ++ # Legacy UI log (bestaand gedrag) ++ # --------------------------------------- + try: +- from scraper.ui_log import push_ui ++ from scraper.ui_log import push_ui # delayed import + + push_ui(message) + except Exception: ++ # UI log mag nooit crash veroorzaken ++ pass ++ ++ # --------------------------------------- ++ # NEW: Indexed Redis log entry ++ # --------------------------------------- ++ try: ++ from scraper.log_index import ingest_indexed_log # delayed import ++ ++ ingest_indexed_log(message) ++ except Exception: ++ # Fail silently — logging mag nooit pipeline breken + pass +diff --git a/bookscraper/scraper/book_scraper.py b/bookscraper/scraper/book_scraper.py +index 922d0c7..bf54375 100644 +--- a/bookscraper/scraper/book_scraper.py ++++ b/bookscraper/scraper/book_scraper.py +@@ -13,10 +13,7 @@ from scraper.models.book_state import Chapter + class BookScraper: + """ + Minimal scraper: only metadata + chapter list. +- The DownloadController handles Celery pipelines for: +- - download +- - parse +- - save ++ The DownloadController handles Celery pipelines. + """ + + def __init__(self, site, url): +@@ -29,6 +26,8 @@ class BookScraper: + self.cover_url = "" + self.chapter_base = None + ++ self.book_idx = None # NUMERIEK ID UIT URL (enige bron) ++ + self.chapters = [] + + # Load custom replacements +@@ -40,6 +39,9 @@ class BookScraper: + """Main entry point. Returns metadata + chapter URLs.""" + soup = self._fetch(self.url) + ++ # Book_idx alleen uit main URL (afspraak) ++ self._extract_book_idx() ++ + self._parse_title(soup) + self._parse_author(soup) + self._parse_description(soup) +@@ -54,14 +56,30 @@ class BookScraper: + "title": self.book_title, + "author": self.book_author, + "description": self.book_description, +- "cover_url": self.cover_url, # ← used by DownloadController ++ "cover_url": self.cover_url, + "book_url": self.url, ++ "book_idx": self.book_idx, # <<< belangrijk voor logging pipeline + "chapters": [ + {"num": ch.number, "title": ch.title, "url": ch.url} + for ch in self.chapters + ], + } + ++ # ------------------------------------------------------------ ++ def _extract_book_idx(self): ++ """ ++ Extract numeric ID from main URL such as: ++ https://www.piaotia.com/bookinfo/15/15618.html ++ This is the ONLY allowed source. ++ """ ++ m = re.search(r"/(\d+)\.html$", self.url) ++ if m: ++ self.book_idx = m.group(1) ++ log_debug(f"[BookScraper] Extracted book_idx = {self.book_idx}") ++ else: ++ self.book_idx = None ++ log_debug("[BookScraper] book_idx NOT FOUND in URL") ++ + # ------------------------------------------------------------ + def _fetch(self, url): + log_debug(f"[BookScraper] Fetch: {url}") +@@ -109,10 +127,7 @@ class BookScraper: + def _parse_cover(self, soup): + """ + Extract correct cover based on book_id path logic. +- 1. primary: match "/files/article/image/{vol}/{book_id}/" +- 2. fallback: endswith "/{book_id}s.jpg" + """ +- # Extract book_id from URL + m = re.search(r"/(\d+)\.html$", self.url) + if not m: + log_debug("[BookScraper] No book_id found in URL → cannot match cover") +@@ -120,20 +135,15 @@ class BookScraper: + + book_id = m.group(1) + +- # Extract vol folder from URL (bookinfo//.html) + m2 = re.search(r"/bookinfo/(\d+)/", self.url) + volume = m2.group(1) if m2 else None + + log_debug(f"[BookScraper] Book ID = {book_id}, Volume = {volume}") + + imgs = soup.find_all("img", src=True) +- + chosen = None + +- # -------------------------------------------------------- +- # PRIORITY 1: Path-match +- # /files/article/image/{vol}/{book_id}/ +- # -------------------------------------------------------- ++ # PATH-MATCH + if volume: + target_path = f"/files/article/image/{volume}/{book_id}/" + for img in imgs: +@@ -143,9 +153,7 @@ class BookScraper: + log_debug(f"[BookScraper] Cover matched by PATH: {src}") + break + +- # -------------------------------------------------------- +- # PRIORITY 2: endswith "/{book_id}s.jpg" +- # -------------------------------------------------------- ++ # SUFFIX-MATCH + if not chosen: + target_suffix = f"/{book_id}s.jpg" + for img in imgs: +@@ -155,9 +163,6 @@ class BookScraper: + log_debug(f"[BookScraper] Cover matched by SUFFIX: {src}") + break + +- # -------------------------------------------------------- +- # No match +- # -------------------------------------------------------- + if not chosen: + log_debug("[BookScraper] No matching cover found") + return +@@ -167,14 +172,12 @@ class BookScraper: + + # ------------------------------------------------------------ + def get_chapter_page(self, soup): +- """Return BeautifulSoup of the main chapter list page.""" + node = soup.select_one( + "html > body > div:nth-of-type(6) > div:nth-of-type(2) > div > table" + ) + href = node.select_one("a").get("href") + chapter_url = urljoin(self.site.root, href) + +- # base for chapter links + parts = chapter_url.rsplit("/", 1) + self.chapter_base = parts[0] + "/" + +diff --git a/bookscraper/scraper/download_controller.py b/bookscraper/scraper/download_controller.py +index 9a9e978..4f2cf32 100644 +--- a/bookscraper/scraper/download_controller.py ++++ b/bookscraper/scraper/download_controller.py +@@ -1,231 +1,133 @@ +-# ========================================================= ++# ============================================================ + # File: scraper/download_controller.py + # Purpose: +-# Build Celery pipelines for all chapters +-# and pass book_id for abort/progress/log functionality. +-# + Download and replicate cover image to all volume folders +-# + Generate scripts (allinone.txt, makebook, say) +-# + Initialize Redis Book State Model (status + counters) +-# ========================================================= +- +-from celery import group +-from scraper.tasks.pipeline import build_chapter_pipeline +-from scraper.scriptgen import generate_all_scripts +-from logbus.publisher import log ++# Prepare folder structure, volumes, cover, and Celery pipelines ++# using ONLY Celery-safe primitive arguments. ++# ++# Workers never receive BookContext/ChapterContext. ++# ============================================================ ++ + import os + import requests +-import shutil +-from scraper.abort import abort_requested # DEBUG allowed ++from logbus.publisher import log ++from celery import chain, group ++ ++from scraper.tasks.download_tasks import download_chapter ++from scraper.tasks.parse_tasks import parse_chapter ++from scraper.tasks.save_tasks import save_chapter ++from scraper.tasks.progress_tasks import update_progress ++ + +-# NEW: Redis State Model (C&U) +-from scraper.progress import ( +- init_book_state, +- set_status, +- set_chapter_total, +-) ++print(">>> [IMPORT] download_controller.py loaded (final Celery-safe mode)") + + + class DownloadController: + """ +- Coordinates all chapter pipelines (download → parse → save), +- including: +- - volume splitting +- - consistent meta propagation +- - book_id-based abort + progress tracking +- - cover download + volume replication +- - script generation (allinone.txt, makebook, say) +- - Redis book state initialisation and status updates ++ Responsibilities: ++ • Determine output root ++ • Download cover ++ • Assign chapters → volumes ++ • Build Celery pipelines (primitive-only arguments) + """ + +- def __init__(self, book_id: str, scrape_result: dict): +- self.book_id = book_id ++ def __init__(self, scrape_result: dict): ++ self.book_idx = scrape_result.get("book_id") ++ self.title = scrape_result.get("title", "Unknown") ++ self.author = scrape_result.get("author") ++ self.cover_url = scrape_result.get("cover") + self.scrape_result = scrape_result + +- # Core metadata +- self.title = scrape_result.get("title", "UnknownBook") +- self.chapters = scrape_result.get("chapters", []) or [] +- self.cover_url = scrape_result.get("cover_url") +- +- # Output base dir +- root = os.getenv("BOOKSCRAPER_OUTPUT_DIR", "output") +- +- # Volume size +- self.max_vol = int(os.getenv("MAX_VOL_SIZE", "200")) +- +- # Base folder for the whole book +- self.book_base = os.path.join(root, self.title) +- os.makedirs(self.book_base, exist_ok=True) +- +- # Meta passed to parse/save stage +- self.meta = { +- "title": self.title, +- "author": scrape_result.get("author"), +- "description": scrape_result.get("description"), +- "book_url": scrape_result.get("book_url"), +- } +- +- # ------------------------------------------------- +- # DEBUG — bevestig dat controller correct book_id ziet +- # ------------------------------------------------- +- log(f"[CTRL_DEBUG] Controller init book_id={book_id} title='{self.title}'") +- +- try: +- abort_state = abort_requested(book_id) +- log(f"[CTRL_DEBUG] abort_requested(book_id={book_id}) → {abort_state}") +- except Exception as e: +- log(f"[CTRL_DEBUG] abort_requested ERROR: {e}") +- +- # ------------------------------------------------- +- # NEW: Initialize Redis Book State Model +- # ------------------------------------------------- +- try: +- init_book_state( +- book_id=self.book_id, +- title=self.title, +- url=self.scrape_result.get("book_url"), +- chapters_total=len(self.chapters), +- ) +- log(f"[CTRL_STATE] init_book_state() completed for {self.title}") +- except Exception as e: +- log(f"[CTRL_STATE] init_book_state FAILED: {e}") +- +- # --------------------------------------------------------- +- # Cover Download +- # --------------------------------------------------------- +- def download_cover(self): +- """Download one cover image into the root of the book folder.""" +- if not self.cover_url: +- log(f"[CTRL] No cover URL found for '{self.title}'") +- return +- +- cover_path = os.path.join(self.book_base, "cover.jpg") +- +- headers = { +- "User-Agent": ( +- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:118.0) " +- "Gecko/20100101 Firefox/118.0" +- ), +- "Referer": self.scrape_result.get("book_url") or "https://www.piaotia.com/", +- } +- +- try: +- log(f"[CTRL] Downloading cover: {self.cover_url}") +- +- resp = requests.get(self.cover_url, timeout=10, headers=headers) +- resp.raise_for_status() ++ # List of dicts from scraper: [{num, title, url}] ++ self.chapters = scrape_result.get("chapter_list", []) ++ self.chapter_count = len(self.chapters) ++ ++ # Output root ++ self.output_root = os.path.join("/app/output", self.title) ++ ++ # Will be filled by assign_volumes() ++ self.volume_paths = {} # chapter_num → volume_path ++ ++ # ----------------------------------------------------------- ++ # Create root folder + download cover ++ # ----------------------------------------------------------- ++ def init_book_output(self): ++ os.makedirs(self.output_root, exist_ok=True) ++ ++ if self.cover_url: ++ try: ++ resp = requests.get(self.cover_url, timeout=10) ++ if resp.ok: ++ p = os.path.join(self.output_root, "cover.jpg") ++ with open(p, "wb") as f: ++ f.write(resp.content) ++ log(f"[CTRL] Cover saved: {p}") ++ except Exception as exc: ++ log(f"[CTRL] Cover download error: {exc}") ++ ++ # ----------------------------------------------------------- ++ # Volume assignment (no BookContext needed) ++ # ----------------------------------------------------------- ++ def assign_volumes(self, max_chapters_per_volume=200): ++ """ ++ Determine per-chapter volume_path and ensure folders exist. ++ """ ++ volume_index = 1 ++ chapters_in_volume = 0 + +- with open(cover_path, "wb") as f: +- f.write(resp.content) +- +- log(f"[CTRL] Cover saved to: {cover_path}") +- +- except Exception as e: +- log(f"[CTRL] Cover download failed: {e} (url={self.cover_url})") +- +- # --------------------------------------------------------- +- # Cover Replication to Volumes +- # --------------------------------------------------------- +- def replicate_cover_to_volumes(self): +- """Copy cover.jpg into each existing Volume_xxx directory.""" +- src = os.path.join(self.book_base, "cover.jpg") +- if not os.path.exists(src): +- log("[CTRL] No cover.jpg found, replication skipped") +- return ++ for ch in self.chapters: ++ if chapters_in_volume >= max_chapters_per_volume: ++ volume_index += 1 ++ chapters_in_volume = 0 + +- try: +- for entry in os.listdir(self.book_base): +- if entry.lower().startswith("volume_"): +- vol_dir = os.path.join(self.book_base, entry) +- dst = os.path.join(vol_dir, "cover.jpg") +- +- shutil.copyfile(src, dst) +- log(f"[CTRL] Cover replicated into: {dst}") +- +- except Exception as e: +- log(f"[CTRL] Cover replication failed: {e}") +- +- # --------------------------------------------------------- +- # Volume isolation +- # --------------------------------------------------------- +- def get_volume_path(self, chapter_num: int) -> str: +- """Returns the correct volume directory for a chapter.""" +- vol_index = (chapter_num - 1) // self.max_vol + 1 +- vol_name = f"Volume_{vol_index:03d}" +- vol_path = os.path.join(self.book_base, vol_name) +- os.makedirs(vol_path, exist_ok=True) +- return vol_path +- +- # --------------------------------------------------------- +- # Pipeline launcher +- # --------------------------------------------------------- +- def start(self): +- total = len(self.chapters) ++ volume_path = os.path.join(self.output_root, f"Volume{volume_index}") ++ os.makedirs(volume_path, exist_ok=True) + +- log( +- f"[CTRL] Initialising pipeline for '{self.title}' " +- f"(book_id={self.book_id}, chapters={total}, max_vol={self.max_vol})" +- ) +- log(f"[CTRL] Output root: {self.book_base}") ++ # C&U FIX — scraper outputs key "num", NOT "number" ++ chapter_num = ch["num"] ++ self.volume_paths[chapter_num] = volume_path + +- # ------------------------------------- +- # NEW: Redis state update +- # ------------------------------------- +- try: +- set_status(self.book_id, "downloading") +- set_chapter_total(self.book_id, total) +- log(f"[CTRL_STATE] Status set to 'downloading' for {self.book_id}") +- except Exception as e: +- log(f"[CTRL_STATE] set_status/set_chapter_total FAILED: {e}") ++ chapters_in_volume += 1 + +- # ------------------------------------- +- # 1) Download cover +- # ------------------------------------- +- self.download_cover() ++ log(f"[CTRL] Volume assignment complete: {len(self.volume_paths)} chapters") + +- tasks = [] ++ # ----------------------------------------------------------- ++ # Build Celery pipelines (primitive-only) ++ # ----------------------------------------------------------- ++ def build_pipelines(self): ++ pipelines = [] + + for ch in self.chapters: +- chapter_num = ch["num"] +- chapter_url = ch["url"] +- +- volume_path = self.get_volume_path(chapter_num) +- +- tasks.append( +- build_chapter_pipeline( +- self.book_id, +- chapter_num, +- chapter_url, +- volume_path, +- self.meta, +- ) ++ # C&U FIX — must use "num" ++ num = ch["num"] ++ url = ch["url"] ++ vp = self.volume_paths[num] ++ ++ p = chain( ++ download_chapter.s(self.book_idx, num, url, vp), ++ parse_chapter.s(self.book_idx, num), ++ save_chapter.s(self.book_idx, num), ++ update_progress.s(self.book_idx), + ) ++ pipelines.append(p) + +- async_result = group(tasks).apply_async() +- +- log( +- f"[CTRL] Pipelines dispatched for '{self.title}' " +- f"(book_id={self.book_id}, group_id={async_result.id})" +- ) +- +- # Debug abort state +- try: +- abort_state = abort_requested(self.book_id) +- log(f"[CTRL_DEBUG] After-dispatch abort state: {abort_state}") +- except Exception as e: +- log(f"[CTRL_DEBUG] abort_requested error after dispatch: {e}") +- +- # ------------------------------------------------------- +- self.replicate_cover_to_volumes() ++ return pipelines + +- # ------------------------------------------------------- ++ # ----------------------------------------------------------- ++ # Launch Celery group(pipelines) ++ # ----------------------------------------------------------- ++ def dispatch_pipelines(self, pipelines): + try: +- generate_all_scripts( +- self.book_base, +- self.title, +- self.meta.get("author"), +- ) +- log(f"[CTRL] Scripts generated for '{self.title}'") +- except Exception as e: +- log(f"[CTRL] Script generation failed: {e}") +- +- return async_result ++ g = group(pipelines) ++ result = g.apply_async() ++ return result ++ except Exception as exc: ++ log(f"[CTRL] ERROR dispatching pipelines: {exc}") ++ raise ++ ++ # ----------------------------------------------------------- ++ # Legacy convenience entrypoint ++ # ----------------------------------------------------------- ++ def start(self): ++ self.init_book_output() ++ self.assign_volumes() ++ return self.dispatch_pipelines(self.build_pipelines()) +diff --git a/bookscraper/scraper/tasks/audio_tasks.py b/bookscraper/scraper/tasks/audio_tasks.py +index fea3285..f43d3a9 100644 +--- a/bookscraper/scraper/tasks/audio_tasks.py ++++ b/bookscraper/scraper/tasks/audio_tasks.py +@@ -1,5 +1,8 @@ + # ============================================================ + # File: scraper/tasks/audio_tasks.py ++# Purpose: ++# Convert a saved chapter's text file into an audio file (.m4a) ++# using macOS "say". Uses BookContext + ChapterContext. + # ============================================================ + + from celery_app import celery_app +@@ -12,29 +15,38 @@ from scraper.abort import abort_requested + from redis import Redis + from urllib.parse import urlparse + +-# Kies lokale redis als aanwezig, anders standaard backend +-redis_url = os.getenv("REDIS_BACKEND_LOCAL") or os.getenv("REDIS_BACKEND") +- +-parsed = urlparse(redis_url) ++from scraper.progress import ( ++ set_status, ++ set_last_update, ++ inc_audio_done, ++ save_skip_reason, ++) + + # ------------------------------------------------------------ +-# REGULIER REDIS CLIENT (slots, file checks, state) ++# REDIS CLIENTS + # ------------------------------------------------------------ ++ ++redis_url = os.getenv("REDIS_BACKEND_LOCAL") or os.getenv("REDIS_BACKEND") ++parsed = urlparse(redis_url) ++ ++# Slot management DB (same as download slot handling) + redis_client = Redis( + host=parsed.hostname, + port=parsed.port, + db=parsed.path.strip("/"), + ) + +-# ------------------------------------------------------------ +-# BACKEND CLIENT (abort flags, progress counters) - altijd DB 0 +-# ------------------------------------------------------------ ++# Backend DB (abort + progress counters) + backend_client = Redis( + host=parsed.hostname, + port=parsed.port, + db=0, + ) + ++# ------------------------------------------------------------ ++# ENVIRONMENT ++# ------------------------------------------------------------ ++ + AUDIO_TIMEOUT = int(os.getenv("AUDIO_TIMEOUT_SECONDS", "300")) + AUDIO_VOICE = os.getenv("AUDIO_VOICE", "SinJi") + AUDIO_RATE = int(os.getenv("AUDIO_RATE", "200")) +@@ -44,20 +56,54 @@ AUDIO_SLOTS = int(os.getenv("AUDIO_SLOTS", "1")) + CONTAINER_PREFIX = os.getenv("BOOKSCRAPER_OUTPUT_DIR", "/app/output") + + ++# ============================================================ ++# CELERY TASK — NOW USING CONTEXT OBJECTS ++# ============================================================ ++ ++ + @celery_app.task(bind=True, queue="audio", ignore_result=True) +-def generate_audio( +- self, book_id, volume_name, chapter_number, chapter_title, chapter_text +-): +- log(f"[AUDIO] CH{chapter_number}: START task → raw_input={chapter_text}") +- +- # Abort early +- if abort_requested(book_id, backend_client): +- log(f"[AUDIO] ABORT detected → skip CH{chapter_number}") ++def generate_audio(self, book_context, chapter_context): ++ """ ++ Create audio using: ++ - book_context.book_idx ++ - chapter_context.number ++ - chapter_context.path (text filepath) ++ """ ++ ++ # ------------------------------------------------------------ ++ # IDENTIFIERS ++ # ------------------------------------------------------------ ++ book_idx = book_context.book_idx ++ chapter_number = chapter_context.number ++ text_file_path = chapter_context.path ++ ++ log(f"[AUDIO] CH{chapter_number}: START task → {text_file_path}") ++ ++ # ------------------------------------------------------------ ++ # Update state: audio stage active ++ # ------------------------------------------------------------ ++ try: ++ set_status(book_idx, "audio") ++ set_last_update(book_idx) ++ except Exception: ++ pass ++ ++ # ------------------------------------------------------------ ++ # Abort BEFORE doing anything ++ # ------------------------------------------------------------ ++ if abort_requested(book_idx, backend_client): ++ log(f"[AUDIO] ABORT detected → skip chapter {chapter_number}") ++ try: ++ chapter_context.add_skip("audio", "abort") ++ save_skip_reason(book_idx, chapter_number, "audio_abort") ++ set_last_update(book_idx) ++ except Exception: ++ pass + return + +- # ============================================================ ++ # ------------------------------------------------------------ + # ACQUIRE AUDIO SLOT +- # ============================================================ ++ # ------------------------------------------------------------ + slot_key = None + ttl = AUDIO_TIMEOUT + 15 + +@@ -68,11 +114,13 @@ def generate_audio( + log(f"[AUDIO] CH{chapter_number}: Acquired slot {i}/{AUDIO_SLOTS}") + break + ++ # If no slot free → wait + if slot_key is None: + log(f"[AUDIO] CH{chapter_number}: All slots busy → waiting...") + start_wait = time.time() + + while slot_key is None: ++ # retry each slot + for i in range(1, AUDIO_SLOTS + 1): + key = f"audio_slot:{i}" + if redis_client.set(key, "1", nx=True, ex=ttl): +@@ -80,101 +128,135 @@ def generate_audio( + log(f"[AUDIO] CH{chapter_number}: Slot acquired after wait") + break + +- if slot_key: +- break +- +- if abort_requested(book_id, backend_client): ++ # abort while waiting ++ if abort_requested(book_idx, backend_client): + log(f"[AUDIO] ABORT while waiting → skip CH{chapter_number}") ++ try: ++ chapter_context.add_skip("audio", "abort_wait") ++ save_skip_reason(book_idx, chapter_number, "audio_abort_wait") ++ set_last_update(book_idx) ++ except Exception: ++ pass + return + ++ # timeout + if time.time() - start_wait > ttl: +- log(f"[AUDIO] CH{chapter_number}: Slot wait timeout → aborting audio") ++ log(f"[AUDIO] CH{chapter_number}: WAIT TIMEOUT → aborting") ++ try: ++ chapter_context.add_skip("audio", "timeout_wait") ++ save_skip_reason(book_idx, chapter_number, "audio_timeout_wait") ++ set_last_update(book_idx) ++ except Exception: ++ pass + return + + time.sleep(0.25) + +- # ============================================================ +- # PATH NORMALISATION +- # ============================================================ +- +- container_path = chapter_text +- +- # Fix 1 — container_path kan None zijn → abort zonder crash +- if not container_path: +- log(f"[AUDIO] CH{chapter_number}: FATAL — no input path provided") +- redis_client.delete(slot_key) ++ # ------------------------------------------------------------ ++ # VALIDATE INPUT PATH ++ # ------------------------------------------------------------ ++ if not text_file_path: ++ log(f"[AUDIO] CH{chapter_number}: No input path") ++ try: ++ chapter_context.add_skip("audio", "missing_input") ++ save_skip_reason(book_idx, chapter_number, "audio_missing_input") ++ set_last_update(book_idx) ++ except Exception: ++ pass ++ if slot_key: ++ redis_client.delete(slot_key) + return + +- # Fix 2 — veilige startswith +- if CONTAINER_PREFIX and container_path.startswith(CONTAINER_PREFIX): +- relative_path = container_path[len(CONTAINER_PREFIX) :].lstrip("/") ++ # Convert container path → host FS path ++ if text_file_path.startswith(CONTAINER_PREFIX): ++ relative = text_file_path[len(CONTAINER_PREFIX) :].lstrip("/") + else: +- relative_path = container_path +- +- parts = relative_path.split("/") +- if len(parts) < 3: +- log( +- f"[AUDIO] CH{chapter_number}: FATAL — cannot parse book/volume from {relative_path}" +- ) +- redis_client.delete(slot_key) +- return +- +- book_from_path = parts[0] +- volume_from_path = parts[1] +- +- host_path = os.path.join(HOST_PATH, relative_path) +- +- # ============================================================ +- # OUTPUT PREP +- # ============================================================ +- +- base_dir = os.path.join(HOST_PATH, book_from_path, volume_from_path, "Audio") +- os.makedirs(base_dir, exist_ok=True) ++ relative = text_file_path ++ ++ host_input_path = os.path.join(HOST_PATH, relative) ++ ++ # ------------------------------------------------------------ ++ # Determine output directory ++ # ------------------------------------------------------------ ++ volume_name = os.path.basename(os.path.dirname(text_file_path)) ++ audio_output_dir = os.path.join( ++ HOST_PATH, ++ os.path.basename(book_context.book_base), ++ volume_name, ++ "Audio", ++ ) ++ os.makedirs(audio_output_dir, exist_ok=True) + +- safe_num = f"{chapter_number:04d}" +- audio_file = os.path.join(base_dir, f"{safe_num}.m4a") ++ audio_file = os.path.join(audio_output_dir, f"{chapter_number:04d}.m4a") + ++ # ------------------------------------------------------------ ++ # Skip if output already exists ++ # ------------------------------------------------------------ + if os.path.exists(audio_file): + log(f"[AUDIO] Skip CH{chapter_number} → already exists") +- redis_client.delete(slot_key) ++ try: ++ chapter_context.add_skip("audio", "already_exists") ++ save_skip_reason(book_idx, chapter_number, "audio_exists") ++ set_last_update(book_idx) ++ except Exception: ++ pass ++ if slot_key: ++ redis_client.delete(slot_key) + return + +- # ============================================================ +- # BUILD CMD +- # ============================================================ +- ++ # ------------------------------------------------------------ ++ # BUILD COMMAND ++ # ------------------------------------------------------------ + cmd = ( + f"say --voice={AUDIO_VOICE} " +- f"--input-file='{host_path}' " ++ f"--input-file='{host_input_path}' " + f"--output-file='{audio_file}' " +- f"--file-format=m4bf " +- f"--quality=127 " +- f"-r {AUDIO_RATE} " +- f"--data-format=aac" ++ f"--file-format=m4bf --quality=127 " ++ f"-r {AUDIO_RATE} --data-format=aac" + ) + + log(f"[AUDIO] CH{chapter_number}: CMD = {cmd}") + +- # ============================================================ ++ # ------------------------------------------------------------ + # RUN TTS +- # ============================================================ ++ # ------------------------------------------------------------ + try: + subprocess.run(cmd, shell=True, check=True, timeout=AUDIO_TIMEOUT) + log(f"[AUDIO] CH{chapter_number}: Completed") + ++ inc_audio_done(book_idx) ++ set_last_update(book_idx) ++ ++ chapter_context.audio_done = True ++ + except subprocess.TimeoutExpired: +- log(f"[AUDIO] CH{chapter_number}: TIMEOUT → remove incomplete file") ++ log(f"[AUDIO] CH{chapter_number}: TIMEOUT → removing incomplete file") ++ try: ++ chapter_context.add_skip("audio", "timeout") ++ save_skip_reason(book_idx, chapter_number, "audio_timeout") ++ set_last_update(book_idx) ++ except Exception: ++ pass + if os.path.exists(audio_file): +- try: +- os.remove(audio_file) +- except Exception: +- pass ++ os.remove(audio_file) + + except subprocess.CalledProcessError as e: +- log(f"[AUDIO] CH{chapter_number}: ERROR during say → {e}") ++ log(f"[AUDIO] CH{chapter_number}: ERROR in say → {e}") ++ try: ++ chapter_context.add_skip("audio", f"cmd_error:{e}") ++ save_skip_reason(book_idx, chapter_number, "audio_cmd_error") ++ set_last_update(book_idx) ++ except Exception: ++ pass + + except Exception as e: + log(f"[AUDIO] CH{chapter_number}: UNEXPECTED ERROR → {e}") ++ try: ++ chapter_context.add_skip("audio", f"unexpected:{e}") ++ save_skip_reason(book_idx, chapter_number, "audio_unexpected_error") ++ set_last_update(book_idx) ++ except Exception: ++ pass + + finally: + if slot_key: +diff --git a/bookscraper/scraper/tasks/controller_tasks.py b/bookscraper/scraper/tasks/controller_tasks.py +index 0f06405..b7336c8 100644 +--- a/bookscraper/scraper/tasks/controller_tasks.py ++++ b/bookscraper/scraper/tasks/controller_tasks.py +@@ -1,81 +1,98 @@ + # ============================================================ + # File: scraper/tasks/controller_tasks.py + # Purpose: +-# Start the download → parse → save pipeline for a scraped book, +-# including progress/abort tracking via book_id. +-# ONLY THE CONTROLLER UPDATES PROGRESS (initial total). ++# Launch the full download/parse/save pipeline. ++# ++# JSON-safe Celery architecture: ++# • controller receives primitive scrape_result ++# • controller builds DownloadController locally ++# • controller assigns volumes & prepares folders ++# • Celery pipelines receive ONLY (book_idx, chapter_num) + # ============================================================ + + from celery_app import celery_app + from logbus.publisher import log + + from scraper.download_controller import DownloadController +-from scraper.progress import ( +- set_total, +-) ++from scraper.progress import set_total, set_status + from scraper.abort import abort_requested + +-print(">>> [IMPORT] controller_tasks.py loaded") ++print(">>> [IMPORT] controller_tasks.py loaded (ID-only mode)") + + + @celery_app.task(bind=True, queue="controller", ignore_result=False) + def launch_downloads(self, book_id: str, scrape_result: dict): +- """ +- Launch the entire pipeline (download → parse → save), +- AND initialize progress counters. +- +- Chapter-level progress is updated INSIDE the download/parse/save tasks. +- This task MUST NOT call .get() on async subtasks (Celery restriction). +- """ +- +- title = scrape_result.get("title", "UnknownBook") +- chapters = scrape_result.get("chapters", []) or [] +- total = len(chapters) +- +- log(f"[CTRL] Book '{title}' → {total} chapters (book_id={book_id})") +- +- # ------------------------------------------------------------ +- # INIT PROGRESS +- # ------------------------------------------------------------ +- set_total(book_id, total) +- log(f"[CTRL] Progress initialized for {book_id}: total={total}") +- +- # ------------------------------------------------------------ +- # BUILD CONTROLLER +- # ------------------------------------------------------------ +- ctl = DownloadController(book_id, scrape_result) +- +- # ------------------------------------------------------------ +- # START PIPELINES (ASYNC) +- # Returns a celery group AsyncResult. We DO NOT iterate or get(). +- # Progress & failures are handled by the worker subtasks. +- # ------------------------------------------------------------ ++ ++ title = scrape_result.get("title", "Unknown") ++ chapter_count = scrape_result.get("chapters", 0) ++ book_idx = scrape_result.get("book_id") ++ ++ log(f"[CTRL] Book '{title}' → {chapter_count} chapters (book_idx={book_idx})") ++ ++ # ----------------------------------------------------------- ++ # Initialize progress counters ++ # ----------------------------------------------------------- ++ try: ++ set_total(book_idx, chapter_count) ++ set_status(book_idx, "downloading") ++ except Exception as exc: ++ log(f"[CTRL] ERROR setting up progress counters: {exc}") ++ raise ++ ++ # ----------------------------------------------------------- ++ # Build controller ++ # ----------------------------------------------------------- ++ try: ++ ctl = DownloadController(scrape_result) ++ except Exception as exc: ++ log(f"[CTRL] ERROR constructing DownloadController: {exc}") ++ raise ++ ++ # ----------------------------------------------------------- ++ # Prepare folders + cover (NEW: init_book_output) ++ # ----------------------------------------------------------- + try: +- group_result = ctl.start() ++ ctl.init_book_output() ++ except Exception as exc: ++ log(f"[CTRL] ERROR initializing context: {exc}") ++ raise ++ ++ log(f"[CTRL_STATE] init_book_output() completed for {title}") + +- log( +- f"[CTRL] Pipelines dispatched for '{title}' " +- f"(book_id={book_id}, group_id={group_result.id})" +- ) ++ # ----------------------------------------------------------- ++ # Abort check BEFORE pipelines ++ # ----------------------------------------------------------- ++ if abort_requested(book_idx): ++ log(f"[CTRL] ABORT requested BEFORE pipeline dispatch → stopping.") ++ set_status(book_idx, "aborted") ++ return {"status": "aborted"} + +- # Abort flag set BEFORE tasks start? +- if abort_requested(book_id): +- log(f"[CTRL] ABORT requested before tasks start") +- return {"book_id": book_id, "aborted": True} ++ # ----------------------------------------------------------- ++ # Assign volumes ++ # ----------------------------------------------------------- ++ try: ++ ctl.assign_volumes() ++ except Exception as exc: ++ log(f"[CTRL] ERROR during volume assignment: {exc}") ++ raise + ++ # ----------------------------------------------------------- ++ # Build pipelines (primitive arguments) ++ # ----------------------------------------------------------- ++ try: ++ tasks = ctl.build_pipelines() ++ except Exception as exc: ++ log(f"[CTRL] ERROR building pipelines: {exc}") ++ raise ++ ++ # ----------------------------------------------------------- ++ # Dispatch all pipelines ++ # ----------------------------------------------------------- ++ try: ++ result_group = ctl.dispatch_pipelines(tasks) + except Exception as exc: +- log(f"[CTRL] ERROR while dispatching pipelines: {exc}") ++ log(f"[CTRL] ERROR dispatching pipelines: {exc}") + raise + +- # ------------------------------------------------------------ +- # CONTROLLER DOES NOT WAIT FOR SUBTASK RESULTS +- # (Download/parse/save tasks update progress themselves) +- # ------------------------------------------------------------ +- log(f"[CTRL] Controller finished dispatch for book_id={book_id}") +- +- return { +- "book_id": book_id, +- "total": total, +- "started": True, +- "group_id": group_result.id, +- } ++ log(f"[CTRL] Pipelines dispatched for {title} (book_idx={book_idx})") ++ return {"book_idx": book_idx, "pipelines": len(tasks)} +diff --git a/bookscraper/scraper/tasks/download_tasks.py b/bookscraper/scraper/tasks/download_tasks.py +index 5110483..1e01630 100644 +--- a/bookscraper/scraper/tasks/download_tasks.py ++++ b/bookscraper/scraper/tasks/download_tasks.py +@@ -1,22 +1,41 @@ + # ============================================================ + # File: scraper/tasks/download_tasks.py +-# Purpose: Download chapter HTML with global concurrency, +-# retry/backoff logic, 429 support, and abort-awareness. + # +-# Logging: +-# - timestamp + book_id in de message +-# - message wordt via publisher.py naar console gestuurd +-# - message wordt via ui_log.push_ui naar Redis GUI logbuffer gestuurd ++# FINAL ARCHITECTURE — CELERY-SAFE VERSION ++# ++# This task downloads EXACTLY ONE CHAPTER using ONLY primitive ++# Celery-safe arguments: ++# ++# download_chapter(book_idx, chapter_num, chapter_url, volume_path) ++# ++# BookContext / ChapterContext are NOT passed into Celery and ++# NOT loaded inside workers. Workers ONLY manipulate: ++# ++# • Redis abort flags ++# • Redis chapter-start markers ++# • Redis progress counters ++# • save_path derived from volume_path + chapter_num ++# ++# The chapter HTML is written to disk and a small Redis skip- ++# reason is stored when needed. All other state lives outside ++# Celery. ++# ++# This is the architecture we agreed on to avoid JSON ++# serialization errors and to remove the need for ++# load_book_context/save_book_context. + # +-# publisher.py en ui_log.py blijven DOM. + # ============================================================ + + from celery_app import celery_app + from scraper.utils import get_save_path + from scraper.abort import abort_requested, chapter_started, mark_chapter_started + +-from logbus.publisher import log # console logging (DOM) +-from scraper.ui_log import push_ui # GUI logging (DOM) ++from scraper.progress import ( ++ inc_chapter_done, ++ save_skip_reason, ++) ++ ++from logbus.publisher import log + + import requests + import redis +@@ -25,50 +44,38 @@ import time + from datetime import datetime + + +-print(">>> [IMPORT] download_tasks.py loaded") ++print(">>> [IMPORT] download_tasks.py loaded (final Celery-safe mode)") + + + # ----------------------------------------------------------- +-# TIMESTAMPED LOG WRAPPER ++# TIMESTAMPED LOGGER (book_idx ONLY) + # ----------------------------------------------------------- +-def log_msg(book_id: str, message: str): ++def log_msg(book_idx: str, message: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") +- full = f"{ts} [{book_id}] {message}" +- log(full) +- push_ui(full) ++ log(f"{ts} [{book_idx}] {message}") + + + # ----------------------------------------------------------- +-# Retry parameters (ENV) ++# ENV SETTINGS + # ----------------------------------------------------------- + MAX_RETRIES = int(os.getenv("DOWNLOAD_MAX_RETRIES", "7")) + BASE_DELAY = int(os.getenv("DOWNLOAD_BASE_DELAY", "2")) + BACKOFF = int(os.getenv("DOWNLOAD_BACKOFF_MULTIPLIER", "2")) + DELAY_429 = int(os.getenv("DOWNLOAD_429_DELAY", "10")) + +-# ----------------------------------------------------------- +-# Global concurrency +-# ----------------------------------------------------------- + MAX_CONCURRENCY = int(os.getenv("DOWNLOAD_MAX_GLOBAL_CONCURRENCY", "1")) +- +-# ----------------------------------------------------------- +-# Global delay sync +-# ----------------------------------------------------------- + GLOBAL_DELAY = int(os.getenv("DOWNLOAD_GLOBAL_MIN_DELAY", "1")) +-DELAY_KEY = "download:delay_lock" + +-# ----------------------------------------------------------- +-# Redis +-# ----------------------------------------------------------- + REDIS_URL = os.getenv("REDIS_BROKER", "redis://redis:6379/0") + redis_client = redis.Redis.from_url(REDIS_URL) + + SEM_KEY = "download:active" ++DELAY_KEY = "download:delay_lock" + + +-# ============================================================ +-# GLOBAL DELAY FUNCTIONS +-# ============================================================ ++# ----------------------------------------------------------- ++# Delay + concurrency helpers ++# ----------------------------------------------------------- + def wait_for_global_delay(): + if GLOBAL_DELAY <= 0: + return +@@ -82,13 +89,10 @@ def set_global_delay(): + redis_client.set(DELAY_KEY, "1", nx=True, ex=GLOBAL_DELAY) + + +-# ============================================================ +-# GLOBAL CONCURRENCY FUNCTIONS +-# ============================================================ +-def acquire_global_slot(max_slots: int, retry_delay: float = 0.5): ++def acquire_global_slot(max_slots: int, retry_delay=0.5): + while True: +- current = redis_client.incr(SEM_KEY) +- if current <= max_slots: ++ cur = redis_client.incr(SEM_KEY) ++ if cur <= max_slots: + return + redis_client.decr(SEM_KEY) + time.sleep(retry_delay) +@@ -98,81 +102,71 @@ def release_global_slot(): + redis_client.decr(SEM_KEY) + + +-print(f">>> [CONFIG] Global concurrency = {MAX_CONCURRENCY}") +-print(f">>> [CONFIG] Global min delay = {GLOBAL_DELAY}s") +-print( +- f">>> [CONFIG] Retries: MAX={MAX_RETRIES}, base={BASE_DELAY}, " +- f"backoff={BACKOFF}, 429={DELAY_429}" +-) +- +- + # ============================================================ +-# CELERY TASK: DOWNLOAD CHAPTER ++# CELERY TASK — PRIMITIVE ARG MODE ++# ++# book_idx: str Unique book identifier used for state tracking ++# chapter_num: int Chapter number (1-based) ++# chapter_url: str URL to download this chapter ++# volume_path: str Filesystem directory where this chapter belongs + # ============================================================ + @celery_app.task(bind=True, queue="download", ignore_result=False) + def download_chapter( +- self, book_id: str, chapter_num: int, chapter_url: str, base_path: str ++ self, book_idx: str, chapter_num: int, chapter_url: str, volume_path: str + ): + """ +- Download chapter HTML. +- Abort logic: +- - If abort active AND chapter not started → SKIP +- - If abort active BUT chapter already started → Proceed normally ++ Download a single chapter using ONLY Celery-safe primitives. ++ No BookContext or ChapterContext is ever loaded. ++ ++ Writes: ++ /.html + """ + + # ----------------------------------------------------------- +- # ABORT BEFORE START ++ # Abort BEFORE start + # ----------------------------------------------------------- +- if abort_requested(book_id) and not chapter_started(book_id, chapter_num): +- msg = f"[ABORT] Skip chapter {chapter_num} (abort active, not started)" +- log_msg(book_id, msg) +- return { +- "book_id": book_id, +- "chapter": chapter_num, +- "url": chapter_url, +- "html": None, +- "skipped": True, +- "path": None, +- "abort": True, +- } +- +- # Mark started +- mark_chapter_started(book_id, chapter_num) ++ if abort_requested(book_idx) and not chapter_started(book_idx, chapter_num): ++ log_msg(book_idx, f"[ABORT] Skip chapter {chapter_num}") ++ ++ save_skip_reason(book_idx, chapter_num, "abort_before_start") ++ inc_chapter_done(book_idx) ++ return None ++ ++ # Mark chapter as started ++ mark_chapter_started(book_idx, chapter_num) + + # ----------------------------------------------------------- +- # NEW POSITION FOR SKIP BLOCK (before any delay logic) ++ # Path resolution + # ----------------------------------------------------------- +- save_path = get_save_path(chapter_num, base_path) ++ save_path = get_save_path(chapter_num, volume_path) ++ os.makedirs(volume_path, exist_ok=True) + ++ # ----------------------------------------------------------- ++ # Skip if file already exists ++ # ----------------------------------------------------------- + if os.path.exists(save_path): +- log_msg(book_id, f"[DL] SKIP {chapter_num} (exists) → {save_path}") +- return { +- "book_id": book_id, +- "chapter": chapter_num, +- "url": chapter_url, +- "html": None, +- "skipped": True, +- "path": save_path, +- } ++ log_msg(book_idx, f"[DL] SKIP {chapter_num} (exists)") ++ ++ save_skip_reason(book_idx, chapter_num, "already_exists") ++ inc_chapter_done(book_idx) ++ return None + + # ----------------------------------------------------------- +- # Hard delay (only for real downloads) ++ # Delay + concurrency enforcement + # ----------------------------------------------------------- + if GLOBAL_DELAY > 0: + time.sleep(GLOBAL_DELAY) + +- # Sync delay + wait_for_global_delay() +- +- # Acquire concurrency slot + acquire_global_slot(MAX_CONCURRENCY) +- log_msg(book_id, f"[DL] ACQUIRED SLOT for chapter {chapter_num}") + ++ log_msg(book_idx, f"[DL] ACQUIRED SLOT for chapter {chapter_num}") ++ ++ # ----------------------------------------------------------- ++ # HTTP Download ++ # ----------------------------------------------------------- + try: +- # ----------------------------------------------------------- +- # HTTP DOWNLOAD +- # ----------------------------------------------------------- +- log_msg(book_id, f"[DL] Downloading chapter {chapter_num}: {chapter_url}") ++ log_msg(book_idx, f"[DL] GET chapter {chapter_num}: {chapter_url}") + + resp = requests.get( + chapter_url, +@@ -184,42 +178,31 @@ def download_chapter( + resp.encoding = resp.apparent_encoding or "gb2312" + html = resp.text + +- log_msg(book_id, f"[DL] OK {chapter_num}: {len(html)} bytes") ++ # Write file ++ with open(save_path, "w", encoding="utf-8") as f: ++ f.write(html) + +- return { +- "book_id": book_id, +- "chapter": chapter_num, +- "url": chapter_url, +- "html": html, +- "skipped": False, +- "path": save_path, +- } ++ log_msg(book_idx, f"[DL] OK {chapter_num}: {len(html)} bytes") ++ inc_chapter_done(book_idx) ++ return None + + except Exception as exc: +- attempt = self.request.retries +- delay = BASE_DELAY * (BACKOFF**attempt) +- +- # 429 hard block ++ # 429: Too Many Requests + if getattr(getattr(exc, "response", None), "status_code", None) == 429: +- log_msg( +- book_id, +- f"[DL] 429 {chapter_num} → WAIT {DELAY_429}s " +- f"(attempt {attempt}/{MAX_RETRIES})", +- ) +- ++ log_msg(book_idx, f"[DL] 429 → wait {DELAY_429}s") + time.sleep(DELAY_429) + set_global_delay() + raise self.retry(exc=exc, countdown=0, max_retries=MAX_RETRIES) + +- # Normal error ++ # Standard error ++ delay = BASE_DELAY * (BACKOFF ** min(self.request.retries, 5)) + log_msg( +- book_id, ++ book_idx, + f"[DL] ERROR {chapter_num}: {exc} → retry in {delay}s " +- f"(attempt {attempt}/{MAX_RETRIES})", ++ f"(attempt {self.request.retries + 1}/{MAX_RETRIES})", + ) ++ + raise self.retry(exc=exc, countdown=delay, max_retries=MAX_RETRIES) + + finally: +- set_global_delay() + release_global_slot() +- log_msg(book_id, f"[DL] RELEASED SLOT for chapter {chapter_num}") +diff --git a/bookscraper/scraper/tasks/parse_tasks.py b/bookscraper/scraper/tasks/parse_tasks.py +index 52066f9..7460f70 100644 +--- a/bookscraper/scraper/tasks/parse_tasks.py ++++ b/bookscraper/scraper/tasks/parse_tasks.py +@@ -1,155 +1,132 @@ +-# ========================================================= ++# ============================================================ + # File: scraper/tasks/parse_tasks.py +-# Purpose: Parse downloaded HTML into clean chapter text. +-# Enhanced version: Piaotia H1→content extractor + clean pipeline +-# NO HARDCODED REPLACEMENTS — everything comes from replacement files +-# ========================================================= ++# ++# FINAL ARCHITECTURE — CELERY-SAFE VERSION ++# ++# This task parses a downloaded chapter using ONLY primitive, ++# Celery-safe arguments: ++# ++# parse_chapter(book_idx, chapter_num, html_path, text_path) ++# ++# NO BookContext or ChapterContext are ever loaded. ++# ++# Responsibilities: ++# • Read HTML file from disk ++# • Convert to cleaned text ++# • Write .txt file back to disk ++# • Update Redis progress counters ++# • Abort-aware ++# • Skip if HTML missing ++# ++# This matches the structure of download_tasks.py and avoids all ++# serialization issues. ++# ============================================================ + + from celery_app import celery_app +-from bs4 import BeautifulSoup + +-from scraper.utils import clean_text, load_all_replacements +-from scraper.tasks.download_tasks import log_msg # unified logger ++from scraper.utils import clean_text ++from scraper.abort import abort_requested, chapter_started, mark_chapter_started ++from scraper.progress import ( ++ inc_chapter_done, ++ save_skip_reason, ++) + +-print(">>> [IMPORT] parse_tasks.py loaded (enhanced parser)") ++from logbus.publisher import log + ++import os ++from datetime import datetime + ++ ++print(">>> [IMPORT] parse_tasks.py loaded (final Celery-safe mode)") ++ ++ ++# ----------------------------------------------------------- ++# TIMESTAMPED LOGGER (book_idx ONLY) ++# ----------------------------------------------------------- ++def log_msg(book_idx: str, message: str): ++ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") ++ log(f"{ts} [{book_idx}] {message}") ++ ++ ++# ============================================================ ++# CELERY TASK — PRIMITIVE ARGUMENT MODE ++# ++# book_idx: str Unique Redis state key ++# chapter_num: int Chapter number (1-based) ++# html_path: str Path to downloaded HTML file ++# text_path: str Path where parsed/cleaned text must be written ++# ============================================================ + @celery_app.task(bind=True, queue="parse", ignore_result=False) +-def parse_chapter(self, download_result: dict, meta: dict): +- +- book_id = download_result.get("book_id", "NOBOOK") +- +- # ------------------------------------------------------------ +- # SKIPPED DOWNLOAD → SKIP PARSE +- # ------------------------------------------------------------ +- if download_result.get("skipped"): +- chapter = download_result.get("chapter") +- log_msg(book_id, f"[PARSE] SKIP chapter {chapter} (download skipped)") +- download_result["book_id"] = book_id +- return download_result +- +- # ------------------------------------------------------------ +- # NORMAL PARSE +- # ------------------------------------------------------------ +- chapter_num = download_result["chapter"] +- chapter_url = download_result["url"] +- html = download_result["html"] +- +- log_msg(book_id, f"[PARSE] Parsing chapter {chapter_num}") +- +- soup = BeautifulSoup(html, "lxml") +- +- # ------------------------------------------------------------ +- # STRICT SELECTORS (direct content blocks) +- # ------------------------------------------------------------ +- selectors = [ +- "#content", +- "div#content", +- ".content", +- "div.content", +- "#chaptercontent", +- "div#chaptercontent", +- "#chapterContent", +- ".read-content", +- "div.read-content", +- ] +- +- node = None +- for sel in selectors: +- tmp = soup.select_one(sel) +- if tmp: +- node = tmp +- break +- +- # ------------------------------------------------------------ +- # PIAOTIA FALLBACK: +- # Extract content between

and the "bottomlink" block. +- # ------------------------------------------------------------ +- raw = None +- if node is None: +- h1 = soup.find("h1") +- if h1: +- content_parts = [] +- for sib in h1.next_siblings: +- +- # stop at bottom navigation/footer block +- sib_class = getattr(sib, "get", lambda *_: None)("class") +- if sib_class and ( +- "bottomlink" in sib_class or sib_class == "bottomlink" +- ): +- break +- +- # ignore typical noise containers +- if getattr(sib, "name", None) in ["script", "style", "center"]: +- continue +- +- if hasattr(sib, "get_text"): +- content_parts.append(sib.get_text(separator="\n")) +- else: +- content_parts.append(str(sib)) +- +- raw = "\n".join(content_parts) +- +- # ------------------------------------------------------------ +- # FINAL FALLBACK +- # ------------------------------------------------------------ +- if raw is None: +- if node: +- raw = node.get_text(separator="\n") +- else: +- # drop scripts & styles +- for tag in soup(["script", "style", "noscript"]): +- tag.decompose() +- +- raw = soup.get_text(separator="\n") +- +- # ------------------------------------------------------------ +- # MULTIPASS CLEANING via replacement files ONLY +- # ------------------------------------------------------------ +- REPL = load_all_replacements() +- +- text = raw +- for _ in range(5): # like the C# CleanText loop +- text = clean_text(text, REPL) +- +- # ------------------------------------------------------------ +- # Collapse excessive empty lines +- # ------------------------------------------------------------ +- cleaned = [] +- prev_blank = False +- +- for line in text.split("\n"): +- stripped = line.rstrip() +- if stripped == "": +- if prev_blank: +- continue +- prev_blank = True +- cleaned.append("") +- else: +- prev_blank = False +- cleaned.append(stripped) +- +- text = "\n".join(cleaned) +- +- # ------------------------------------------------------------ +- # Add header to chapter 1 +- # ------------------------------------------------------------ +- if chapter_num == 1: +- book_url = meta.get("book_url") or meta.get("url") or "UNKNOWN" +- header = ( +- f"{meta.get('title','')}\n" +- f"Author: {meta.get('author','')}\n" +- f"Description:\n{meta.get('description','')}\n" +- f"Book URL: {book_url}\n" + "-" * 50 + "\n\n" +- ) +- text = header + text +- +- log_msg(book_id, f"[PARSE] Parsed chapter {chapter_num}: {len(text)} chars") +- +- return { +- "book_id": book_id, +- "chapter": chapter_num, +- "url": chapter_url, +- "text": text, +- "length": len(text), +- } ++def parse_chapter( ++ self, book_idx: str, chapter_num: int, html_path: str, text_path: str ++): ++ """ ++ Parse a downloaded chapter using ONLY primitive arguments. ++ Converts HTML → cleaned text and writes it to disk. ++ """ ++ ++ # ----------------------------------------------------------- ++ # Abort BEFORE start ++ # ----------------------------------------------------------- ++ if abort_requested(book_idx) and not chapter_started(book_idx, chapter_num): ++ log_msg(book_idx, f"[ABORT] Skip PARSE {chapter_num}") ++ ++ save_skip_reason(book_idx, chapter_num, "abort_before_start") ++ inc_chapter_done(book_idx) ++ return None ++ ++ # Mark as started ++ mark_chapter_started(book_idx, chapter_num) ++ ++ # ----------------------------------------------------------- ++ # Check if HTML file exists ++ # ----------------------------------------------------------- ++ if not os.path.exists(html_path): ++ log_msg(book_idx, f"[PARSE] SKIP {chapter_num} (no HTML file)") ++ ++ save_skip_reason(book_idx, chapter_num, "no_html_file") ++ inc_chapter_done(book_idx) ++ return None ++ ++ # ----------------------------------------------------------- ++ # Load HTML ++ # ----------------------------------------------------------- ++ try: ++ with open(html_path, "r", encoding="utf-8") as f: ++ raw_html = f.read() ++ except Exception as exc: ++ log_msg(book_idx, f"[PARSE] ERROR reading HTML {chapter_num}: {exc}") ++ ++ save_skip_reason(book_idx, chapter_num, f"read_error: {exc}") ++ inc_chapter_done(book_idx) ++ return None ++ ++ if not raw_html.strip(): ++ log_msg(book_idx, f"[PARSE] SKIP {chapter_num} (empty HTML)") ++ ++ save_skip_reason(book_idx, chapter_num, "html_empty") ++ inc_chapter_done(book_idx) ++ return None ++ ++ # ----------------------------------------------------------- ++ # Clean HTML → Text ++ # ----------------------------------------------------------- ++ try: ++ log_msg(book_idx, f"[PARSE] Start {chapter_num}") ++ ++ cleaned = clean_text(raw_html) ++ ++ os.makedirs(os.path.dirname(text_path), exist_ok=True) ++ with open(text_path, "w", encoding="utf-8") as f: ++ f.write(cleaned) ++ ++ log_msg(book_idx, f"[PARSE] OK {chapter_num}: {len(cleaned)} chars") ++ inc_chapter_done(book_idx) ++ return None ++ ++ except Exception as exc: ++ log_msg(book_idx, f"[PARSE] ERROR {chapter_num}: {exc}") ++ ++ save_skip_reason(book_idx, chapter_num, f"parse_error: {exc}") ++ inc_chapter_done(book_idx) ++ return None +diff --git a/bookscraper/scraper/tasks/pipeline.py b/bookscraper/scraper/tasks/pipeline.py +index 9da657e..2dae558 100644 +--- a/bookscraper/scraper/tasks/pipeline.py ++++ b/bookscraper/scraper/tasks/pipeline.py +@@ -1,17 +1,19 @@ +-# ========================================================= ++# ============================================================ + # File: scraper/tasks/pipeline.py + # Purpose: +-# Build Celery chains for chapter processing. ++# Build a per-chapter Celery pipeline using ONLY JSON-safe ++# arguments: (book_idx, chapter_num). + # +-# Chain: +-# download_chapter(book_id, chapter_num, url, base_path) +-# → parse_chapter(download_result, meta) +-# → save_chapter(parsed_result, base_path) +-# → update_progress(final_result, book_id) ++# Pipeline: ++# download_chapter(book_idx, chapter_num) ++# → parse_chapter(book_idx, chapter_num) ++# → save_chapter(book_idx, chapter_num) ++# → update_progress(book_idx) + # +-# All subtasks must pass through result dicts untouched so the +-# next stage receives the correct fields. +-# ========================================================= ++# No BookContext/ChapterContext objects are passed through ++# Celery anymore. All context is loaded internally inside ++# each task. ++# ============================================================ + + from celery import chain + +@@ -21,25 +23,41 @@ from scraper.tasks.save_tasks import save_chapter + from scraper.tasks.progress_tasks import update_progress + + +-def build_chapter_pipeline( +- book_id: str, +- chapter_number: int, +- chapter_url: str, +- base_path: str, +- meta: dict, +-): ++print(">>> [IMPORT] pipeline.py loaded (ID-only mode)") ++ ++ ++# ============================================================ ++# Build chapter pipeline ++# ============================================================ ++def build_chapter_pipeline(book_idx: str, chapter_num: int): + """ +- Build a Celery chain for one chapter. ++ Constructs the Celery chain for a single chapter. + +- download_chapter(book_id, chapter_number, chapter_url, base_path) +- → parse_chapter(download_result, meta) +- → save_chapter(parsed_result, base_path) +- → update_progress(result, book_id) ++ Every task receives only JSON-safe arguments. All state ++ (BookContext + ChapterContext) is loaded inside the task. + """ + + return chain( +- download_chapter.s(book_id, chapter_number, chapter_url, base_path), +- parse_chapter.s(meta), +- save_chapter.s(base_path), +- update_progress.s(book_id), ++ download_chapter.s(book_idx, chapter_num), ++ parse_chapter.s(book_idx, chapter_num), ++ save_chapter.s(book_idx, chapter_num), ++ # update_progress only needs book_idx. ++ # BUT chain forwards previous task's result → so we accept *args. ++ update_progress.s(book_idx), + ) ++ ++ ++# ============================================================ ++# Build pipelines for all chapters ++# (usually called from download_controller) ++# ============================================================ ++def build_all_pipelines(book_idx: str, chapters): ++ """ ++ Utility: given a list of chapter numbers, build a list of chains. ++ ++ chapters = [1, 2, 3, ...] ++ """ ++ pipelines = [] ++ for ch in chapters: ++ pipelines.append(build_chapter_pipeline(book_idx, ch)) ++ return pipelines +diff --git a/bookscraper/scraper/tasks/progress_tasks.py b/bookscraper/scraper/tasks/progress_tasks.py +index 9045fab..717a70c 100644 +--- a/bookscraper/scraper/tasks/progress_tasks.py ++++ b/bookscraper/scraper/tasks/progress_tasks.py +@@ -1,43 +1,72 @@ + # ============================================================ + # File: scraper/tasks/progress_tasks.py +-# Purpose: Central progress updater for chapter pipelines. ++# Purpose: ++# Update pipeline progress after each chapter finishes. ++# ++# MUST accept chain-call semantics: ++# update_progress(previous_result, book_idx) ++# ++# Only book_idx is meaningful; previous_result is ignored. ++# ++# JSON-safe: no BookContext or ChapterContext objects are ++# ever passed into Celery tasks. + # ============================================================ + + from celery_app import celery_app +-from scraper.progress import inc_completed, inc_skipped, inc_failed ++from scraper.progress import inc_chapter_done, set_status, get_progress ++ + from logbus.publisher import log ++from datetime import datetime ++ ++ ++print(">>> [IMPORT] progress_tasks.py loaded (ID-only mode)") + +-print(">>> [IMPORT] progress_tasks.py loaded") + ++# ----------------------------------------------------------- ++# TIMESTAMPED LOGGER (book_idx ONLY) ++# ----------------------------------------------------------- ++def log_msg(book_idx: str, msg: str): ++ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") ++ log(f"{ts} [{book_idx}] {msg}") + +-@celery_app.task(bind=False, name="progress.update", queue="controller") +-def update_progress(result: dict, book_id: str): ++ ++# ============================================================ ++# CELERY TASK — must accept chain semantics ++# ============================================================ ++@celery_app.task(bind=True, queue="progress", ignore_result=False) ++def update_progress(self, *args): + """ +- Central progress logic: +- - result: output of save_chapter +- - book_id: explicitly passed by pipeline ++ Chain-safe progress update. ++ ++ Celery chain will call: ++ update_progress(previous_result, book_idx) ++ ++ Therefore: ++ book_idx = args[-1] + +- IMPORTANT: +- - save_chapter already updates counters for skipped & normal chapters +- - progress.update MUST NOT double-increment ++ This increments the done counter and sets status to ++ "completed" once all chapters are done. + """ + +- ch = result.get("chapter") +- skipped = result.get("skipped", False) +- failed = result.get("failed", False) ++ if not args: ++ return None ++ ++ # Last argument is ALWAYS the book_idx ++ book_idx = args[-1] ++ ++ # Increment chapter_done counter ++ inc_chapter_done(book_idx) + +- if failed: +- inc_failed(book_id) +- log(f"[PROG] FAILED chapter {ch}") ++ # Fetch progress counters ++ prog = get_progress(book_idx) ++ done = prog.get("done", 0) ++ total = prog.get("total", 0) + +- elif skipped: +- # save_chapter already did: +- # inc_skipped + inc_completed +- log(f"[PROG] SKIPPED chapter {ch}") ++ log_msg(book_idx, f"[PROGRESS] Updated: {done}/{total}") + +- else: +- # Normal completion: save_chapter only does inc_completed +- inc_completed(book_id) +- log(f"[PROG] DONE chapter {ch}") ++ # If finished → update status ++ if total > 0 and done >= total: ++ set_status(book_idx, "completed") ++ log_msg(book_idx, "[PROGRESS] All chapters completed") + +- return result ++ return None +diff --git a/bookscraper/scraper/tasks/save_tasks.py b/bookscraper/scraper/tasks/save_tasks.py +index 8aa0578..e615e9a 100644 +--- a/bookscraper/scraper/tasks/save_tasks.py ++++ b/bookscraper/scraper/tasks/save_tasks.py +@@ -1,123 +1,128 @@ + # ============================================================ + # File: scraper/tasks/save_tasks.py +-# Purpose: Save parsed chapter text to disk + trigger audio. ++# ++# FINAL ARCHITECTURE — CELERY-SAFE VERSION ++# ++# save_chapter(book_idx, chapter_num, text_path, json_path) ++# ++# Deze task slaat GEEN BookContext meer op, en laadt hem ook niet. ++# Alle data komt uit PRIMITIEVE argumenten zodat Celery nooit ++# hoeft te serialiseren of picklen. ++# ++# Functionaliteit: ++# • Abort-aware ++# • Skip als text ontbreekt ++# • Schrijft JSON chapter-object naar disk ++# • Houdt Redis progress state bij ++# ++# Deze versie is 100% consistent met download → parse → save pipeline. + # ============================================================ + +-print(">>> [IMPORT] save_tasks.py loaded") ++from celery_app import celery_app + +-from celery import shared_task ++from scraper.abort import abort_requested, chapter_started, mark_chapter_started ++from scraper.progress import inc_chapter_done, save_skip_reason ++ ++from logbus.publisher import log ++import json + import os ++from datetime import datetime ++ + +-from scraper.utils import get_save_path +-from scraper.tasks.download_tasks import log_msg # unified logger +-from scraper.progress import ( +- inc_completed, +- inc_skipped, +- inc_failed, +- add_failed_chapter, +-) ++print(">>> [IMPORT] save_tasks.py loaded (final Celery-safe mode)") + +-from scraper.tasks.audio_tasks import generate_audio + ++# ----------------------------------------------------------- ++# TIMESTAMP LOGGER ++# ----------------------------------------------------------- ++def log_msg(book_idx: str, message: str): ++ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") ++ log(f"{ts} [{book_idx}] {message}") + +-@shared_task(bind=True, queue="save", ignore_result=False) +-def save_chapter(self, parsed: dict, base_path: str): ++ ++# ============================================================ ++# CELERY TASK — primitive arguments only ++# ++# book_idx: str ++# chapter_num: int ++# text_path: str path to parsed .txt file ++# json_path: str output path for chapter JSON model ++# ============================================================ ++@celery_app.task(bind=True, queue="save", ignore_result=False) ++def save_chapter(self, book_idx: str, chapter_num: int, text_path: str, json_path: str): + """ +- Save parsed chapter text to disk. +- +- parsed = { +- "book_id": str, +- "chapter": int, +- "text": str, +- "url": str, +- "skipped": bool, +- "path": optional str +- } ++ Save parsed chapter text + metadata to JSON on disk. ++ No BookContext is loaded or saved. + """ + +- book_id = parsed.get("book_id", "NOBOOK") +- chapter = parsed.get("chapter") +- +- # ------------------------------------------------------------ +- # SKIP CASE (download or parse skipped the chapter) +- # ------------------------------------------------------------ +- if parsed.get("skipped"): +- path = parsed.get("path", "(no-path)") +- log_msg(book_id, f"[SAVE] SKIP chapter {chapter} → {path}") +- +- inc_skipped(book_id) +- +- # Determine volume name from the base path +- volume_name = os.path.basename(base_path.rstrip("/")) +- +- # Queue audio using the existing saved file +- try: +- generate_audio.delay( +- book_id, +- volume_name, +- chapter, +- f"Chapter {chapter}", +- path, # <<-- correct: this is always the real file path +- ) +- log_msg( +- book_id, +- f"[AUDIO] Task queued (SKIPPED) for chapter {chapter} in {volume_name}", +- ) +- except Exception as audio_exc: +- log_msg( +- book_id, +- f"[AUDIO] ERROR queueing (SKIPPED) chapter {chapter}: {audio_exc}", +- ) +- +- return { +- "book_id": book_id, # <<< FIXED +- "chapter": chapter, +- "path": path, +- "skipped": True, +- } +- +- # ------------------------------------------------------------ +- # NORMAL SAVE CASE +- # ------------------------------------------------------------ +- try: +- text = parsed.get("text", "") ++ # ----------------------------------------------------------- ++ # Abort BEFORE the task starts ++ # ----------------------------------------------------------- ++ if abort_requested(book_idx) and not chapter_started(book_idx, chapter_num): ++ log_msg(book_idx, f"[ABORT] Skip SAVE {chapter_num}") ++ ++ save_skip_reason(book_idx, chapter_num, "abort_before_start") ++ inc_chapter_done(book_idx) ++ return None + +- if chapter is None: +- raise ValueError("Missing chapter number in parsed payload") ++ # Mark chapter as started ++ mark_chapter_started(book_idx, chapter_num) + +- # Ensure chapter folder exists +- os.makedirs(base_path, exist_ok=True) ++ # ----------------------------------------------------------- ++ # Ensure parsed text exists ++ # ----------------------------------------------------------- ++ if not os.path.exists(text_path): ++ log_msg(book_idx, f"[SAVE] SKIP {chapter_num} (missing text file)") + +- # Build chapter file path +- path = get_save_path(chapter, base_path) ++ save_skip_reason(book_idx, chapter_num, "no_text_file") ++ inc_chapter_done(book_idx) ++ return None + +- # Save chapter text to disk +- with open(path, "w", encoding="utf-8") as f: +- f.write(text) ++ # ----------------------------------------------------------- ++ # Read parsed text ++ # ----------------------------------------------------------- ++ try: ++ with open(text_path, "r", encoding="utf-8") as f: ++ text = f.read() ++ except Exception as exc: ++ log_msg(book_idx, f"[SAVE] ERROR reading text {chapter_num}: {exc}") ++ ++ save_skip_reason(book_idx, chapter_num, f"text_read_error: {exc}") ++ inc_chapter_done(book_idx) ++ return None ++ ++ if not text.strip(): ++ log_msg(book_idx, f"[SAVE] SKIP {chapter_num} (text empty)") + +- log_msg(book_id, f"[SAVE] Saved chapter {chapter} → {path}") ++ save_skip_reason(book_idx, chapter_num, "text_empty") ++ inc_chapter_done(book_idx) ++ return None + +- inc_completed(book_id) ++ # ----------------------------------------------------------- ++ # Build JSON chapter representation ++ # ----------------------------------------------------------- ++ chapter_obj = { ++ "chapter_num": chapter_num, ++ "text": text, ++ } ++ ++ # ----------------------------------------------------------- ++ # Write JSON output ++ # ----------------------------------------------------------- ++ try: ++ os.makedirs(os.path.dirname(json_path), exist_ok=True) + +- # Determine volume name +- volume_name = os.path.basename(base_path.rstrip("/")) ++ with open(json_path, "w", encoding="utf-8") as f: ++ json.dump(chapter_obj, f, ensure_ascii=False, indent=2) + +- # Queue audio task (always use the saved file path) +- try: +- generate_audio.delay( +- book_id, +- volume_name, +- chapter, +- f"Chapter {chapter}", +- path, +- ) +- log_msg( +- book_id, f"[AUDIO] Task queued for chapter {chapter} in {volume_name}" +- ) +- except Exception as audio_exc: +- log_msg(book_id, f"[AUDIO] ERROR queueing chapter {chapter}: {audio_exc}") ++ log_msg(book_idx, f"[SAVE] OK {chapter_num}: {json_path}") + +- return {"book_id": book_id, "chapter": chapter, "path": path} ++ inc_chapter_done(book_idx) ++ return None + + except Exception as exc: +- log_msg(book_id, f"[SAVE] ERROR saving chapter {chapter}: {exc}") ++ log_msg(book_idx, f"[SAVE] ERROR writing JSON {chapter_num}: {exc}") ++ ++ save_skip_reason(book_idx, chapter_num, f"json_write_error: {exc}") ++ inc_chapter_done(book_idx) ++ return None +diff --git a/bookscraper/scraper/tasks/scraping.py b/bookscraper/scraper/tasks/scraping.py +index 0694089..70198d0 100644 +--- a/bookscraper/scraper/tasks/scraping.py ++++ b/bookscraper/scraper/tasks/scraping.py +@@ -12,11 +12,11 @@ import redis + from scraper.sites import BookSite + from scraper.book_scraper import BookScraper + from scraper.abort import clear_abort # no circular deps +-from scraper.ui_log import reset_ui_logs # <-- NEW IMPORT ++from scraper.ui_log import reset_ui_logs # NEW + + print(">>> [IMPORT] scraping.py loaded") + +-# Redis connection (same as Celery broker) ++# Redis = same URL as Celery broker + REDIS_URL = os.getenv("REDIS_BROKER", "redis://redis:6379/0") + r = redis.Redis.from_url(REDIS_URL, decode_responses=True) + +@@ -26,24 +26,24 @@ def start_scrape_book(self, url: str): + """Scrapes metadata + chapters and prepares download tracking.""" + + # ------------------------------------------------------------ +- # NEW: clear UI log buffer at start of new run ++ # Clear UI logs for a fresh run + # ------------------------------------------------------------ + reset_ui_logs() + + log(f"[SCRAPING] Start scraping for: {url}") + + # ------------------------------------------------------------ +- # Book scrape ++ # Scrape metadata + chapters + # ------------------------------------------------------------ + site = BookSite() + scraper = BookScraper(site, url) +- result = scraper.execute() # returns dict with metadata + chapters ++ result = scraper.execute() # dict with metadata + chapters list + + chapters = result.get("chapters", []) + full_count = len(chapters) + + # ------------------------------------------------------------ +- # DRY RUN ++ # DRY RUN (limit number of chapters) + # ------------------------------------------------------------ + DRY_RUN = os.getenv("DRY_RUN", "0") == "1" + TEST_LIMIT = int(os.getenv("TEST_LIMIT", "5")) +@@ -51,34 +51,45 @@ def start_scrape_book(self, url: str): + if DRY_RUN: + log(f"[SCRAPING] DRY_RUN: limiting chapters to {TEST_LIMIT}") + chapters = chapters[:TEST_LIMIT] +- result["chapters"] = chapters ++ ++ # ------------------------------------------------------------ ++ # NORMALISE OUTPUT FORMAT ++ # - chapters = INT ++ # - chapter_list = LIST ++ # ------------------------------------------------------------ ++ result["chapter_list"] = chapters ++ result["chapters"] = len(chapters) + + log(f"[SCRAPING] Completed scrape: {len(chapters)}/{full_count} chapters") + + # ------------------------------------------------------------ +- # BOOK RUN ID (using title as ID) ++ # Ensure book_id exists + # ------------------------------------------------------------ +- title = result.get("title") or "UnknownBook" +- book_id = title # user requirement ++ book_idx = result.get("book_idx") ++ if not book_idx: ++ raise ValueError("BookScraper did not return book_idx") + ++ book_id = book_idx + result["book_id"] = book_id + +- log(f"[SCRAPING] Assigned book_id = '{book_id}'") ++ log(f"[SCRAPING] Assigned book_id = {book_id}") + + # ------------------------------------------------------------ + # RESET ABORT + INITIALISE PROGRESS + # ------------------------------------------------------------ + clear_abort(book_id) + +- r.set(f"progress:{book_id}:total", len(chapters)) ++ r.set(f"progress:{book_id}:total", result["chapters"]) + r.set(f"progress:{book_id}:done", 0) +- r.delete(f"logs:{book_id}") # clear old logs if any ++ ++ # clear legacy logs ++ r.delete(f"logs:{book_id}") + + r.rpush(f"logs:{book_id}", f":: SCRAPING STARTED for {url}") +- r.rpush(f"logs:{book_id}", f":: Found {len(chapters)} chapters") ++ r.rpush(f"logs:{book_id}", f":: Found {result['chapters']} chapters") + + # ------------------------------------------------------------ +- # DISPATCH DOWNLOAD CONTROLLER ++ # DISPATCH CONTROLLER (book_idx + primitive metadata) + # ------------------------------------------------------------ + celery_app.send_task( + "scraper.tasks.controller_tasks.launch_downloads", +@@ -86,11 +97,11 @@ def start_scrape_book(self, url: str): + queue="controller", + ) + +- log(f"[SCRAPING] Dispatched download controller for '{book_id}'") ++ log(f"[SCRAPING] Dispatched download controller for {book_id}") + + return { + "book_id": book_id, + "title": result.get("title"), + "author": result.get("author"), +- "chapters": len(chapters), ++ "chapters": result["chapters"], # integer + } +diff --git a/bookscraper/templates/index.html b/bookscraper/templates/index.html +index a8a4b76..a751f12 100644 +--- a/bookscraper/templates/index.html ++++ b/bookscraper/templates/index.html +@@ -1,34 +1,28 @@ +- +- +- +- +- BookScraper +- +- +- ++{% extends "base.html" %} {% block content %} + +-

BookScraper WebGUI

++

BookScraper

++ ++
++

Start new scrape

++ ++
++ ++ + +- +-

+- + +-
++ ++
++ ++
++

Library

++

Bekijk alle eerder gescrapete boeken.

++ Open Library ++
+ +- +- ++{% endblock %} +diff --git a/bookscraper/templates/result.html b/bookscraper/templates/result.html +index 57aabf9..379cf6b 100644 +--- a/bookscraper/templates/result.html ++++ b/bookscraper/templates/result.html +@@ -1,239 +1,33 @@ +- +- +- +- +- BookScraper – Resultaat ++{% extends "base.html" %} {% block content %} + +- +- ++
++ Terug ++
+ +- +- ← Terug +-

Scrape Resultaat--

++ {% else %} {% if message %} ++

{{ message }}

++ {% endif %} {% if scraping_task_id %} ++

Task ID: {{ scraping_task_id }}

++ {% endif %} + +- {% if error %} +-
+- Fout: {{ error }} +-
+- {% endif %} {% if message %} +-
{{ message }}
+- {% endif %} ++

++ Je scraper is gestart.
++ Zodra het eerste resultaat beschikbaar is, verschijnt het boek automatisch ++ in de Library. ++

+ +- +- {% if book_title %} +-
+- Cover:
+- Cover +-
+- {% endif %} ++ + +- ++ {% endif %} ++ + +- +- +- +-
+- Live log:
+- +- +- +- +-
+-
+- +- +- +- ++{% endblock %}