Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"build:web": "npm run build:web:landing && npm run build:blog && npm run build:docs",
"build:zip": "node scripts/build-zip.mjs",
"trace:otlp": "node scripts/trace-to-otlp.mjs",
"update:coupon-domains": "node scripts/update-coupon-domains.mjs",
"sync:logo": "python3 scripts/sync-logo-assets.py && python3 scripts/gen-store-promos.py && node assets/webstore-explainer-2026/render.mjs && node assets/brand-assets-2026-2/render.mjs",
"bump": "node scripts/bump-version.mjs",
"release": "node scripts/bump-version.mjs --release"
Expand Down
217 changes: 217 additions & 0 deletions scripts/update-coupon-domains.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
#!/usr/bin/env node

import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const COUPON_FOLLOW_ORIGIN = 'https://couponfollow.com';
const COUPON_FOLLOW_INDEXES = ['0', ...'abcdefghijklmnopqrstuvwxyz'];
const DEFAULT_CONCURRENCY = 4;
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
const PUBLIC_SUFFIX_ONLY = new Set([
'co.in', 'co.jp', 'co.kr', 'co.nz', 'co.uk',
'com.ar', 'com.au', 'com.br', 'com.cn', 'com.co', 'com.hk', 'com.mx',
'com.my', 'com.pe', 'com.ph', 'com.pk', 'com.sg', 'com.tr', 'com.tw',
'com.ua', 'co.za',
]);

// Preserve the original conservative rollout and regional storefronts even if
// a third-party directory temporarily removes or renames one of its entries.
const VETTED_DOMAINS = [
'amazon.com', 'amazon.ca', 'amazon.com.mx', 'amazon.com.br', 'amazon.co.uk', 'amazon.de', 'amazon.fr',
'amazon.it', 'amazon.es', 'amazon.nl', 'amazon.pl', 'amazon.se', 'amazon.com.be', 'amazon.co.jp',
'amazon.in', 'amazon.com.au', 'amazon.sg', 'amazon.ae', 'amazon.sa', 'amazon.com.tr', 'amazon.eg',
'ebay.com', 'ebay.ca', 'ebay.co.uk', 'ebay.de', 'ebay.fr', 'ebay.it', 'ebay.es', 'ebay.com.au',
'etsy.com', 'walmart.com', 'target.com', 'bestbuy.com', 'aliexpress.com', 'mercadolivre.com.br',
'hepsiburada.com', 'trendyol.com', 'n11.com', 'shopeekh.com',
'mercadolibre.com.ar', 'mercadolibre.com.mx', 'mercadolibre.com.co', 'mercadolibre.cl',
'mercadolibre.com.pe', 'mercadolibre.com.uy', 'mercadolibre.com.ve', 'mercadolibre.com.ec',
'mercadolibre.com.bo', 'mercadolibre.com.py', 'mercadolibre.com.do', 'mercadolibre.com.gt',
'mercadolibre.com.hn', 'mercadolibre.com.ni', 'mercadolibre.com.pa', 'mercadolibre.com.sv',
'mercadolibre.co.cr',
'shopee.com', 'shopee.com.br', 'shopee.com.co', 'shopee.com.mx', 'shopee.cl', 'shopee.co.id',
'shopee.com.my', 'shopee.com.ph', 'shopee.sg', 'shopee.co.th', 'shopee.vn', 'shopee.tw',
'lazada.com', 'lazada.co.id', 'lazada.com.my', 'lazada.com.ph', 'lazada.sg', 'lazada.co.th', 'lazada.vn',
];

const OUTPUTS = [
'src/chrome/src/ui/coupon-domains.js',
'src/firefox/src/ui/coupon-domains.js',
];

function decodeHtmlAttribute(value) {
return String(value || '')
.replace(/&/gi, '&')
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
.replace(/&#([0-9]+);/g, (_, decimal) => String.fromCodePoint(Number.parseInt(decimal, 10)));
}

export function normalizeCouponDomain(value) {
let input = String(value || '').trim().toLowerCase();
if (!input) return null;
try {
input = decodeURIComponent(input);
} catch {
return null;
}

let hostname;
try {
hostname = new URL(input.includes('://') ? input : `https://${input}`).hostname;
} catch {
return null;
}
hostname = hostname.replace(/^www\./, '').replace(/\.$/, '');
if (
hostname.length > 253
|| !hostname.includes('.')
|| !/^[a-z0-9.-]+$/.test(hostname)
|| hostname.includes('..')
|| PUBLIC_SUFFIX_ONLY.has(hostname)
) return null;

const labels = hostname.split('.');
if (labels.some((label) => !label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label))) {
return null;
}
if (!/^[a-z]{2,63}$/.test(labels.at(-1))) return null;
return hostname;
}

export function extractCouponFollowDomains(html) {
const domains = new Set();
const hrefPattern = /<a\b[^>]*\bhref\s*=\s*(["'])(.*?)\1/gi;
for (const match of String(html || '').matchAll(hrefPattern)) {
let url;
try {
url = new URL(decodeHtmlAttribute(match[2]), COUPON_FOLLOW_ORIGIN);
} catch {
continue;
}
if (url.hostname !== 'couponfollow.com' && url.hostname !== 'www.couponfollow.com') continue;
const merchantRoute = /^\/site\/([^/]+)\/?$/.exec(url.pathname);
if (!merchantRoute) continue;
const domain = normalizeCouponDomain(merchantRoute[1]);
if (domain) domains.add(domain);
}
return [...domains].sort();
}

function normalizedDomains(domains) {
const result = new Set();
for (const candidate of [...VETTED_DOMAINS, ...(domains || [])]) {
const domain = normalizeCouponDomain(candidate);
if (domain) result.add(domain);
}
return [...result].sort();
}

export function renderCouponDomainModule(domains, { sources = [] } = {}) {
const normalized = normalizedDomains(domains);
if (!normalized.length) throw new Error('Refusing to render an empty coupon merchant domain set.');
const sourceLines = sources.length
? sources.map((source) => ` * Source: ${source}`).join('\n')
: ' * Source: https://couponfollow.com/site/browse/{0,a-z}/all';
return `/**\n * Generated by \`npm run update:coupon-domains\`; do not edit by hand.\n${sourceLines}\n * Runtime updates are intentionally disabled: this reviewed snapshot ships with the extension.\n */\nexport const COUPON_MERCHANT_DOMAINS = new Set([\n${normalized.map((domain) => ` '${domain}',`).join('\n')}\n]);\n`;
}

async function fetchText(url, fetchImpl = fetch) {
const response = await fetchImpl(url, {
headers: {
accept: 'text/html',
'user-agent': 'WebBrainCouponDomainUpdater/1.0 (+https://github.com/webbrain-one/webbrain)',
},
redirect: 'follow',
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`);
const declaredLength = Number(response.headers.get('content-length') || 0);
if (declaredLength > MAX_RESPONSE_BYTES) {
throw new Error(`${url} declared ${declaredLength} bytes; limit is ${MAX_RESPONSE_BYTES}`);
}
const text = await response.text();
if (Buffer.byteLength(text) > MAX_RESPONSE_BYTES) {
throw new Error(`${url} exceeded the ${MAX_RESPONSE_BYTES}-byte response limit`);
}
return text;
}

async function mapConcurrent(items, concurrency, task) {
const results = new Array(items.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await task(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
return results;
}

export async function collectCouponFollowDomains({ concurrency = DEFAULT_CONCURRENCY, fetchImpl = fetch } = {}) {
const boundedConcurrency = Math.max(1, Math.min(8, Number(concurrency) || DEFAULT_CONCURRENCY));
const urls = COUPON_FOLLOW_INDEXES.map((index) => `${COUPON_FOLLOW_ORIGIN}/site/browse/${index}/all`);
const pages = await mapConcurrent(urls, boundedConcurrency, async (url) => {
const html = await fetchText(url, fetchImpl);
const domains = extractCouponFollowDomains(html);
if (!domains.length) throw new Error(`${url} contained no merchant domains`);
return domains;
});
return normalizedDomains(pages.flat());
}

function parseArgs(argv) {
const args = { check: false, concurrency: DEFAULT_CONCURRENCY };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--check') {
args.check = true;
continue;
}
if (arg === '--concurrency') {
args.concurrency = Number(argv[index + 1]);
index += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
if (!Number.isInteger(args.concurrency) || args.concurrency < 1 || args.concurrency > 8) {
throw new Error('--concurrency must be an integer from 1 to 8');
}
return args;
}

async function runCli() {
const args = parseArgs(process.argv.slice(2));
const domains = await collectCouponFollowDomains({ concurrency: args.concurrency });
const rendered = renderCouponDomainModule(domains);
const changed = [];

for (const relativePath of OUTPUTS) {
const outputPath = path.join(ROOT, relativePath);
let current = null;
try {
current = await readFile(outputPath, 'utf8');
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (current === rendered) continue;
changed.push(relativePath);
if (!args.check) await writeFile(outputPath, rendered);
}

if (args.check && changed.length) {
throw new Error(`coupon domain snapshot is stale: ${changed.join(', ')}`);
}
console.log(`${args.check ? 'Checked' : 'Wrote'} ${domains.length} coupon merchant domains${changed.length ? ` (${changed.join(', ')})` : ' (no changes)'}.`);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runCli().catch((error) => {
console.error(`update-coupon-domains: ${error.message}`);
process.exitCode = 1;
});
}
4 changes: 3 additions & 1 deletion src/chrome/src/agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -3429,7 +3429,7 @@ export class Agent extends LoopDetector {
static EXECUTION_APP_STATE_WRITE_TOOLS = new Set(['scratchpad_write', 'progress_update']);
static DELIVERY_OBSERVATION_TOOLS = new Set(['read_page', 'get_accessibility_tree', 'get_interactive_elements', 'extract_data', 'get_selection', 'find_text', 'scroll', 'wait_for_stable', 'wait_for_element', 'read_pdf', 'fetch_url', 'research_url', 'read_downloaded_file', 'iframe_read', 'get_window_info', 'list_downloads', 'progress_read', 'inspect_viewport', 'screenshot', 'get_frames', 'get_shadow_dom', 'shadow_dom_query', 'read_youtube_transcript']);
static NAV_PRONE_TOOLS = new Set(['click', 'click_ax', 'set_checked', 'navigate', 'go_back', 'go_forward', 'execute_js', 'iframe_click', 'execute_webmcp_tool']);
static RECOMMENDED_ACTION_FAST_PATH_IDS = new Set(['download-media', 'tweet-webbrain', 'post-webbrain-linkedin']);
static RECOMMENDED_ACTION_FAST_PATH_IDS = new Set(['download-media', 'tweet-webbrain', 'post-webbrain-linkedin', 'find-coupons']);
static RECOMMENDED_ACTION_FIRST_TOOLS = Object.freeze({
'download-media': new Set(['screenshot']),
'summarize-page': new Set(['read_page']),
Expand All @@ -3439,6 +3439,7 @@ export class Agent extends LoopDetector {
'find-followups': new Set(['get_accessibility_tree']),
'rewrite-focused-draft': new Set(['get_accessibility_tree']),
'compare-price': new Set(['get_accessibility_tree']),
'find-coupons': new Set(['get_accessibility_tree']),
});
static RECOMMENDED_ACTION_READ_ONLY_FIRST_TOOLS = new Set(['screenshot', 'read_page', 'get_accessibility_tree', 'read_youtube_transcript']);

Expand Down Expand Up @@ -8595,6 +8596,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
if (!this._skillToolForName(tool)) return null;
}
if (['tweet-webbrain', 'post-webbrain-linkedin'].includes(id) && tool !== 'navigate') return null;
if (id === 'find-coupons' && tool !== 'get_accessibility_tree') return null;
const summary = sanitizePlannerText(action.summary || 'Run the selected recommended action.', 500, { collapseWhitespace: true });
const stepLimit = ['tweet-webbrain', 'post-webbrain-linkedin'].includes(id) ? 600 : 300;
const steps = Array.isArray(action.steps)
Expand Down
Loading
Loading