← 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); });