import type { ConnectorContext, ExtractedRecord, Parser, RawDocument } from "@dci/connectors"; import { cleanText } from "@dci/core"; import { articleContent, buildRecords, indexSentinel, shouldSkipDocument, type ArticleContent } from "./article-parser.js"; import { EXTRACTOR_VERSION, extractAnnouncement, parseDateLoose, type Announcement } from "./extract-project.js"; import { detectOperators } from "./operators-lexicon.js"; /** * `news_planning_pdf_v1` — public planning documents (staff reports, decision notices, rezoning applications, * committee agendas) as PDF or HTML. Public records: the text is kept by default (`keepText: true`). * Emits a `news_event` (planning_filed / planning_approved) and a `project` with status permitting / approved. * A refused application yields the event only (a refusal is not a cancellation by the developer). */ export type PlanningDecision = "approved" | "refused" | "pending"; const DECISION_LINE_RE = /\b(?:decision|outcome|resolution|recommendation|status)\s*[:\-–]\s*(approved?|approve with conditions|granted?|permitted|refused?|rejected|denied|declined|withdrawn|pending|under consideration|awaiting decision)\b/i; const APPROVED_RE = /\b(approved|approval granted|permission (?:is |be |has been |was )?granted|grant(?:ed|s)? (?:planning |conditional |outline )?(?:permission|approval|consent)|resolved to approve|resolves to approve|recommend(?:ed|ation)?(?: that the application be| to)? approv(?:e|al|ed)|approve(?:d)? with conditions|conditional approval|be approved|voted to approve|unanimously approved|planning permission granted|consent granted)\b/i; const REFUSED_RE = /\b(refused|refusal|rejected|denied|declined|recommend(?:ed|ation)?(?: that the application be| to)? refus(?:e|al|ed)|not approved|dismissed|permission (?:is |be |has been |was )?refused)\b/i; const APPLICANT_RE = /\b(?:applicant(?:'s)? name|name of applicant|applicant|on behalf of|proposed by|developer|submitted by|agent for|owner\/applicant|applicant\/owner|property owner|petitioner)\s*[:\-–]?\s*([A-Z][A-Za-z0-9&,.'’ -]{2,80}?)(?=\s{2,}|\n|\s(?:Site|Address|Location|Proposal|Description|Agent|Ward|Parish|Case|Reference|Ref|Application|Date|Tel|Email)\b|[;|]|\.\s|$)/m; const ADDRESS_RE = /\b(\d{1,5}[A-Za-z]?\s+(?:[A-Z][A-Za-z0-9'.-]+\s){1,4}(?:Road|Rd|Street|St|Avenue|Ave|Drive|Dr|Lane|Ln|Way|Boulevard|Blvd|Parkway|Pkwy|Highway|Hwy|Court|Ct|Place|Pl|Trail|Trl|Circle|Cir|Loop|Route|Rte|Pike|Turnpike)\b\.?)/; const REF_RE = /\b(?:application|planning|case|file|reference|ref\.?|permit|rezoning|petition)\s*(?:no\.?|number|#|ref\.?|:)?\s*[:#]?\s*((?:[A-Z]{1,5}[-/ ])?\d{1,4}[-/]\d{2,6}(?:[-/][A-Z0-9]{1,8})?)\b/i; const DOC_DATE_RE = /\b(?:date(?: of (?:decision|issue|report|meeting|validation|receipt))?|decision date|meeting date|issued|dated|hearing date)\s*[:\-–]?\s*((?:\d{1,2}\s+(?: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+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-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/20\d\d)/i; export function detectDecision(text: string): { decision: PlanningDecision; method: string } { const head = text.slice(0, 20_000); const line = head.match(DECISION_LINE_RE); if (line) { const v = line[1]!.toLowerCase(); if (/refus|reject|deni|declin|withdrawn/.test(v)) return { decision: "refused", method: "regex:decision_line" }; if (/approv|grant|permitted/.test(v)) return { decision: "approved", method: "regex:decision_line" }; return { decision: "pending", method: "regex:decision_line" }; } const refused = (head.match(new RegExp(REFUSED_RE.source, "gi")) ?? []).length; const approved = (head.match(new RegExp(APPROVED_RE.source, "gi")) ?? []).length; if (refused && refused >= approved) return { decision: "refused", method: "regex:decision_keywords" }; if (approved) return { decision: "approved", method: "regex:decision_keywords" }; return { decision: "pending", method: "regex:decision_default" }; } export function detectApplicant(text: string): { name: string | null; method: string } { const ops = detectOperators(text.slice(0, 20_000)); const m = text.slice(0, 20_000).match(APPLICANT_RE); if (m) { const raw = cleanText(m[1]!.replace(/[,.;:\s]+$/, "")); if (raw && raw.length >= 3 && !/^(the|a|an|n\/a|none|same|as above)$/i.test(raw)) { // prefer the canonical lexicon name when the applicant line names a known operator const inLine = detectOperators(raw); return { name: inLine[0]?.name ?? raw.slice(0, 120), method: inLine.length ? "regex:applicant+lexicon" : "regex:applicant" }; } } return { name: ops[0]?.name ?? null, method: ops.length ? "lexicon:operator" : "none" }; } export const planningPdfParser: Parser = { name: "news_planning_pdf_v1", version: EXTRACTOR_VERSION, pageTypes: ["planning_document"], async parse(doc: RawDocument, ctx: ConnectorContext, params: Record = {}): Promise { if (shouldSkipDocument(doc)) return [indexSentinel(ctx.connectorId, doc.finalUrl)]; let content: ArticleContent; try { content = await articleContent(doc); } catch (e) { ctx.log("warn", `news_planning_pdf_v1: cannot read ${doc.finalUrl}: ${(e as Error).message}`); return []; } if (!content.text || content.text.length < 120) return []; const text = content.text; const a = extractAnnouncement(content.title, text, { publishedAt: content.published }); if (a.relevance !== "strong") { ctx.log("debug", `news_planning_pdf_v1: not a data center document ${doc.finalUrl}`); return []; } const decision = detectDecision(text); const applicant = detectApplicant(text); const address = text.slice(0, 20_000).match(ADDRESS_RE)?.[1] ?? null; const reference = text.slice(0, 20_000).match(REF_RE)?.[1] ?? null; if (!content.published) { const dm = text.slice(0, 6000).match(DOC_DATE_RE); if (dm) { const d = parseDateLoose(dm[1]!.replace(/(\d{1,2})\/(\d{1,2})\/(20\d\d)/, (_, mo: string, da: string, y: string) => `${y}-${mo.padStart(2, "0")}-${da.padStart(2, "0")}`)); if (d) content = { ...content, published: d, publishedMethod: "text:document_date" }; } } const status = decision.decision === "approved" ? "approved" : decision.decision === "pending" ? "permitting" : null; const eventType = decision.decision === "approved" ? "planning_approved" : "planning_filed"; const operatorName = applicant.name ?? a.operator?.name ?? null; const adjusted: Announcement = { ...a, pageType: "planning_document", eventType, status: status ?? "cancelled", // refused → no project (qualifiesAsProject rejects "cancelled"); the event keeps the decision operator: a.operator ?? (operatorName ? { name: operatorName, kind: "other", index: 0, matched: operatorName } : null), methods: { ...a.methods, status: decision.method, operatorName: applicant.method !== "none" ? applicant.method : a.methods.operatorName ?? "none", pageType: "parser:planning" }, }; if (adjusted.operator && !adjusted.explicitName) { const city = adjusted.location?.city; adjusted.projectName = city ? `${adjusted.operator.name} ${city} data center (planning)` : `${adjusted.operator.name} data center planning application`; adjusted.methods.name = "generated:planning"; } // A planning document describes exactly one application: a project is warranted as soon as the decision status is // known and the document gives a size (MW or acreage) or names the applicant. const forceProject = status != null && (adjusted.headlineMw != null || adjusted.acreage != null || operatorName != null); return buildRecords({ connectorId: ctx.connectorId, url: doc.finalUrl, a: adjusted, content, params: { keepText: params.keepText !== false, minProjectMw: 0.5, minInvestmentUsd: 1, minAcres: 1, lenient: true }, pageTypeOverride: "planning_document", extraEvent: { decision: decision.decision, applicant: operatorName, address, reference }, extraProject: address ? { address } : {}, certaintyEvent: 0.75, forceProject, }); }, };