← back to AbramsOS

lib/claim-openclaw-run.js

192 lines

// lib/claim-openclaw-run.js — live openclaw auto-TYPE for a settlement claim form.
// Connects to the already-running openclaw Chrome (CDP :18800) via puppeteer-core and drives
// its OWN dedicated tab (immune to whatever else is churning the shared browser's active tab).
// It navigates to the claim form, label-matches the identity + payment fields across ANY
// administrator's markup, types the values, screenshots the filled form, and STOPS.
//
// HARD LINE: never checks the penalty-of-perjury attestation, never clicks Submit/File. The tab
// is left open for Steve to review, attest, and submit himself.

const path = require('path');
const fs = require('fs');
const puppeteer = require('puppeteer-core');

const CDP = process.env.OPENCLAW_CDP || 'http://127.0.0.1:18800';
const SHOT_DIR = path.join(__dirname, '..', 'public', 'claim-shots');

// The label-matcher runs INSIDE the page. Matches inputs by their label/aria/placeholder/name,
// fills identity fields, and selects the PayPal payment radio. Never touches submit/attestation.
function inPageFill(values) {
  var STATES = { AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado',CT:'Connecticut',DE:'Delaware',FL:'Florida',GA:'Georgia',HI:'Hawaii',ID:'Idaho',IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky',LA:'Louisiana',ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota',MS:'Mississippi',MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire',NJ:'New Jersey',NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota',OH:'Ohio',OK:'Oklahoma',OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina',SD:'South Dakota',TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia',WA:'Washington',WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming' };
  // checked in this ORDER (specific first); email before street; street excludes email/apt/line-2.
  var ORDER = ['email','first_name','middle','last_name','zip','city','state','phone','street','full_name'];
  var KEY = {
    email:      { rx: /e-?mail/i },
    first_name: { rx: /first\s*(name)?|fname|given/i },
    middle:     { rx: /middle|m\.?i\.?|initial/i },
    last_name:  { rx: /last\s*(name)?|lname|surname|family/i },
    full_name:  { rx: /full\s*name|^\s*name\s*$/i },
    street:     { rx: /(street|mailing|^\s*address\s*$|address\s*1|street\s*1|addr)/i, not: /e-?mail|apartment|unit|suite|\bbox\b|street\s*2|address\s*2|line\s*2/i },
    city:       { rx: /city|town/i },
    state:      { rx: /\bstate\b|province/i },
    zip:        { rx: /zip|postal/i },
    phone:      { rx: /phone|tel(?!e?vision)|mobile/i },
  };
  function txt(node) { if (!node) return ''; var c = node.cloneNode(true); var kids = c.querySelectorAll ? c.querySelectorAll('input,select,textarea,button,script,style') : []; for (var j = 0; j < kids.length; j++) kids[j].remove(); return (c.innerText || c.textContent || ''); }
  function labelFor(el) {
    var t = '';
    // 1) standard associations
    try { if (el.id) { var l = document.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(el.id) : el.id) + '"]'); if (l) t += ' ' + l.textContent; } } catch (e) {}
    var w = el.closest && el.closest('label'); if (w) t += ' ' + txt(w);
    t += ' ' + (el.getAttribute('aria-label') || '') + ' ' + (el.placeholder || '') + ' ' + (el.name || '') + ' ' + (el.id || '');
    // 2) PROXIMITY — these forms use bare inputs with the label as nearby visible text
    if (el.previousElementSibling) t += ' ' + txt(el.previousElementSibling);
    var cell = el.closest && el.closest('td,th,div,li,p,span,section,fieldset');
    if (cell) t += ' ' + txt(cell);
    var td = el.closest && el.closest('td'); if (td && td.previousElementSibling) t += ' ' + txt(td.previousElementSibling);
    // walk back a couple prior siblings for "label then input" layouts
    var pv = el.previousElementSibling, hop = 0;
    while (pv && hop < 3) { t += ' ' + txt(pv); pv = pv.previousElementSibling; hop++; }
    return t.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 200);
  }
  var filled = [];
  var usedKey = {};            // single-use keys (fill each identity field once)
  var firstEl = null;          // remember the input that got first_name (for positional last-name)
  var els = Array.prototype.slice.call(document.querySelectorAll('input, select, textarea'));
  for (var i = 0; i < els.length; i++) {
    var el = els[i];
    var ty = (el.type || '').toLowerCase();
    if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file', 'password'].indexOf(ty) >= 0) continue;
    if (el.value && el.tagName !== 'SELECT') continue;   // don't overwrite what's already there
    var lab = labelFor(el);
    // Combined single name field ("First Name, Middle Initial, Last Name") → full name.
    if (!usedKey.full_name && values.full_name && el.tagName === 'INPUT' && /first\s*name/.test(lab) && /last\s*name/.test(lab)) {
      try { el.focus(); } catch (e) {}
      el.value = values.full_name;
      el.dispatchEvent(new Event('input', { bubbles: true }));
      el.dispatchEvent(new Event('change', { bubbles: true }));
      el.dispatchEvent(new Event('blur', { bubbles: true }));
      usedKey.full_name = usedKey.first_name = usedKey.last_name = 1;
      filled.push('full_name'); firstEl = el; continue;
    }
    for (var oi = 0; oi < ORDER.length; oi++) {
      var k = ORDER[oi];
      var def = KEY[k];
      if (usedKey[k]) continue;
      if (!def.rx.test(lab)) continue;
      if (def.not && def.not.test(lab)) continue;
      if (k === 'middle') { usedKey[k] = 1; break; }      // claim the MI input so it's not mis-filled; no value passed
      if (!values[k]) { break; }                          // matched but nothing to put → leave blank, stop trying other keys
      if (el.tagName === 'SELECT') {
        var want = String(values[k]).toLowerCase();
        var full = (k === 'state' && STATES[String(values[k]).toUpperCase()] || '').toLowerCase();
        for (var o = 0; o < el.options.length; o++) {
          var op = el.options[o]; var ov = op.value.toLowerCase(), otx = op.textContent.trim().toLowerCase();
          if (ov === want || otx === want || (full && (ov === full || otx === full))) { el.value = op.value; break; }
        }
        el.dispatchEvent(new Event('change', { bubbles: true }));
      } else {
        try { el.focus(); } catch (e) {}
        el.value = values[k];
        el.dispatchEvent(new Event('input', { bubbles: true }));
        el.dispatchEvent(new Event('change', { bubbles: true }));
        el.dispatchEvent(new Event('blur', { bubbles: true }));
      }
      usedKey[k] = 1; filled.push(k);
      if (k === 'first_name') firstEl = el;
      break;
    }
  }
  // POSITIONAL last-name: forms that put "First Name · Middle Initial · Last Name" under one
  // label leave the last-name input unmatched. Take the empty text inputs after the first-name
  // input in the same container: [next]=middle (if 3), [last]=last name.
  if (firstEl && values.last_name && !usedKey.last_name) {
    try {
      var cont = firstEl.closest('tr,div,fieldset,section,p') || firstEl.parentElement;
      var row = Array.prototype.slice.call(cont.querySelectorAll('input')).filter(function (x) { var tt = (x.type || 'text').toLowerCase(); return ['hidden', 'submit', 'button', 'checkbox', 'radio', 'file', 'password'].indexOf(tt) < 0; });
      var fi = row.indexOf(firstEl);
      if (fi >= 0 && row.length >= 2) {
        var lastInput = row[row.length - 1];
        if (lastInput && lastInput !== firstEl && !lastInput.value) {
          lastInput.focus(); lastInput.value = values.last_name;
          lastInput.dispatchEvent(new Event('input', { bubbles: true }));
          lastInput.dispatchEvent(new Event('change', { bubbles: true }));
          lastInput.dispatchEvent(new Event('blur', { bubbles: true }));
          usedKey.last_name = 1; filled.push('last_name');
        }
      }
    } catch (e) {}
  }
  // payment: select PayPal option if the form has one (radio or its label)
  var paid = false;
  var radios = Array.prototype.slice.call(document.querySelectorAll('input[type=radio]'));
  for (var r = 0; r < radios.length; r++) {
    var rl = (radios[r].value || '') + ' ' + labelForRadio(radios[r]);
    if (/paypal/i.test(rl)) { try { radios[r].click(); paid = true; } catch (e) {} break; }
  }
  function labelForRadio(el) { var w = el.closest && el.closest('label'); var t = w ? w.textContent : ''; if (el.id) { var l = document.querySelector('label[for="' + el.id + '"]'); if (l) t += ' ' + l.textContent; } return t; }
  return { filled: filled, paypal_selected: paid, url: location.href, title: document.title };
}

async function runFill(claimId, formUrl, values) {
  fs.mkdirSync(SHOT_DIR, { recursive: true });
  let browser;
  try {
    browser = await puppeteer.connect({ browserURL: CDP, defaultViewport: null, protocolTimeout: 60000 });
  } catch (e) {
    return { ok: false, error: 'openclaw browser not reachable at ' + CDP + ' (' + e.message + ')' };
  }
  let page;
  try {
    page = await browser.newPage(); // OUR tab — not the shared active tab
    await page.goto(formUrl, { waitUntil: 'domcontentloaded', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 3500)); // let SPA render
    // If we landed on the Mode aggregator, hop to the OFFICIAL settlement site first
    // (real Chrome, so Mode's bot-403 doesn't apply). Pick the strongest external claim link.
    try {
      if (/modeclassactionsdaily\.com/i.test(page.url())) {
        const off = await page.evaluate(() => {
          var here = location.hostname;
          var links = Array.prototype.slice.call(document.querySelectorAll('a[href^="http"]'));
          var cand = links.filter(function (e) { try { var u = new URL(e.href); return u.hostname !== here && !/modeclassaction|facebook|twitter|instagram|google|youtube/i.test(u.hostname); } catch (_) { return false; } });
          var best = cand.find(function (e) { return /file (a )?claim|official (settlement )?(site|website)|claim (here|now|form)|settlement website|visit/i.test(e.textContent || ''); })
            || cand.find(function (e) { try { return /settlement|claim|litigation|classaction|pnclassaction|dataincident/i.test(new URL(e.href).hostname); } catch (_) { return false; } });
          return best ? best.href : null;
        });
        if (off) { await page.goto(off, { waitUntil: 'domcontentloaded', timeout: 45000 }); await new Promise((r) => setTimeout(r, 3000)); }
      }
    } catch (e) {}
    // follow an obvious "File a Claim / Submit a Claim / Claim Form" link if we landed on a landing page
    try {
      const RX = /file a claim|submit a claim|online claim|claim form|start (your )?claim|enter online|online filing|filing site|enter .*filing|proceed to|continue to (the )?claim|begin/i;
      // Try up to 3 hops of "enter/continue" gateways (instructions page → filing site → form).
      for (var hop = 0; hop < 3; hop++) {
        var acted = await page.evaluate((rxSrc) => {
          var rx = new RegExp(rxSrc, 'i');
          var el = Array.prototype.slice.call(document.querySelectorAll('a,button,input[type=submit],input[type=button]'))
            .find(function (e) { return rx.test(e.textContent || e.value || ''); });
          if (!el) return null;
          if (el.href && /^https?:/.test(el.href)) return { href: el.href };
          el.click(); return { clicked: true };
        }, RX.source);
        if (!acted) break;
        if (acted.href) { await page.goto(acted.href, { waitUntil: 'domcontentloaded', timeout: 45000 }); }
        await new Promise((r) => setTimeout(r, 3500));
        // stop hopping once we see real name/email inputs
        var hasForm = await page.evaluate(() => document.querySelectorAll('input[type=text],input[type=email]').length >= 3);
        if (hasForm) break;
      }
    } catch (e) {}
    const result = await page.evaluate(inPageFill, values);
    const shot = path.join(SHOT_DIR, claimId + '.png');
    await page.screenshot({ path: shot, fullPage: true }).catch(() => {});
    browser.disconnect(); // LEAVE the tab open for Steve to review + attest + submit
    return { ok: true, filled: result.filled, paypal_selected: result.paypal_selected, on: result.url, title: result.title, screenshot: '/claim-shots/' + claimId + '.png' };
  } catch (e) {
    try { browser.disconnect(); } catch (_) {}
    return { ok: false, error: e.message };
  }
}

module.exports = { runFill };