metawarc — кастомные экстракторы и Plugin API
metawarc v2.x поставляется с набором экстракторов «из коробки»: PDF, OOXML (DOCX/XLSX/PPTX), изображения, видео, аудио, шрифты, ссылки, plain-text. Для собственных форматов (например, региональных CAD-форматов или научных датасетов) можно реализовать свой экстрактор через Plugin API.
Когда это нужно: вы работаете с «длинным хвостом» форматов или специфичной отрасли (кадастровые XML, медицинские DICOM с метаданными, CAD/DWG, особые научные журналы).
Архитектура Extractor API
metawarc/
├── extractor/
│ ├── registry.py # Реестр экстракторов
│ ├── pdf.py # PdfExtractor (встроенный)
│ ├── text_pdf.py # PdfTextExtractor
│ ├── text.py # TextExtractor (HTML→plain text)
│ ├── office.py # OoxmlExtractor (DOCX/XLSX/PPTX)
│ ├── text_ooxml.py # OoxmlTextExtractor
│ ├── links.py # LinkExtractor
│ ├── media.py # MediaMetadataExtractor
│ └── record.py # RecordMetadataExtractor
Все встроенные экстракторы наследуют общий базовый класс с интерфейсом extract(record) → dict.
Базовый класс ExtractorBase
# metawarc/extractor/base.py (упрощённо)
from abc import ABC, abstractmethod
from typing import Any, Optional
class ExtractorBase(ABC):
"""Базовый класс для всех экстракторов metawarc."""
#: Какие MIME-типы обрабатывает
handles_mimes: list[str] = []
#: Какие расширения файлов обрабатывает (для fallback)
handles_exts: list[str] = []
@abstractmethod
def extract(self, record: WarcRecord, record_id: str) -> dict[str, Any]:
"""Вернуть dict c извлечёнными метаданными. Ключи — стабильные."""
raise NotImplementedError
@classmethod
def can_handle(cls, record: WarcRecord) -> bool:
"""Этот экстрактор умеет работать с такой записью?"""
ct = record.http_headers.get_header("Content-Type", "").lower()
return any(mime in ct for mime in cls.handles_mimes)
# Реестр всех встроенных экстракторов
from metawarc.extractor.registry import ExtractorRegistry
ExtractorRegistry.register(PdfExtractor)
ExtractorRegistry.register(OoxmlExtractor)
# ... и др.
Написание своего экстрактора
Пример: экстрактор для FB2 (FictionBook 2)
# myplugin/fb2_extractor.py
from metawarc.extractor.base import ExtractorBase
from typing import Any
import xml.etree.ElementTree as ET
from dataclasses import dataclass
@dataclass
class FB2Metadata:
title: str
author: str
genre: str
lang: str
words: int | None
def to_dict(self) -> dict[str, Any]:
return {
"title": self.title,
"author": self.author,
"genre": self.genre,
"language": self.lang,
"word_count": self.words,
}
class FB2Extractor(ExtractorBase):
handles_mimes = ["application/x-fictionbook+xml", "application/fb2"]
handles_exts = [".fb2"]
def extract(self, record, record_id: str) -> dict[str, Any]:
payload = record.content_stream().read()
try:
root = ET.fromstring(payload)
except ET.ParseError:
return {"error": "invalid-fb2"}
# FB2 namespace
ns = {"fb": "http://www.gribuser.ru/xml/fictionbook/2.0"}
title = root.findtext(".//fb:book-title", namespaces=ns) or ""
author = root.findtext(".//fb:author/fb:first-name", namespaces=ns) or ""
last_name = root.findtext(".//fb:author/fb:last-name", namespaces=ns) or ""
genre = root.findtext(".//fb:genre", namespaces=ns) or ""
lang = root.findtext(".//fb:lang", namespaces=ns) or ""
body_text = "".join(root.itertext())
return {
"kind": "fb2",
"metadata": FB2Metadata(
title=title,
author=f"{author} {last_name}".strip(),
genre=genre,
lang=lang,
words=len(body_text.split()),
).to_dict(),
}