app.bettersight.io/backend/core/scraper.py

318 lines
12 KiB
Python

import re
import hashlib
import asyncio
import logging
from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
from urllib.robotparser import RobotFileParser
from playwright.async_api import async_playwright
from playwright_stealth import stealth_async
from bs4 import BeautifulSoup
from core.proxy_service import proxy_service
logger = logging.getLogger(__name__)
# Default staleness threshold for the on-demand fast/refresh path
# decision (CLAUDE.md §7). This is the canonical, adjustable location
# per §7 — services/analysis_service.py imports it from here rather
# than redefining it.
CACHE_STALENESS_HOURS = 24
# Block only known third-party analytics/ad domains — never block the
# competitor's own first-party assets.
BLOCKED_DOMAINS = [
'google-analytics.com', 'googletagmanager.com', 'doubleclick.net',
'facebook.net', 'facebook.com/tr', 'hotjar.com', 'intercom.io',
'zendesk.com', 'hubspot.com', 'marketo.com', 'segment.com',
'amplitude.com', 'mixpanel.com', 'optimizely.com', 'crazyegg.com',
]
def extract_domain(url):
"""Strips scheme/path down to a bare domain — used for proxy_overrides and robots.txt lookups."""
return urlparse(url if '://' in url else f'//{url}').netloc.lower().lstrip('www.')
def check_robots_txt(url):
"""
Checks if the given URL is allowed to be scraped per robots.txt.
Returns True if scraping is permitted, False if disallowed.
Defaults to True if robots.txt cannot be fetched (permissive
default) — CLAUDE.md §30 Web Scraping Legal Policy.
Data flow:
url → extract root domain → fetch /robots.txt →
parse with urllib.robotparser →
check if Bettersight's user-agent is allowed for this path
"""
root = f'{urlparse(url).scheme}://{urlparse(url).netloc}'
parser = RobotFileParser()
parser.set_url(f'{root}/robots.txt')
try:
parser.read()
return parser.can_fetch('*', url)
except Exception:
return True
def normalise_html(content):
"""
Normalises scraped page content before hashing so cosmetic-only
differences (whitespace, digit/word-case variation) never trigger a
false change_detected. Operates on scrape()'s already-cleaned `text`
field — script/style/nav/footer tags are stripped upstream in
_render_with_proxy() — so this only needs to collapse whitespace
and lowercase for a stable comparison.
"""
return re.sub(r'\s+', ' ', content).strip().lower()
def compute_content_hash(content):
"""
Returns the SHA-256 hash of normalised page content. Used by the
daily scrape job to detect competitor page changes without an
external monitoring service (CLAUDE.md §4/§10 — replaces the
Firecrawl Monitor feature, which self-hosted requires credit
accounting and Supabase auth).
"""
return hashlib.sha256(normalise_html(content).encode('utf-8')).hexdigest()
def clean_url(url):
"""Strips URL fragments and common tracking parameters."""
parsed = urlparse(url)
strip_params = {
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'referrerView', 'ref', 'referrer', 'source', 'fbclid', 'gclid',
'mc_cid', 'mc_eid', '_ga', 'affiliate',
}
if parsed.query:
params = parse_qs(parsed.query, keep_blank_values=True)
cleaned = {k: v for k, v in params.items() if k not in strip_params}
query = urlencode(cleaned, doseq=True)
else:
query = ''
clean = urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, query, ''))
if clean != url:
logger.info(f'URL cleaned: {url}{clean}')
return clean
def _is_blocked(result):
"""
Detects common block signals from a scrape result. Returns True if
the response looks like a bot wall rather than real content.
Signals: low char count, HTTP 403/429, Cloudflare challenge, CAPTCHA text.
"""
if result.get('char_count', 0) < 500:
return True
if result.get('status_code') in (403, 429):
return True
block_signals = ['cf-browser-verification', 'captcha', 'access denied', 'blocked', 'robot']
return any(signal in result.get('text', '').lower() for signal in block_signals)
async def _render_with_proxy(url, proxy):
"""
Launches a stealth Playwright browser through the given proxy
config and extracts cleaned page text + tables. This is the actual
page-rendering logic — split out from render_page() so it can be
retried with a different proxy (Bright Data) on the first attempt
being blocked, without duplicating the browser setup.
Data flow:
url + proxy config → Playwright chromium launch (proxied) →
playwright-stealth applied → navigate → dismiss cookie banner →
wait for content → scroll to trigger lazy-loading →
HTML → BeautifulSoup cleaning → { text, tables, url, char_count, success }
"""
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
proxy=proxy,
args=['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu']
)
context = await browser.new_context(
user_agent=(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
),
viewport={'width': 1366, 'height': 768},
locale='en-GB',
timezone_id='Europe/London',
extra_http_headers={
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-GB,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'DNT': '1',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
}
)
page = await context.new_page()
# Comprehensive stealth (navigator.webdriver, plugins, canvas/WebGL
# fingerprint, audio context, chrome runtime, ~20 evasions total)
# replaces the old manual add_init_script block per CLAUDE.md §10.
await stealth_async(page)
async def block_trackers(route):
if any(domain in route.request.url for domain in BLOCKED_DOMAINS):
await route.abort()
else:
await route.continue_()
await page.route('**/*', block_trackers)
try:
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
logger.info(f'Loaded: {url}')
except Exception as e:
logger.warning(f'domcontentloaded failed, trying networkidle: {str(e)[:100]}')
await page.goto(url, wait_until='networkidle', timeout=60000)
# Dismiss cookie consent banners
try:
await page.click(
'button:has-text("Accept All"), button:has-text("Accept all"), '
'button:has-text("Accept"), button:has-text("OK"), '
'button:has-text("I agree"), button:has-text("Agree"), '
'[id*="cookie"] button, [class*="cookie"] button, '
'[id*="consent"] button, [class*="consent"] button',
timeout=3000
)
logger.info('Cookie banner dismissed')
await page.wait_for_timeout(1000)
except Exception:
pass
# Wait for meaningful content
try:
await page.wait_for_selector(
'h1, .trip-overview, main, article, #overview, '
'[class*="itinerary"], [class*="price"], [class*="trip"]',
timeout=8000
)
except Exception:
pass
await page.wait_for_timeout(3000)
# Two scroll passes to trigger lazy-loaded content
for _ in range(2):
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
await page.wait_for_timeout(1500)
await page.evaluate('window.scrollTo(0, 0)')
await page.wait_for_timeout(500)
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
await page.wait_for_timeout(2000)
html = await page.content()
soup = BeautifulSoup(html, 'lxml')
for tag in soup(['script', 'style', 'nav', 'footer', 'header', 'iframe', 'noscript', 'svg', 'button', 'form']):
tag.decompose()
main_content = (
soup.find('main') or soup.find('article') or soup.find(id='main')
or soup.find(id='content') or soup.find(class_='content') or soup
)
text = main_content.get_text(separator=' ', strip=True)
text = re.sub(r'\s+', ' ', text).strip()
tables = [t.get_text(separator=' | ', strip=True) for t in soup.find_all('table')]
table_text = '\n'.join(tables)
char_count = len(text)
if char_count < 500:
logger.warning(f'Low char count ({char_count}) for {url} — will attempt AI web fetch fallback')
return {
'text': text[:25000],
'tables': table_text[:5000],
'url': url,
'char_count': char_count,
'success': True,
}
except Exception as e:
logger.error(f'Render failed for {url}: {str(e)}')
return {'text': f'Render failed: {str(e)}', 'tables': '', 'url': url, 'char_count': 0, 'success': False}
finally:
await browser.close()
async def render_page(url, country=None):
"""
Renders a page using the smart proxy service with country
targeting. `country` comes from competitors.primary_market in
PocketBase. Detects blocks and auto-escalates to Bright Data if
Webshare fails.
Data flow:
url + country → domain extracted →
proxy_service.get_playwright_proxy_for_domain(domain, country) →
_render_with_proxy() → if blocked → escalate_to_brightdata(domain) →
retry once with Bright Data (no country targeting at fallback level)
"""
domain = extract_domain(url)
proxy = proxy_service.get_playwright_proxy_for_domain(domain, country)
result = await _render_with_proxy(url, proxy)
if _is_blocked(result):
logger.warning(f'Webshare blocked on {domain} — escalating to Bright Data')
proxy_service.escalate_to_brightdata(domain)
result = await _render_with_proxy(url, proxy_service._brightdata_playwright())
return result
def scrape(url, country=None):
"""
Synchronous entry point for scraping one competitor page. Checks
robots.txt first (legal/compliance gate per §30), then renders via
Playwright, falling back to the AI web fetch path if Playwright's
char count is too low (bot-protected sites).
Data flow:
url + country → clean_url() → check_robots_txt() →
disallowed: return failure immediately (no scrape attempted) →
allowed: render_page() (sync wrapper via nest_asyncio) →
char_count < 1000: fetch_with_ai() fallback →
final { text, tables, url, char_count, success } returned
"""
import nest_asyncio
nest_asyncio.apply()
url = clean_url(url)
if not check_robots_txt(url):
logger.warning(f'robots.txt disallows scraping {url} — skipping')
return {
'text': 'Scraping disallowed by robots.txt for this URL',
'tables': '', 'url': url, 'char_count': 0, 'success': False,
}
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(render_page(url, country=country))
finally:
loop.close()
if result.get('char_count', 0) < 1000 and result.get('success'):
logger.info(f'Playwright char count low — trying AI web fetch for {url}')
from core.ai_fetch import fetch_with_ai
ai_result = fetch_with_ai(url)
if ai_result:
return ai_result
return result