// EnergyNetWatch county permit example. Node.js 18+, no dependencies. // Run on your own machine/server; never embed an API key in a web page. import { mkdir, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' export async function fetchCountyPermits({ apiKey, county = 'Ward', maxPages = 5, fetchImpl = fetch }) { if (!apiKey) throw new Error('Set ENERGYNETWATCH_API_KEY in your environment; do not put it in this file.') if (!county.trim()) throw new Error('County is required.') if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 10) throw new Error('maxPages must be 1–10.') const rows = [], requests = [], seen = new Set() let page = 1, total, first for (let attempt = 0; attempt < maxPages; attempt++) { const url = new URL('https://api.energynetwatch.com/api/public/permits') url.search = new URLSearchParams({ state: 'TX', county, days: '90', page: String(page), limit: '25' }).toString() const response = await fetchImpl(url, { headers: { 'X-API-Key': apiKey, Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(30_000), }) const requestId = response.headers.get('x-request-id') if (!response.ok) { const reasons = { 401: 'Check the key.', 402: 'Check available units.', 403: 'Check permits:read scope and access.', 429: 'Check rate/quota headers before trying again.' } throw new Error(`HTTP ${response.status}; request ${requestId || 'unavailable'}. ${reasons[response.status] || 'Stop and inspect the request.'} No automatic retry.`) } const body = await response.json(), data = body.data, p = data?.pagination, req = data?.meta?.request if (!body.success || !Array.isArray(data?.permits) || !p || p.page !== page || p.limit !== 25 || !Number.isInteger(p.total) || p.total < 0 || p.returned !== data.permits.length || req?.state !== 'TX' || req?.days !== 90 || !req?.canonicalCounty || req.canonicalCounty.toLowerCase() !== county.toLowerCase() || req.countyBasis !== 'well_api_county_with_w1_fallback') { throw new Error('Unexpected response or county scope. Stop; do not present a partial export as complete.') } if (total !== undefined && (p.total !== total || JSON.stringify(data.countWindow) !== JSON.stringify(first.countWindow))) { throw new Error('Results changed during pagination. Stop and review before spending units on another run.') } total = p.total first ??= data requests.push({ url: url.toString(), requestId, returned: p.returned, generatedAt: data.meta.generatedAt, freshness: data.meta.freshness }) for (const row of data.permits) { const identity = row.id ?? (row.permit_number != null && row.api12 != null ? `${row.permit_number}|${row.api12}` : null) if (identity == null || seen.has(String(identity))) throw new Error('Missing or repeated record identity. Stop; do not silently drop records.') seen.add(String(identity)); rows.push(row) } if (p.hasMore === false && p.nextPage === null) { if (rows.length !== total) throw new Error('Collected row count does not match pagination.total.') return { retrievedAt: new Date().toISOString(), complete: true, total, estimatedUnits: requests.length * 6, query: { state: 'TX', county: req.canonicalCounty, countyFips: req.countyFips, days: 90, dateField: 'issue_date', limit: 25 }, countWindow: first.countWindow, requests, permits: rows } } if (p.hasMore !== true || p.nextPage !== page + 1 || p.returned === 0) throw new Error('Invalid next page; stopping without retry.') page = p.nextPage } throw new Error(`Stopped at ${maxPages} pages (${maxPages * 6} estimated units). No complete export. Review the budget before increasing maxPages.`) } // Quote every cell and neutralize spreadsheet-formula prefixes. Raw JSON is unchanged. export function csvCell(value) { let text = value == null ? '' : String(value) if (/^[\s]*[=+\-@\t\r\n]/.test(text)) text = "'" + text return '"' + text.replaceAll('"', '""') + '"' } export function toCsv(rows) { const fields = ['permit_number', 'api12', 'operator_name', 'county_name', 'issue_date', 'spud_date'] return [fields.map(csvCell).join(','), ...rows.map(row => fields.map(field => csvCell(row[field])).join(','))].join('\r\n') + '\r\n' } async function main() { const result = await fetchCountyPermits({ apiKey: process.env.ENERGYNETWATCH_API_KEY, county: process.env.ENW_COUNTY || 'Ward' }) const directory = resolve(process.argv[2] || `county-permits-${Date.now()}`) await mkdir(directory, { recursive: true }) await writeFile(resolve(directory, 'permits.json'), JSON.stringify(result, null, 2), { flag: 'wx' }) await writeFile(resolve(directory, 'permits.csv'), toCsv(result.permits), { flag: 'wx' }) console.log(`${result.total} records; ${result.requests.length} requests; ${result.estimatedUnits} estimated API units. Files: ${directory}`) } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { main().catch(error => { console.error(error.message); process.exitCode = 1 }) }