[object Object]

← back to Nationalrealestate

auto-save: 2026-07-21T14:38:01 (1 files) — src/enrich/firm_website_discovery.ts

bf3451dd9659ae36a0e03b2446fdd64b333d5239 · 2026-07-21 14:38:02 -0700 · Steve Abrams

Files touched

Diff

commit bf3451dd9659ae36a0e03b2446fdd64b333d5239
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 21 14:38:02 2026 -0700

    auto-save: 2026-07-21T14:38:01 (1 files) — src/enrich/firm_website_discovery.ts
---
 src/enrich/firm_website_discovery.ts | 47 +++++++++++++++++++++++++++++++++---
 1 file changed, 43 insertions(+), 4 deletions(-)

diff --git a/src/enrich/firm_website_discovery.ts b/src/enrich/firm_website_discovery.ts
index 75fcb2e..d33d645 100644
--- a/src/enrich/firm_website_discovery.ts
+++ b/src/enrich/firm_website_discovery.ts
@@ -15,6 +15,7 @@
  * timeout, custom UA, escalating backoff + abort on persistent throttle. $0.
  */
 import 'dotenv/config';
+import { chromium, type Browser, type Page } from 'playwright';
 import { pool, query } from '../../db/pool.ts';
 
 const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 usrealestate/0.1 (firm-website-discovery; +local)';
@@ -95,6 +96,25 @@ async function ddgSearch(q: string): Promise<string[]> {
   return out.slice(0, 10);
 }
 
+// Browser-mode search: DDG's JS site via real Chromium. The plain-fetch html
+// endpoint anomaly-flags this IP quickly (HTTP 202 challenge); a real browser
+// passes. One shared page, new search per firm.
+async function ddgBrowserSearch(page: Page, q: string): Promise<string[]> {
+  await page.goto('https://duckduckgo.com/?q=' + encodeURIComponent(q) + '&ia=web', {
+    waitUntil: 'domcontentloaded', timeout: 20_000,
+  });
+  try {
+    await page.waitForSelector('[data-testid="result-title-a"], article a[href^="http"]', { timeout: 8000 });
+  } catch {
+    const body = (await page.textContent('body')) || '';
+    if (/unfortunately|bots|anomaly|browser check/i.test(body)) throw new ThrottleError('ddg browser challenge');
+    return []; // genuinely no results
+  }
+  const hrefs: string[] = await page.$$eval('[data-testid="result-title-a"]',
+    els => els.map(e => (e as HTMLAnchorElement).href).filter(h => /^https?:\/\//.test(h)));
+  return hrefs.slice(0, 10);
+}
+
 function pickWinner(urls: string[], firmName: string): string | null {
   const credible = urls.filter(u => { const h = host(u); return h && !blocked(h); });
   if (!credible.length) return null;
@@ -128,22 +148,40 @@ async function main() {
   console.log(`[discover] run #${runId} · ${queue.length} firms · jittered 2.5-4s/query`);
 
   let ok = 0, miss = 0, err = 0, consecThrottle = 0;
+  let engine: 'fetch' | 'browser' = process.argv.includes('--engine=browser') ? 'browser' : 'fetch';
+  let browser: Browser | null = null;
+  let page: Page | null = null;
+  const ensureBrowser = async () => {
+    if (!page) {
+      browser = await chromium.launch({ headless: true });
+      page = await (await browser.newContext({ userAgent: UA.replace(/ usrealestate.*$/, ''), viewport: { width: 1280, height: 900 } })).newPage();
+    }
+    return page;
+  };
+
   for (let i = 0; i < queue.length; i++) {
     const f = queue[i];
     const q = `${f.name} ${f.hq_city || ''} ${f.license_state} real estate`.replace(/\s+/g, ' ').trim();
     let urls: string[] | null = null;
-    // Up to 3 attempts with escalating backoff on throttle; a firm is only
-    // recorded as a miss when DDG actually returned a results page.
+    // Up to 3 attempts; a plain-fetch throttle escalates to browser mode first,
+    // then to timed backoff. A firm is only recorded as a miss when DDG actually
+    // returned a results page.
     for (let attempt = 0; attempt < 3 && urls === null; attempt++) {
       try {
-        urls = await ddgSearch(q);
+        urls = engine === 'fetch' ? await ddgSearch(q) : await ddgBrowserSearch(await ensureBrowser(), q);
         consecThrottle = 0;
       } catch (e: any) {
         if (e instanceof ThrottleError || /ddg (403|429)/.test(e.message || '')) {
+          if (engine === 'fetch') {
+            console.log(`  [throttle] ${e.message} — switching to browser engine`);
+            engine = 'browser';
+            continue;
+          }
           const wait = [30_000, 90_000, 180_000][attempt];
           console.log(`  [throttle] ${e.message} — backing off ${wait / 1000}s (attempt ${attempt + 1}/3)`);
           await new Promise(res => setTimeout(res, wait));
         } else {
+          console.log(`  [err] ${f.name.slice(0, 40)}: ${(e.message || e).slice(0, 80)}`);
           break; // transport error — skip this firm, don't record
         }
       }
@@ -184,7 +222,8 @@ async function main() {
             notes = notes || $4 WHERE id = $1`,
     [runId, ok, miss, ` · ${ok} found, ${miss} no-result, ${err} search-errors`],
   );
-  console.log(`[discover] done · ${ok} found · ${miss} no-result · ${err} errors`);
+  console.log(`[discover] done · ${ok} found · ${miss} no-result · ${err} errors · engine=${engine}`);
+  if (browser) await (browser as Browser).close().catch(() => {});
   await pool.end();
 }
 

← 12bd5ee CBSA crosswalk backfill: 1915 counties get cbsa_code from Ce  ·  back to Nationalrealestate  ·  Map page: Top Markets strip (top-8 ranked, clickable to deta dd7b002 →