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>
96 lines
3.3 KiB
JavaScript
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))
|