← back to AbramsOS
lib/mode-claims.js
155 lines
// lib/mode-claims.js — parse a "Mode Class Actions" daily newsletter body into distinct
// settlement claims. Dependency-free + deterministic so it can run in CI or DB-free.
//
// The newsletter lists 1-5 settlements per email, each as:
// <Name> Settlement – Up to $X (heading: name + payout, separated by – / — / -)
// If you ... / Bought ... / Received … (the plain-English eligibility test)
// • benefit bullets
// Deadline: <Month DD, YYYY>
// https://modeclassactionsdaily.com/<slug>/?utm_source=email
//
// Quirk handled: Mode occasionally shifts the URL under the WRONG heading (observed 2026-08-07:
// Hard Rock heading carried the Aetna slug and vice-versa). So we match each heading to the mode
// URL whose slug shares the most words with the heading name — not by position.
const MONTHS = { january:0,february:1,march:2,april:3,may:4,june:5,july:6,august:7,september:8,october:9,november:10,december:11,
jan:0,feb:1,mar:2,apr:3,jun:5,jul:6,aug:7,sep:8,sept:8,oct:9,nov:10,dec:11 };
const STOP = new Set(['the','a','an','of','and','or','to','for','up','per','settlement','claim','claims','inc','llc','co','com',
'class','action','data','breach','payment','payments','refund','refunds','cash','credit','loan','loans','www','http','https',
'utm','source','email','2024','2025','2026','2023']);
function words(s) {
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter(w => w && w.length > 2 && !STOP.has(w));
}
function jaccard(a, b) {
const A = new Set(a), B = new Set(b);
if (!A.size || !B.size) return 0;
let inter = 0; for (const x of A) if (B.has(x)) inter++;
return inter / (A.size + B.size - inter);
}
function parseDate(str) {
if (!str) return null;
const m = String(str).match(/([A-Za-z]+)\.?\s+(\d{1,2}),?\s+(\d{4})/);
if (m) { const mon = MONTHS[m[1].toLowerCase()]; if (mon != null) return new Date(Date.UTC(+m[3], mon, +m[2])); }
const m2 = String(str).match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/);
if (m2) return new Date(Date.UTC(+m2[3], +m2[1] - 1, +m2[2]));
return null;
}
function ymd(d) { return d ? d.toISOString().slice(0, 10) : null; }
function dollarsToCents(str) {
const nums = (String(str).match(/\$\s?([\d,]+(?:\.\d{2})?)/g) || []).map(x => Math.round(parseFloat(x.replace(/[$,\s]/g, '')) * 100)).filter(n => !isNaN(n));
return nums.length ? Math.max(...nums) : null;
}
function categorize(text) {
const t = text.toLowerCase();
if (/data breach|personal information|credit monitoring|notice about the/.test(t)) return 'data_breach';
if (/mortgage|loan|bank|overdraft|insurance|investment|kickback|fund|fee|financial|529|brokerage/.test(t)) return 'financial';
if (/employe|job applicant|wage|hour|payroll|background check|hired/.test(t)) return 'employment';
if (/purchase|bought|product|label|mislabel|inhaler|supplement|device|vehicle|repair|coupon|ticket|voucher/.test(t)) return 'product';
return 'other';
}
const isBullet = (l) => /^\s*[•\-\*·]/.test(l);
// a heading = "<name> <dash> <payout>" or "<payout> per <thing>"
// A heading names a settlement + (usually) its payout. Mode writes payouts many ways:
// "Name – Up to $X" | "Name — $X Per Ticket" | "Name – Estimated $92" | "Name – At Least $10"
// "Name – About $1.49 Per Share" | "Comcast $117.5M Data Breach Settlement – Estimated $50"
// embedded, no dash: "Virgin Galactic $8.5M Stock Settlement" | "Country Bank ... $495K Settlement"
// no payout at all: "Navistar ... Settlement – No Claim Form Required" (automatic, nothing to file)
// Old logic only accepted a dash followed immediately by "$"/"up to $", silently dropping ~half.
function headingMatch(line) {
const l = line.trim();
if (!l || isBullet(l)) return null;
// Skip greetings, deadlines, benefit prose, and eligibility/sentence lines (they end with a period).
if (/^(deadline|more details|available benefits|what you|make sure|hey|hi |don'?t|- ?dan|- ?emma|received|if you|bought|plus|check your|this |that |these |those |worked|opened|current|former|people|investors|eligible|documentation|benefits|both|most|payments? will|final|some )/i.test(l)) return null;
if (/[.]\s*$/.test(l)) return null; // full sentences end with a period
if (!/settlement|class action/i.test(l)) return null; // a heading always names the settlement
const money = l.match(/\$\s?[\d,]+(?:\.\d{2})?\s*(?:million|billion|[mkb])?\b/i);
const noClaimish = /no claim|no action|automatic/i.test(l);
if (!money && !noClaimish) return null; // no $ and not an automatic → not a heading
// Split name / payout on a dash if one separates them; otherwise the whole line is the name.
let name = l, payout = null;
const dash = l.match(/^(.{4,120}?)\s+[–—]\s+(.+)$/) || l.match(/^(.{4,120}?)\s+-\s+([A-Z0-9$].+)$/);
if (dash) { name = dash[1].trim(); payout = dash[2].trim(); }
// Prefer a dollar-figure payout; if the dash side has no "$", fall back to the first $ on the line.
if ((!payout || !/\$/.test(payout)) && money) payout = money[0].trim();
if (!payout) payout = noClaimish ? 'No claim required (automatic)' : 'See settlement notice';
name = name.replace(/\s+/g, ' ').replace(/\s*[–—-]\s*$/, '').trim();
if (name.length < 4) return null;
return { name, payout };
}
/**
* Parse one Mode newsletter body → array of settlement objects.
* @param {string} body plain-text email body
* @param {object} meta { emailId, emailDate }
*/
function parseModeEmail(body, meta = {}) {
const text = String(body || '').replace(/\r\n/g, '\n');
const lines = text.split('\n');
// 1. collect headings with their line index + the block text until the next heading
const heads = [];
for (let i = 0; i < lines.length; i++) {
const h = headingMatch(lines[i]);
if (h) heads.push({ ...h, idx: i });
}
for (let k = 0; k < heads.length; k++) {
const start = heads[k].idx, end = k + 1 < heads.length ? heads[k + 1].idx : lines.length;
heads[k].block = lines.slice(start, end).join('\n');
}
// 2. collect all mode URLs with slugs
const urlRe = /https?:\/\/(?:www\.)?modeclassactionsdaily\.com\/([a-z0-9\-]+)\/?/gi;
const urls = [];
let u; while ((u = urlRe.exec(text))) urls.push({ url: u[0].replace(/[).,]+$/, ''), slug: u[1], used: false });
const out = [];
for (const h of heads) {
const block = h.block || '';
// deadline: first "Deadline:" in the block
const dl = (block.match(/deadline[:\s]+([^\n]+)/i) || [])[1];
const deadline = parseDate(dl);
// eligibility test = first line that reads like a membership test
const eLine = (block.split('\n').map(s => s.trim())
.find(s => /^(if you|bought|received|had |were |used |own(ed)?|purchased|applied|paid)/i.test(s) || (s.endsWith('?') && s.length < 200))) || null;
// best-matching URL by slug↔name word overlap (fixes Mode's swapped-URL quirk)
const nameW = words(h.name);
let best = null, bestScore = 0;
for (const cand of urls) {
const s = jaccard(nameW, words(cand.slug));
if (s > bestScore) { bestScore = s; best = cand; }
}
if (best && bestScore >= 0.12) best.used = true; else best = null;
const proofNeg = /no proof required|no receipts?|no claim form|no documentation|receipts? (are )?not required/i.test(block);
const proofPos = /documented losses|proof of|receipts? (are )?required|upload|submit documentation|itemized/i.test(block);
const noClaim = /no claim required|automatic payment|paid automatically|receive payments? automatically|won'?t have to (fill|file)|no action (is )?(required|needed)/i.test(block);
out.push({
name: h.name.replace(/\s+/g, ' ').trim(),
slug: best ? best.slug : (h.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80)),
mode_url: best ? best.url : null,
payout_text: h.payout.replace(/\s+/g, ' ').trim(),
payout_max_cents: dollarsToCents(h.payout),
deadline: ymd(deadline),
proof_required: proofNeg ? false : (proofPos ? true : null),
no_claim_required: noClaim,
eligibility_question: eLine,
category: categorize(block),
source_email_id: meta.emailId || null,
source_email_date: meta.emailDate || null,
raw_block: block.trim().slice(0, 2000),
});
}
return out;
}
module.exports = { parseModeEmail, parseDate, dollarsToCents, categorize };