validops-east-1 commited on
Commit
978b74e
·
1 Parent(s): 8939ab1

feat: added firecrawl anydoc parser

Browse files
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", ".ogg", ".flac",
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", ".ogg", ".flac"}
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 IMAGE_EXTENSIONS, IMAGE_MIME_PREFIXES
 
 
 
 
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
- if _is_image(path.suffix, mime_type):
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
- if _is_image(ext, mime_type):
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[all]>=0.1.5",
 
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
- markitdown[all]==0.1.5
 
 
 
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