| 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) |