bhcto.com

lib/scrape.js

// Fetch a page's full HTML with Puppeteer. Tries a direct headless fetch first;
// on block/timeout/error retries through the Bright Data proxy (if creds are in
// .env). Returns { ok, html, via, status, error }. Never throws — callers log+skip.

const { env, envAny } = require("./env");

const UA =
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";

// Bright Data's standard super-proxy endpoint (used by residential / datacenter /
// Web-Unlocker zones; the zone is encoded in the username). Override with
// BRIGHTDATA_HOST / BRIGHTDATA_PORT or a full BRIGHTDATA_PROXY URL if your zone differs.
const BD_DEFAULT_HOST = "brd.superproxy.io";
const BD_DEFAULT_PORT = "33335";

// Resolve Bright Data proxy config from .env. Accepts a full BRIGHTDATA_PROXY URL,
// or username/password (your keys: Brightdata_Username / Brightdata_Password) + an
// optional host/port override.
function brightDataProxy() {
  const single = env("BRIGHTDATA_PROXY");
  if (single) {
    try {
      const u = new URL(single);
      return {
        server: `${u.protocol}//${u.host}`,
        username: decodeURIComponent(u.username || ""),
        password: decodeURIComponent(u.password || ""),
      };
    } catch {
      return null;
    }
  }
  let username = envAny(["Brightdata_Username", "BRIGHTDATA_USER", "BRIGHTDATA_USERNAME"]);
  const password = envAny(["Brightdata_Password", "BRIGHTDATA_PASS", "BRIGHTDATA_PASSWORD"]);
  if (!username || !password) return null;
  // Route through a US exit node so pages render as a US visitor sees them
  // (faithful to the Chicago, IL intent). Append -country-xx only if the username
  // doesn't already pin a country. Set BRIGHTDATA_COUNTRY="" to disable.
  const country = env("BRIGHTDATA_COUNTRY") ?? "us";
  if (country && !/-country-/.test(username)) {
    username = `${username}-country-${country}`;
  }
  const host = env("BRIGHTDATA_HOST") || BD_DEFAULT_HOST;
  const port = env("BRIGHTDATA_PORT") || BD_DEFAULT_PORT;
  return { server: `http://${host}:${port}`, username, password };
}

async function fetchWith(puppeteer, url, proxy) {
  const args = ["--no-sandbox", "--disable-setuid-sandbox"];
  if (proxy) args.push(`--proxy-server=${proxy.server}`);
  const browser = await puppeteer.launch({ headless: "new", args });
  try {
    const page = await browser.newPage();
    if (proxy && proxy.username) {
      await page.authenticate({
        username: proxy.username,
        password: proxy.password,
      });
    }
    await page.setUserAgent(UA);
    await page.setExtraHTTPHeaders({ "Accept-Language": "en-US,en;q=0.9" });
    const resp = await page.goto(url, {
      waitUntil: "networkidle2",
      timeout: 60000,
    });
    const status = resp ? resp.status() : 0;
    // Wait for client-rendered pages to actually populate body text before capture.
    await page
      .waitForFunction(
        () => document.body && document.body.innerText.trim().length > 500,
        { timeout: 8000 },
      )
      .catch(() => {});
    const html = await page.content();
    const text = await page
      .evaluate(() => (document.body ? document.body.innerText : ""))
      .catch(() => "");
    return { status, html, text };
  } finally {
    await browser.close().catch(() => {});
  }
}

// Judge by EXTRACTED body text, not raw HTML size (raw size misfires both ways:
// huge framework shells look "fine", and content-rich pages like Wikipedia were
// wrongly flagged). A real article has substantial visible text.
function looksBlocked(status, html, text) {
  if (status === 403 || status === 429 || status === 503) return true;
  if (!text || text.trim().length < 600) return true; // ~100+ words of real content
  return /are you a human|cf-browser-verification|just a moment\.\.\.|attention required|enable javascript and cookies|verifying you are human/i.test(
    text.slice(0, 4000),
  );
}

async function scrape(url) {
  let puppeteer;
  try {
    puppeteer = require("puppeteer");
  } catch {
    return { ok: false, error: "puppeteer not installed", via: null };
  }

  // 1) Direct.
  try {
    const { status, html, text } = await fetchWith(puppeteer, url, null);
    if (!looksBlocked(status, html, text)) {
      return { ok: true, html, status, via: "direct" };
    }
    // 2) Bright Data fallback.
    const proxy = brightDataProxy();
    if (!proxy) {
      return {
        ok: false,
        status,
        via: "direct",
        error: `blocked (HTTP ${status}); no Bright Data creds for fallback`,
      };
    }
    try {
      const r2 = await fetchWith(puppeteer, url, proxy);
      if (!looksBlocked(r2.status, r2.html, r2.text)) {
        return { ok: true, html: r2.html, status: r2.status, via: "brightdata" };
      }
      return {
        ok: false,
        status: r2.status,
        via: "brightdata",
        error: `blocked via proxy (HTTP ${r2.status})`,
      };
    } catch (e) {
      return { ok: false, via: "brightdata", error: String(e.message || e) };
    }
  } catch (e) {
    // Direct fetch threw — try proxy if available.
    const proxy = brightDataProxy();
    if (proxy) {
      try {
        const r2 = await fetchWith(puppeteer, url, proxy);
        if (!looksBlocked(r2.status, r2.html, r2.text)) {
          return { ok: true, html: r2.html, status: r2.status, via: "brightdata" };
        }
      } catch (e2) {
        return { ok: false, via: "brightdata", error: String(e2.message || e2) };
      }
    }
    return { ok: false, via: "direct", error: String(e.message || e) };
  }
}

module.exports = { scrape, brightDataProxy };