Files
2026-07-12 10:17:17 -04:00

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

  1. Split blocks by blank lines
  2. Strip separator lines (---, ===) and short ALL-CAPS headers (SCHEDULE, NAME)
  3. Table detection: if block has 2+ lines and header line contains name|phone|date|time|service|vehicle|vin, parse each line individually
  4. Per-block: extract phone → VIN → date → time → duration (replacing matches with single space to preserve column gaps)
  5. Split remaining on \s{2,} (multi-space column boundaries) — if that fails, fall back to single-space word heuristics
  6. 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 headerDateRe for a date pattern, call parseDate(), store as headerDate.
  • Phase 4 (applied at BOTH return points — main return and table fallback): If headerDate exists, any appointment without appointmentDate gets 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:

  1. parseOne() entry — validate carryName before using it:

    if (carryName) {
        if (isServiceWord(carryName)) carryName = null;
        else appt.customerName = carryName;
    }
    
  2. 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++; }
    
  3. 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

  1. 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.

  2. Bracket mismatch from refactoring: Extracting inline code into a parseBlock() helper left a leftover }); from the old blocks.forEach() closure. Caused SyntaxError: Unexpected token ')'.

  3. Dead function declarations: Two handleFile declarations in same scope — second silently overwrites first. Always delete dead code.

  4. Global dependency gaps: Inline scripts need window.closeModal, window.escapeHtml, window.batchCreateAppointments — module exports aren't accessible.