7.5 KiB
OCR Rule-Based Parser for Appointment Extraction
Full code of parseWithRules() from appointments.html inline script, with test cases. Built to replace a DeepSeek API call with zero-dependency CPU parsing.
Design
Layered extraction order (5 phases now):
Phase 0 — Extract header date BEFORE stripping: Screenshots often have the appointment date in the top-middle header (e.g., "Monday, Jun 09, 2026"). Scan the first ~15 lines for a date pattern BEFORE Phase 1 strips metadata lines. Save as headerDate. After all appointments are parsed, apply headerDate as the fallback for any appointment missing its own date.
Phases 1-4 (unchanged):
- Split blocks by blank lines
- Strip separator lines (
---,===) and short ALL-CAPS headers (SCHEDULE,NAME) - Table detection: if block has 2+ lines and header line contains
name|phone|date|time|service|vehicle|vin, parse each line individually - Per-block: extract phone → VIN → date → time → duration (replacing matches with single space to preserve column gaps)
- Split remaining on
\s{2,}(multi-space column boundaries) — if that fails, fall back to single-space word heuristics - Name = first consecutive capitalized words without digits. Vehicle = chunk containing year (
19xx/20xx) or known make. Service = everything else.
Key Patterns
| Field | Regex | Notes |
|---|---|---|
| Phone | \(?\d{3}\)?[\s.\-]*\d{3}[\s.\-]*\d{4} |
Captures (555)123-4567, 555.123.4567, 555 123 4567 |
| VIN | \b[A-HJ-NPR-Z0-9]{17}\b |
Excludes I, O, Q per VIN standard |
| Date (ISO) | (\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2}) |
2024-06-15 |
| Date (US) | (\d{1,2})[\/](\d{1,2})(?:\/(\d{4}))? |
06/20/2024 or 06/20 |
| Date (named) | (jan|feb|...)[a-z]*\s+(\d{1,2})(?:[,\s]+(\d{4}))? |
Jan 15, 2024 |
| Time (HH:MM) | (\d{1,2}):(\d{2})\s*(AM|PM)? |
9:00 AM, 14:00 |
| Time (bare) | (\d{1,2})\s*(AM|PM) |
8am, 1PM |
| Duration | (\d+)\s*(?:min|minutes?|hrs?|hours?) |
Converts 1 hour → 60, 90 min → 90 |
| Vehicle (year) | \b(19|20)\d{2}\b |
Detects year-in-text |
| Vehicle (make) | ford|chevy|chevrolet|toyota|honda|... |
Known make list |
Verified Test Cases
1. Two appointments with double spacing
John Smith (555) 123-4567 2024-06-15 9:00 AM Oil Change 60 min 2018 Toyota Camry
Jane Doe (555) 987-6543 06/20/2024 2:00 PM Brake Inspection 90 min 2020 Honda Civic
→ 2 appts: John Smith / 2024-06-15 09:00 / 2018 Toyota Camry / Oil Change, Jane Doe / 2024-06-20 14:00 / 2020 Honda Civic / Brake Inspection
2. Headers and separator lines
SCHEDULE
--------
Robert Brown 865-555-1234 Jan 15, 2024 8:00am Tire Rotation 1 hour Toyota Tacoma
Sarah Wilson (423)555-6789 03/01/2024 1:30PM AC Repair 2 hours Jeep Grand Cherokee
→ 2 appts (headers stripped): Robert Brown / 2024-01-15 08:00 / Toyota Tacoma / Tire Rotation, Sarah Wilson / 2024-03-01 13:30 / Jeep Grand Cherokee / AC Repair
3. Single appointment
Mike Johnson 2024-07-01 10:30 AM Transmission Fluid 2022 Ford F-150
→ 1 appt: Mike Johnson / 2024-07-01 10:30 / 2022 Ford F-150 / Transmission Fluid
4. Table format with header row
Name Phone Date Time Service Vehicle
John Smith 555-123-4567 2024-06-15 9:00 AM Oil Change 2018 Toyota Camry
Jane Doe 555-987-6543 06/20/2024 2:00 PM Brake Inspection 2020 Honda Civic
→ 2 appts (header stripped, rows parsed): correct fields for both
5. Screenshot with header date — no per-appointment dates
Monday, Jun 09, 2026
8:00 AM 1.5 hrs John Smith 555-123-4567 Oil Change 2018 Toyota Camry
10:30 AM 1 hr Jane Doe 555-987-6543 Brake Inspection 2020 Honda Civic
→ 2 appts, both get appointmentDate: "2026-06-09" from Phase 0 header extraction. The "Monday Jun 09" line is extracted before Phase 1 strips it.
Phase 0 Header Date + Phase 4 Fallback (added 2026-06-09)
Problem: Screenshots have the date in the top-middle header (e.g., "Monday, Jun 09, 2026"), but Phase 1 strips it as metadata before any appointments are parsed. Each appointment ends up with no date.
Solution:
- Phase 0 (before Phase 1): Scan first ~15 lines with
headerDateRefor a date pattern, callparseDate(), store asheaderDate. - Phase 4 (applied at BOTH return points — main return and table fallback): If
headerDateexists, any appointment withoutappointmentDategets it assigned.
// Phase 0
var headerDate = null;
var headerLines = t.split('\n').slice(0, 15);
var headerDateRe = /\b(?:(?:Mon|Tue|...)[,\s]+)?(?:Jan|Feb|...)[a-z]*\s+\d{1,2}(?:[,\s]+\d{4})?\b|.../i;
for (var hi = 0; hi < headerLines.length; hi++) {
var hm = headerLines[hi].match(headerDateRe);
if (hm) { var hd = parseDate(hm[0]); if (hd) { headerDate = hd; break; } }
}
// Phase 4 (before each return)
if (headerDate) {
appointments.forEach(function(a) {
if (!a.appointmentDate) a.appointmentDate = headerDate;
});
}
Critical: Apply at ALL return points. The table fallback (lines.forEach(...); return appointments;) has its own early return that bypasses the main Phase 4 block. Add the headerDate fallback guard before that return too.
Service Word Blocklist for Name Extraction (added 2026-06-09)
Problem: Cross-block name carry (Step 10) picks up trailing all-caps words from serviceType and treats them as the next appointment's customer name. Words like "SERVICE", "REPAIR", "CHECK" get carried as names.
Solution — isServiceWord() helper:
var serviceWords = {SERVICE:1,REPAIR:1,CHECK:1,MAINTENANCE:1,INSPECTION:1,DIAGNOSTIC:1,
DIAG:1,REPLACE:1,REPLACEMENT:1,INSTALL:1,REMOVE:1,ADJUST:1,ALIGNMENT:1,ROTATION:1,
BALANCE:1,FLUSH:1,DRAIN:1,FILL:1,TUNE:1,UP:1,ESTIMATE:1,WAITING:1,APPOINTMENT:1,
REQUEST:1,CUSTOMER:1,VEHICLE:1,ADVISOR:1,TECHNICIAN:1,MECHANIC:1};
function isServiceWord(w) {
w = w.toUpperCase().replace(/[^A-Z]/g,'');
return serviceWords[w] || w.length > 12;
}
Applied in three places:
-
parseOne() entry — validate
carryNamebefore using it:if (carryName) { if (isServiceWord(carryName)) carryName = null; else appt.customerName = carryName; } -
Step 7 name extraction — skip parts that are service words, year-prefixed vehicle descriptions, or long all-caps:
var nameIdx = 0; while (nameIdx < parts.length && ( isServiceWord(parts[nameIdx]) || /^\d{4}\s/.test(parts[nameIdx]) || /^[A-Z\d\s\-]{6,}$/.test(parts[nameIdx]) )) { nameIdx++; } -
Step 10 cross-block carry — reject service words before carrying:
if (tn && tn[1].length > 3 && tn[1].length < 20 && !isServiceWord(tn[1])) { ... }
Pitfalls Encountered During Development
-
Whitespace collapse order is critical: Collapsing
\s+to single space BEFORE splitting on\s{2,}destroys column boundaries. Must split on multi-spaces first, then clean each part. -
Bracket mismatch from refactoring: Extracting inline code into a
parseBlock()helper left a leftover});from the oldblocks.forEach()closure. CausedSyntaxError: Unexpected token ')'. -
Dead function declarations: Two
handleFiledeclarations in same scope — second silently overwrites first. Always delete dead code. -
Global dependency gaps: Inline scripts need
window.closeModal,window.escapeHtml,window.batchCreateAppointments— module exports aren't accessible.