Spaces:
Running
Running
Commit ·
978b74e
1
Parent(s): 8939ab1
feat: added firecrawl anydoc parser
Browse files- app/core/constants.py +19 -2
- app/services/converter_service.py +96 -16
- pyproject.toml +2 -1
- requirements.txt +4 -1
- start.sh +2 -1
app/core/constants.py
CHANGED
|
@@ -5,7 +5,7 @@ SUPPORTED_EXTENSIONS = {
|
|
| 5 |
".xlsx", ".xls", ".csv", ".json", ".xml",
|
| 6 |
".html", ".htm", ".txt", ".md", ".rst",
|
| 7 |
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff",
|
| 8 |
-
".mp3", ".wav",
|
| 9 |
".zip", ".epub",
|
| 10 |
}
|
| 11 |
|
|
@@ -15,9 +15,26 @@ IMAGE_EXTENSIONS = {
|
|
| 15 |
|
| 16 |
IMAGE_MIME_PREFIXES = {"image/"}
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
TABULAR_EXTENSIONS = {".csv", ".xls", ".xlsx"}
|
| 19 |
|
| 20 |
-
AUDIO_EXTENSIONS = {".mp3", ".wav"
|
| 21 |
|
| 22 |
DOCUMENT_EXTENSIONS = {".pdf", ".docx", ".doc", ".epub"}
|
| 23 |
|
|
|
|
| 5 |
".xlsx", ".xls", ".csv", ".json", ".xml",
|
| 6 |
".html", ".htm", ".txt", ".md", ".rst",
|
| 7 |
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff",
|
| 8 |
+
".mp3", ".wav",
|
| 9 |
".zip", ".epub",
|
| 10 |
}
|
| 11 |
|
|
|
|
| 15 |
|
| 16 |
IMAGE_MIME_PREFIXES = {"image/"}
|
| 17 |
|
| 18 |
+
# Formats processed by Firecrawl AnyDoc (local Rust engine).
|
| 19 |
+
# Verified against anydoc.format_from_extension() for firecrawl-anydoc 0.1.8.
|
| 20 |
+
ANYDOC_EXTENSIONS = frozenset({
|
| 21 |
+
".pdf", ".docx", ".doc", ".docm",
|
| 22 |
+
".pptx", ".ppt", ".pptm", ".pps", ".ppsx", ".ppsm", ".pot",
|
| 23 |
+
".xlsx", ".xls", ".xlsm", ".xlsb",
|
| 24 |
+
".odt", ".ods", ".odp", ".rtf", ".epub", ".csv",
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
# Formats AnyDoc cannot parse; these fall back to Microsoft MarkItDown:
|
| 28 |
+
# html/htm, txt/md/rst, json/xml, zip, and audio (mp3/wav only — the
|
| 29 |
+
# MarkItDown AudioConverter accepts .wav/.mp3/.m4a, not .ogg/.flac).
|
| 30 |
+
MARKITDOWN_EXTENSIONS = frozenset({
|
| 31 |
+
".json", ".xml", ".html", ".htm", ".txt", ".md", ".rst", ".zip",
|
| 32 |
+
".mp3", ".wav",
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
TABULAR_EXTENSIONS = {".csv", ".xls", ".xlsx"}
|
| 36 |
|
| 37 |
+
AUDIO_EXTENSIONS = {".mp3", ".wav"}
|
| 38 |
|
| 39 |
DOCUMENT_EXTENSIONS = {".pdf", ".docx", ".doc", ".epub"}
|
| 40 |
|
app/services/converter_service.py
CHANGED
|
@@ -7,9 +7,14 @@ import time
|
|
| 7 |
from pathlib import Path
|
| 8 |
from urllib.parse import urlparse
|
| 9 |
|
|
|
|
| 10 |
from markitdown import MarkItDown
|
| 11 |
|
| 12 |
-
from app.core.constants import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from app.core.logger import get_logger
|
| 14 |
from app.models.domain import ConversionError, ConversionResult
|
| 15 |
from app.services.ocr_service import ocr_image, ocr_pdf
|
|
@@ -21,6 +26,14 @@ def _is_image(ext: str, mime: str) -> bool:
|
|
| 21 |
return ext.lower() in IMAGE_EXTENSIONS or any(mime.startswith(p) for p in IMAGE_MIME_PREFIXES)
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def _build_result(
|
| 25 |
source: str,
|
| 26 |
markdown: str,
|
|
@@ -45,6 +58,19 @@ def _build_result(
|
|
| 45 |
|
| 46 |
|
| 47 |
class ConverterService:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def __init__(self, enable_plugins: bool = False) -> None:
|
| 49 |
kwargs: dict = {"enable_plugins": enable_plugins}
|
| 50 |
self._engine = MarkItDown(**kwargs)
|
|
@@ -66,13 +92,7 @@ class ConverterService:
|
|
| 66 |
mime_type = mime_type or "application/octet-stream"
|
| 67 |
|
| 68 |
try:
|
| 69 |
-
|
| 70 |
-
markdown = ocr_image(str(path))
|
| 71 |
-
else:
|
| 72 |
-
markdown = self._engine.convert(str(path)).text_content
|
| 73 |
-
if not markdown.strip() and path.suffix.lower() == ".pdf":
|
| 74 |
-
_logger.info("No text from PDF, falling back to OCR")
|
| 75 |
-
markdown = ocr_pdf(str(path))
|
| 76 |
elapsed = (time.perf_counter() - start) * 1000
|
| 77 |
return _build_result(str(path), markdown, file_size, mime_type, elapsed)
|
| 78 |
except Exception as exc:
|
|
@@ -101,7 +121,11 @@ class ConverterService:
|
|
| 101 |
if url_ext in IMAGE_EXTENSIONS:
|
| 102 |
markdown = ocr_image(url)
|
| 103 |
mime_type = mimetypes.guess_type(url)[0] or "image/jpeg"
|
|
|
|
|
|
|
|
|
|
| 104 |
else:
|
|
|
|
| 105 |
result = self._engine.convert(url)
|
| 106 |
markdown = result.text_content
|
| 107 |
mime_type = "text/html"
|
|
@@ -123,14 +147,7 @@ class ConverterService:
|
|
| 123 |
ext = Path(filename).suffix.lower()
|
| 124 |
|
| 125 |
try:
|
| 126 |
-
|
| 127 |
-
markdown = ocr_image(data)
|
| 128 |
-
else:
|
| 129 |
-
result = self._engine.convert_stream(io.BytesIO(data), file_extension=ext)
|
| 130 |
-
markdown = result.text_content
|
| 131 |
-
if not markdown.strip() and ext == ".pdf":
|
| 132 |
-
_logger.info("No text from PDF stream, falling back to OCR")
|
| 133 |
-
markdown = ocr_pdf(data)
|
| 134 |
elapsed = (time.perf_counter() - start) * 1000
|
| 135 |
return _build_result(filename, markdown, len(data), mime_type, elapsed)
|
| 136 |
except Exception as exc:
|
|
@@ -141,3 +158,66 @@ class ConverterService:
|
|
| 141 |
message=str(exc),
|
| 142 |
duration_ms=elapsed,
|
| 143 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from urllib.parse import urlparse
|
| 9 |
|
| 10 |
+
import anydoc
|
| 11 |
from markitdown import MarkItDown
|
| 12 |
|
| 13 |
+
from app.core.constants import (
|
| 14 |
+
ANYDOC_EXTENSIONS,
|
| 15 |
+
IMAGE_EXTENSIONS,
|
| 16 |
+
IMAGE_MIME_PREFIXES,
|
| 17 |
+
)
|
| 18 |
from app.core.logger import get_logger
|
| 19 |
from app.models.domain import ConversionError, ConversionResult
|
| 20 |
from app.services.ocr_service import ocr_image, ocr_pdf
|
|
|
|
| 26 |
return ext.lower() in IMAGE_EXTENSIONS or any(mime.startswith(p) for p in IMAGE_MIME_PREFIXES)
|
| 27 |
|
| 28 |
|
| 29 |
+
def _fetch_url(url: str) -> bytes:
|
| 30 |
+
"""Download *url* for engines that only accept local bytes (AnyDoc)."""
|
| 31 |
+
import httpx
|
| 32 |
+
resp = httpx.get(url, follow_redirects=True, timeout=30.0)
|
| 33 |
+
resp.raise_for_status()
|
| 34 |
+
return resp.content
|
| 35 |
+
|
| 36 |
+
|
| 37 |
def _build_result(
|
| 38 |
source: str,
|
| 39 |
markdown: str,
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
class ConverterService:
|
| 61 |
+
"""Converts documents to Markdown.
|
| 62 |
+
|
| 63 |
+
Routing is a static extension map — the extension decides the engine:
|
| 64 |
+
|
| 65 |
+
* ``ANYDOC_EXTENSIONS`` (pdf, doc/docx/docm, ppt/pptx/pptm/pps/ppsx/
|
| 66 |
+
ppsm/pot, xls/xlsx/xlsm/xlsb, odt/ods/odp, rtf, epub, csv)
|
| 67 |
+
-> Firecrawl AnyDoc. Scanned / image-only PDFs that AnyDoc cannot
|
| 68 |
+
read fall back to OCR.
|
| 69 |
+
* ``IMAGE_EXTENSIONS`` -> OCR.
|
| 70 |
+
* ``MARKITDOWN_EXTENSIONS`` (html/htm, txt/md/rst, json/xml, zip,
|
| 71 |
+
mp3/wav/ogg/flac) -> Microsoft MarkItDown.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
def __init__(self, enable_plugins: bool = False) -> None:
|
| 75 |
kwargs: dict = {"enable_plugins": enable_plugins}
|
| 76 |
self._engine = MarkItDown(**kwargs)
|
|
|
|
| 92 |
mime_type = mime_type or "application/octet-stream"
|
| 93 |
|
| 94 |
try:
|
| 95 |
+
markdown = self._dispatch(path.suffix, mime_type, str(path))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
elapsed = (time.perf_counter() - start) * 1000
|
| 97 |
return _build_result(str(path), markdown, file_size, mime_type, elapsed)
|
| 98 |
except Exception as exc:
|
|
|
|
| 121 |
if url_ext in IMAGE_EXTENSIONS:
|
| 122 |
markdown = ocr_image(url)
|
| 123 |
mime_type = mimetypes.guess_type(url)[0] or "image/jpeg"
|
| 124 |
+
elif url_ext in ANYDOC_EXTENSIONS:
|
| 125 |
+
markdown = self._convert_with_anydoc(_fetch_url(url), url_ext, url)
|
| 126 |
+
mime_type = mimetypes.guess_type(url)[0] or "application/octet-stream"
|
| 127 |
else:
|
| 128 |
+
# HTML pages and other MarkItDown formats served over HTTP.
|
| 129 |
result = self._engine.convert(url)
|
| 130 |
markdown = result.text_content
|
| 131 |
mime_type = "text/html"
|
|
|
|
| 147 |
ext = Path(filename).suffix.lower()
|
| 148 |
|
| 149 |
try:
|
| 150 |
+
markdown = self._dispatch(ext, mime_type, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
elapsed = (time.perf_counter() - start) * 1000
|
| 152 |
return _build_result(filename, markdown, len(data), mime_type, elapsed)
|
| 153 |
except Exception as exc:
|
|
|
|
| 158 |
message=str(exc),
|
| 159 |
duration_ms=elapsed,
|
| 160 |
)
|
| 161 |
+
|
| 162 |
+
# ------------------------------------------------------------------
|
| 163 |
+
# Internal routing — the static extension map lives here
|
| 164 |
+
# ------------------------------------------------------------------
|
| 165 |
+
|
| 166 |
+
def _dispatch(self, ext: str, mime_type: str, source) -> str:
|
| 167 |
+
"""Route *source* to OCR, AnyDoc or MarkItDown based on extension.
|
| 168 |
+
|
| 169 |
+
``source`` is a local path (convert_file) or raw bytes
|
| 170 |
+
(convert_stream). No engine is probed for routing decisions — the
|
| 171 |
+
extension fully determines the processor.
|
| 172 |
+
"""
|
| 173 |
+
ext = ext.lower()
|
| 174 |
+
|
| 175 |
+
if _is_image(ext, mime_type):
|
| 176 |
+
return ocr_image(source)
|
| 177 |
+
|
| 178 |
+
if ext in ANYDOC_EXTENSIONS:
|
| 179 |
+
data = source if isinstance(source, bytes) else _read_bytes(source)
|
| 180 |
+
label = f"<{len(data)} bytes>" if isinstance(source, bytes) else str(source)
|
| 181 |
+
return self._convert_with_anydoc(data, ext, label)
|
| 182 |
+
|
| 183 |
+
if isinstance(source, bytes):
|
| 184 |
+
result = self._engine.convert_stream(io.BytesIO(source), file_extension=ext)
|
| 185 |
+
else:
|
| 186 |
+
result = self._engine.convert(str(source))
|
| 187 |
+
return result.text_content
|
| 188 |
+
|
| 189 |
+
def _convert_with_anydoc(self, data: bytes, ext: str, source_label: str) -> str:
|
| 190 |
+
"""Convert bytes with Firecrawl AnyDoc.
|
| 191 |
+
|
| 192 |
+
PDFs get a special path: scanned / image-only PDFs carry no text
|
| 193 |
+
operators, so AnyDoc raises ``anydoc.UnsupportedError`` — that is
|
| 194 |
+
the documented signal to fall back to OCR.
|
| 195 |
+
"""
|
| 196 |
+
if ext == ".pdf":
|
| 197 |
+
try:
|
| 198 |
+
markdown = self._anydoc_to_markdown(data, ext)
|
| 199 |
+
except anydoc.UnsupportedError as exc:
|
| 200 |
+
_logger.info(
|
| 201 |
+
"Scanned PDF rejected by anydoc, falling back to OCR | source=%s | error=%s",
|
| 202 |
+
source_label,
|
| 203 |
+
exc,
|
| 204 |
+
)
|
| 205 |
+
markdown = ""
|
| 206 |
+
if not markdown.strip():
|
| 207 |
+
_logger.info("PDF text empty, falling back to OCR | source=%s", source_label)
|
| 208 |
+
markdown = ocr_pdf(data)
|
| 209 |
+
return markdown
|
| 210 |
+
return self._anydoc_to_markdown(data, ext)
|
| 211 |
+
|
| 212 |
+
@staticmethod
|
| 213 |
+
def _anydoc_to_markdown(data: bytes, ext: str) -> str:
|
| 214 |
+
"""Canonical-format name for the extension, then AnyDoc conversion."""
|
| 215 |
+
fmt = anydoc.format_from_extension(ext)
|
| 216 |
+
if fmt is None:
|
| 217 |
+
raise ValueError(f"Unsupported file extension: {ext}")
|
| 218 |
+
return anydoc.to_markdown_bytes(data, fmt)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _read_bytes(source: str | Path) -> bytes:
|
| 222 |
+
with open(source, "rb") as fh:
|
| 223 |
+
return fh.read()
|
pyproject.toml
CHANGED
|
@@ -11,7 +11,8 @@ requires-python = ">=3.10"
|
|
| 11 |
license = { text = "MIT" }
|
| 12 |
|
| 13 |
dependencies = [
|
| 14 |
-
"markitdown[
|
|
|
|
| 15 |
"fastapi>=0.111",
|
| 16 |
"uvicorn[standard]>=0.30",
|
| 17 |
"pydantic>=2.7",
|
|
|
|
| 11 |
license = { text = "MIT" }
|
| 12 |
|
| 13 |
dependencies = [
|
| 14 |
+
"markitdown[audio-transcription]>=0.1.5",
|
| 15 |
+
"firecrawl-anydoc>=0.1.8",
|
| 16 |
"fastapi>=0.111",
|
| 17 |
"uvicorn[standard]>=0.30",
|
| 18 |
"pydantic>=2.7",
|
requirements.txt
CHANGED
|
@@ -20,7 +20,10 @@ pypdfium2==4.30.0
|
|
| 20 |
# in app/services/ocr_service.py (enables easy rollback).
|
| 21 |
rapidocr-onnxruntime==1.4.4
|
| 22 |
onnxruntime==1.20.1
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
# --- OCR: PaddleOCR PP-OCRv6 Small on ONNX Runtime ---
|
| 26 |
# PaddleOCR 3.7.0 ships the PP-OCRv6 model family (tiny/small/medium). The
|
|
|
|
| 20 |
# in app/services/ocr_service.py (enables easy rollback).
|
| 21 |
rapidocr-onnxruntime==1.4.4
|
| 22 |
onnxruntime==1.20.1
|
| 23 |
+
# MarkItDown handles the formats AnyDoc cannot parse: html/htm, txt/md/rst,
|
| 24 |
+
# json/xml, zip (all in the base install) and audio transcription (extra).
|
| 25 |
+
markitdown[audio-transcription]==0.1.5
|
| 26 |
+
firecrawl-anydoc>=0.1.8
|
| 27 |
|
| 28 |
# --- OCR: PaddleOCR PP-OCRv6 Small on ONNX Runtime ---
|
| 29 |
# PaddleOCR 3.7.0 ships the PP-OCRv6 model family (tiny/small/medium). The
|
start.sh
CHANGED
|
@@ -58,10 +58,11 @@ mkdir -p /app/data /app/logs
|
|
| 58 |
log "Data directories: OK"
|
| 59 |
|
| 60 |
# Check core deps
|
| 61 |
-
python -c "import markitdown, fastapi, httpx, pandas" 2>/dev/null
|
| 62 |
if [ $? -ne 0 ]; then
|
| 63 |
log "ERROR: Missing core dependencies"
|
| 64 |
python -c "import markitdown" 2>&1 | log
|
|
|
|
| 65 |
python -c "import fastapi" 2>&1 | log
|
| 66 |
python -c "import httpx" 2>&1 | log
|
| 67 |
python -c "import pandas" 2>&1 | log
|
|
|
|
| 58 |
log "Data directories: OK"
|
| 59 |
|
| 60 |
# Check core deps
|
| 61 |
+
python -c "import markitdown, anydoc, fastapi, httpx, pandas" 2>/dev/null
|
| 62 |
if [ $? -ne 0 ]; then
|
| 63 |
log "ERROR: Missing core dependencies"
|
| 64 |
python -c "import markitdown" 2>&1 | log
|
| 65 |
+
python -c "import anydoc" 2>&1 | log
|
| 66 |
python -c "import fastapi" 2>&1 | log
|
| 67 |
python -c "import httpx" 2>&1 | log
|
| 68 |
python -c "import pandas" 2>&1 | log
|