← back to CelebritySignatures

scripts/build-tm-index.mjs

133 lines

#!/usr/bin/env node
// Build data/tm-index.json — a compact index of LIVE US trademark wordmarks and
// whether each covers Nice Class 25 (clothing/apparel). Merges:
//   1. data/tm-index-seed.json  (bootstrap sample of well-known live apparel marks)
//   2. any USPTO bulk XML fetched into tmp_tm_cache/ (real register data)
//
// The index is marked complete:true ONLY once real register data has been parsed;
// until then it stays complete:false and the storefront discloses a PARTIAL screen.
//
// USPTO trademark case-file XML shape (relevant bits):
//   <case-file>
//     <serial-number>…</serial-number>
//     <case-file-header>
//       <mark-identification>ANDY WARHOL</mark-identification>
//       <status-code>700</status-code>            (6xx/7xx ≈ live/registered)
//     </case-file-header>
//     <classifications><classification>
//        <international-code>025</international-code>   (Nice class)
//        <status-code>…</status-code>
//     </classification></classifications>
//   </case-file>
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import { join, extname } from 'node:path';
import { normName } from './tm-normalize.mjs';

const pexec = promisify(execFile);
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const DATA = join(ROOT, 'data');
const CACHE = join(ROOT, 'tmp_tm_cache');

// Completeness floor (TK-10286, Cody-gated 2026-08-10). A PARTIAL register must
// never masquerade as complete: build-tm-clearance's screen() returns CLEAR on the
// ABSENCE of a match, so declaring complete:true on a truncated ingest (e.g. the
// go-live loop interrupted after 1 of ~91 parts) would false-CLEAR every name the
// missing parts would have flagged as MARK_FOUND — and that write lands on disk
// BEFORE any Steve go-live approval. The full USPTO annual register holds millions
// of live marks; require a real floor of parsed marks before trusting it as a
// complete screen. Override for legit edge cases via --min-marks N or TM_MIN_MARKS.
// (A daily-only product is a current-window file, not the full register, so it
// intentionally never reaches this floor — nothing sells off a daily-only screen.)
const _argv = process.argv.slice(2);
const _argGet = (flag) => { const i = _argv.indexOf(flag); return i >= 0 ? _argv[i + 1] : null; };
const MIN_COMPLETE_MARKS = Number(_argGet('--min-marks') || process.env.TM_MIN_MARKS || 500000);

// Status codes that indicate a LIVE mark (registered / pending-live). USPTO uses
// 6xx (registered) and 7xx (registered/renewed/sections); dead marks are 6xx-abandoned
// or explicit cancellation/abandonment dates. We treat presence of a live status-code
// AND absence of an abandonment/cancellation date as live.
function isLive(caseXml) {
  if (/<abandonment-date>\s*\d/.test(caseXml)) return false;
  if (/<cancellation-date>\s*\d/.test(caseXml)) return false;
  const m = caseXml.match(/<status-code>(\d+)<\/status-code>/);
  if (!m) return false;
  const code = +m[1];
  return code >= 600 && code < 900;   // pragmatic live band
}

function parseCaseFiles(xml) {
  const out = [];
  const cases = xml.split('<case-file>').slice(1);
  for (const c of cases) {
    const body = c.split('</case-file>')[0];
    if (!isLive(body)) continue;
    const mark = (body.match(/<mark-identification>([^<]+)<\/mark-identification>/) || [])[1];
    if (!mark) continue;
    const serial = (body.match(/<serial-number>(\d+)<\/serial-number>/) || [])[1] || null;
    const classes = [...body.matchAll(/<international-code>(\d{1,3})<\/international-code>/g)].map(x => +x[1]);
    out.push({ wordmark: mark.trim(), serial, status: 'LIVE', class25: classes.includes(25), classes });
  }
  return out;
}

async function readXmlFromFile(path) {
  if (extname(path) === '.zip') {
    try { const { stdout } = await pexec('unzip', ['-p', path], { maxBuffer: 1 << 30 }); return stdout; }
    catch { console.warn('  (could not unzip', path, '— `unzip` missing? skipping)'); return ''; }
  }
  return readFile(path, 'utf8');
}

async function main() {
  const seed = JSON.parse(await readFile(join(DATA, 'tm-index-seed.json'), 'utf8'));
  const marks = [...seed.marks];
  let parsedReal = 0;

  let files = [];
  try { files = (await readdir(CACHE)).filter(f => /\.(xml|zip)$/i.test(f)); } catch {}
  for (const f of files) {
    const xml = await readXmlFromFile(join(CACHE, f));
    if (!xml) continue;
    const rows = parseCaseFiles(xml);
    console.log(`  parsed ${rows.length} live marks from ${f}`);
    marks.push(...rows);
    parsedReal += rows.length;
  }

  // De-dupe by normalized wordmark, OR-ing class25 across duplicates.
  const byNorm = new Map();
  for (const m of marks) {
    const key = normName(m.wordmark);
    if (!key) continue;
    const prev = byNorm.get(key);
    if (prev) { prev.class25 = prev.class25 || m.class25; }
    else byNorm.set(key, { normalized: key, wordmark: m.wordmark, class25: !!m.class25, status: 'LIVE' });
  }

  // complete ONLY when a real register of plausibly-full size was parsed — NOT on a
  // truncated ingest (see MIN_COMPLETE_MARKS above). parsedReal excludes the seed.
  const complete = parsedReal >= MIN_COMPLETE_MARKS;
  if (parsedReal > 0 && !complete) {
    console.warn(`  WARNING: parsed ${parsedReal} real marks — BELOW the ${MIN_COMPLETE_MARKS} completeness floor.`);
    console.warn('  Treating the register as PARTIAL (complete=false); the store stays gated/empty by design.');
    console.warn('  Ingest the full annual register (all parts) or override with --min-marks / TM_MIN_MARKS.');
  }
  const index = {
    generatedAt: new Date().toISOString(),
    complete,                                 // true only once a plausibly-COMPLETE register was parsed
    source: complete ? 'uspto-bulk+seed' : (parsedReal > 0 ? 'uspto-bulk-partial+seed' : 'seed-sample'),
    parsedRealMarks: parsedReal,
    minCompleteMarks: MIN_COMPLETE_MARKS,
    liveMarkCount: byNorm.size,
    class25Count: [...byNorm.values()].filter(m => m.class25).length,
    marks: [...byNorm.values()],
  };
  await writeFile(join(DATA, 'tm-index.json'), JSON.stringify(index, null, 2));
  console.log(`tm-index.json: ${index.liveMarkCount} live marks (${index.class25Count} apparel), parsedReal=${parsedReal}, complete=${index.complete}, source=${index.source}`);
  if (!index.complete) console.log('  NOTE: not a complete register. Ingest the full annual register via scripts/fetch-tm-register.mjs (--file or --odp), then rebuild for a full screen.');
}
main().catch(e => { console.error(e); process.exit(1); });