OCR-generated text is not clean data — it is a stream of character mutations, phantom tokens, and structural noise. Unstructured receipts, POS exports, and scraped product feeds all arrive with the same problem: O where 0 belongs, garbage rows mixed in with real records, and no way to tell how many records were silently dropped.
This engine solves that in three discrete layers, available as a CLI, a Python library, and an HTTP API:
-
Extraction — Deterministic regex pipeline that ingests raw text files, applies pattern matching, corrects OCR character confusions, and emits structured JSONL. Designed for large files: constant memory footprint, real-time disk/memory monitoring, and a full audit log of every record dropped.
-
Sanitizer — A composable
DataSanitizerclass for line-level OCR cleaning. Zero-trust input model: every line is assumed corrupt until proven structured. ReturnsAPPROVED/PARTIAL/REJECTEDstatus per record so downstream consumers know exactly what they received. Locale-aware amount normalization handles European (1.234,50) and US (1,234.50) formats. -
Recon (Seer V4) — HTTP probe that identifies the tech stack of a target URL list (Next.js, React, VTEX, Squarespace, Static HTML, JSON API). Classifies every failure by type and automatically activates the correct fallback module, with exponential backoff on rate limits and a per-host circuit breaker. Sends notifications to Slack and Discord after each run.
A Tier 2 API service (dih-engine[api]) exposes the sanitizer and extraction engine over HTTP with fail-closed API-key auth — see the API Service section.
Input (raw .txt / OCR stream)
|
v
+-----------------------+
| DataSanitizer | <- line-level noise filter + OCR correction
| sanitizer/core.py |
+-----------+-----------+
| clean lines
v
+-----------------------+
| Extraction Engine | <- regex match -> structured record
| extraction/ | <- audit log: total / matched / skipped
+-----------+-----------+
| structured records
v
output.jsonl | output.csv | output.db
URL List (CSV)
|
v
+-----------------------+
| Seer V4 Recon | <- HTTP probe -> error classification
| recon/seer.py | <- error taxonomy -> fallback routing
+-----------+-----------+
|
[requests_probe] -- on failure --> [error_taxonomy]
| |
| http_403 / ssl_error -> [curlffi_probe]
| http_429 / timeout -> [delay_retry]
| js_required -> [playwright_probe]
v
master_plan.csv (Status + Error_Detail + Fallback_Module columns)
+
Intelligence Report (stdout + Slack + Discord)
# Install core
pip install -e .
# Install optional fallback modules
pip install "dih-engine[tls]" # curl_cffi -- WAF bypass
pip install "dih-engine[browser]" # playwright -- headless browser for JS-only pages
pip install "dih-engine[api]" # fastapi + uvicorn -- HTTP service (Tier 2)
pip install "dih-engine[full]" # all of the above
# Extract structured records from a raw OCR text file
dih-engine extract --input data/raw_ocr_export.txt --output output/records.jsonl
dih-engine extract --input data/raw_ocr_export.txt --output output/records.csv --output-format csv
dih-engine extract --input data/raw_ocr_export.txt --output output/records.db --output-format sqlite
# Probe a URL list and produce a tech stack strategy plan
dih-engine recon --input data/urls.csv --output output/recon_plan.csv
dih-engine recon --input data/urls.csv --output output/recon_plan.csv --sample-size 50
# Use DataSanitizer as a library in your own pipeline
from dih_engine import DataSanitizer
sanitizer = DataSanitizer()
result = sanitizer.extract_data("O01234 SOME PRODUCT 14.50")
# {'id': '001234', 'amount': '14.50', 'status': 'APPROVED'}
# Docker (mounts ./data as /data inside the container)
docker compose run --rm extract extract --input /data/raw.txt --output /data/out.jsonl
docker compose run --rm recon recon --input /data/urls.csv --output /data/plan.csv
# Run the API service (Tier 2)
uvicorn "dih_engine.api:create_app" --factory --port 8000
# or via Docker: DIH_API_KEY=$(python -c "import secrets;print(secrets.token_urlsafe(32))") docker compose up api
# Run the full test suite
pytestThe engine is also available over HTTP for teams that do not want to manage a Python
environment. Install with pip install "dih-engine[api]" and run:
uvicorn "dih_engine.api:create_app" --factory --port 8000Every data route requires the X-API-Key header matching the DIH_API_KEY env var.
The service is fail-closed: if DIH_API_KEY is unset on the server, every data route
returns 503 rather than running open.
| Method | Route | Auth | Purpose |
|---|---|---|---|
GET |
/health |
none | Liveness probe — returns {"status":"ok","version":"..."} |
POST |
/sanitize |
X-API-Key |
One OCR line → one typed record (APPROVED/PARTIAL/REJECTED/NOISE) |
POST |
/extract |
X-API-Key |
Full OCR text → records + reconciliation audit (total/matched/skipped) |
POST |
/extract/async |
X-API-Key |
Submit large text, returns 202 + job_id |
GET |
/jobs/{id} |
X-API-Key |
Poll an async job (queued/running/done/failed) |
# Sanitize one line
curl -H "X-API-Key: $DIH_API_KEY" -H "Content-Type: application/json" \
-d '{"line":"O01234 SOME PRODUCT 1.234,50"}' \
http://localhost:8000/sanitize
# {"id":"001234","amount":"1234.50","status":"APPROVED"}/extract reuses the same bulletproof_processor engine as the CLI, so it inherits every
guardrail: a server disk above threshold returns 507, never a silent 200 with empty
records. Large payloads belong on /extract/async — a synchronous request that takes
minutes is a client timeout, not a feature.
When a probe fails, the error is classified and the appropriate fallback module is activated automatically:
| Error code | Cause | Fallback module |
|---|---|---|
http_401 |
Site requires authentication (paid API, login wall) | terminal -- no fallback resolves missing credentials |
http_403 |
WAF / Cloudflare block | curl_cffi -> flaresolverr -> proxy |
http_429 |
Rate limited | delay_retry (10-12s backoff + retry) |
http_521 |
Cloudflare origin server down | curl_cffi (sometimes bypasses) |
ssl_error |
TLS handshake mismatch | curl_cffi -> flaresolverr -> proxy |
timeout |
Slow site | delay_retry |
js_required |
CSR-only page (empty body) | playwright (headless browser) |
connection_error |
DNS failure | terminal -- documented in CSV, no retry |
The output CSV gains three columns per probed URL: Status, Error_Detail, Fallback_Module.
Real-world results from a 101-URL live test (2026-05-21, parallel probing):
- 91 ok -- direct success
- 3 http_other -- reqres.in + reuters.com went paid (401), chilli.com.br 521
- 2 http_403 -- centauro.com.br, etsy.com (chain exhausted, proxy needed)
- 2 timeout -- asos.com (retry → 403), bestbuy.com (retry → timeout)
- 1 ssl_error -- expired server cert (terminal, correctly buried)
- 1 connection_error -- DNS failure (terminal)
- 1 connection_error -- DNS failure (terminal)
Self-hosted Cloudflare challenge solver. Runs a real Chrome in Docker.
# Start once (add to docker-compose is already done):
docker compose up -d flaresolverr
# Add to .env:
FLARE_SOLVER_URL=http://localhost:8191/v1FlareSolverr activates automatically after curl_cffi fails. No registration required.
When curl_cffi also fails on a 403, proxy_probe is tried automatically.
Configure one backend in .env:
# Option A: any HTTP/SOCKS5 proxy (Oxylabs, BrightData, Smartproxy, etc.)
DIH_PROXY_URL=http://user:pass@proxy.provider.com:8080
# Option B: Scrapfly free tier (https://scrapfly.io)
SCRAPFLY_API_KEY=scp-live-...SOCKS5 support requires pip install "dih-engine[proxy]". If neither is set, the module
documents module_unavailable in the CSV and continues without raising.
After each recon run, Seer V4 sends an Intelligence Report to all configured channels.
Set env vars in .env (copy from .env.example):
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...Both are optional and independent. Unset = silent no-op.
Copy .env.example to .env and set values before running.
| Variable | Type | Default | Description |
|---|---|---|---|
DEE_PAUSE_THRESHOLD |
float | 80.0 |
Memory % that triggers a GC pause in extraction |
DEE_DISK_THRESHOLD |
float | 95.0 |
Disk % that aborts extraction |
SEER_INPUT_CSV |
string | seer_mapa_v2.csv |
Default URL list path (overridden by --input) |
SEER_OUTPUT_FILE |
string | seer_mapa_master_plan.csv |
Default output path (overridden by --output) |
SEER_REQUEST_TIMEOUT |
int | 10 |
HTTP timeout per probe in seconds |
SEER_SAMPLE_SIZE |
int | 3 |
URLs sampled for tech stack majority vote |
SLACK_WEBHOOK_URL |
string | — | Slack incoming webhook URL |
DISCORD_WEBHOOK_URL |
string | — | Discord webhook URL |
DIH_API_KEY |
string | — | Required by every API data route; unset = 503 (fail-closed) |
No hand-editing required — manage everything with the config subcommand:
dih-engine config list # status, masked value, set/rotation dates, provider
dih-engine config set SCRAPFLY_API_KEY # hidden prompt -- value never echoed
dih-engine config set SCRAPFLY_API_KEY --provider "Scrapfly free tier"
dih-engine config unset SCRAPFLY_API_KEY # key expired or provider changedSecret values are never printed: list shows **** + the last 4 characters, plus the
date each key was set and last rotated (.env.meta.json, gitignored). Unknown variable
names are rejected — a typo'd var is an hour of debugging a silent 503.
Logging: This is a library -- it does not configure logging internally. Call
logging.basicConfig(level=logging.DEBUG)in your own script before importing.
src/dih_engine/
├── extraction/
│ ├── engine.py # Main processing loop + resource monitoring
│ └── patterns.py # Compiled regex and OCR correction maps
├── sanitizer/
│ └── core.py # DataSanitizer class
├── recon/
│ ├── seer.py # Orchestrator: probe -> classify -> fallback -> notify
│ ├── error_taxonomy.py # Maps error codes to fallback module names
│ ├── loading_messages.py # CLI flavor text: waiting / success / failure phrases
│ └── modules/
│ ├── requests_probe.py # Default HTTP probe (always available)
│ ├── curlffi_probe.py # TLS fingerprint bypass [dih-engine[tls]]
│ ├── playwright_probe.py # Headless browser [dih-engine[browser]]
│ ├── flaresolverr_probe.py # Self-hosted Cloudflare solver (no account needed)
│ └── proxy_probe.py # HTTP/SOCKS5 or Scrapfly rotation [dih-engine[proxy]]
├── notifications/
│ ├── slack_notifier.py # Slack Block Kit report
│ └── discord_notifier.py # Discord rich embed report
└── api/ # Tier 2 HTTP service [dih-engine[api]]
├── app.py # create_app() factory + endpoints
├── auth.py # X-API-Key dependency (fail-closed)
├── schemas.py # Pydantic request/response contracts
└── jobs.py # In-memory async JobStore
tests/
├── test_extraction.py # Extraction engine + edge cases
├── test_sanitizer.py # DataSanitizer + locale amount normalization
├── test_seer.py # Recon orchestrator + backoff + circuit breaker
├── test_seer_followups.py # Thread-pool timeout + FlareSolverr error paths
├── test_probe_modules.py # Direct probe() coverage: requests, curlffi, playwright
├── test_cli.py # CLI argument parsing + sys.exit codes
├── test_notifications.py # Slack + Discord notifiers
└── test_api.py # API auth, /sanitize, /extract, async jobs
The engine is production-ready for its current scope. The following are deliberate next steps, not gaps:
| Priority | Item | Notes |
|---|---|---|
| High | API deployment | Deploy the Tier 2 service to Railway or Render with usage-based metering |
| High | Redis-backed JobStore | Replace the in-memory job store the moment a second API instance runs behind a load balancer |
| High | Native async probing (aiohttp) |
Replace ThreadPoolExecutor with true async I/O for better resource efficiency at scale |
| High | FlareSolverr live validation | End-to-end test against real Cloudflare-protected sites (stackoverflow.com, etsy.com) once Docker is in CI |
| Medium | Playwright live validation | End-to-end test for js_required detection against real CSR-only pages |
| Low | Streaming extraction | Pipeline output as a generator instead of collecting all records in memory |
| Low | Done — 94%, 198 tests | |
| Low | delay_retry |
Done — base 5s, 2x, cap 60s, jitter |
| Low | Done — 1.234,50 and 1,234.50 via rightmost-separator rule |
Persistent WAF blocks (Seer V4)
Sites with advanced bot protection (Akamai Enterprise, Cloudflare Pro) block both
requests and curl_cffi. Live test confirmed: stackoverflow.com, etsy.com, centauro.com.br.
Full chain: curl_cffi -> flaresolverr (start with docker compose up -d flaresolverr)
-> proxy (set DIH_PROXY_URL or SCRAPFLY_API_KEY). Each step activates only if configured.
Expired server certificates
ssl_error where the remote server has an expired cert (e.g. tricae.com.br) is terminal --
no client-side module resolves a bad server cert. The site is documented in the output CSV
and should be removed from the URL list.
Mixed-locale amounts in one file
Amount normalization handles 14,50, 1.234,50 (European) and 1,234.50 (US) by treating
the rightmost separator as the decimal mark. It does not infer a single locale per file — each
amount is normalized independently. A token with no 2-digit decimal tail (a bare integer, or a
date like 12.06.26) is correctly not captured as an amount.
Parallel Recon (10 workers)
Seer V4 probes up to 10 URLs concurrently via ThreadPoolExecutor. At 100 URLs, expect
~1 minute. Aggressive WAF sites may 429 more readily under concurrent load -- delay_retry
handles this with exponential backoff. A per-host circuit breaker stops probing a domain
after 3 terminal failures, so a catalog with many URLs on one blocked host does not burn
the full fallback chain on every row. Future: aiohttp for true async I/O.
API job store is in-memory
The async /extract/async job store lives in the API process. This is correct for a single
instance. The moment a second instance runs behind a load balancer, GET /jobs/{id} returns
404 for jobs created on the other instance. Resolution: back the store with Redis (Tier 3).
V4.3.0 (current)
- Added:
dih-engine config-- list/set/unset .env credentials with hidden prompts, masked display (****+ last 4), set/rotation dates, and provider labels. Atomic writes, typo and injection protection. Values never echoed. - Tests: 219 tests, 94% coverage (was 198)
V4.2.0
- Added: Tier 2 API service (
dih-engine[api]) --/health,/sanitize,/extract,/extract/async,/jobs/{id}with fail-closedX-API-Keyauth - Added: exponential backoff in
delay_retry-- base 5s, 2x, cap 60s, jitter, aborts on error-class change - Added: per-host circuit breaker -- skips remaining URLs of a host after 3 terminal failures
- Added: locale-aware amount normalization -- European
1.234,50and US1,234.50 - Tests: 198 tests, 94% coverage (was 162 tests, 93%)
V4.1.1
- Fixed: CI green on all Python versions --
cffi_requests/sync_playwrightnow assignedNoneinexcept ImportErrorsopatch()works when optional deps are absent - Fixed:
pythonpath = ["."]added to pytest config --from src.dih_engineimports reliable in all runner environments
V4.1
- Added: loading-screen flavor text (
loading_messages.py) -- waiting / success / failure phrases forwarded to CLI, Slack, and Discord - Added: strict URL schema validation -- rejects entries missing
http://orhttps://before probing - Hardened: CLI + programmatic input validation for all numeric parameters
- Tests: 162 tests, 93% coverage (was 124 tests, 83%)
V4.0
- Added:
ProbeResultdataclass replaces raw tuples -- every URL gets a typed result - Added: error taxonomy (
error_taxonomy.py) -- maps failure codes to fallback modules - Added: modular fallback chain --
requests->curl_cffi->flaresolverr->proxy - Added:
http_401error code -- terminal, no fallback (site requires credentials) - Added:
http_521error code -- Cloudflare origin down, routes tocurl_cffi - Added:
Status,Error_Detail,Fallback_Modulecolumns in output CSV - Added: Intelligence Report probe breakdown (e.g. "91 ok, 3 http_other, 2 http_403")
- Added: Slack notifier -- Block Kit formatted report after each recon run
- Added: Discord notifier -- rich embed with color-coded status after each recon run
- Added: FlareSolverr probe -- self-hosted Cloudflare JS solver, no account needed
- Added: proxy_probe -- generic HTTP/SOCKS5 or Scrapfly as third-level fallback
- Added: parallel probing via
ThreadPoolExecutor(max_workers=10)-- ~8x faster on 100 URLs - Added:
[tls],[browser],[proxy],[full]optional extras inpyproject.toml - Added:
publish.ymlGitHub Actions workflow -- auto-publishes to PyPI on git tag push - Published:
dih-engine 4.0.0to TestPyPI -- verified installable - Live tested: 101 URLs; 91 ok; curl_cffi rescued WAF blocks; delay_retry resolved rate limits
V3.2
- Fixed HIGH:
RECORD_PATTERNregex --Namewas capturingPRICEandStockcontent. Fixed with non-greedy.+?+\s*$anchor. - Fixed HIGH:
B->8inOCR_ID_FIXEScorrupting alphanumeric IDs (ABC-001->A8C-001). RemovedBfrom translation table. - Fixed MEDIUM:
logging.basicConfig()at module import hijacking root logger. Removed. - Fixed LOW:
pyproject.tomlbuild backend nonexistent. Corrected tosetuptools.build_meta. - Added:
dih-engineCLI --extractandreconsubcommands - Added:
--output-format csv|sqliteonextract - Added: Dockerfile + docker-compose.yml
- Added: injectable sleep in
analyze_tech_stack-- tests no longer sleep 1-4s each - Added: Squarespace detection in Seer
- Refactored: seer.py module-level globals -> function parameters
V3.1
- Fixed CRITICAL: product name corruption (
replace("3", "e")removed) - Fixed CRITICAL: Windows crash on
psutil.disk_usage('/')-- derives path from input file drive - Fixed HIGH: silent record loss -- extraction engine logs total/matched/skipped on every run
- Fixed HIGH: no connection reuse in Seer --
requests.Session()wraps all probe calls - Fixed MEDIUM:
re.search()+^anchor inconsistency -- corrected tore.match() - Fixed MEDIUM: blacklist substring false positives -- whole-word boundary matching
- Fixed MEDIUM: single-URL tech stack sampling -- majority vote over configurable N samples
- Fixed MEDIUM: bare
except:catchingKeyboardInterrupt-- specific exception types only - Added: structured logging, pytest suite, GitHub Actions CI, pyproject.toml
V3.0
- Initial PoC: deterministic sanitizer + extraction engine + recon probe