← back to Nationalrealestate

src/ingest/parcels/csv.ts

59 lines

/**
 * Streaming CSV parser — async generator of string[] records. Handles quoted
 * fields (embedded commas/newlines/"" escapes), CRLF, and never buffers the
 * whole file (PLUTO ~165MB, King RPSale ~500MB unzipped).
 */
import { createReadStream } from 'node:fs';

export async function* csvRecords(path: string, encoding: BufferEncoding = 'utf8'): AsyncGenerator<string[]> {
  const stream = createReadStream(path, { encoding, highWaterMark: 1 << 20 });
  let field = '';
  let record: string[] = [];
  let inQuotes = false;
  let sawQuote = false; // current field was quoted (so '' stays distinguishable)
  let prevCR = false;

  for await (const chunk of stream) {
    const s = chunk as string;
    for (let i = 0; i < s.length; i++) {
      const c = s[i];
      if (inQuotes) {
        if (c === '"') {
          if (s[i + 1] === '"') { field += '"'; i++; }
          else inQuotes = false;
        } else field += c;
        continue;
      }
      if (c === '"') { inQuotes = true; sawQuote = true; continue; }
      if (c === ',') { record.push(field); field = ''; sawQuote = false; prevCR = false; continue; }
      if (c === '\r') { prevCR = true; continue; }
      if (c === '\n') {
        record.push(field); field = ''; sawQuote = false; prevCR = false;
        if (record.length > 1 || record[0] !== '') yield record;
        record = [];
        continue;
      }
      if (prevCR) { field += '\r'; prevCR = false; } // lone CR inside a field
      field += c;
    }
  }
  if (field !== '' || sawQuote || record.length) {
    record.push(field);
    if (record.length > 1 || record[0] !== '') yield record;
  }
}

/** Header-mapped variant: yields Record<lowercased header, value>. */
export async function* csvObjects(path: string, encoding: BufferEncoding = 'utf8'): AsyncGenerator<Record<string, string>> {
  let headers: string[] | null = null;
  for await (const rec of csvRecords(path, encoding)) {
    if (!headers) {
      headers = rec.map(h => h.replace(/^/, '').trim().toLowerCase());
      continue;
    }
    const obj: Record<string, string> = {};
    for (let i = 0; i < headers.length; i++) obj[headers[i]] = (rec[i] ?? '').trim();
    yield obj;
  }
}