feat(ops): assisted Cogeco spot-check on overpriced-internet report

Cogeco's address checker is gated by reCAPTCHA Enterprise (risk-score 401
on the protected /boutique/api/address/search call), so per-address
serviceability can't be scraped reliably from a datacenter IP without a
residential proxy. Per product decision, pivot to an assisted spot-check
instead of automated qualification.

- ReportInternetCherPage: add a "Concurrent" column with a one-click
  button that copies the full service address and opens Cogeco's
  availability page in a new tab (human reads the verdict in ~10s, only
  for the leads that matter). fullAddress() builds "addr, city, QC ZIP".
- cogeco-checker: harden the POC anyway — track service-address/search
  responses, retry the verdict call on 401 (re-register cadence), and
  prioritize the authoritative JSON body in interpret(). Recon confirmed
  the wall is reCAPTCHA scoring, not a timing/selector bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-01 21:24:36 -04:00
parent 74b89f5490
commit 68ba64c47b
2 changed files with 151 additions and 38 deletions

View File

@ -144,6 +144,15 @@
</span>
</q-td>
</template>
<template #body-cell-cogeco="props">
<q-td :props="props">
<q-btn dense flat round size="sm" icon="travel_explore" color="indigo-7"
@click="checkCogeco(props.row)">
<q-tooltip max-width="260px">Vérifier Cogeco copie l'adresse et ouvre le vérificateur de disponibilité dans un nouvel onglet</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<div v-if="loaded && !rows.length" class="text-center q-pa-xl text-grey-7">
@ -155,7 +164,7 @@
<script setup>
import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
import { useQuasar, copyToClipboard } from 'quasar'
import { fetchOverpricedInternet, overpricedInternetCsvUrl } from 'src/api/reports'
const $q = useQuasar()
@ -184,6 +193,7 @@ const columns = [
{ name: 'n_lines', label: 'Lignes', field: 'n_lines', align: 'center', sortable: true },
{ name: 'detail', label: 'Détail forfait', field: 'detail', align: 'left' },
{ name: 'contact', label: 'Contact', field: 'email', align: 'left' },
{ name: 'cogeco', label: 'Concurrent', field: 'delivery_id', align: 'center' },
]
const totalNet = computed(() => rows.value.reduce((s, r) => s + Number(r.net_internet || 0), 0))
@ -212,6 +222,36 @@ function shorten (s) {
return s.length > 48 ? s.slice(0, 48) + '…' : s
}
// Build a clean single-line address for the competitor's address checker.
// e.g. "147 Montée Richard, Saint-Bernard-de-Lacolle, QC J0J 1V0"
function fullAddress (row) {
const prov = 'QC' + (row.zip ? ' ' + String(row.zip).toUpperCase() : '')
return [row.address1, row.city, prov].filter(Boolean).join(', ')
}
// Assisted spot-check: Cogeco's checker is gated by reCAPTCHA Enterprise so we
// can't qualify addresses automatically at volume. Instead, one click copies
// the address and opens Cogeco's availability page in a new tab a human
// pastes + reads the verdict in ~10s, only for the leads that matter.
const COGECO_URL = 'https://www.cogeco.ca/en/internet/packages'
async function checkCogeco (row) {
const addr = fullAddress(row)
let copied = true
try { await copyToClipboard(addr) } catch { copied = false }
window.open(COGECO_URL, '_blank', 'noopener')
$q.notify({
color: 'indigo-7',
icon: 'travel_explore',
message: copied ? 'Adresse copiée : ' + addr : addr,
caption: copied
? 'Dans longlet Cogeco : clique « Check Availability », colle (⌘V / Ctrl+V) puis choisis la suggestion.'
: 'Copie ladresse ci-dessus, puis dans Cogeco clique « Check Availability » et colle-la.',
timeout: 7000,
multiLine: true,
actions: [{ label: 'OK', color: 'white', round: true }],
})
}
async function loadReport () {
loading.value = true
try {

View File

@ -6,16 +6,23 @@
* page's own JS. A pure HTTP call can't produce those, hence the browser.
*
* Flow (reverse-engineered 2026-06):
* 1. load /en/internet/packages
* 2. click "Check Availability" address dialog
* 3. type the address into the autocomplete combobox
* 4. pick the first suggestion (triggers GET /boutique/api/address/search
* then the serviceability lookup)
* 5. capture the JSON responses + the rendered result text
* 1. GET /boutique/api/register mints a SHORT-LIVED JWT (Bearer)
* 2. GET /boutique/api/address/search?query=&sessionId= Loqate suggestions
* 3. pick a suggestion, click "Find out now"
* 4. GET /boutique/api/service-address/search?id=<loqate-id>&version=v10
* &sessionId= THE serviceability verdict
*
* We intercept every /boutique/api/* and /api/check-avail/* response and also
* read the visible result, then return a normalized verdict. Cogeco can change
* this flow at any time treat parsing defensively and keep `raw` for debug.
* CRITICAL: the register token rotates fast and is ~single-use. Requests fired
* on a stale token return 401 we observed address/search alternating 200/401
* and service-address/search 401'ing outright on the first try. The real page
* silently re-registers and retries; so do we: after clicking "Find out now"
* we wait for a 200 service-address/search response and, on 401/timeout,
* re-click (forcing a fresh register) up to VERDICT_ATTEMPTS times.
*
* We intercept every /boutique/api/* response (tracking service-address/search
* specially) and also read the visible result, then return a normalized
* verdict. Cogeco can change this flow at any time parse defensively, keep
* `raw`/`captured` for debug.
*/
// playwright-extra + stealth masks the headless automation signals
@ -34,6 +41,11 @@ try {
const PAGE_URL = 'https://www.cogeco.ca/en/internet/packages'
const NAV_TIMEOUT = 45000
const STEP_TIMEOUT = 20000
// The serviceability call (service-address/search) often 401s on a stale token.
// Re-trigger it this many times, waiting for the page to re-register in between.
const VERDICT_ATTEMPTS = 5
const VERDICT_WAIT_MS = 7000 // per-attempt wait for a 200 verdict response
const REREGISTER_PAUSE_MS = 2500 // let the page mint a fresh token before retry
let _browser = null
async function getBrowser () {
@ -45,37 +57,58 @@ async function getBrowser () {
return _browser
}
// Normalize Cogeco's serviceability payload into a stable verdict. The exact
// shape varies, so we probe several likely fields and fall back to scanning
// the captured JSON + UI text for availability keywords + speed numbers.
function interpret (captured, uiText) {
// Pull availability + speeds out of a single serviceability JSON body.
// The exact shape is unconfirmed (the endpoint 401'd during recon), so probe
// several likely flags. Returns {available, max_download_mbps} (nulls if unsure).
function readServiceBody (b) {
const out = { available: null, max_download_mbps: null }
if (!b || typeof b !== 'object') return out
const flat = JSON.stringify(b).toLowerCase()
if (/"(?:serviceable|available|iseligible|qualified|isserviceable|eligible)"\s*:\s*true/.test(flat)) out.available = true
else if (/"(?:serviceable|available|iseligible|qualified|isserviceable|eligible)"\s*:\s*false/.test(flat)) out.available = false
// A non-empty list of plans/products/offers also implies serviceable.
if (out.available === null && /"(?:plans|products|offers|packages)"\s*:\s*\[\s*\{/.test(flat)) out.available = true
const speeds = [...flat.matchAll(/"(?:download|downloadspeed|speed|maxspeed|maxdownload)"\s*:\s*"?(\d{2,5})"?/g)].map(m => parseInt(m[1], 10))
if (speeds.length) out.max_download_mbps = Math.max(...speeds)
return out
}
// Normalize the whole capture into a stable verdict. Priority:
// 1. a 200 service-address/search body (the authoritative serviceability call)
// 2. any other captured JSON with a serviceability flag
// 3. rendered UI result text (weakest — kept as a last resort)
function interpret (captured, uiText, serviceVerdict) {
const verdict = { available: null, max_download_mbps: null, plans: [], confidence: 'low' }
// 1. Look for an explicit serviceability object in the captured responses.
for (const c of captured) {
const b = c.body
if (!b || typeof b !== 'object') continue
const flat = JSON.stringify(b).toLowerCase()
// Common serviceability flags
if (verdict.available === null) {
if (/"serviceable"\s*:\s*true|"available"\s*:\s*true|"iseligible"\s*:\s*true|"qualified"\s*:\s*true/.test(flat)) {
verdict.available = true; verdict.confidence = 'high'
} else if (/"serviceable"\s*:\s*false|"available"\s*:\s*false|"iseligible"\s*:\s*false|"qualified"\s*:\s*false/.test(flat)) {
verdict.available = false; verdict.confidence = 'high'
}
// 1. Authoritative: the service-address/search 200 body, if we got one.
if (serviceVerdict && serviceVerdict.status === 200) {
const r = readServiceBody(serviceVerdict.body)
if (r.available !== null) {
verdict.available = r.available
verdict.max_download_mbps = r.max_download_mbps
verdict.confidence = 'high'
verdict.source = 'service-address/search'
return verdict
}
// Speed markers anywhere in the payload (e.g. download 1000)
const speeds = [...flat.matchAll(/"(?:download|downloadspeed|speed|maxspeed)"\s*:\s*"?(\d{2,5})"?/g)].map(m => parseInt(m[1], 10))
if (speeds.length) verdict.max_download_mbps = Math.max(verdict.max_download_mbps || 0, ...speeds)
}
// 2. Fall back to the rendered result text.
// 2. Any other captured JSON with an explicit flag.
for (const c of captured) {
if (c.status && c.status !== 200) continue
const r = readServiceBody(c.body)
if (verdict.available === null && r.available !== null) {
verdict.available = r.available; verdict.confidence = 'medium'; verdict.source = 'captured-json'
}
if (r.max_download_mbps) verdict.max_download_mbps = Math.max(verdict.max_download_mbps || 0, r.max_download_mbps)
}
// 3. Last resort: rendered result text.
if (verdict.available === null && uiText) {
const t = uiText.toLowerCase()
if (/available|disponible|good news|great news|we('| a)re in your area|select your plan|choose your/i.test(t)) {
verdict.available = true; verdict.confidence = 'medium'
} else if (/not available|non disponible|unfortunately|pas (encore )?disponible|sorry/i.test(t)) {
verdict.available = false; verdict.confidence = 'medium'
if (/great news|good news|we('| a)re in your area|service is available|is available at|select your (plan|package)|choose your (plan|package)/i.test(t)) {
verdict.available = true; verdict.confidence = 'low'; verdict.source = 'ui-text'
} else if (/not (yet )?available|isn't available|unfortunately|pas (encore )?disponible|non disponible|sorry, we|we don't (yet )?(serve|offer)/i.test(t)) {
verdict.available = false; verdict.confidence = 'low'; verdict.source = 'ui-text'
}
}
return verdict
@ -92,12 +125,18 @@ async function checkAddress (address, { debug = false } = {}) {
})
const page = await ctx.newPage()
const captured = []
// Track the serviceability call specifically; keep the best (200 wins over 401).
let serviceResp = null
page.on('response', async (resp) => {
const u = resp.url()
if (/\/(boutique\/api|api\/check-avail)\//.test(u)) {
let body = null
try { body = await resp.json() } catch { try { body = (await resp.text()).slice(0, 2000) } catch { /* ignore */ } }
captured.push({ url: u, status: resp.status(), body })
const rec = { url: u, status: resp.status(), body }
captured.push(rec)
if (/\/service-address\/search/.test(u)) {
if (!serviceResp || (rec.status === 200 && serviceResp.status !== 200)) serviceResp = rec
}
}
})
@ -138,15 +177,49 @@ async function checkAddress (address, { debug = false } = {}) {
try { await input.press('ArrowDown'); await input.press('Enter'); picked = true } catch { /* ignore */ }
}
// Give the serviceability lookup time to fire + render.
await page.waitForTimeout(5000)
// Locate the dialog submit button ("Find out now" / "Vérifier" / "Submit").
// It's distinct from the page's "Check Availability" opener (already gone).
const submitBtn = (await dialog.count().catch(() => 0))
? dialog.getByRole('button', { name: /find out|v[ée]rifier|check|submit|continue|suivant|next/i }).first()
: page.getByRole('button', { name: /find out|v[ée]rifier|submit/i }).first()
// Retry loop: the verdict call frequently 401s on a stale token. Click the
// submit button, wait for a 200 service-address/search; on failure pause so
// the page re-registers a fresh token, then re-click. Bail as soon as we
// have a 200 verdict (or a clear UI result).
let attempts = 0
for (let i = 0; i < VERDICT_ATTEMPTS; i++) {
attempts = i + 1
// (Re)submit the lookup if a submit button is present & enabled.
if (await submitBtn.count().catch(() => 0)) {
const enabled = await submitBtn.isEnabled().catch(() => false)
if (enabled) await submitBtn.click({ timeout: 4000 }).catch(() => {})
}
// Wait for a 200 verdict response this round (event-driven, no clock needed).
try {
await page.waitForResponse(
r => /\/service-address\/search/.test(r.url()) && r.status() === 200,
{ timeout: VERDICT_WAIT_MS },
)
} catch { /* timed out waiting for a 200 this round */ }
if (serviceResp && serviceResp.status === 200) break
// Also stop early if the UI already rendered a clear verdict.
const peek = (await page.locator('body').innerText().catch(() => '') || '').toLowerCase()
if (/great news|good news|not (yet )?available|unfortunately|isn't available/.test(peek)) break
await page.waitForTimeout(REREGISTER_PAUSE_MS) // let the page mint a fresh token
}
// Grab the visible result text (whatever the page now shows).
const uiText = (await page.locator('body').innerText().catch(() => '') || '').slice(0, 4000)
Object.assign(result, interpret(captured, uiText), { picked_suggestion: picked })
Object.assign(result, interpret(captured, uiText, serviceResp), {
picked_suggestion: picked,
verdict_attempts: attempts,
verdict_http_status: serviceResp ? serviceResp.status : null,
})
if (debug) {
result.captured = captured
result.service_response = serviceResp
result.ui_excerpt = uiText.slice(0, 1200)
result.screenshot = (await page.screenshot({ fullPage: false }).catch(() => null))?.toString('base64') || null
}