● Zion Boggan
repos/Sightline/backend/app/services/parsers/pdf_report.py
zionboggan.com ↗
66 lines · python
History for this file →
1
from __future__ import annotations
2
 
3
import io
4
import re
5
 
6
from app.services.normalize import RawRead
7
from app.services.parsers.base import BaseParser, ParseResult
8
 
9
 
10
class PdfReportParser(BaseParser):
11
    """Extracts ALPR reads from PDF reports. Many ALPR/Flock report exports are tabular text;
12
    pdfminer.six gives us the text layer, from which we pull rows containing a plate-like token
13
    and a timestamp. A camera token and coordinates are captured when present on the same line.
14
 
15
    Defensive by design: a missing dependency or a malformed PDF returns errors, never raises -
16
    so one bad upload can't take down ingest."""
17
    name = "pdf_report"
18
 
19
    _PLATE = re.compile(r"\b([A-Z0-9]{5,8})\b")
20
    _TS = re.compile(r"(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:?\d{2})?)")
21
    _CAM = re.compile(r"\b(CAM[-_][A-Z0-9-]+|[A-Z]{2,}-\d{2,})\b")
22
    _LATLON = re.compile(r"(-?\d{1,3}\.\d{3,}),\s*(-?\d{1,3}\.\d{3,})")
23
    _STOP = {"REPORT", "CAMERA", "PLATE", "VEHICLE", "SYSTEM", "EXPORT", "RESULTS", "DETAIL"}
24
 
25
    def parse(self, data: bytes) -> ParseResult:
26
        try:
27
            from pdfminer.high_level import extract_text
28
        except Exception:
29
            return ParseResult(headers=[], rows=[],
30
                               errors=["pdf parsing requires 'pdfminer.six'"])
31
        try:
32
            text = extract_text(io.BytesIO(data))
33
        except Exception as e:
34
            return ParseResult(headers=[], rows=[], errors=[f"pdf extract failed: {e}"])
35
 
36
        rows: list[RawRead] = []
37
        errors: list[str] = []
38
        for i, line in enumerate(text.splitlines(), start=1):
39
            up = line.upper()
40
            ts = self._TS.search(line)
41
            if not ts:
42
                continue
43
            without_ts = up.replace(ts.group(1).upper(), " ")
44
            cam = self._CAM.search(up)
45
            plate = None
46
            for cand in self._PLATE.finditer(without_ts):
47
                tok = cand.group(1)
48
                if tok in self._STOP or (cam and tok == cam.group(1)):
49
                    continue
50
                if tok.isdigit():
51
                    continue
52
                plate = tok
53
                break
54
            if not plate:
55
                continue
56
            fields = {"plate": plate, "timestamp": ts.group(1),
57
                      "camera": cam.group(1) if cam else "pdf-report"}
58
            ll = self._LATLON.search(line)
59
            if ll:
60
                fields["latitude"], fields["longitude"] = ll.group(1), ll.group(2)
61
            rows.append(RawRead(fields=fields, source_row=i))
62
 
63
        if not rows:
64
            errors.append("no plate+timestamp rows found in PDF text layer")
65
        return ParseResult(headers=["plate", "timestamp", "camera", "latitude", "longitude"],
66
                           rows=rows, errors=errors)