This commit is contained in:
2026-06-15 01:06:05 +07:00
commit 6e42f05b55
15 changed files with 3410 additions and 0 deletions
+534
View File
@@ -0,0 +1,534 @@
# controllers/scraperZLibraryController.py
import json
import pathlib
import queue
import re
import tempfile
import threading
import time
import unicodedata
import uuid
from datetime import datetime
from typing import Dict, List, Optional
from urllib.parse import quote
from flask import Response, jsonify, render_template, request
from playwright.sync_api import sync_playwright
from models.scraperZLibraryModel import ZLibraryScraperModel
# ---------------------------------------------------------------------------
# Temp file store — filesystem-backed so ALL gunicorn workers can access it.
#
# WHY: Python dicts are per-process. With multiple gunicorn workers the SSE
# stream (worker A) stores a token in memory, but the follow-up GET request
# for that token may be routed to worker B whose dict is empty → 404.
#
# Using a shared temp directory on disk solves this without Redis or any
# external dependency. Each token is two files in _STORE_DIR:
# <token>.bin — raw file bytes
# <token>.meta — JSON: {filename, size, expires}
# ---------------------------------------------------------------------------
_STORE_DIR = pathlib.Path(tempfile.gettempdir()) / 'zlibrary_file_store'
_STORE_DIR.mkdir(exist_ok=True)
_TTL = 300 # seconds a token stays valid (5 min)
def _store_file(filename: str, content: bytes) -> str:
"""Write file bytes + metadata to disk; return a one-time token."""
token = uuid.uuid4().hex
meta = json.dumps({
'filename': filename,
'size': len(content),
'expires': time.time() + _TTL,
})
(_STORE_DIR / f'{token}.meta').write_text(meta, encoding='utf-8')
(_STORE_DIR / f'{token}.bin').write_bytes(content)
return token
def _pop_file(token: str) -> Optional[Dict]:
"""
Read and delete a stored file by token.
Returns None if the token does not exist or has expired.
Token is one-time-use: files are removed whether expired or not.
"""
meta_path = _STORE_DIR / f'{token}.meta'
bin_path = _STORE_DIR / f'{token}.bin'
if not meta_path.exists() or not bin_path.exists():
return None
try:
meta = json.loads(meta_path.read_text(encoding='utf-8'))
content = bin_path.read_bytes()
except Exception:
return None
finally:
meta_path.unlink(missing_ok=True)
bin_path.unlink(missing_ok=True)
if time.time() > meta['expires']:
return None
return {
'filename': meta['filename'],
'size': meta['size'],
'content': content,
}
def _evict_expired():
"""Delete any token files whose TTL has passed."""
now = time.time()
for meta_path in _STORE_DIR.glob('*.meta'):
try:
meta = json.loads(meta_path.read_text(encoding='utf-8'))
if now > meta['expires']:
meta_path.unlink(missing_ok=True)
(_STORE_DIR / meta_path.name.replace('.meta', '.bin')).unlink(missing_ok=True)
except Exception:
pass
def _content_disposition(filename: str) -> str:
"""
Build a Content-Disposition header safe for all HTTP clients.
Provides both:
- legacy filename= with non-ASCII stripped (latin-1 safe)
- RFC 5987 filename*= with full UTF-8 percent-encoding
Example:
attachment; filename="Lap_trinh_Python.pdf"; filename*=UTF-8''L%E1%BA%ADp%20tr%C3%ACnh%20Python.pdf
"""
ascii_name = filename.encode('ascii', 'ignore').decode('ascii')
ascii_name = ascii_name.replace('"', '_').replace('\\', '_') or 'download'
encoded_name = quote(filename, safe='.-_~')
return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{encoded_name}"
def _mimetype_for(filename: str) -> str:
"""Return a sensible Content-Type for common ebook/document extensions."""
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
return {
'pdf': 'application/pdf',
'epub': 'application/epub+zip',
'mobi': 'application/x-mobipocket-ebook',
'azw': 'application/vnd.amazon.ebook',
'azw3': 'application/vnd.amazon.ebook',
'fb2': 'application/x-fictionbook+xml',
'djvu': 'image/vnd.djvu',
'txt': 'text/plain',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}.get(ext, 'application/octet-stream')
class ScraperController:
"""
Controller owns all business logic:
- orchestrating login + scraping + download flows
- streaming individual files directly to the client via SSE + token fetch
- computing statistics
- formatting responses
The model is only called for browser/scraping primitives.
"""
_model = ZLibraryScraperModel()
# ------------------------------------------------------------------
# Routes
# ------------------------------------------------------------------
@classmethod
def index(cls):
return render_template('scraperZLibraryPages/scraperZLibraryMain.html')
@classmethod
def search_books(cls):
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Invalid request data'}), 400
query = data.get('query', '').strip()
page_num = data.get('page', 1)
headless = data.get('headless', True)
if not query:
return jsonify({'success': False, 'error': 'Query is required'}), 400
if len(query) < 2:
return jsonify({'success': False, 'error': 'Query must be at least 2 characters'}), 400
try:
books, total_pages, total_books = cls._run_search(query, page_num, headless)
statistics = cls._calculate_statistics(books)
return jsonify({
'success': True,
'query': query,
'books': [b.to_dict() for b in books],
'statistics': statistics,
'total_count': len(books),
'current_page': page_num,
'total_pages': total_pages,
'total_books_count': total_books,
})
except Exception as e:
import traceback; traceback.print_exc()
return jsonify({'success': False, 'error': str(e), 'books': [], 'statistics': {}}), 500
@classmethod
def download_txt(cls):
data = request.get_json()
if not data:
return jsonify({'error': 'No results provided'}), 400
text_content = cls._format_results_as_text(data)
return Response(
text_content,
mimetype='text/plain',
headers={
'Content-Disposition': (
f'attachment;filename=zlib_{data.get("query", "search")}_results.txt'
)
},
)
@classmethod
def download_json(cls):
data = request.get_json()
if not data:
return jsonify({'error': 'No results provided'}), 400
return Response(
json.dumps(data, indent=2, ensure_ascii=False),
mimetype='application/json',
headers={
'Content-Disposition': (
f'attachment;filename=zlib_{data.get("query", "search")}_results.json'
)
},
)
@classmethod
def download_books_stream(cls):
"""
SSE endpoint. Opens one browser session, iterates through the requested
books, and for each book:
1. Downloads the file in the background thread.
2. Parks the bytes on disk under a one-time token (shared across all
gunicorn workers via the filesystem).
3. Emits a 'ready' SSE event with the token.
4. The frontend immediately triggers GET /api/download/file/<token>
which streams the file to the user with its native MIME type.
Events emitted (newline-delimited JSON after 'data: '):
{"type": "progress", "index": i, "total": n, "title": "..."}
{"type": "ready", "index": i, "total": n, "title": "...",
"token": "<hex>", "filename": "book.pdf"}
{"type": "error", "index": i, "total": n, "title": "...", "message": "..."}
{"type": "done", "total": n, "success": k, "failed": m}
A heartbeat comment (': heartbeat') is sent every 5 seconds while the
worker is busy but has not yet produced an event. This keeps Cloudflare
Tunnel (and any other idle-connection-killing proxy) from closing the
stream before the backend finishes downloading a book.
SSE comments are valid per spec and are silently ignored by all
EventSource / fetch-stream clients.
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
books = data.get('books', [])
headless = data.get('headless', True)
if not books:
return jsonify({'error': 'No books provided'}), 400
q: queue.Queue = queue.Queue()
def _worker():
with sync_playwright() as p:
context = cls._model.create_context(p, headless)
try:
page, ok = cls._model.login(context)
if not ok:
print("⚠ Proceeding without confirmed login — downloads may fail")
success = failed = 0
for idx, book_data in enumerate(books, 1):
title = book_data.get('title', f'Book {idx}')
q.put({'type': 'progress', 'index': idx,
'total': len(books), 'title': title})
book_link = book_data.get('link', 'N/A')
if not book_link or book_link == 'N/A':
failed += 1
q.put({'type': 'error', 'index': idx, 'total': len(books),
'title': title, 'message': 'No link available'})
continue
try:
print(f"\n[{idx}/{len(books)}] Navigating to: {book_link}")
page.goto(book_link, timeout=30000)
page.wait_for_load_state('networkidle')
download_url, _ = cls._model.extract_download_info(page)
if download_url == 'N/A':
raise ValueError('Could not find download URL')
content = cls._model.download_file(page, context, download_url)
if not content:
raise ValueError('No file content received')
filename = cls._build_filename(book_data, idx)
token = _store_file(filename, content)
success += 1
print(f" ✅ Ready: {filename} ({len(content):,} bytes) — token: {token}")
q.put({'type': 'ready', 'index': idx, 'total': len(books),
'title': title, 'token': token, 'filename': filename})
except Exception as e:
failed += 1
print(f" ❌ Error: {e}")
q.put({'type': 'error', 'index': idx, 'total': len(books),
'title': title, 'message': str(e)})
q.put({'type': 'done', 'total': len(books),
'success': success, 'failed': failed})
finally:
context.close()
q.put(None) # sentinel — tells generator to stop
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
def _generate():
"""
Yield SSE data frames from the worker queue.
Heartbeat strategy
------------------
q.get(timeout=5) blocks for up to 5 seconds waiting for the next
event. If nothing arrives within that window we emit an SSE comment
line (': heartbeat\\n\\n'). SSE comments are defined in the spec
(lines beginning with ':') and are completely ignored by all
compliant clients, but they DO transmit bytes over the wire.
Transmitting bytes resets the idle timer on Cloudflare Tunnels,
nginx proxy_read_timeout, and similar infrastructure that would
otherwise kill a connection that appears silent.
"""
while True:
try:
event = q.get(timeout=5)
except queue.Empty:
# No event yet — send a heartbeat to keep the tunnel alive
yield ': heartbeat\n\n'
continue
if event is None:
# Sentinel: worker finished
break
yield f'data: {json.dumps(event, ensure_ascii=False)}\n\n'
_evict_expired()
return Response(
_generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', # disable nginx buffering if present
},
)
@classmethod
def fetch_stored_file(cls, token: str):
"""
GET endpoint. Pops a previously stored file by token and streams it
to the client with the correct MIME type. One-time use — token is
consumed on first access.
"""
entry = _pop_file(token)
if not entry:
return jsonify({'error': 'File not found or expired'}), 404
mimetype = _mimetype_for(entry['filename'])
print(f"📤 Serving: {entry['filename']} ({entry['size']:,} bytes)")
return Response(
entry['content'],
mimetype=mimetype,
headers={
'Content-Disposition': _content_disposition(entry['filename']),
'Content-Length': str(entry['size']),
},
)
# Keep old route names as aliases so any existing calls still work
@classmethod
def download_books(cls):
return cls.download_books_stream()
download_books_zip = download_books
# ------------------------------------------------------------------
# Business logic — search
# ------------------------------------------------------------------
@classmethod
def _run_search(cls, query: str, page_num: int, headless: bool):
"""Open browser, login, scrape one results page, return raw data."""
encoded = query.replace(' ', '%20')
url = f"{cls._model.BASE_URL}/s/{encoded}?view=table"
if page_num > 1:
url += f"&page={page_num}"
with sync_playwright() as p:
context = cls._model.create_context(p, headless)
try:
page, ok = cls._model.login(context)
if not ok:
print("⚠ Proceeding without confirmed login")
print(f"Accessing: {url}")
page.goto(url, timeout=60000)
page.wait_for_load_state("networkidle")
total_pages = cls._model.get_total_pages(page)
total_books = cls._model.get_total_books_count(page)
print(f"Total pages available: {total_pages}")
print(f"Total books found: {total_books}")
try:
page.wait_for_selector('table.table_book tbody tr', timeout=10000)
except Exception:
print(f"No table found on page {page_num}")
return [], total_pages, total_books
books = cls._model.extract_books_from_table(page)
print(f"Found {len(books)} books on page {page_num}")
return books, total_pages, total_books
except Exception as e:
import traceback; traceback.print_exc()
raise
finally:
context.close()
# ------------------------------------------------------------------
# Business logic — helpers
# ------------------------------------------------------------------
@staticmethod
def _build_filename(book_data: Dict, idx: int) -> str:
"""Derive a safe ASCII filename from book metadata."""
raw_title = book_data.get('title', '')[:50]
# Normalize unicode → closest ASCII, then drop anything remaining non-ASCII
ascii_title = unicodedata.normalize('NFKD', raw_title)
ascii_title = ascii_title.encode('ascii', 'ignore').decode('ascii')
safe_title = re.sub(r'[^\w\s-]', '', ascii_title)
safe_title = re.sub(r'[-\s]+', '_', safe_title).strip('_') or f"book_{idx}"
extension = book_data.get('file', 'txt').split(',')[0].strip().lower()
if not extension or extension == 'n/a':
extension = 'txt'
return f"{safe_title}.{extension}"
@staticmethod
def _calculate_statistics(books) -> Dict:
"""Compute language/year/format distribution from a list of Book objects."""
if not books:
return {}
stats: Dict = {
'total_books': len(books),
'languages': {},
'years': {},
'formats': {},
}
for book in books:
lang = book.language
stats['languages'][lang] = stats['languages'].get(lang, 0) + 1
year = book.year
if year != 'N/A' and year.isdigit():
stats['years'][year] = stats['years'].get(year, 0) + 1
if book.file != 'N/A':
m = re.match(r'([a-zA-Z0-9]+)', book.file)
if m:
fmt = m.group(1).upper()
stats['formats'][fmt] = stats['formats'].get(fmt, 0) + 1
return stats
@classmethod
def _format_results_as_text(cls, results: Dict) -> str:
if not results.get('success'):
return f"Error: {results.get('error', 'Unknown error')}"
lines = [
"=" * 80,
"Z-Library Search Results",
"=" * 80,
f"Search Query: {results['query']}",
f"Total Books Found: {results.get('total_books_count', results['total_count'])}",
f"Books in this export: {results['total_count']}",
f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"=" * 80,
"",
]
for idx, book in enumerate(results['books'], 1):
authors_str = ', '.join(book.get('authors', [])) or 'N/A'
lines += [
f"Book #{idx}",
"-" * 60,
f"Title: {book['title']}",
f"Author(s): {authors_str}",
f"Publisher: {book.get('publisher', 'N/A')}",
f"Year: {book.get('year', 'N/A')}",
f"Pages: {book.get('pages', 'N/A')}",
f"Language: {book.get('language', 'N/A')}",
f"File: {book.get('file', 'N/A')}",
f"Link: {book.get('link', 'N/A')}",
]
if book.get('download_url') and book['download_url'] != 'N/A':
lines.append(f"Download: {book['download_url']}")
if book.get('file_size') and book['file_size'] != 'N/A':
lines.append(f"Size: {book['file_size']}")
lines += ["=" * 80, ""]
stats = results.get('statistics', {})
if stats:
total = results['total_count']
lines += ["", "=" * 80, "SUMMARY STATISTICS", "=" * 80,
f"Total Books: {stats.get('total_books', 0)}"]
if stats.get('languages'):
lines.append("\nLanguages:")
for lang, count in sorted(stats['languages'].items(), key=lambda x: -x[1]):
pct = count / total * 100 if total else 0
lines.append(f" {lang}: {count} ({pct:.1f}%)")
if stats.get('formats'):
lines.append("\nFormats:")
for fmt, count in sorted(stats['formats'].items(), key=lambda x: -x[1]):
pct = count / total * 100 if total else 0
lines.append(f" {fmt}: {count} ({pct:.1f}%)")
if stats.get('years'):
lines.append("\nPublication Years (Top 10):")
for year, count in sorted(stats['years'].items(), key=lambda x: -x[1])[:10]:
lines.append(f" {year}: {count}")
return "\n".join(lines)
+111
View File
@@ -0,0 +1,111 @@
# controllers/transcriptionController.py
import os
from werkzeug.utils import secure_filename
from flask import request, jsonify, send_file, render_template
from models.transcriptionModel import TranscriptionJob
from config import Config
class TranscriptionController:
"""Controller for handling transcription requests"""
@staticmethod
def index():
"""Render the main page"""
return render_template('transcriptionPages/transcriptionMain.html')
@staticmethod
def upload_chunk():
"""Handle chunk upload"""
if 'chunk' not in request.files:
return jsonify({'error': 'No chunk part'}), 400
chunk_file = request.files['chunk']
job_id = request.form.get('job_id')
chunk_index = int(request.form.get('chunk_index', 0))
total_chunks = int(request.form.get('total_chunks', 1))
filename = request.form.get('filename')
language = request.form.get('language', 'en')
model = request.form.get('model', 'medium')
if not job_id:
# First chunk - create new job
job = TranscriptionJob(filename, None, language, model)
job_id = job.id
job.total_chunks = total_chunks
else:
job = TranscriptionJob.get_job(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
# Save chunk
job_chunks_folder = os.path.join(Config.CHUNKS_FOLDER, job_id)
os.makedirs(job_chunks_folder, exist_ok=True)
chunk_path = os.path.join(job_chunks_folder, f"chunk_{chunk_index}.mp3")
chunk_file.save(chunk_path)
# Update job progress
job.received_chunks = job.received_chunks + 1 if hasattr(job, 'received_chunks') else 1
# If all chunks received, start processing
if job.received_chunks == total_chunks:
job.start_processing()
return jsonify({
'job_id': job_id,
'chunk_index': chunk_index,
'received': job.received_chunks,
'total': total_chunks,
'status': 'processing' if job.received_chunks == total_chunks else 'uploading'
})
@staticmethod
def status(job_id):
"""Get job status"""
job = TranscriptionJob.get_job(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
return jsonify(job.to_dict())
@staticmethod
def download_transcript(job_id):
"""Download transcript file"""
job = TranscriptionJob.get_job(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
if job.status != 'completed':
return jsonify({'error': 'Transcription not ready'}), 400
return send_file(
job.transcript_path,
as_attachment=True,
download_name=f"transcript_{job.filename}.txt"
)
@staticmethod
def view_transcript(job_id):
"""View transcript in browser"""
job = TranscriptionJob.get_job(job_id)
if not job:
return "Job not found", 404
if job.status != 'completed':
return "Transcription not ready yet", 400
content = job.get_transcript_content()
if not content:
return "Transcript not found", 404
return render_template('transcriptionPages/transcriptionReview.html',
view='transcriptionPages',
content=content,
filename=job.filename,
job_id=job_id)
@staticmethod
def list_jobs():
"""List all jobs"""
jobs = TranscriptionJob.get_all_jobs()
return jsonify([job.to_dict() for job in jobs])