import type { EventType, FacilityStatus, PageType, ClaimScope, Evidence, ProjectClassification, AiEvidence } from "@dci/core"; import { PIPELINE_STATUSES, cleanText, parseAllMw, parseAreaHa, parseMoney, parsePartialDate, partialDateSortKey, round, classifyProjectEvent, classifyScope, classifyCapacitySemantics, classifyInvestmentSemantics, classifyAiEvidence, findEvidence } from "@dci/core"; import { classifyPage, eventTypeFor, isDataCenterRelevant, projectMwFigures } from "@dci/connectors"; import { detectOperators, primaryOperator, type OperatorHit } from "./operators-lexicon.js"; import { detectLocation, type LocationHit } from "./locations-lexicon.js"; /** * Announcement → project extraction. Pure text → structured fields, every field with the method that produced * it (for provenance). Nothing is invented: a missing figure stays null. See docs/PROJECT-EXTRACTION.md. */ export interface Money { amount: number; currency: string } export interface Announcement { title: string; /** classification from the text only (URL patterns ignored), title first */ pageType: PageType; eventType: EventType | undefined; relevance: "strong" | "weak" | "none"; /** the data-center vocabulary appears in the title or the first 600 chars (not only deep in the body) */ leadRelevance: boolean; /** the title itself names a data center / campus / MW figure or a known operator */ titleSignal: boolean; /** the headline is about people, awards, deals, financing, market research, policy or opinion — never a project */ nonProjectTitle: boolean; mwAll: number[]; headlineMw: number | null; money: Money | null; /** only when the headline figure is in USD — other currencies are never converted into a stored value */ investmentUsd: number | null; /** approximate USD equivalent, used ONLY for the ≥ $50M project gate, never stored */ investmentUsdApprox: number | null; acreage: number | null; phaseCount: number | null; expectedOpening: string | null; status: FacilityStatus | null; location: LocationHit | null; operators: OperatorHit[]; operator: OperatorHit | null; explicitName: string | null; projectName: string; methods: Record; /** what the announcement is about (people / money / deal / build…) — decides whether a project may exist at all */ classification: ProjectClassification; /** supporting sentences for the headline figures (no sentence → no claim) */ evidence: { plannedMw: Evidence | null; investment: Evidence | null }; capacityScope: ClaimScope; capacitySemantics: string | null; investmentScope: ClaimScope; investmentSemantics: string | null; aiEvidence: AiEvidence; /** a "City, State-based" / "headquartered in" mention was removed before locating the project */ hqGuarded: boolean; } export const EXTRACTOR_VERSION = "news_v4"; /** Operators are looked up in the title and the first 4 000 characters of the story only — never in trailing teasers or footers. */ export const OPERATOR_SCOPE_CHARS = 4000; /** * Company-domicile mentions must not locate a project: "Denver-based Vantage plans a campus in Abilene, Texas" is in * Abilene. Strips "-based", "headquartered in " and ", based in " before location detection. */ export function stripHqMentions(text: string): { text: string; stripped: boolean } { let stripped = false; const out = text .replace(/\b([A-Z][a-zA-Z.'-]+(?:,? [A-Z][a-zA-Z.'-]+){0,3})[- ]based\b/g, () => { stripped = true; return " "; }) .replace(/\b(?:headquartered|HQ'?d|domiciled) in ([A-Z][a-zA-Z.'-]+(?:,? [A-Z][a-zA-Z.'-]+){0,3})/g, () => { stripped = true; return " "; }) .replace(/\b(Inc\.?|LLC|Ltd\.?|Limited|Group|plc|Corp\.?|Corporation|company|firm|developer|operator|provider|startup|REIT|fund)[,]? (?:which is |that is )?based in ([A-Z][a-zA-Z.'-]+(?:,? [A-Z][a-zA-Z.'-]+){0,3})/g, (_m, w: string) => { stripped = true; return `${w} `; }); return { text: out, stripped }; } const FULL_MONTHS: Record = { january: "Jan", february: "Feb", march: "Mar", april: "Apr", june: "Jun", july: "Jul", august: "Aug", september: "Sep", october: "Oct", november: "Nov", december: "Dec" }; /** * `parsePartialDate` only knows 3-letter month abbreviations ("Sep 2026"); English prose uses full names. * Normalise "8 September 2026" → "8 Sep 2026" before parsing. Same output format as parsePartialDate. */ export function parseDateLoose(text: string | null | undefined): string | null { if (!text) return null; const s = text.replace(/\b(January|February|March|April|June|July|August|September|October|November|December)\b/gi, (m) => FULL_MONTHS[m.toLowerCase()] ?? m).replace(/\bSept\b/gi, "Sep"); return parsePartialDate(s); } // ─── status ──────────────────────────────────────────────────────────────────────────────────────── /** Ordered by precedence: the first rule that matches the scope wins (title scope before body scope). */ export const STATUS_RULES: Array<[RegExp, FacilityStatus]> = [ [/\b(cancel+ed|cancels|scrapped|scraps|abandon(ed|s)|withdrawn|withdraws|shelved|shelves|pulls? (the )?plug|axed|axes|drops? plans)\b/i, "cancelled"], [/\b(closed|closes|decommissioned|shut(s|ting)? ?down|ceased operations|to close|closure of)\b/i, "closed"], [/\b(delayed|delays|postponed|postpones|on hold|paused|pushed back|setback)\b/i, "delayed"], [/\b(approved|approves|approval granted|grants? (planning )?(permission|approval|rezoning)|received (planning )?approval|rezoning approved|permits? (were |was |have been |has been )?(granted|issued|approved)|green-?light(ed|s)?|wins? (planning )?(approval|permission)|planning permission (granted|secured)|secures? (planning )?(approval|permission|consent)|unanimously approved|votes? to approve|voted to approve|signed off)\b/i, "approved"], [/\b(planning application|permit application|rezoning (request|application|case|bid)|seeking approval|applied for|files? (plans|an application|for|a planning|planning)|filed (plans|an application|for)|submitted (plans|an application|a proposal|a planning)|submits? (plans|an application|a proposal)|under review by|seeks? (approval|permission|rezoning|to rezone|planning)|planning (commission|board|committee) (will|to|is set to) (consider|review|hear|vote)|public hearing|lodged (plans|a planning|an application)|lodges? (plans|a planning|an application))\b/i, "permitting"], [/\b(under construction|being built|construction (is )?under ?way|break(s|ing)? ground|broke ground|groundbreaking|topped out|construction (has |have )?(started|began|begun|commenced)|begins? construction|starts? construction|kicks? off construction|construction (start|begins|kicks off))\b/i, "under_construction"], [/\b(partially operational|phase\s*(1|one|I)\s*(is\s*)?(now\s*)?(live|operational|complete|completed|open|opens|opened))\b/i, "partially_operational"], [/\b(announc(es|ed|ing)|unveil(s|ed)?|plans? (to build|for|a|to develop|to invest|to construct|to create|to open)|will build|to build|to develop|reveal(s|ed)? plans|launch(es|ed)? plans|proposes? to build|set to build|to construct|will invest|to invest|is planning|are planning|planning to (build|develop|invest|open)|commits? (to|\$|€|£)|pledges?|earmarks?|to open|will open|set to open|due to open|slated to open)\b/i, "announced"], [/\b(proposed|proposal|proposes|pitches|pitched)\b/i, "proposed"], [/\b(rumou?red|reportedly|is said to|considering|exploring|in talks|eyes|eyeing|mulls?|mulling|weighs?|weighing|scouting|could build)\b/i, "rumored"], [/\b(expansion|expanding|expands|adds? capacity|new phase|additional \d+ ?MW|second phase|next phase|extension of)\b/i, "expansion"], [/\b(now open|opened|opens|operational|goes? live|is live|went live|commissioned|inaugurat(ed|es|ion)|ready for service|RFS|completes? construction|construction (is )?complete|fully operational|energi[sz]ed|comes online|came online)\b/i, "operational"], ]; /** "will come online in 2027" is a plan, not an operational facility. */ const FUTURE_ONLINE_RE = /\b(to|will|would|expected to|slated to|set to|due to|scheduled to|is expected to|are expected to|aims? to|plans? to) (come|go|be|become) (online|live|operational|ready for service|energi[sz]ed|commissioned)\b|\b(come|coming|go|going) (online|live) (in|by) (Q[1-4]|20\d\d|early|late|mid|the)\b|\b(open|opening|operational|online|live|ready for service|RFS|completion|complete|delivered?|delivery) (in|by|for|from) (Q[1-4]|H[12]|20\d\d|early|late|mid|the (first|second) half)\b/i; /** * A headline about a facility that is open / online / delivered describes an existing facility, whatever else the * sentence "announces" ("AirTrunk today announced the opening of…", "Utah County, We Are Online!", "2MW Delivered"). */ export const OPENING_TITLE_RE = /\b(opens|opened|opening of|now open|officially open(?:s|ed)?|now online|we are online|is online|are online|now live|goes live|went live|now serving|inaugurat\w+|delivered|commissioned|completes|completion of|celebrates? (?:the )?(?:opening|launch|grand opening)|ribbon[- ]cutting|welcomes? (?:its )?first customers?)\b/i; const FUTURE_OPEN_RE = /\b(to|will|set to|plans? to|due to|expected to|slated to|scheduled to|aims? to|on track to) (open|be online|go live|be delivered|be completed|be commissioned)\b|\b(open|opening|online|live|delivered|completed) (in|by) (Q[1-4]|H[12]|20\d\d|early|late|mid)\b/i; export function inferStatusPrecedence(title: string | null, text: string): { status: FacilityStatus | null; method: string } { const scopes: Array<[string, string]> = [["title", title ?? ""], ["lead", text.slice(0, 1500)], ["body", text.slice(0, 6000)]]; if (title && OPENING_TITLE_RE.test(title) && !FUTURE_OPEN_RE.test(title) && !FUTURE_ONLINE_RE.test(title) && !STATUS_RULES.slice(0, 3).some(([re]) => re.test(title))) { return { status: "operational", method: "regex:status_v1:title-opening" }; } for (const [name, scope] of scopes) { if (!scope.trim()) continue; const matched = STATUS_RULES.filter(([re]) => re.test(scope)).map(([, st]) => st); if (!matched.length) continue; let status = matched[0]!; if (status === "operational" && FUTURE_ONLINE_RE.test(scope)) status = "announced"; if (status === "expansion" && FUTURE_ONLINE_RE.test(scope) && matched.includes("operational")) status = "expansion"; return { status, method: `regex:status_v1:${name}` }; } return { status: null, method: "none" }; } // ─── headline vetoes ─────────────────────────────────────────────────────────────────────────────── /** * Headlines that are about something other than a facility being planned or built: people, awards, events, * partnerships and commercial deals, financing, market research, legal and policy stories, opinion. None of them * yields a project (whatever MW figures the body quotes), and their event type falls back to plain `news`. */ export const NON_PROJECT_TITLE_RE = new RegExp( [ // people "appoint(?:s|ed|ment)?|names? [A-Z][a-z]+ [A-Z][a-z]+ (?:as|to)|joins? (?:as|the board)|(?:new|hires?|promotes?) (?:CEO|CFO|COO|CTO|CRO|CIO|chief|president|head of|director|vice president|VP|managing director)|leadership (?:change|team|appointments?)|steps? down|retire(?:s|ment)|passing of|obituary|in memoriam|welcomes [A-Z][a-z]+ [A-Z][a-z]+", // awards, rankings, events, marketing "awards?\\b|award-winning|recogni[sz]ed|honou?red|wins? (?:the )?(?:[A-Z]\\w+ )?(?:award|prize)|finalist|shortlist|named (?:a |one of |to )?(?:top|best|leader|great place)|certif(?:ied|ication) (?:as|for|by)|(?:earns?|achieves?|receives?) (?:[A-Z]\\w+ )?(?:certification|rating|accreditation|iso \\d)|interview|webinar|podcast|fireside|keynote|to speak|speaks? at|panel|summit|conference|expo\\b|exhibit|booth|hosts? (?:inaugural |annual )?(?:event|gala|open house|tour)|women in|diversity|scholarship|volunteer|charity|donat(?:es|ion)|community (?:day|commitment|grant)|tree|sponsor|customer spotlight|case study|white ?paper|e-?book|guide to|checklist|tips|best practices|explained|101\\b|q&a|faq|behind the scenes|inside look|a look (?:at|inside)|day in the life|celebrat(?:es|ing) \\d+ years|anniversary", // partnerships / commercial deals (not construction) "partnership|partners? with|join forces|team(?:s|ed)? up|collaborat(?:es?|ion)|alliance|memorandum|mou\\b|deals?\\b|sends? (?:a )?message|deploys? (?:a )?pop|point of presence|peering|interconnect(?:s|ion)? (?:with|to)|cloud on-?ramp|exchange point|selects? [A-Z]\\w+ (?:for|as|to)|chooses? [A-Z]\\w+ (?:for|as|to)|(?:hosting|colocation|lease|leasing|services?|master|supply|framework|offtake) agreements?|signs? (?:multi-year |long-term |a )?(?:agreement|contract|deal|lease)s?|secures? (?:\\$[\\d.,]+[mb]n? )?(?:hyperscaler |anchor |major )?(?:contract|tenant|customer|lease)|renews? (?:lease|contract)|expands? (?:its )?(?:relationship|partnership)|adds? [A-Z]\\w+ (?:as|to its)|launches? (?:new )?(?:service|platform|product|portal|program|offering|solution|marketplace)|available (?:now|in)|now available|integrat(?:es|ion) with|certified by", // financing / corporate "financ(?:es|ing)|refinanc\\w+|raises? \\$|series [a-e]\\b|valuation|funding (?:round|arranged|secured)|loan|credit facility|green bond|bonds?\\b|notes? offering|debt|equity|ipo\\b|listing|shareholders?|dividend|quarterly results|half[- ]year results|annual results|q[1-4] (?:fy)?\\d{2}(?:\\d{2})? results|(?:1h|2h|fy)\\d{2} results|earnings|revenue|ebitda|guidance|investor (?:day|presentation|update)|s&p|moody|fitch|rating agency|acquisition of [A-Z]\\w+ (?:group|holdings|inc|ltd|llc)|acquires? [A-Z]\\w+ (?:group|holdings|inc|ltd|llc)|merger|takeover|buyout|stake in", // market research / policy / legal / opinion "market (?:to|will|set to|expected to|projected to) (?:surpass|reach|hit|grow|exceed|top)|market size|cagr|forecast|outlook|report(?: finds| shows| reveals|:)|survey|study (?:finds|shows|reveals)|index\\b|trends?|predictions?|what to expect|state of|whitepaper|bill (?:would|to|passes|introduced)|legislation|lawmakers|senate|congress|house bill|regulat(?:ion|ors?)|tax (?:on|credit|break)|moratorium|ban\\b|sues?|lawsuit|litigation|court|settles?|settlement|fine[ds]?\\b|penalty|opinion|op-ed|analysis|why |how |what |the case for|explainer|myth|debate|lessons", ] .map((alt) => `\\b(?:${alt})`) .join("|"), "i", ); /** A headline that clearly describes building / planning a facility overrides the veto ("Vantage breaks ground…", "… files plans for 300MW campus"). */ export const BUILD_TITLE_RE = /\b(breaks? ground|broke ground|groundbreaking|to build|will build|plans? (?:for|to build|to develop|to construct|new|\$|€|£|\d)|proposes?|proposed|approv(?:es|ed|al)|files? (?:plans|application|for)|rezon\w+|to develop|to construct|construction (?:begins|starts|start|of|on|underway|under way)|unveils? (?:plans|new|\d|\$)|announces? (?:plans|new|its|a new|\$|€|£|\d)|to invest|invests? [\$€£]|expansion (?:of|at|in)|expands? (?:with|in|into|its)|new (?:\d+ ?MW|\d+ ?GW|hyperscale|data cent(?:er|re)|campus)|(?:\d+ ?(?:MW|GW)) (?:campus|data cent(?:er|re)|facility|site|project)|permit(?:s|ted)? (?:granted|issued|approved)|greenlit|green-?light|land (?:purchase|acquisition|deal) for|acquires? (?:\d+[- ]acre|land|site)|topping out|topped out|phase \d|energi[sz](?:ed|es)|breaks? ground)\b/i; /** true when the headline is about people / deals / money / markets / policy and does not describe a build. */ export function isNonProjectTitle(title: string | null): boolean { if (!title) return false; return NON_PROJECT_TITLE_RE.test(title) && !BUILD_TITLE_RE.test(title); } /** * Title subject: the proper-noun phrase before the first announcement verb. When it names a company that is not in * the operator lexicon ("Cerebras Begins Construction…", "Greenfield And Finsbury Infrastructure To Invest…", * "JV Plans 54MW Data Center…"), operators found in the body are partners, tenants or neighbours — not the developer. */ const TITLE_VERBS = "to|plans?|begins?|breaks?|files?|proposes?|announces?|secures?|invests?|unveils?|expands?|starts?|wins?|gets?|seeks?|eyes|raises|acquires|buys|signs?|launches?|opens?|confirms?|reveals?|sues|picks|selects|lands|inks|taps|targets|commits|pledges|explores|weighs|mulls|readies|advances|completes|delivers|scraps|cancels|delays|pauses|halts|receives|obtains|submits|lodges|withdraws|kicks|moves|set|slated|eyeing|weighing|considering"; const TITLE_SUBJECT_RE = new RegExp(`^((?:[A-Z][\\w&'’.-]*|and|&|of|de|du|the)(?:\\s(?:[A-Z][\\w&'’.-]*|and|&|of|de|du|the)){0,5})(?:’s|'s)?\\s+(?:${TITLE_VERBS})\\b`, "i"); export function unknownTitleSubject(title: string | null): boolean { if (!title) return false; const m = title.match(TITLE_SUBJECT_RE); if (!m) return false; const subject = m[1]!.trim(); if (!/[A-Z]/.test(subject)) return false; // no proper noun at all if (subject.split(/\s+/).length === 1 && GENERIC_WORD.test(subject)) return false; // "New", "Local", "Massive" if (/^(Data Cent(?:er|re)s?|Developer|Company|Firm|Group|Council|County|City|Board|Utility|Town|State|Officials?|Residents?|Neighbou?rs|Report|Study|Bill|Judge|Court|Governor|Mayor|Senator)\b/i.test(subject) && subject.split(/\s+/).length <= 3) return true; return detectOperators(subject).length === 0 && !/\b(county|city|town|township|parish|council|board|commission|state|province|government|ministry|department)\b/i.test(subject); } // ─── money ───────────────────────────────────────────────────────────────────────────────────────── const CURRENCY_START_RE = /(US\$|USD|\$|CA\$|C\$|€|EUR|£|GBP|A\$|AUD|¥|JPY|S\$|SGD|₹|INR|CHF|SEK|NOK|DKK)\s?\d/g; /** Approximate rates — gating only (is this ≥ $50M?), never persisted. */ const APPROX_USD: Record = { USD: 1, EUR: 1.1, GBP: 1.3, CAD: 0.73, AUD: 0.66, SGD: 0.75, INR: 0.012, JPY: 0.0067, CHF: 1.12, SEK: 0.095, NOK: 0.093, DKK: 0.147 }; /** Every money figure in a text (largest first); `keep(start)` may veto a figure from its position in `text`. */ export function parseAllMoney(text: string, keep: (start: number) => boolean = () => true): Money[] { const out: Money[] = []; const seen = new Set(); CURRENCY_START_RE.lastIndex = 0; for (const m of text.matchAll(CURRENCY_START_RE)) { const window = text.slice(m.index, (m.index ?? 0) + 40); // ignore rates such as "$25/MWh" or "$0.05 per kWh" if (/^\S+\s?[\d.,]+\s*(\/|per)\s*(MWh|kWh|MW|kW|sq|ft|month|year|hour)/i.test(window)) continue; const money = parseMoney(window); // < $1M is not a project investment; > $500B is an industry-wide statistic ("$31.6 trillion AI buildout") if (!money || money.amount < 1_000_000 || money.amount > 500_000_000_000) continue; if (!keep(m.index ?? 0)) continue; const k = `${money.currency}:${money.amount}`; if (seen.has(k)) continue; seen.add(k); out.push(money); } return out.sort((a, b) => b.amount - a.amount); } /** * Company-background wording before a money figure, in the same sentence: the developer's portfolio, balance sheet or * history ("a total investment volume of €8.1 billion ($9.4bn)", "assets under management of…", "revenue of…") — never * this project's budget. */ export const MONEY_PORTFOLIO_RE = /\b(investment volume|portfolio|assets under management|under management|AUM|market (?:cap|capitali[sz]ation)|net worth|revenues?|turnover|balance sheet|(?:manages|owns|operates|holds|controls) an? (?:estate|portfolio|footprint)|estate of|founded in \d{4}|since (?:its )?(?:founding|inception)|to date|over the (?:past|last) (?:\d+|two|three|five|ten) years)\b/i; /** The sentence fragment before position `at` (from the previous sentence break, at most `max` chars). */ function sentenceBefore(text: string, at: number, max = 300): string { const seg = text.slice(Math.max(0, at - max), at); const brk = Math.max(seg.lastIndexOf(". "), seg.lastIndexOf("\n"), seg.lastIndexOf("? "), seg.lastIndexOf("! ")); return brk >= 0 ? seg.slice(brk + 1) : seg; } /** Money figures that may describe the announced project: company-background figures (portfolio volume, revenue…) are skipped. */ export function projectMoneyFigures(scope: string): Money[] { return parseAllMoney(scope, (start) => !MONEY_PORTFOLIO_RE.test(sentenceBefore(scope, start))); } export function approxUsd(m: Money | null): number | null { if (!m) return null; const r = APPROX_USD[m.currency]; return r ? Math.round(m.amount * r) : null; } // ─── phases ──────────────────────────────────────────────────────────────────────────────────────── const WORD_NUM: Record = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, twelve: 12 }; const num = (s: string): number | null => { const n = WORD_NUM[s.toLowerCase()] ?? Number(s); return Number.isFinite(n) && n >= 1 && n <= 30 ? n : null; }; const NUM_ALT = "\\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten|twelve"; const PHASE_RES: RegExp[] = [ new RegExp(`\\b(?:in|across|over|into|comprising|consisting of|spanning|built in|delivered in|developed in|through|rolled out in|constructed in|completed in)\\s+(${NUM_ALT})\\s+(?:separate\\s+|distinct\\s+|construction\\s+|development\\s+)?phases\\b`, "i"), new RegExp(`\\bphase\\s+(?:\\d{1,2}|one|two|three|I|II|III)\\s+of\\s+(${NUM_ALT})\\b`, "i"), new RegExp(`\\b(${NUM_ALT})[- ]phase(?:d)?\\s+(?:project|development|campus|build(?:-?out)?|plan|rollout|programme|program|scheme|expansion|construction)\\b`, "i"), new RegExp(`\\b(${NUM_ALT})\\s+phases\\b`, "i"), ]; export function parsePhaseCount(text: string): number | null { for (const re of PHASE_RES) { const m = text.match(re); if (m?.[1]) { const n = num(m[1]); if (n) return n; } } return null; } // ─── expected opening ───────────────────────────────────────────────────────────────────────────── /** Tier 1: the sentence talks about the facility being ready (opening / RFS / online / completion / delivery). */ const OPENING_KEYWORD_RE = /\b(ready for service|RFS|come online|comes online|coming online|go live|goes live|going live|go-live|open(?:ing|s)?\s+(?:in|by|during|for|early|late|mid)|to open|due to open|set to open|operational\s+(?:in|by|from|early|late|mid)|be operational|become operational|fully operational by|completion|complete(?:d)?\s+(?:in|by|early|late|mid)|to be completed|deliver(?:ed|y|ies)?\s+(?:in|by|from|early|late|mid)|energi[sz](?:ed|ation)\s+(?:in|by)|commission(?:ed|ing)?\s+(?:in|by)|first phase|initial phase|phase\s+(?:1|one|I)\b)/i; /** Tier 2: generic expectation verbs; sentences about construction start or land purchase are excluded. */ const EXPECT_KEYWORD_RE = /\b(expected|expects?|scheduled|slated|due|anticipated|anticipates?|targeted|targeting|targets?|projected|forecast|timeline|on track)\b/i; const CONSTRUCTION_START_RE = /\b(break(?:s|ing)? ground|groundbreaking|construction\s+(?:to|will|is expected to|set to|would|should|could)\s+(?:begin|start|commence|kick off|get under ?way)|start (?:of )?construction|begin construction|construction (?:start|is slated to start|is scheduled to start|is expected to start|is set to start|starting)|site work|land (?:purchase|acquisition)|closing on the land)\b/i; const DATE_TOKEN_RE = /\b(Q[1-4]\s*(?:of\s*)?20\d\d|(?:first|second|third|fourth|1st|2nd|3rd|4th)\s+(?:quarter|half)\s+(?:of\s+)?20\d\d|H[12]\s*20\d\d|20\d\d\s*-?\s*Q[1-4]|(?:early|mid|late|end of|end-|the end of|year-end|fiscal|FY)\s*-?\s*20\d\d|(?:January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)\.?\s+(?:\d{1,2},?\s+)?20\d\d|20\d\d)\b/g; export function sentences(text: string): string[] { return text.split(/(?<=[.!?])\s+(?=[A-Z0-9"'“(])/).map((s) => s.trim()).filter(Boolean); } interface DateBounds { minYear: number; maxYear: number; /** partial date the expected date must come after (the publication date) */ after?: string | null } function firstDateAfter(s: string, from: number, opts: DateBounds): string | null { const after = s.slice(from); DATE_TOKEN_RE.lastIndex = 0; for (const dm of after.matchAll(DATE_TOKEN_RE)) { const d = parseDateLoose(dm[1]!); if (!d) continue; const y = Number(d.slice(0, 4)); if (y < opts.minYear || y > opts.maxYear) continue; // "announced September 2026" is the dateline, not the opening: the expected date must lie after publication if (opts.after && partialDateSortKey(d) <= partialDateSortKey(opts.after)) continue; return d; } return null; } /** * Expected opening / ready-for-service date. Sentences about the facility opening win over generic * "expected … 2027" sentences; sentences about construction start are ignored unless nothing else exists. */ export function parseExpectedOpening(text: string, opts: DateBounds): { value: string | null; method: string } { const sents = sentences(text.slice(0, 12_000)); for (const s of sents) { if (CONSTRUCTION_START_RE.test(s) && !OPENING_KEYWORD_RE.test(s)) continue; const km = OPENING_KEYWORD_RE.exec(s); if (!km) continue; // in "construction to begin in 2026, with completion in 2028" take the date after the opening keyword const d = firstDateAfter(s, km.index, opts); if (d) return { value: d, method: "regex:expected_date_v1:opening" }; } for (const s of sents) { if (CONSTRUCTION_START_RE.test(s)) continue; const km = EXPECT_KEYWORD_RE.exec(s); if (!km) continue; const d = firstDateAfter(s, km.index, opts); if (d) return { value: d, method: "regex:expected_date_v1:expected" }; } return { value: null, method: "none" }; } // ─── project name ────────────────────────────────────────────────────────────────────────────────── const NAME_RE = /(?) { NAME_RE.lastIndex = 0; for (const m of scope.matchAll(NAME_RE)) { const name = cleanText(m[1]!); if (!name || name.length < 8 || name.length > 80 || /\b\d+\s?(MW|GW|kW)\b/i.test(name)) continue; // "1.5 GW Texas Data Center", "8,000 Acre AI Campus": a figure before the match means the first word is a unit if (/[\d.,%]\s*$/.test(scope.slice(0, m.index ?? 0))) continue; // Title-Case headlines ("JV Faces Delays On Data Center") look like names: a real name recurs in the body if (scopeName === "title" && !body.includes(name)) continue; const words = name.split(/\s+/); if (UNIT_WORD.test(words[0]!) || GENERIC_WORD.test(words[0]!)) continue; const typeIdx = words.findIndex((w) => /^(Campus|Data|Technology|Digital|Innovation|Business|AI|Hub|Gateway|Cluster|Supercomputer)$/.test(w)); const lead = words.slice(0, typeIdx > 0 ? typeIdx : words.length - 1); if (!lead.length || lead.every((w) => GENERIC_WORD.test(w) || UNIT_WORD.test(w))) continue; // "Vantage Data Centers" / "Microsoft Data Center" are operators, not project names const ops = detectOperators(name); if (ops.length && (ops[0]!.matched.length >= name.length - 1 || lead.join(" ") === ops[0]!.matched || (operator && lead.join(" ") === operator.matched))) continue; if (/^(Data Cent(er|re)s?|Campus)$/i.test(lead.join(" "))) continue; return name.replace(/’/g, "'"); } } // "Named ‘NIMB,’ the firm…", "dubbed 'Project Sail'" — a quoted proper name given to the facility in the lead const q = text.slice(0, 1500).match(QUOTED_NAME_RE); if (q) { const name = cleanText(q[1]!.replace(/’/g, "'")); if (name && name.length >= 3 && name.length <= 60 && /^[A-Z0-9]/.test(name) && !GENERIC_WORD.test(name) && !UNIT_WORD.test(name) && !detectOperators(name).length) return name; } return null; } const QUOTED_NAME_RE = /\b(?:named|dubbed|called|known as|branded|code-?named|titled)\s+[‘'"“]([^‘’'"“”\n]{2,60}?)[,.]?[’'"”]/i; export function cleanTitle(title: string | null): string { if (!title) return ""; let t = cleanText(title) ?? ""; // strip a trailing publisher suffix: "… | DCD", "… - Data Center Knowledge" const m = t.match(/^(.{20,})\s[|–-]\s([^|–-]{2,45})$/); if (m && !/\d\s?MW|\$|€|£/.test(m[2]!)) t = m[1]!.trim(); return t; } export function generateProjectName(a: { title: string; explicitName: string | null; operator: OperatorHit | null; location: LocationHit | null }): { name: string; method: string } { if (a.explicitName) return { name: a.explicitName.slice(0, 160), method: "regex:project_name_v1" }; const op = a.operator?.name; const loc = a.location; if (op && loc?.city) return { name: `${op} ${loc.city} campus`, method: "generated:operator_city" }; if (op && loc?.region) return { name: `${op} ${loc.region} data center project`, method: "generated:operator_region" }; if (op && loc?.country) return { name: `${op} data center project (${loc.country})`, method: "generated:operator_country" }; if (!op && loc?.city) return { name: `${loc.city} data center project`, method: "generated:city" }; return { name: cleanTitle(a.title).slice(0, 160), method: "title" }; } // ─── relevance / classification ──────────────────────────────────────────────────────────────────── const STRONG_RELEVANCE_RE = /\b(data ?cent(?:er|re)s?|datacent(?:er|re)s?|colocation|colo\b|hyperscale|hyperscaler|cloud region|availability zones?|server farm|AI (?:factory|factories|infrastructure|campus|cluster|compute|supercomputer)|GPU (?:cluster|campus|cloud|capacity)|compute capacity|bitcoin min(?:er|ing)|rechenzentr\w*|centre de données|centro de datos|data hall|IBX|megawatts? of (?:IT|critical) (?:load|capacity|power))\b/i; /** "200MW campus", "1GW site" — a power figure attached to a campus/site is a data center in this industry's vocabulary. */ const MW_CAMPUS_RE = /\b\d[\d.,]*\s?(?:MW|GW|megawatts?|gigawatts?)\s+(?:AI\s+)?(?:campus|campuses|facility|facilities|site|sites|development|project|hyperscale|data hall)\b/i; export function relevanceOf(title: string | null, text: string): Announcement["relevance"] { const head = `${title ?? ""}\n${text.slice(0, 5000)}`; if (STRONG_RELEVANCE_RE.test(head) || MW_CAMPUS_RE.test(head)) return "strong"; // a known operator plus a MW figure in the lead is unambiguous ("Data4 to invest £1.2bn in 200MW campus") const lead = `${title ?? ""}\n${text.slice(0, 1500)}`; if (parseAllMw(lead).length && detectOperators(lead).length) return "strong"; if (isDataCenterRelevant(head)) return "weak"; return "none"; } /** Strong vocabulary in the title or the lead — a body-only mention (an aside in a rate case) is not an announcement. */ export function leadRelevanceOf(title: string | null, text: string): boolean { const lead = `${title ?? ""}\n${text.slice(0, 600)}`; return STRONG_RELEVANCE_RE.test(lead) || MW_CAMPUS_RE.test(lead) || (parseAllMw(lead).length > 0 && detectOperators(lead).length > 0); } const TITLE_SIGNAL_RE = /\b(data ?cent(?:er|re)s?|datacent(?:er|re)s?|campus(?:es)?|\d[\d.,]*\s?(?:MW|GW)\b|megawatts?|gigawatts?|hyperscale|colocation|cloud region|AI (?:factory|infrastructure|hub|cluster|compute)|GPU|data hall|rechenzentr\w*|centre de données|centro de datos)/i; export function titleSignalOf(title: string | null): boolean { if (!title) return false; return TITLE_SIGNAL_RE.test(title) || detectOperators(title).length > 0; } /** * When the text classifier only sees a generic press release, the inferred status tells us what kind of event * this is: approvals / applications are planning documents, groundbreakings are construction updates, plans are * project announcements (only when a size is known — otherwise leave it alone). */ function pageTypeFromStatus(pageType: PageType, status: FacilityStatus | null, hasSize: boolean): PageType { if (pageType !== "unknown" && pageType !== "press_release") return pageType; if (status === "approved" || status === "permitting") return "planning_document"; if (status === "under_construction") return "construction_update"; if ((status === "announced" || status === "proposed" || status === "rumored") && hasSize) return "project_announcement"; return pageType; } /** Text-only classification, title first (URL rules are deliberately ignored: "/news/…" tells us nothing about the event). */ export function classifyAnnouncement(title: string | null, text: string): { pageType: PageType; eventType: EventType | undefined; rule: string } { const byTitle = title ? classifyPage("", title, "") : null; // body classification limited to the lead: "applications closing", "acquired in 2019" deep in an article are not the event const cls = byTitle && byTitle.pageType !== "unknown" && byTitle.pageType !== "press_release" ? byTitle : classifyPage("", title, text.slice(0, 1500)); const pageType = cls.pageType; const eventType = eventTypeFor(pageType, `${title ?? ""}\n${text.slice(0, 6000)}`); return { pageType, eventType, rule: `${byTitle && cls === byTitle ? "title" : "text"}:${cls.rule}` }; } // ─── main ────────────────────────────────────────────────────────────────────────────────────────── export function extractAnnouncement(rawTitle: string | null, rawText: string, opts: { publishedAt?: string | null; now?: Date; /** primary source (operator / government / utility newsroom): the lead may supply class + verb */ trustLead?: boolean } = {}): Announcement { // "118-acre", "192-MW", "1.2-GW" → the core parsers expect a space (or nothing) between figure and unit const unhyphen = (s: string) => s.replace(/(\d)[-‑–](?=(?:acres?|hectares?|ha|MW|GW|kW|megawatts?|gigawatts?)\b)/gi, "$1 "); const title = unhyphen(cleanTitle(rawTitle)); const text = unhyphen(rawText ?? "").replace(/ /g, " ").replace(/[ \t]+/g, " "); const head = text.slice(0, 600); const body = text.slice(0, 6000); const methods: Record = {}; const now = opts.now ?? new Date(); const relevance = relevanceOf(title, text); const cls = classifyAnnouncement(title, text); methods.pageType = cls.rule; // MW: headline figure = largest project figure in title/lead, else in body. Figures above 20 GW are market statistics; // portfolio totals ("capacity to over 410MW in Japan", "1.2GW across APJ") are skipped when a site figure exists. const mwTitle = projectMwFigures(title); const mwLead = projectMwFigures(`${title} ${head}`); const mwBodySite = projectMwFigures(`${title} ${body}`); const mwBody = parseAllMw(`${title} ${body}`).filter((v) => v > 0 && v <= 20_000); // every figure, for the event's `mw` list let headlineMw: number | null = null; if (mwTitle.length) { headlineMw = mwTitle[0]!; methods.plannedMw = "regex:mw_v1:title"; } else if (mwLead.length) { headlineMw = mwLead[0]!; methods.plannedMw = "regex:mw_v1:lead"; } else if (mwBodySite.length) { headlineMw = mwBodySite[0]!; methods.plannedMw = "regex:mw_v1:body"; } // money: same title → lead → body preference, largest figure in the winning scope let money: Money | null = null; for (const [scopeName, scope] of [["title", title], ["lead", `${title} ${head}`], ["body", `${title} ${body}`]] as Array<[string, string]>) { const all = projectMoneyFigures(scope); if (all.length) { money = all[0]!; methods.investment = `regex:money_v1:${scopeName}`; break; } } const investmentUsd = money && money.currency === "USD" ? money.amount : null; const investmentUsdApprox = approxUsd(money); // acreage (project field is in acres; parseAreaHa returns hectares) let acreage: number | null = null; for (const [scopeName, scope] of [["lead", `${title} ${head}`], ["body", body]] as Array<[string, string]>) { const ha = parseAreaHa(scope); if (ha != null && ha > 0) { const acres = round(ha / 0.40468564, 1); if (acres <= 50_000) { acreage = acres; methods.acreage = `regex:area_v1:${scopeName}`; break; } } } const phaseCount = parsePhaseCount(`${title}. ${body}`); if (phaseCount) methods.phaseCount = "regex:phase_v1"; const st = inferStatusPrecedence(title, text); if (st.status) methods.status = st.method; const publishedYear = opts.publishedAt ? Number(opts.publishedAt.slice(0, 4)) : NaN; const minYear = Number.isFinite(publishedYear) ? publishedYear : now.getFullYear() - 1; const exp = parseExpectedOpening(`${title}. ${body}`, { minYear, maxYear: now.getFullYear() + 15, after: opts.publishedAt ?? null }); if (exp.value) methods.expectedOpening = exp.method; // location: title first, then lead, then body; a title hit without a city is completed from the lead when consistent. // Company domiciles ("Denver-based", "headquartered in Ashburn") are removed first — they are not the project's location. const hqTitle = stripHqMentions(title); const hqLead = stripHqMentions(text.slice(0, 1500)); const hqBody = stripHqMentions(body); let location = detectLocation(hqTitle.text); const leadLoc = detectLocation(hqLead.text); if (!location) location = leadLoc ?? detectLocation(hqBody.text); else if (!location.city && leadLoc?.city && (!location.country || leadLoc.country === location.country) && (!location.region || !leadLoc.region || leadLoc.region === location.region)) location = { ...leadLoc, method: `${location.method}+${leadLoc.method}` }; if (location) { methods.location = location.method; } const hqGuarded = hqTitle.stripped || hqLead.stripped || hqBody.stripped; // operators: title + the first OPERATOR_SCOPE_CHARS of the story only (a "More in …" teaser or a footer never names the developer) const opScope = text.slice(0, OPERATOR_SCOPE_CHARS); const operators = detectOperators(`${title}\n${opScope}`); let operator = primaryOperator(title, opScope); // the headline's subject is a company we do not know → lexicon operators in the body are not the developer if (operator && !detectOperators(title).length && unknownTitleSubject(title)) { operator = null; methods.operatorName = "none:unknown-title-subject"; } else if (operator) methods.operatorName = "lexicon:operator"; const explicitName = findExplicitName(title, text, operator); const gen = generateProjectName({ title, explicitName, operator, location }); methods.name = gen.method; const nonProject = isNonProjectTitle(title); let pageType = pageTypeFromStatus(cls.pageType, st.status, headlineMw != null || money != null || acreage != null); let eventType = pageType === cls.pageType ? cls.eventType : eventTypeFor(pageType, `${title}\n${body}`); if (pageType !== cls.pageType) methods.pageType = `${cls.rule}+status:${st.status}`; // people / deals / money / market headlines are plain news whatever the body's vocabulary says if (nonProject && !["acquisition", "closure", "cloud_region", "financial_disclosure"].includes(pageType)) { pageType = "press_release"; eventType = "news"; methods.pageType = `${methods.pageType}+veto:title`; } // classification BEFORE anything becomes a project: people / money / deal / market headlines never do const classification = classifyProjectEvent({ title, lead: head, hasExplicitName: !!explicitName, hasOperator: !!operator, hasLocation: !!(location?.city || location?.region || location?.country), status: st.status, trustLead: !!opts.trustLead }); // evidence + scope + semantics of the headline figures (the sentence decides; no sentence → the claim is not site-scoped) const campusHint: ClaimScope = /\b(campus|park|complex|hub)\b/i.test(`${explicitName ?? ""} ${title}`) ? "campus" : "facility"; const mwEvidence = headlineMw != null ? findEvidence(`${title}. ${body}`, headlineMw, "mw") : null; const mwScope = headlineMw != null ? (mwEvidence ? classifyScope(mwEvidence.text, campusHint) : { scope: "unknown" as ClaimScope, reason: "no-evidence" }) : { scope: "unknown" as ClaimScope, reason: "none" }; const mwSem = mwEvidence ? classifyCapacitySemantics(mwEvidence.text) : { predicate: null, reason: "none" }; const invEvidence = money ? findEvidence(`${title}. ${body}`, money.amount, "usd") : null; const invSem = money ? classifyInvestmentSemantics(invEvidence?.text ?? title, campusHint) : { predicate: "project_investment_usd" as const, scope: "unknown" as ClaimScope, reason: "none" }; const ai = classifyAiEvidence(`${title}. ${head}`); methods.classification = classification.reasons.join("+") || "none"; if (mwEvidence) methods.plannedMwEvidence = "sentence"; if (invEvidence) methods.investmentEvidence = "sentence"; return { title, pageType, eventType, relevance, leadRelevance: leadRelevanceOf(title, text), titleSignal: titleSignalOf(title), nonProjectTitle: nonProject, mwAll: mwBodySite.length ? mwBodySite : mwBody.slice(0, 3), headlineMw, money, investmentUsd, investmentUsdApprox, acreage, phaseCount, expectedOpening: exp.value, status: st.status, location, operators, operator, explicitName, projectName: gen.name, methods, classification, evidence: { plannedMw: mwEvidence, investment: invEvidence }, capacityScope: mwScope.scope, capacitySemantics: mwSem.predicate ?? (headlineMw != null ? "planned_power_mw" : null), investmentScope: invSem.scope, investmentSemantics: invSem.predicate, aiEvidence: ai.level, hqGuarded, }; } /** * Site-vs-portfolio MW filter — lives in @dci/connectors (mw-figures.ts) so the page classifier applies the same * rule; re-exported here so existing imports keep working. */ export { projectMwFigures, PORTFOLIO_BEFORE_RE, PORTFOLIO_AFTER_RE } from "@dci/connectors"; const PROJECT_PAGE_TYPES: readonly PageType[] = ["project_announcement", "expansion", "construction_update", "planning_document", "power_infrastructure", "press_release", "utility_announcement"]; const PROJECT_STATUSES: readonly FacilityStatus[] = [...PIPELINE_STATUSES, "expansion"]; /** * Should this announcement yield a project record? Requires a pipeline status on an announcement-like page and a * material size: MW ≥ 5, or investment ≥ $50M (approximate conversion for the gate only), or a site ≥ 100 acres * (large rezonings are often announced without a MW figure). */ export function qualifiesAsProject(a: Announcement, opts: { minMw?: number; minInvestmentUsd?: number; minAcres?: number; lenient?: boolean } = {}): boolean { if (a.relevance !== "strong") return false; if (a.nonProjectTitle) return false; // the announcement classifier: only physical development classes with (name or operator) + location + development verb if (!a.classification.physical) return false; if (!opts.lenient && !a.classification.mayCreateProject) return false; if (opts.lenient && a.classification.evidence.strength === "none") return false; if (!a.status || !PROJECT_STATUSES.includes(a.status)) return false; if (!PROJECT_PAGE_TYPES.includes(a.pageType)) return false; // An announcement names its subject up front: data-center vocabulary in the lead, and a title that mentions the // facility / capacity / operator. Opinion pieces and rate cases that mention data centers in passing do not qualify. if (!opts.lenient && (!a.leadRelevance || !a.titleSignal)) return false; // The qualifying size must come from the title or the lead, not from a figure buried in the body. const fromLead = (m: string | undefined) => !!m && !m.endsWith(":body"); const minMw = opts.minMw ?? 5, minInv = opts.minInvestmentUsd ?? 50_000_000, minAcres = opts.minAcres ?? 100; const mwOk = a.headlineMw != null && a.headlineMw >= minMw && (opts.lenient || fromLead(a.methods.plannedMw)); const invOk = a.investmentUsdApprox != null && a.investmentUsdApprox >= minInv && (opts.lenient || fromLead(a.methods.investment)); const acresOk = a.acreage != null && a.acreage >= minAcres && (opts.lenient || fromLead(a.methods.acreage)); return mwOk || invOk || acresOk; } export function isPipelineStatus(s: FacilityStatus | null | undefined): boolean { return !!s && PROJECT_STATUSES.includes(s); } /** Extraction certainty 0.5–0.8: more corroborating signals → higher. */ export function projectCertainty(a: Announcement): number { let c = 0.5; if (a.operator) c += 0.1; if (a.location?.city || a.location?.region) c += 0.1; if (a.methods.plannedMw?.endsWith(":title") || a.methods.investment?.endsWith(":title")) c += 0.1; return Math.min(0.8, round(c, 2)); } /** Summary trimmed for third-party publishers (≤ 400 chars, on a word boundary). */ export function shortSummary(s: string | null | undefined, max = 400): string | null { const t = cleanText(s); if (!t) return null; if (t.length <= max) return t; const cut = t.slice(0, max); return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), max - 40)).trim()}…`; }