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:
parent
74b89f5490
commit
68ba64c47b
|
|
@ -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 l’onglet Cogeco : clique « Check Availability », colle (⌘V / Ctrl+V) puis choisis la suggestion.'
|
||||
: 'Copie l’adresse 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user