gigafibre-fsm/services/cogeco-checker/server.js
louispaulb 74b89f5490 feat(cogeco-checker): POC competitor-serviceability microservice (WIP)
Playwright/Chromium microservice (mirrors modem-bridge: node:20-slim +
Chromium, token auth, port 3302, serialized + rate-limited) that drives
Cogeco's public address checker to determine if a competitor serves a
given address.

What works (proven on prod):
- Anti-bot bypass: vanilla headless gets 403 on /boutique/api/register
  (reCAPTCHA Enterprise blocks datacenter headless). Adding
  playwright-extra + stealth flips it to 200 — register + autocomplete
  succeed.
- Reaches Cogeco's address system and pulls real autocomplete
  suggestions. Confirmed it's Loqate/AddressComplete (id + next:
  Retrieve/Find shape).

What's NOT reliable yet (do not use the verdict for decisions):
- The serviceability verdict. The Loqate flow is multi-step
  (Find → Retrieve → Cogeco serviceability) and a single option click
  doesn't complete it, so the final yes/no API call isn't captured.
- Current interpret() falls back to scanning UI text and produces FALSE
  POSITIVES (a rural out-of-Cogeco address returned available=true off
  generic marketing copy). Needs the real Retrieve+serviceability
  endpoint wired before it can be trusted.

Next: capture the post-selection Retrieve + serviceability call (likely
needs a "continue" step and handling the multi-dwelling "N Addresses"
branch), then parse the real verdict + speeds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:56:05 -04:00

96 lines
3.3 KiB
JavaScript

// cogeco-checker/server.js — REST API for competitor (Cogeco) serviceability.
// targo-hub (3300) -> cogeco-checker (3302) -> cogeco.ca address checker
// Internal only, token auth, rate-limited (real browser + reCAPTCHA upstream).
const http = require('http')
const url = require('url')
const cogeco = require('./lib/cogeco-session')
const PORT = parseInt(process.env.CHECKER_PORT || '3302')
const TOKEN = process.env.CHECKER_TOKEN || ''
// Serialize checks: one real browser context at a time + a small gap so we
// don't hammer Cogeco (reCAPTCHA score protection). Concurrency=1 by design.
const MIN_GAP_MS = parseInt(process.env.CHECKER_MIN_GAP_MS || '4000')
let _chain = Promise.resolve()
let _lastRun = 0
function enqueue (fn) {
const run = _chain.then(async () => {
const wait = Math.max(0, MIN_GAP_MS - (Date.now() - _lastRun))
if (wait) await new Promise(r => setTimeout(r, wait))
try { return await fn() } finally { _lastRun = Date.now() }
})
// Keep the chain alive even if one job throws.
_chain = run.catch(() => {})
return run
}
function json (res, data, status = 200) {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(data))
}
function err (res, msg, status = 400) { json(res, { error: msg }, status) }
function parseBody (req) {
return new Promise((resolve, reject) => {
let body = ''
req.on('data', c => { body += c })
req.on('end', () => { try { resolve(body ? JSON.parse(body) : {}) } catch { reject(new Error('Invalid JSON')) } })
req.on('error', reject)
})
}
function checkAuth (req, res) {
if (!TOKEN) return true
if (req.headers['authorization'] === `Bearer ${TOKEN}`) return true
err(res, 'Unauthorized', 401)
return false
}
const server = http.createServer(async (req, res) => {
const parsed = url.parse(req.url, true)
const path = parsed.pathname
const method = req.method
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
if (method === 'OPTIONS') { res.writeHead(204); res.end(); return }
if (path === '/health' && method === 'GET') {
return json(res, { status: 'ok', uptime: process.uptime() })
}
if (!checkAuth(req, res)) return
try {
// POST /check { address, debug? } → { available, max_download_mbps, plans, confidence }
if (path === '/check' && method === 'POST') {
const body = await parseBody(req)
const address = (body.address || '').trim()
if (!address || address.length < 5) return err(res, 'address required (min 5 chars)')
const debug = !!body.debug
const out = await enqueue(() => cogeco.checkAddress(address, { debug }))
return json(res, out)
}
err(res, 'Not found', 404)
} catch (e) {
console.error('[cogeco-checker] error:', e)
err(res, 'Internal error: ' + e.message, 500)
}
})
server.listen(PORT, () => {
console.log(`[cogeco-checker] listening on ${PORT}, auth ${TOKEN ? 'on' : 'OFF (dev)'}, min-gap ${MIN_GAP_MS}ms`)
})
for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, async () => {
console.log(`[cogeco-checker] ${sig}, shutting down`)
await cogeco.shutdown()
server.close()
process.exit(0)
})
}
process.on('uncaughtException', e => console.error('[cogeco-checker] uncaught:', e))