← back to Trademarks Copyright
src/lib/lifecycle.ts
137 lines
/**
* Domain lifecycle + activity + opportunity classification.
* Pure business logic — no I/O, so it's cheap to unit-test and tweak.
*/
export type DomainLifecycle = "live" | "expiring_soon" | "expired" | "unknown";
export type AbandonmentStatus = "active" | "stale" | "abandoned" | "unknown";
export type OpportunityLabel =
| "domain_snipe" // domain expired/expiring + brand dormant — clean buy
| "abandonment_watch" // site still live but no activity 2+ yrs — approaching abandonment
| "licensing_outreach" // active brand, unregistered — pitch THEM services
| "do_not_target" // active brand actively using mark — ACPA/TTAB risk
| "unclear";
/** From HTTP status: 0 = fetch failed, 2xx = live, 4xx/5xx = likely dead. */
export function domainLifecycle(
httpStatus: number | null,
expiresOn: Date | null
): DomainLifecycle {
if (expiresOn) {
const days = (expiresOn.getTime() - Date.now()) / 86400000;
if (days < 0) return "expired";
if (days < 90) return "expiring_soon";
}
if (httpStatus === null || httpStatus === 0) return "unknown";
if (httpStatus >= 200 && httpStatus < 400) return "live";
if (httpStatus >= 400) return "expired"; // site returns errors — likely dead or parked
return "unknown";
}
export function abandonmentStatus(yearsSinceActivity: number | null): AbandonmentStatus {
if (yearsSinceActivity === null) return "unknown";
if (yearsSinceActivity < 1) return "active";
if (yearsSinceActivity < 3) return "stale";
return "abandoned";
}
/**
* The rule engine that turns the three signals into a business label.
* Ordering matters — first match wins.
*
* isRegistered: USPTO has a live federal registration for this brand
* yearsInBusiness: age from WHOIS
* lifecycle: HTTP + domain-expiry lifecycle
* abandonment: activity age bucket
*/
export function opportunityLabel(input: {
isRegistered: boolean | null; // null = unchecked
yearsInBusiness: number | null;
lifecycle: DomainLifecycle;
abandonment: AbandonmentStatus;
isParked: boolean;
}): OpportunityLabel {
if (input.isParked) return "unclear";
if (input.isRegistered === null) return "unclear";
// Hard blocker — actively registered + actively used = keep away.
if (input.isRegistered && input.abandonment === "active") return "do_not_target";
// A registered mark whose domain is expiring is NOT a clean play — the owner
// can still assert Lanham-Act rights even if they let the domain drop.
// Only suggest domain_snipe when the mark itself is unregistered OR abandoned.
if (input.lifecycle === "expired" || input.lifecycle === "expiring_soon") {
if (input.isRegistered && input.abandonment !== "abandoned") {
return "unclear";
}
return "domain_snipe";
}
// Live domain, brand unregistered federally, inactive 3+ yrs → trademark-abandonment window opening.
if (!input.isRegistered && input.abandonment === "abandoned") {
return "abandonment_watch";
}
// Live active brand with no federal registration → legitimate outreach, NOT squatting.
if (!input.isRegistered && input.abandonment === "active") {
return "licensing_outreach";
}
// Stale but still live, unregistered — keep an eye on.
if (!input.isRegistered && input.abandonment === "stale") {
return "abandonment_watch";
}
// Registered + stale — the owner may not fight but also isn't selling.
if (input.isRegistered && input.abandonment !== "active") {
return "unclear";
}
return "unclear";
}
/**
* Best years extractor — copyright years, "since 20XX", year-dated posts.
* Returns the HIGHEST year found (latest activity signal).
*/
export function extractLatestYear(text: string): { year: number | null; signal: string | null } {
const now = new Date().getFullYear();
const yearsFound: { year: number; context: string }[] = [];
const copyRe = /©\s*(\d{4})(?:\s*[-–]\s*(\d{4}))?[^\n]{0,80}/g;
for (const m of text.matchAll(copyRe)) {
const y1 = Number(m[1]);
const y2 = m[2] ? Number(m[2]) : y1;
const y = Math.max(y1, y2);
if (y >= 1990 && y <= now + 1) yearsFound.push({ year: y, context: `©${y} — "${m[0].slice(0, 80).trim()}"` });
}
const sinceRe = /\b(?:since|est\.?|established|founded)\s+(?:in\s+)?(\d{4})\b/gi;
for (const m of text.matchAll(sinceRe)) {
const y = Number(m[1]);
if (y >= 1800 && y <= now) yearsFound.push({ year: y, context: `${m[0]}` });
}
// "Copyright 2024 BrandCo" without symbol
const copyWordRe = /\bcopyright\s+(\d{4})(?:\s*[-–]\s*(\d{4}))?[^\n]{0,40}/gi;
for (const m of text.matchAll(copyWordRe)) {
const y1 = Number(m[1]);
const y2 = m[2] ? Number(m[2]) : y1;
const y = Math.max(y1, y2);
if (y >= 1990 && y <= now + 1) yearsFound.push({ year: y, context: `${m[0].slice(0, 80).trim()}` });
}
// Bare 4-digit year near the footer (less reliable, only use if we have nothing else)
if (!yearsFound.length) {
const bareRe = /\b(20\d{2})\b/g;
for (const m of text.matchAll(bareRe)) {
const y = Number(m[1]);
if (y >= 2000 && y <= now) yearsFound.push({ year: y, context: `bare year ${y}` });
}
}
if (!yearsFound.length) return { year: null, signal: null };
const best = yearsFound.reduce((a, b) => (b.year > a.year ? b : a));
return { year: best.year, signal: best.context };
}