spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1import type { EventType, FacilityStatus, PageType, ClaimScope, Evidence, ProjectClassification, AiEvidence } from "@dci/core";2import { PIPELINE_STATUSES, cleanText, parseAllMw, parseAreaHa, parseMoney, parsePartialDate, partialDateSortKey, round, classifyProjectEvent, classifyScope, classifyCapacitySemantics, classifyInvestmentSemantics, classifyAiEvidence, findEvidence } from "@dci/core";3import { classifyPage, eventTypeFor, isDataCenterRelevant, projectMwFigures } from "@dci/connectors";4import { detectOperators, primaryOperator, type OperatorHit } from "./operators-lexicon.js";5import { detectLocation, type LocationHit } from "./locations-lexicon.js";67/**8 * Announcement → project extraction. Pure text → structured fields, every field with the method that produced9 * it (for provenance). Nothing is invented: a missing figure stays null. See docs/PROJECT-EXTRACTION.md.10 */11export interface Money { amount: number; currency: string }1213export interface Announcement {14 title: string;15 /** classification from the text only (URL patterns ignored), title first */16 pageType: PageType;17 eventType: EventType | undefined;18 relevance: "strong" | "weak" | "none";19 /** the data-center vocabulary appears in the title or the first 600 chars (not only deep in the body) */20 leadRelevance: boolean;21 /** the title itself names a data center / campus / MW figure or a known operator */22 titleSignal: boolean;23 /** the headline is about people, awards, deals, financing, market research, policy or opinion — never a project */24 nonProjectTitle: boolean;25 mwAll: number[];26 headlineMw: number | null;27 money: Money | null;28 /** only when the headline figure is in USD — other currencies are never converted into a stored value */29 investmentUsd: number | null;30 /** approximate USD equivalent, used ONLY for the ≥ $50M project gate, never stored */31 investmentUsdApprox: number | null;32 acreage: number | null;33 phaseCount: number | null;34 expectedOpening: string | null;35 status: FacilityStatus | null;36 location: LocationHit | null;37 operators: OperatorHit[];38 operator: OperatorHit | null;39 explicitName: string | null;40 projectName: string;41 methods: Record<string, string>;42 /** what the announcement is about (people / money / deal / build…) — decides whether a project may exist at all */43 classification: ProjectClassification;44 /** supporting sentences for the headline figures (no sentence → no claim) */45 evidence: { plannedMw: Evidence | null; investment: Evidence | null };46 capacityScope: ClaimScope;47 capacitySemantics: string | null;48 investmentScope: ClaimScope;49 investmentSemantics: string | null;50 aiEvidence: AiEvidence;51 /** a "City, State-based" / "headquartered in" mention was removed before locating the project */52 hqGuarded: boolean;53}5455export const EXTRACTOR_VERSION = "news_v4";56/** Operators are looked up in the title and the first 4 000 characters of the story only — never in trailing teasers or footers. */57export const OPERATOR_SCOPE_CHARS = 4000;5859/**60 * Company-domicile mentions must not locate a project: "Denver-based Vantage plans a campus in Abilene, Texas" is in61 * Abilene. Strips "<Place>-based", "headquartered in <Place>" and "<company>, based in <Place>" before location detection.62 */63export function stripHqMentions(text: string): { text: string; stripped: boolean } {64 let stripped = false;65 const out = text66 .replace(/\b([A-Z][a-zA-Z.'-]+(?:,? [A-Z][a-zA-Z.'-]+){0,3})[- ]based\b/g, () => { stripped = true; return " "; })67 .replace(/\b(?:headquartered|HQ'?d|domiciled) in ([A-Z][a-zA-Z.'-]+(?:,? [A-Z][a-zA-Z.'-]+){0,3})/g, () => { stripped = true; return " "; })68 .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} `; });69 return { text: out, stripped };70}7172const FULL_MONTHS: Record<string, string> = { january: "Jan", february: "Feb", march: "Mar", april: "Apr", june: "Jun", july: "Jul", august: "Aug", september: "Sep", october: "Oct", november: "Nov", december: "Dec" };73/**74 * `parsePartialDate` only knows 3-letter month abbreviations ("Sep 2026"); English prose uses full names.75 * Normalise "8 September 2026" → "8 Sep 2026" before parsing. Same output format as parsePartialDate.76 */77export function parseDateLoose(text: string | null | undefined): string | null {78 if (!text) return null;79 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");80 return parsePartialDate(s);81}8283// ─── status ────────────────────────────────────────────────────────────────────────────────────────84/** Ordered by precedence: the first rule that matches the scope wins (title scope before body scope). */85export const STATUS_RULES: Array<[RegExp, FacilityStatus]> = [86 [/\b(cancel+ed|cancels|scrapped|scraps|abandon(ed|s)|withdrawn|withdraws|shelved|shelves|pulls? (the )?plug|axed|axes|drops? plans)\b/i, "cancelled"],87 [/\b(closed|closes|decommissioned|shut(s|ting)? ?down|ceased operations|to close|closure of)\b/i, "closed"],88 [/\b(delayed|delays|postponed|postpones|on hold|paused|pushed back|setback)\b/i, "delayed"],89 [/\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"],90 [/\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"],91 [/\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"],92 [/\b(partially operational|phase\s*(1|one|I)\s*(is\s*)?(now\s*)?(live|operational|complete|completed|open|opens|opened))\b/i, "partially_operational"],93 [/\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"],94 [/\b(proposed|proposal|proposes|pitches|pitched)\b/i, "proposed"],95 [/\b(rumou?red|reportedly|is said to|considering|exploring|in talks|eyes|eyeing|mulls?|mulling|weighs?|weighing|scouting|could build)\b/i, "rumored"],96 [/\b(expansion|expanding|expands|adds? capacity|new phase|additional \d+ ?MW|second phase|next phase|extension of)\b/i, "expansion"],97 [/\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"],98];99/** "will come online in 2027" is a plan, not an operational facility. */100const 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;101102/**103 * A headline about a facility that is open / online / delivered describes an existing facility, whatever else the104 * sentence "announces" ("AirTrunk today announced the opening of…", "Utah County, We Are Online!", "2MW Delivered").105 */106export 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;107const 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;108109export function inferStatusPrecedence(title: string | null, text: string): { status: FacilityStatus | null; method: string } {110 const scopes: Array<[string, string]> = [["title", title ?? ""], ["lead", text.slice(0, 1500)], ["body", text.slice(0, 6000)]];111 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))) {112 return { status: "operational", method: "regex:status_v1:title-opening" };113 }114 for (const [name, scope] of scopes) {115 if (!scope.trim()) continue;116 const matched = STATUS_RULES.filter(([re]) => re.test(scope)).map(([, st]) => st);117 if (!matched.length) continue;118 let status = matched[0]!;119 if (status === "operational" && FUTURE_ONLINE_RE.test(scope)) status = "announced";120 if (status === "expansion" && FUTURE_ONLINE_RE.test(scope) && matched.includes("operational")) status = "expansion";121 return { status, method: `regex:status_v1:${name}` };122 }123 return { status: null, method: "none" };124}125126// ─── headline vetoes ───────────────────────────────────────────────────────────────────────────────127/**128 * Headlines that are about something other than a facility being planned or built: people, awards, events,129 * partnerships and commercial deals, financing, market research, legal and policy stories, opinion. None of them130 * yields a project (whatever MW figures the body quotes), and their event type falls back to plain `news`.131 */132export const NON_PROJECT_TITLE_RE = new RegExp(133 [134 // people135 "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]+",136 // awards, rankings, events, marketing137 "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",138 // partnerships / commercial deals (not construction)139 "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",140 // financing / corporate141 "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",142 // market research / policy / legal / opinion143 "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",144 ]145 .map((alt) => `\\b(?:${alt})`)146 .join("|"),147 "i",148);149/** A headline that clearly describes building / planning a facility overrides the veto ("Vantage breaks ground…", "… files plans for 300MW campus"). */150export 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;151152/** true when the headline is about people / deals / money / markets / policy and does not describe a build. */153export function isNonProjectTitle(title: string | null): boolean {154 if (!title) return false;155 return NON_PROJECT_TITLE_RE.test(title) && !BUILD_TITLE_RE.test(title);156}157158/**159 * Title subject: the proper-noun phrase before the first announcement verb. When it names a company that is not in160 * the operator lexicon ("Cerebras Begins Construction…", "Greenfield And Finsbury Infrastructure To Invest…",161 * "JV Plans 54MW Data Center…"), operators found in the body are partners, tenants or neighbours — not the developer.162 */163const 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";164const 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");165export function unknownTitleSubject(title: string | null): boolean {166 if (!title) return false;167 const m = title.match(TITLE_SUBJECT_RE);168 if (!m) return false;169 const subject = m[1]!.trim();170 if (!/[A-Z]/.test(subject)) return false; // no proper noun at all171 if (subject.split(/\s+/).length === 1 && GENERIC_WORD.test(subject)) return false; // "New", "Local", "Massive"172 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;173 return detectOperators(subject).length === 0 && !/\b(county|city|town|township|parish|council|board|commission|state|province|government|ministry|department)\b/i.test(subject);174}175176// ─── money ─────────────────────────────────────────────────────────────────────────────────────────177const CURRENCY_START_RE = /(US\$|USD|\$|CA\$|C\$|€|EUR|£|GBP|A\$|AUD|¥|JPY|S\$|SGD|₹|INR|CHF|SEK|NOK|DKK)\s?\d/g;178/** Approximate rates — gating only (is this ≥ $50M?), never persisted. */179const APPROX_USD: Record<string, number> = { 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 };180181/** Every money figure in a text (largest first); `keep(start)` may veto a figure from its position in `text`. */182export function parseAllMoney(text: string, keep: (start: number) => boolean = () => true): Money[] {183 const out: Money[] = [];184 const seen = new Set<string>();185 CURRENCY_START_RE.lastIndex = 0;186 for (const m of text.matchAll(CURRENCY_START_RE)) {187 const window = text.slice(m.index, (m.index ?? 0) + 40);188 // ignore rates such as "$25/MWh" or "$0.05 per kWh"189 if (/^\S+\s?[\d.,]+\s*(\/|per)\s*(MWh|kWh|MW|kW|sq|ft|month|year|hour)/i.test(window)) continue;190 const money = parseMoney(window);191 // < $1M is not a project investment; > $500B is an industry-wide statistic ("$31.6 trillion AI buildout")192 if (!money || money.amount < 1_000_000 || money.amount > 500_000_000_000) continue;193 if (!keep(m.index ?? 0)) continue;194 const k = `${money.currency}:${money.amount}`;195 if (seen.has(k)) continue;196 seen.add(k);197 out.push(money);198 }199 return out.sort((a, b) => b.amount - a.amount);200}201202/**203 * Company-background wording before a money figure, in the same sentence: the developer's portfolio, balance sheet or204 * history ("a total investment volume of €8.1 billion ($9.4bn)", "assets under management of…", "revenue of…") — never205 * this project's budget.206 */207export 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;208209/** The sentence fragment before position `at` (from the previous sentence break, at most `max` chars). */210function sentenceBefore(text: string, at: number, max = 300): string {211 const seg = text.slice(Math.max(0, at - max), at);212 const brk = Math.max(seg.lastIndexOf(". "), seg.lastIndexOf("\n"), seg.lastIndexOf("? "), seg.lastIndexOf("! "));213 return brk >= 0 ? seg.slice(brk + 1) : seg;214}215216/** Money figures that may describe the announced project: company-background figures (portfolio volume, revenue…) are skipped. */217export function projectMoneyFigures(scope: string): Money[] {218 return parseAllMoney(scope, (start) => !MONEY_PORTFOLIO_RE.test(sentenceBefore(scope, start)));219}220221export function approxUsd(m: Money | null): number | null {222 if (!m) return null;223 const r = APPROX_USD[m.currency];224 return r ? Math.round(m.amount * r) : null;225}226227// ─── phases ────────────────────────────────────────────────────────────────────────────────────────228const WORD_NUM: Record<string, number> = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, twelve: 12 };229const num = (s: string): number | null => { const n = WORD_NUM[s.toLowerCase()] ?? Number(s); return Number.isFinite(n) && n >= 1 && n <= 30 ? n : null; };230const NUM_ALT = "\\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten|twelve";231const PHASE_RES: RegExp[] = [232 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"),233 new RegExp(`\\bphase\\s+(?:\\d{1,2}|one|two|three|I|II|III)\\s+of\\s+(${NUM_ALT})\\b`, "i"),234 new RegExp(`\\b(${NUM_ALT})[- ]phase(?:d)?\\s+(?:project|development|campus|build(?:-?out)?|plan|rollout|programme|program|scheme|expansion|construction)\\b`, "i"),235 new RegExp(`\\b(${NUM_ALT})\\s+phases\\b`, "i"),236];237238export function parsePhaseCount(text: string): number | null {239 for (const re of PHASE_RES) {240 const m = text.match(re);241 if (m?.[1]) { const n = num(m[1]); if (n) return n; }242 }243 return null;244}245246// ─── expected opening ─────────────────────────────────────────────────────────────────────────────247/** Tier 1: the sentence talks about the facility being ready (opening / RFS / online / completion / delivery). */248const 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;249/** Tier 2: generic expectation verbs; sentences about construction start or land purchase are excluded. */250const EXPECT_KEYWORD_RE = /\b(expected|expects?|scheduled|slated|due|anticipated|anticipates?|targeted|targeting|targets?|projected|forecast|timeline|on track)\b/i;251const 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;252const 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;253254export function sentences(text: string): string[] {255 return text.split(/(?<=[.!?])\s+(?=[A-Z0-9"'“(])/).map((s) => s.trim()).filter(Boolean);256}257258interface DateBounds { minYear: number; maxYear: number; /** partial date the expected date must come after (the publication date) */ after?: string | null }259260function firstDateAfter(s: string, from: number, opts: DateBounds): string | null {261 const after = s.slice(from);262 DATE_TOKEN_RE.lastIndex = 0;263 for (const dm of after.matchAll(DATE_TOKEN_RE)) {264 const d = parseDateLoose(dm[1]!);265 if (!d) continue;266 const y = Number(d.slice(0, 4));267 if (y < opts.minYear || y > opts.maxYear) continue;268 // "announced September 2026" is the dateline, not the opening: the expected date must lie after publication269 if (opts.after && partialDateSortKey(d) <= partialDateSortKey(opts.after)) continue;270 return d;271 }272 return null;273}274275/**276 * Expected opening / ready-for-service date. Sentences about the facility opening win over generic277 * "expected … 2027" sentences; sentences about construction start are ignored unless nothing else exists.278 */279export function parseExpectedOpening(text: string, opts: DateBounds): { value: string | null; method: string } {280 const sents = sentences(text.slice(0, 12_000));281 for (const s of sents) {282 if (CONSTRUCTION_START_RE.test(s) && !OPENING_KEYWORD_RE.test(s)) continue;283 const km = OPENING_KEYWORD_RE.exec(s);284 if (!km) continue;285 // in "construction to begin in 2026, with completion in 2028" take the date after the opening keyword286 const d = firstDateAfter(s, km.index, opts);287 if (d) return { value: d, method: "regex:expected_date_v1:opening" };288 }289 for (const s of sents) {290 if (CONSTRUCTION_START_RE.test(s)) continue;291 const km = EXPECT_KEYWORD_RE.exec(s);292 if (!km) continue;293 const d = firstDateAfter(s, km.index, opts);294 if (d) return { value: d, method: "regex:expected_date_v1:expected" };295 }296 return { value: null, method: "none" };297}298299// ─── project name ──────────────────────────────────────────────────────────────────────────────────300const NAME_RE = /(?<![A-Za-z])((?:[A-Z][A-Za-z0-9&'’.-]*\s){1,4}(?:Campus|Data Cent(?:er|re)|Technology Park|Digital (?:Gateway|Park|Hub|Campus)|Innovation Park|Business Park|AI (?:Campus|Factory|Hub)|Hub|Gateway|Cluster|Supercomputer)(?:\s(?:Campus|Park|Project))?)(?![A-Za-z])/g;301const GENERIC_WORD = /^(Trillion|Billion|Million|Trillion-Dollar|Billion-Dollar|Gigawatt|Megawatt|Faces|Delays|Pushes|Enters|Assessing|Latest|Why|How|What|When|Where|Inside|Beyond|Behind|Building|Powering|Cooling|Designing|Scaling|Financing|New|The|A|An|First|Second|Third|Largest|Massive|Huge|Giant|Hyperscale|AI|Its|Their|Planned|Proposed|Major|Big|Another|Future|Existing|Local|Global|National|Regional|Green|Sustainable|Modular|Mega|Own|Our|This|That|These|Those|Two|Three|Four|Five|Multiple|Several|Sprawling|Controversial|Proposed|Multi-Billion|Billion-Dollar|Large|Small|Wholesale|Colocation|Enterprise|Edge|Cloud|Sovereign|Nuclear|Solar|Gas|Data|Center|Centre|Digital|Tech|Technology|Business|Innovation|Industrial|Park|Site|In|Of|For|At|On|To|And|With|Near|Inc|LLC|Corp|County|City|Township|State|Mr|Ms|Dr|CEO|President|Governor|Mayor|Board|Council|Commission|Supervisors|Planning|Zoning|Company|Group|Holdings|Partners|Capital|Energy|Power|Grid|Utility|Electric)$/i;302303const UNIT_WORD = /^(GW|MW|kW|KW|MWh|Acre|Acres|Hectare|Hectares|Sq|Ft|Sqft|Percent|Pct)$/i;304305export function findExplicitName(title: string | null, text: string, operator: OperatorHit | null): string | null {306 const body = text.slice(0, 6000);307 for (const [scopeName, scope] of [["title", title ?? ""], ["lead", text.slice(0, 1500)]] as Array<[string, string]>) {308 NAME_RE.lastIndex = 0;309 for (const m of scope.matchAll(NAME_RE)) {310 const name = cleanText(m[1]!);311 if (!name || name.length < 8 || name.length > 80 || /\b\d+\s?(MW|GW|kW)\b/i.test(name)) continue;312 // "1.5 GW Texas Data Center", "8,000 Acre AI Campus": a figure before the match means the first word is a unit313 if (/[\d.,%]\s*$/.test(scope.slice(0, m.index ?? 0))) continue;314 // Title-Case headlines ("JV Faces Delays On Data Center") look like names: a real name recurs in the body315 if (scopeName === "title" && !body.includes(name)) continue;316 const words = name.split(/\s+/);317 if (UNIT_WORD.test(words[0]!) || GENERIC_WORD.test(words[0]!)) continue;318 const typeIdx = words.findIndex((w) => /^(Campus|Data|Technology|Digital|Innovation|Business|AI|Hub|Gateway|Cluster|Supercomputer)$/.test(w));319 const lead = words.slice(0, typeIdx > 0 ? typeIdx : words.length - 1);320 if (!lead.length || lead.every((w) => GENERIC_WORD.test(w) || UNIT_WORD.test(w))) continue;321 // "Vantage Data Centers" / "Microsoft Data Center" are operators, not project names322 const ops = detectOperators(name);323 if (ops.length && (ops[0]!.matched.length >= name.length - 1 || lead.join(" ") === ops[0]!.matched || (operator && lead.join(" ") === operator.matched))) continue;324 if (/^(Data Cent(er|re)s?|Campus)$/i.test(lead.join(" "))) continue;325 return name.replace(/’/g, "'");326 }327 }328 // "Named ‘NIMB,’ the firm…", "dubbed 'Project Sail'" — a quoted proper name given to the facility in the lead329 const q = text.slice(0, 1500).match(QUOTED_NAME_RE);330 if (q) {331 const name = cleanText(q[1]!.replace(/’/g, "'"));332 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;333 }334 return null;335}336const QUOTED_NAME_RE = /\b(?:named|dubbed|called|known as|branded|code-?named|titled)\s+[‘'"“]([^‘’'"“”\n]{2,60}?)[,.]?[’'"”]/i;337338export function cleanTitle(title: string | null): string {339 if (!title) return "";340 let t = cleanText(title) ?? "";341 // strip a trailing publisher suffix: "… | DCD", "… - Data Center Knowledge"342 const m = t.match(/^(.{20,})\s[|–-]\s([^|–-]{2,45})$/);343 if (m && !/\d\s?MW|\$|€|£/.test(m[2]!)) t = m[1]!.trim();344 return t;345}346347export function generateProjectName(a: { title: string; explicitName: string | null; operator: OperatorHit | null; location: LocationHit | null }): { name: string; method: string } {348 if (a.explicitName) return { name: a.explicitName.slice(0, 160), method: "regex:project_name_v1" };349 const op = a.operator?.name;350 const loc = a.location;351 if (op && loc?.city) return { name: `${op} ${loc.city} campus`, method: "generated:operator_city" };352 if (op && loc?.region) return { name: `${op} ${loc.region} data center project`, method: "generated:operator_region" };353 if (op && loc?.country) return { name: `${op} data center project (${loc.country})`, method: "generated:operator_country" };354 if (!op && loc?.city) return { name: `${loc.city} data center project`, method: "generated:city" };355 return { name: cleanTitle(a.title).slice(0, 160), method: "title" };356}357358// ─── relevance / classification ────────────────────────────────────────────────────────────────────359const 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;360361/** "200MW campus", "1GW site" — a power figure attached to a campus/site is a data center in this industry's vocabulary. */362const 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;363364export function relevanceOf(title: string | null, text: string): Announcement["relevance"] {365 const head = `${title ?? ""}\n${text.slice(0, 5000)}`;366 if (STRONG_RELEVANCE_RE.test(head) || MW_CAMPUS_RE.test(head)) return "strong";367 // a known operator plus a MW figure in the lead is unambiguous ("Data4 to invest £1.2bn in 200MW campus")368 const lead = `${title ?? ""}\n${text.slice(0, 1500)}`;369 if (parseAllMw(lead).length && detectOperators(lead).length) return "strong";370 if (isDataCenterRelevant(head)) return "weak";371 return "none";372}373374/** Strong vocabulary in the title or the lead — a body-only mention (an aside in a rate case) is not an announcement. */375export function leadRelevanceOf(title: string | null, text: string): boolean {376 const lead = `${title ?? ""}\n${text.slice(0, 600)}`;377 return STRONG_RELEVANCE_RE.test(lead) || MW_CAMPUS_RE.test(lead) || (parseAllMw(lead).length > 0 && detectOperators(lead).length > 0);378}379380const 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;381export function titleSignalOf(title: string | null): boolean {382 if (!title) return false;383 return TITLE_SIGNAL_RE.test(title) || detectOperators(title).length > 0;384}385386/**387 * When the text classifier only sees a generic press release, the inferred status tells us what kind of event388 * this is: approvals / applications are planning documents, groundbreakings are construction updates, plans are389 * project announcements (only when a size is known — otherwise leave it alone).390 */391function pageTypeFromStatus(pageType: PageType, status: FacilityStatus | null, hasSize: boolean): PageType {392 if (pageType !== "unknown" && pageType !== "press_release") return pageType;393 if (status === "approved" || status === "permitting") return "planning_document";394 if (status === "under_construction") return "construction_update";395 if ((status === "announced" || status === "proposed" || status === "rumored") && hasSize) return "project_announcement";396 return pageType;397}398399/** Text-only classification, title first (URL rules are deliberately ignored: "/news/…" tells us nothing about the event). */400export function classifyAnnouncement(title: string | null, text: string): { pageType: PageType; eventType: EventType | undefined; rule: string } {401 const byTitle = title ? classifyPage("", title, "") : null;402 // body classification limited to the lead: "applications closing", "acquired in 2019" deep in an article are not the event403 const cls = byTitle && byTitle.pageType !== "unknown" && byTitle.pageType !== "press_release" ? byTitle : classifyPage("", title, text.slice(0, 1500));404 const pageType = cls.pageType;405 const eventType = eventTypeFor(pageType, `${title ?? ""}\n${text.slice(0, 6000)}`);406 return { pageType, eventType, rule: `${byTitle && cls === byTitle ? "title" : "text"}:${cls.rule}` };407}408409// ─── main ──────────────────────────────────────────────────────────────────────────────────────────410export 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 {411 // "118-acre", "192-MW", "1.2-GW" → the core parsers expect a space (or nothing) between figure and unit412 const unhyphen = (s: string) => s.replace(/(\d)[-‑–](?=(?:acres?|hectares?|ha|MW|GW|kW|megawatts?|gigawatts?)\b)/gi, "$1 ");413 const title = unhyphen(cleanTitle(rawTitle));414 const text = unhyphen(rawText ?? "").replace(/ /g, " ").replace(/[ \t]+/g, " ");415 const head = text.slice(0, 600);416 const body = text.slice(0, 6000);417 const methods: Record<string, string> = {};418 const now = opts.now ?? new Date();419420 const relevance = relevanceOf(title, text);421 const cls = classifyAnnouncement(title, text);422 methods.pageType = cls.rule;423424 // MW: headline figure = largest project figure in title/lead, else in body. Figures above 20 GW are market statistics;425 // portfolio totals ("capacity to over 410MW in Japan", "1.2GW across APJ") are skipped when a site figure exists.426 const mwTitle = projectMwFigures(title);427 const mwLead = projectMwFigures(`${title} ${head}`);428 const mwBodySite = projectMwFigures(`${title} ${body}`);429 const mwBody = parseAllMw(`${title} ${body}`).filter((v) => v > 0 && v <= 20_000); // every figure, for the event's `mw` list430 let headlineMw: number | null = null;431 if (mwTitle.length) { headlineMw = mwTitle[0]!; methods.plannedMw = "regex:mw_v1:title"; }432 else if (mwLead.length) { headlineMw = mwLead[0]!; methods.plannedMw = "regex:mw_v1:lead"; }433 else if (mwBodySite.length) { headlineMw = mwBodySite[0]!; methods.plannedMw = "regex:mw_v1:body"; }434435 // money: same title → lead → body preference, largest figure in the winning scope436 let money: Money | null = null;437 for (const [scopeName, scope] of [["title", title], ["lead", `${title} ${head}`], ["body", `${title} ${body}`]] as Array<[string, string]>) {438 const all = projectMoneyFigures(scope);439 if (all.length) { money = all[0]!; methods.investment = `regex:money_v1:${scopeName}`; break; }440 }441 const investmentUsd = money && money.currency === "USD" ? money.amount : null;442 const investmentUsdApprox = approxUsd(money);443444 // acreage (project field is in acres; parseAreaHa returns hectares)445 let acreage: number | null = null;446 for (const [scopeName, scope] of [["lead", `${title} ${head}`], ["body", body]] as Array<[string, string]>) {447 const ha = parseAreaHa(scope);448 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; } }449 }450451 const phaseCount = parsePhaseCount(`${title}. ${body}`);452 if (phaseCount) methods.phaseCount = "regex:phase_v1";453454 const st = inferStatusPrecedence(title, text);455 if (st.status) methods.status = st.method;456457 const publishedYear = opts.publishedAt ? Number(opts.publishedAt.slice(0, 4)) : NaN;458 const minYear = Number.isFinite(publishedYear) ? publishedYear : now.getFullYear() - 1;459 const exp = parseExpectedOpening(`${title}. ${body}`, { minYear, maxYear: now.getFullYear() + 15, after: opts.publishedAt ?? null });460 if (exp.value) methods.expectedOpening = exp.method;461462 // location: title first, then lead, then body; a title hit without a city is completed from the lead when consistent.463 // Company domiciles ("Denver-based", "headquartered in Ashburn") are removed first — they are not the project's location.464 const hqTitle = stripHqMentions(title);465 const hqLead = stripHqMentions(text.slice(0, 1500));466 const hqBody = stripHqMentions(body);467 let location = detectLocation(hqTitle.text);468 const leadLoc = detectLocation(hqLead.text);469 if (!location) location = leadLoc ?? detectLocation(hqBody.text);470 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}` };471 if (location) { methods.location = location.method; }472 const hqGuarded = hqTitle.stripped || hqLead.stripped || hqBody.stripped;473474 // operators: title + the first OPERATOR_SCOPE_CHARS of the story only (a "More in …" teaser or a footer never names the developer)475 const opScope = text.slice(0, OPERATOR_SCOPE_CHARS);476 const operators = detectOperators(`${title}\n${opScope}`);477 let operator = primaryOperator(title, opScope);478 // the headline's subject is a company we do not know → lexicon operators in the body are not the developer479 if (operator && !detectOperators(title).length && unknownTitleSubject(title)) { operator = null; methods.operatorName = "none:unknown-title-subject"; }480 else if (operator) methods.operatorName = "lexicon:operator";481482 const explicitName = findExplicitName(title, text, operator);483 const gen = generateProjectName({ title, explicitName, operator, location });484 methods.name = gen.method;485486 const nonProject = isNonProjectTitle(title);487 let pageType = pageTypeFromStatus(cls.pageType, st.status, headlineMw != null || money != null || acreage != null);488 let eventType = pageType === cls.pageType ? cls.eventType : eventTypeFor(pageType, `${title}\n${body}`);489 if (pageType !== cls.pageType) methods.pageType = `${cls.rule}+status:${st.status}`;490 // people / deals / money / market headlines are plain news whatever the body's vocabulary says491 if (nonProject && !["acquisition", "closure", "cloud_region", "financial_disclosure"].includes(pageType)) { pageType = "press_release"; eventType = "news"; methods.pageType = `${methods.pageType}+veto:title`; }492493 // classification BEFORE anything becomes a project: people / money / deal / market headlines never do494 const classification = classifyProjectEvent({ title, lead: head, hasExplicitName: !!explicitName, hasOperator: !!operator, hasLocation: !!(location?.city || location?.region || location?.country), status: st.status, trustLead: !!opts.trustLead });495 // evidence + scope + semantics of the headline figures (the sentence decides; no sentence → the claim is not site-scoped)496 const campusHint: ClaimScope = /\b(campus|park|complex|hub)\b/i.test(`${explicitName ?? ""} ${title}`) ? "campus" : "facility";497 const mwEvidence = headlineMw != null ? findEvidence(`${title}. ${body}`, headlineMw, "mw") : null;498 const mwScope = headlineMw != null ? (mwEvidence ? classifyScope(mwEvidence.text, campusHint) : { scope: "unknown" as ClaimScope, reason: "no-evidence" }) : { scope: "unknown" as ClaimScope, reason: "none" };499 const mwSem = mwEvidence ? classifyCapacitySemantics(mwEvidence.text) : { predicate: null, reason: "none" };500 const invEvidence = money ? findEvidence(`${title}. ${body}`, money.amount, "usd") : null;501 const invSem = money ? classifyInvestmentSemantics(invEvidence?.text ?? title, campusHint) : { predicate: "project_investment_usd" as const, scope: "unknown" as ClaimScope, reason: "none" };502 const ai = classifyAiEvidence(`${title}. ${head}`);503 methods.classification = classification.reasons.join("+") || "none";504 if (mwEvidence) methods.plannedMwEvidence = "sentence"; if (invEvidence) methods.investmentEvidence = "sentence";505 return {506 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,507 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,508 };509}510511/**512 * Site-vs-portfolio MW filter — lives in @dci/connectors (mw-figures.ts) so the page classifier applies the same513 * rule; re-exported here so existing imports keep working.514 */515export { projectMwFigures, PORTFOLIO_BEFORE_RE, PORTFOLIO_AFTER_RE } from "@dci/connectors";516517const PROJECT_PAGE_TYPES: readonly PageType[] = ["project_announcement", "expansion", "construction_update", "planning_document", "power_infrastructure", "press_release", "utility_announcement"];518const PROJECT_STATUSES: readonly FacilityStatus[] = [...PIPELINE_STATUSES, "expansion"];519520/**521 * Should this announcement yield a project record? Requires a pipeline status on an announcement-like page and a522 * material size: MW ≥ 5, or investment ≥ $50M (approximate conversion for the gate only), or a site ≥ 100 acres523 * (large rezonings are often announced without a MW figure).524 */525export function qualifiesAsProject(a: Announcement, opts: { minMw?: number; minInvestmentUsd?: number; minAcres?: number; lenient?: boolean } = {}): boolean {526 if (a.relevance !== "strong") return false;527 if (a.nonProjectTitle) return false;528 // the announcement classifier: only physical development classes with (name or operator) + location + development verb529 if (!a.classification.physical) return false;530 if (!opts.lenient && !a.classification.mayCreateProject) return false;531 if (opts.lenient && a.classification.evidence.strength === "none") return false;532 if (!a.status || !PROJECT_STATUSES.includes(a.status)) return false;533 if (!PROJECT_PAGE_TYPES.includes(a.pageType)) return false;534 // An announcement names its subject up front: data-center vocabulary in the lead, and a title that mentions the535 // facility / capacity / operator. Opinion pieces and rate cases that mention data centers in passing do not qualify.536 if (!opts.lenient && (!a.leadRelevance || !a.titleSignal)) return false;537 // The qualifying size must come from the title or the lead, not from a figure buried in the body.538 const fromLead = (m: string | undefined) => !!m && !m.endsWith(":body");539 const minMw = opts.minMw ?? 5, minInv = opts.minInvestmentUsd ?? 50_000_000, minAcres = opts.minAcres ?? 100;540 const mwOk = a.headlineMw != null && a.headlineMw >= minMw && (opts.lenient || fromLead(a.methods.plannedMw));541 const invOk = a.investmentUsdApprox != null && a.investmentUsdApprox >= minInv && (opts.lenient || fromLead(a.methods.investment));542 const acresOk = a.acreage != null && a.acreage >= minAcres && (opts.lenient || fromLead(a.methods.acreage));543 return mwOk || invOk || acresOk;544}545546export function isPipelineStatus(s: FacilityStatus | null | undefined): boolean { return !!s && PROJECT_STATUSES.includes(s); }547548/** Extraction certainty 0.5–0.8: more corroborating signals → higher. */549export function projectCertainty(a: Announcement): number {550 let c = 0.5;551 if (a.operator) c += 0.1;552 if (a.location?.city || a.location?.region) c += 0.1;553 if (a.methods.plannedMw?.endsWith(":title") || a.methods.investment?.endsWith(":title")) c += 0.1;554 return Math.min(0.8, round(c, 2));555}556557/** Summary trimmed for third-party publishers (≤ 400 chars, on a word boundary). */558export function shortSummary(s: string | null | undefined, max = 400): string | null {559 const t = cleanText(s);560 if (!t) return null;561 if (t.length <= max) return t;562 const cut = t.slice(0, max);563 return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), max - 40)).trim()}…`;564}565