'use strict' /** * serviceability.js — per-address Internet provider lookup using Québec's * "Accès Internet haute vitesse" open ArcGIS data (the same data behind the * gouv.qc.ca interactive map). Authoritative: providers are DECLARED to the * government by the providers themselves. Replaces the abandoned * cogeco-checker (Cogeco's own site is gated by reCAPTCHA Enterprise). * * Two public ArcGIS services (full recipe in memory/reference_quebec_ihv.md): * ADR (Adresse_S) → address points: Adresse_complete, Etat_hiv, IdAdresse * FRN (Fournisseurs_S) → TABLE keyed by IdAdresse: FRN_nom (provider), * FRN_URL_inscrip (signup link), Date_fin * The usrsvcs proxy is REFERER-GATED: send `Referer: https://www.quebec.ca/` * (a direct call 403s). No token, no anti-bot. * * Flow: address → ADR (civic + postal, JS street disambiguation) → IdAdresse * → FRN (where IdAdresse=…) → [{nom, url, date_fin}] * * Results are cached on disk (data/serviceability-cache.json) keyed by a * normalized address, so re-loads are instant and we stay a polite consumer * of the gov API (rate-limited, low concurrency). * * Routes: * POST /serviceability/lookup { address1, city, zip } → one result * POST /serviceability/lookup-batch { items:[{key,address1,city,zip}] } * GET /serviceability/cache-stats */ const fs = require('fs') const path = require('path') const { json, parseBody, httpRequest, log } = require('./helpers') const ARCGIS_BASE = 'https://utility.arcgis.com' const ADR_PATH = '/usrsvcs/servers/396469b496554883b36948d66eba40f5/rest/services/ADR/FeatureServer/0/query' const FRN_PATH = '/usrsvcs/servers/5aa672072a9f43129b97b53d06eb3ae9/rest/services/FRN/FeatureServer/0/query' const REFERER = 'https://www.quebec.ca/' const ETAT_LABEL = { 1: 'Desservie', 2: 'Projet en cours', 3: 'Projet à venir', 4: 'Non admissible' } const CACHE_FILE = path.join(__dirname, '..', 'data', 'serviceability-cache.json') const CACHE_TTL_MS = 90 * 24 * 3600 * 1000 // gov data refreshes periodically; 90d is safe const BATCH_MAX = 80 // max items processed per /lookup-batch call (bounds latency) // ── disk-persisted cache ──────────────────────────────────────────────────── let cache = new Map() try { if (fs.existsSync(CACHE_FILE)) { const obj = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) cache = new Map(Object.entries(obj)) log(`[serviceability] cache loaded: ${cache.size} entries`) } } catch (e) { log('[serviceability] cache load failed:', e.message) } let saveTimer = null function saveCacheSoon () { if (saveTimer) return saveTimer = setTimeout(() => { saveTimer = null try { fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true }) fs.writeFileSync(CACHE_FILE, JSON.stringify(Object.fromEntries(cache))) } catch (e) { log('[serviceability] cache save failed:', e.message) } }, 2000) } // ── polite rate limiter (concurrency + min interval) ──────────────────────── const MAX_CONCURRENT = parseInt(process.env.IHV_MAX_CONCURRENT || '3') const MIN_INTERVAL_MS = parseInt(process.env.IHV_MIN_INTERVAL_MS || '120') let active = 0 let lastStart = 0 const queue = [] function gate (fn) { return new Promise((resolve, reject) => { const run = () => { active++ const wait = Math.max(0, MIN_INTERVAL_MS - (Date.now() - lastStart)) setTimeout(() => { lastStart = Date.now() fn().then(resolve, reject).finally(() => { active-- if (queue.length) queue.shift()() }) }, wait) } if (active >= MAX_CONCURRENT) queue.push(run); else run() }) } // ── helpers ───────────────────────────────────────────────────────────────── function norm (s) { return String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '') .toLowerCase().replace(/[^a-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim() } function sqlEsc (s) { return String(s).replace(/'/g, "''") } function parseCivic (address1) { const m = String(address1 || '').trim().match(/^(\d+[a-zA-Z]?)/) return m ? m[1] : null } // distinctive street words (drop civic + generic street-type words) const STREET_TYPES = new Set(['rue', 'ch', 'chemin', 'rang', 'montee', 'mtee', 'boul', 'boulevard', 'av', 'ave', 'avenue', 'route', 'rte', 'place', 'pl', 'cote', 'côte', 'terrasse', 'impasse', 'croissant', 'allee', 'allée', 'st', 'ste', 'saint', 'sainte', 'de', 'du', 'des', 'la', 'le', 'les']) function streetWords (address1) { return norm(address1).split(' ').filter(w => w && !/^\d/.test(w) && !STREET_TYPES.has(w)) } async function arcgisQuery (pathBase, params) { const qs = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&') const r = await gate(() => httpRequest(ARCGIS_BASE, `${pathBase}?${qs}`, { method: 'GET', headers: { Referer: REFERER }, timeout: 15000, })) if (r.status !== 200 || !r.data || !Array.isArray(r.data.features)) { const msg = r.data && r.data.error ? r.data.error.message : `HTTP ${r.status}` throw new Error('ArcGIS query failed: ' + msg) } return r.data.features.map(f => f.attributes) } // Resolve an address to a single ADR record (IdAdresse + Etat_hiv). async function resolveAddress ({ address1, city, zip }) { const civic = parseCivic(address1) if (!civic) return null const postal = String(zip || '').replace(/\s+/g, '').toUpperCase() const target = norm(`${address1} ${city || ''}`) const myWords = new Set(streetWords(address1)) // Pick the best candidate by street-token overlap against the input. const best = (rows) => { let top = null, topScore = -1 for (const a of rows) { const cand = norm(a.Adresse_complete) const candWords = new Set(streetWords(a.Adresse_complete)) let overlap = 0 for (const w of myWords) if (candWords.has(w)) overlap++ // tie-break: also reward overall string containment of city const cityBonus = city && cand.includes(norm(city)) ? 0.5 : 0 const score = overlap + cityBonus + (cand === target ? 5 : 0) if (score > topScore) { topScore = score; top = a } } return top } // 1) civic + postal — most reliable (postal is embedded in Adresse_complete). if (postal && /^[A-Z]\d[A-Z]\d[A-Z]\d$/.test(postal)) { const rows = await arcgisQuery(ADR_PATH, { where: `Adresse_complete LIKE '${sqlEsc(civic)} %' AND Adresse_complete LIKE '%${sqlEsc(postal)}'`, outFields: 'IdAdresse,Adresse_complete,Etat_hiv,Type_adresse', returnGeometry: 'false', resultRecordCount: '25', f: 'json', }) if (rows.length === 1) return rows[0] if (rows.length > 1) return best(rows) } // 2) fallback: civic + distinctive street word, then JS-filter by city. const sw = streetWords(address1).sort((a, b) => b.length - a.length)[0] if (sw && /^[a-z]+$/.test(sw)) { const rows = await arcgisQuery(ADR_PATH, { where: `Adresse_complete LIKE '${sqlEsc(civic)} %' AND UPPER(Adresse_complete) LIKE '%${sqlEsc(sw.toUpperCase())}%'`, outFields: 'IdAdresse,Adresse_complete,Etat_hiv,Type_adresse', returnGeometry: 'false', resultRecordCount: '50', f: 'json', }) const inCity = city ? rows.filter(a => norm(a.Adresse_complete).includes(norm(city))) : rows const pool = inCity.length ? inCity : rows if (pool.length) return best(pool) } return null } async function fetchProviders (idAdresse) { const rows = await arcgisQuery(FRN_PATH, { where: `IdAdresse='${sqlEsc(idAdresse)}'`, outFields: 'FRN_nom,FRN_URL_inscrip,Date_fin', returnGeometry: 'false', resultRecordCount: '50', f: 'json', }) const seen = new Set(); const out = [] for (const a of rows) { const nom = (a.FRN_nom || '').trim() if (!nom || seen.has(nom)) continue seen.add(nom) out.push({ nom, url: a.FRN_URL_inscrip || null, date_fin: a.Date_fin || null }) } out.sort((a, b) => a.nom.localeCompare(b.nom, 'fr')) return out } // Main entry: address → { matched, idAdresse, adresse_complete, etat_hiv, // etat_label, providers:[{nom,url,date_fin}], cogeco:bool } async function lookupProviders ({ address1, city, zip }) { const key = `${norm(address1)}|${String(zip || '').replace(/\s+/g, '').toUpperCase() || norm(city)}` const hit = cache.get(key) if (hit && (Date.now() - hit.ts) < CACHE_TTL_MS) return { ...hit.v, cached: true } let result try { const adr = await resolveAddress({ address1, city, zip }) if (!adr) { result = { matched: false, etat_label: 'Adresse introuvable', providers: [], cogeco: false } } else { const providers = await fetchProviders(adr.IdAdresse) result = { matched: true, idAdresse: adr.IdAdresse, adresse_complete: adr.Adresse_complete, etat_hiv: adr.Etat_hiv, etat_label: ETAT_LABEL[adr.Etat_hiv] || String(adr.Etat_hiv), type_adresse: adr.Type_adresse || null, providers, cogeco: providers.some(p => /cogeco/i.test(p.nom)), } } } catch (e) { // Don't cache transient errors — let the next call retry. return { matched: false, error: e.message, etat_label: 'Erreur', providers: [], cogeco: false } } cache.set(key, { ts: Date.now(), v: result }) saveCacheSoon() return result } // ── HTTP handler ───────────────────────────────────────────────────────────── async function handle (req, res, method, path) { if (path === '/serviceability/cache-stats' && method === 'GET') { return json(res, 200, { entries: cache.size, ttl_days: CACHE_TTL_MS / 86400000 }) } if (path === '/serviceability/lookup' && method === 'POST') { const b = await parseBody(req) if (!b.address1) return json(res, 400, { error: 'address1 required' }) return json(res, 200, await lookupProviders(b)) } if (path === '/serviceability/lookup-batch' && method === 'POST') { const b = await parseBody(req) const items = Array.isArray(b.items) ? b.items.slice(0, BATCH_MAX) : [] if (!items.length) return json(res, 400, { error: 'items[] required' }) const results = await Promise.all(items.map(async (it, i) => { const r = await lookupProviders(it) return [it.key != null ? it.key : i, r] })) return json(res, 200, { results: Object.fromEntries(results), processed: items.length, batch_max: BATCH_MAX }) } return json(res, 404, { error: 'Not found' }) } module.exports = { handle, lookupProviders }