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