diff --git a/.env.example b/.env.example index cd00cbd..8710a69 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ -APP_NAME="" -APP_VERSION="" +APP_NAME="OCR PDF" +APP_VERSION="0.0.1" +APP_ORIGINS=http://127.0.0.1:8001 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 23aed61..bea5e6d 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -28,7 +28,9 @@ jobs: - name: Smoke test container run: | - docker run --rm -d --name test_app -p 8000:8000 myapp:test + docker run -d --name test_app -p 8000:8000 myapp:test sleep 10 - docker ps | grep test_app + docker ps -a docker logs test_app + curl --fail http://localhost:8000/docs + docker rm -f test_app diff --git a/Dockerfile b/Dockerfile index c53708a..4f51ebb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,11 @@ -# ========== Base ========== FROM python:3.11-slim AS base -# Evita prompts e melhora logs +WORKDIR /app + ENV DEBIAN_FRONTEND=noninteractive \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -# Dependências do sistema para o ocrmypdf e Tesseract -# (inclui ghostscript, qpdf e idiomas pt/en) RUN apt-get update && \ apt-get install -y --no-install-recommends \ ocrmypdf \ @@ -17,30 +15,24 @@ RUN apt-get update && \ ghostscript \ qpdf \ # utilitários úteis - curl ca-certificates && \ - rm -rf /var/lib/apt/lists/* + curl ca-certificates \ + redis-tools \ + && rm -rf /var/lib/apt/lists/* -# ========== App ========== -WORKDIR /app -# Copia requirements primeiro para aproveitar cache de camadas COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -r /app/requirements.txt -# Copia o código da API (ajuste se tiver mais arquivos) COPY app.py /app/app.py +COPY worker.py /app/worker.py +COPY tasks.py /app/tasks.py COPY .env /app/.env -# Exponha a porta do Uvicorn EXPOSE 8000 -# Opcional: usuário sem privilégios -RUN useradd -m appuser -USER appuser - -# Healthcheck simples (tente GET /health se você adicionou esse endpoint) -# HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD curl -fsS http://localhost:8000/health || exit 1 +# usuário sem privilégios +# RUN useradd -m appuser +# USER appuser -# Comando padrão (ajuste host/port conforme seu ambiente) CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app.py b/app.py index 69a720b..149d3bc 100644 --- a/app.py +++ b/app.py @@ -1,119 +1,36 @@ +import uuid import shutil -import subprocess -import tempfile -import time from pathlib import Path -from typing import Literal - from fastapi import FastAPI, UploadFile, File, Form, HTTPException -from fastapi.responses import JSONResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware -from pypdf import PdfReader from dotenv import load_dotenv import os +from redis import Redis +from rq import Queue +from fastapi.responses import FileResponse +from tasks import process_ocr_job + +load_dotenv() -load_dotenv() # Lê as variáveis do .env +origins_env = os.getenv("APP_ORIGINS", "") +allowed_origins = [o.strip() for o in origins_env.split(",") if o.strip()] +app = FastAPI(title=os.getenv("APP_NAME"), version=os.getenv("APP_VERSION")) -app = FastAPI(title="OCR API", version="1.2.0") app.add_middleware( CORSMiddleware, - allow_origins=["http://127.0.0.1:8001",], # ajuste + allow_origins=[allowed_origins], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +# Conexão Redis e fila +redis_conn = Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379")) +q = Queue("ocr", connection=redis_conn) -def extract_text_with_pypdf(pdf_path: Path) -> str: - reader = PdfReader(str(pdf_path)) - texts = [] - for page in reader.pages: - try: - t = page.extract_text() or "" - except Exception: - t = "" - texts.append(t.strip()) - return "\n\n".join(texts) - - -def run_ocrmypdf(src: Path, dst: Path, lang: str = "por+eng") -> None: - cmd = [ - "ocrmypdf", - "--language", lang, - "--deskew", - "--optimize", "1", - "--force-ocr", - str(src), - str(dst), - ] - completed = subprocess.run(cmd, capture_output=True, text=True) - if completed.returncode != 0: - raise RuntimeError( - f"OCR falhou (code {completed.returncode}). " - f"stderr: {completed.stderr.strip() or 'sem stderr'}" - ) - - -@app.post("/ocr") -async def ocr_endpoint( - file: UploadFile = File(..., description="PDF para OCR"), - lang: str = Form("por+eng"), - response_type: Literal["json", "pdf"] = Form("pdf"), -): - ct = (file.content_type or "").lower() - if ct not in {"application/pdf", "application/x-pdf", "application/octet-stream"}: - raise HTTPException(status_code=400, detail=f"Tipo de arquivo não suportado: {ct or 'desconhecido'}") - - try: - start_time = time.perf_counter() - - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir = Path(tmpdir) - - # Nome do arquivo de saída - original_name = file.filename or "arquivo.pdf" - original_stem = Path(original_name).stem or "arquivo" - download_name = f"{original_stem}_ocr.pdf" - - in_path = tmpdir / "entrada.pdf" - out_pdf = tmpdir / download_name - - # Salva upload no disco - with in_path.open("wb") as f: - shutil.copyfileobj(file.file, f) - - # Executa OCR - run_ocrmypdf(in_path, out_pdf, lang=lang) - - elapsed = round(time.perf_counter() - start_time, 2) # segundos - - if response_type == "json": - text_ocr = extract_text_with_pypdf(out_pdf) - pages = len(PdfReader(str(out_pdf)).pages) - return JSONResponse( - { - "filename_in": original_name, - "filename_out": download_name, - "lang": lang, - "pages": pages, - "elapsed_seconds": elapsed, - "text": text_ocr, - } - ) - - # PDF como resposta - headers = { - "Content-Disposition": f'attachment; filename="{download_name}"', - "X-Original-Filename": original_name, - "X-Processing-Time": f"{elapsed}s", - } - return StreamingResponse(out_pdf.open("rb"), media_type="application/pdf", headers=headers) - - except RuntimeError as e: - raise HTTPException(status_code=500, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Erro inesperado: {e}") +UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "./uploads")) +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) @app.get("/") @@ -122,3 +39,56 @@ async def index(): "status": "success", "message": f"{os.getenv('APP_NAME')} - v{os.getenv('APP_VERSION')}" } + + +@app.post("/ocr") +async def enqueue_ocr( + file: UploadFile = File(...), + lang: str = Form("por+eng"), + response_type: str = Form("pdf") +): + # Salva o upload em disco para o worker ler + tmp_path = UPLOAD_DIR / f"{uuid.uuid4()}_{file.filename}" + with tmp_path.open("wb") as f: + shutil.copyfileobj(file.file, f) + + job = q.enqueue( + process_ocr_job, + str(tmp_path), + lang, + response_type, + job_timeout=3600 + ) + return {"task_id": job.get_id(), "status": "queued"} + + +@app.get("/status/{task_id}") +async def job_status(task_id: str): + from rq.job import Job + try: + job = Job.fetch(task_id, connection=redis_conn) + except Exception: + raise HTTPException(status_code=404, detail="Job não encontrado") + + if job.is_queued: + return {"status": "queued"} + if job.is_started: + return {"status": "processing"} + if job.is_finished: + result = job.result + download_url = f"/download/{result['filename_out']}" + return {"status": "done", "result": result, "download_url": download_url} + if job.is_failed: + return {"status": "failed", "error": str(job.exc_info)} + + +@app.get("/download/{filename}") +async def download_file(filename: str): + file_path = UPLOAD_DIR / filename + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Arquivo não encontrado") + return FileResponse( + file_path, + media_type="application/pdf", + filename=filename + ) diff --git a/requirements.txt b/requirements.txt index 6765bd6..14b3619 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ python-dotenv uvicorn fastapi python-multipart +rq +redis diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..37444f1 --- /dev/null +++ b/tasks.py @@ -0,0 +1,74 @@ +import os +import time +import subprocess +import tempfile +from pathlib import Path +from pypdf import PdfReader + + +def extract_text_with_pypdf(pdf_path: Path) -> str: + reader = PdfReader(str(pdf_path)) + texts = [] + for page in reader.pages: + try: + t = page.extract_text() or "" + except Exception: + t = "" + texts.append(t.strip()) + return "\n\n".join(texts) + + +def run_ocrmypdf(src: Path, dst: Path, lang: str = "por+eng") -> None: + cmd = [ + "ocrmypdf", + "--language", lang, + "--deskew", + "--optimize", "1", + "--force-ocr", + str(src), + str(dst), + ] + completed = subprocess.run(cmd, capture_output=True, text=True) + if completed.returncode != 0: + raise RuntimeError( + f"OCR falhou (code {completed.returncode}). " + f"stderr: {completed.stderr.strip() or 'sem stderr'}" + ) + + +def process_ocr_job(file_path: str, lang: str, response_type: str): + """ + Função executada pelo worker RQ. + Recebe o caminho do arquivo salvo pelo endpoint. + """ + start = time.perf_counter() + + shared_dir = Path(os.getenv("UPLOAD_DIR", "/shared_uploads")) + shared_dir.mkdir(parents=True, exist_ok=True) + + original_path = Path(file_path) + download_name = f"{original_path.stem}_ocr.pdf" + out_pdf = shared_dir / download_name # <- grava no volume compartilhado + + run_ocrmypdf(original_path, out_pdf, lang=lang) + + elapsed = round(time.perf_counter() - start, 2) + + if response_type == "json": + text_ocr = extract_text_with_pypdf(out_pdf) + pages = len(PdfReader(str(out_pdf)).pages) + return { + "status": "done", + "filename_in": original_path.name, + "filename_out": download_name, # apenas o nome do arquivo + "elapsed_seconds": elapsed, + "pages": pages, + "text": text_ocr, + } + else: + return { + "status": "done", + "filename_in": original_path.name, + "filename_out": download_name, + "elapsed_seconds": elapsed, + } diff --git a/worker.py b/worker.py new file mode 100644 index 0000000..27fab92 --- /dev/null +++ b/worker.py @@ -0,0 +1,13 @@ +import os +from redis import Redis +from rq import Worker, Queue + +listen = ["ocr"] +redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") +conn = Redis.from_url(redis_url) + +if __name__ == "__main__": + # Cria as filas e inicia o worker usando a conexão direta + queues = [Queue(name, connection=conn) for name in listen] + worker = Worker(queues, connection=conn) + worker.work()