You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1215 lines
54 KiB
1215 lines
54 KiB
#!/usr/bin/env python3 |
|
"""Authorized scraper for dfimoveis.com.br. |
|
|
|
Features: |
|
- Multiple search URLs |
|
- pagina=N pagination |
|
- Async bounded HTTP requests with retries and jitter |
|
- Listing detail extraction from JSON-LD, OpenGraph and HTML text |
|
- Full image URL discovery and concurrent photo downloads |
|
- SQLite persistence, change history, raw HTML snapshots and resumability |
|
- Playwright (preferring the Patchright driver, if installed, to avoid |
|
Cloudflare's CDP-leak detection) driving real Google Chrome by default |
|
- Automatic Cloudflare-challenge handling: broader challenge detection, a |
|
persistent browser profile that keeps its clearance cookie across runs, and |
|
a fallback that reuses the browser session's cookies for plain HTTP and |
|
photo requests so they don't have to re-solve the challenge themselves |
|
- Optional direct HTTP-only mode |
|
|
|
Python 3.11+ |
|
""" |
|
from __future__ import annotations |
|
|
|
import argparse |
|
import asyncio |
|
import contextlib |
|
import dataclasses |
|
import datetime as dt |
|
import gzip |
|
import hashlib |
|
import html |
|
import json |
|
import logging |
|
import mimetypes |
|
import os |
|
import random |
|
import re |
|
import sqlite3 |
|
import sys |
|
import time |
|
from pathlib import Path |
|
from typing import Any, Iterable |
|
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse |
|
|
|
import requests |
|
from bs4 import BeautifulSoup |
|
|
|
BASE_URL = "https://www.dfimoveis.com.br" |
|
DEFAULT_USER_AGENT = "AuthorizedDFImoveisResearchBot/1.0 (+contact@example.com)" |
|
LISTING_HREF_RE = re.compile(r"^/imovel/[^?#]+-(\d+)(?:[/?#]|$)", re.I) |
|
ABS_LISTING_RE = re.compile(r"https?://(?:www\.)?dfimoveis\.com\.br/imovel/[^\"'<>\\s]+?-(\d+)(?:[/?#]|$)", re.I) |
|
IMAGE_URL_RE = re.compile( |
|
r"https?://[^\"'<>\\s]+?\.(?:jpe?g|png|webp|avif)(?:\?[^\"'<>\\s]*)?", |
|
re.I, |
|
) |
|
MONEY_RE = re.compile(r"R\$\s*([\d.]+(?:,\d{1,2})?)") |
|
AREA_RE = re.compile(r"([\d.,]+)\s*m[²2]", re.I) |
|
NUMERIC_ID_RE = re.compile(r"-(\d+)(?:[/?#]|$)") |
|
|
|
|
|
def now_utc() -> str: |
|
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") |
|
|
|
|
|
def clean_text(value: Any) -> str | None: |
|
if value is None: |
|
return None |
|
text = re.sub(r"\s+", " ", html.unescape(str(value))).strip() |
|
return text or None |
|
|
|
|
|
def parse_brl(value: str | None) -> float | None: |
|
if not value: |
|
return None |
|
m = MONEY_RE.search(value) |
|
raw = m.group(1) if m else value |
|
raw = re.sub(r"[^\d,.]", "", raw) |
|
if not raw: |
|
return None |
|
if "," in raw: |
|
raw = raw.replace(".", "").replace(",", ".") |
|
else: |
|
parts = raw.split(".") |
|
if len(parts) > 1 and all(len(p) == 3 for p in parts[1:]): |
|
raw = "".join(parts) |
|
with contextlib.suppress(ValueError): |
|
return float(raw) |
|
return None |
|
|
|
|
|
def parse_number(value: str | None) -> float | None: |
|
if not value: |
|
return None |
|
raw = re.sub(r"[^\d,.]", "", value) |
|
if not raw: |
|
return None |
|
if "," in raw: |
|
raw = raw.replace(".", "").replace(",", ".") |
|
with contextlib.suppress(ValueError): |
|
return float(raw) |
|
return None |
|
|
|
|
|
def int_near_label(text: str, labels: Iterable[str]) -> int | None: |
|
for label in labels: |
|
patterns = [ |
|
rf"(\d+)\s*{label}", |
|
rf"{label}\s*[:\-]?\s*(\d+)", |
|
] |
|
for pattern in patterns: |
|
m = re.search(pattern, text, re.I) |
|
if m: |
|
return int(m.group(1)) |
|
return None |
|
|
|
|
|
def canonical_url(url: str) -> str: |
|
p = urlparse(urljoin(BASE_URL, url)) |
|
return urlunparse((p.scheme or "https", p.netloc.lower(), p.path.rstrip("/"), "", p.query, "")) |
|
|
|
|
|
def page_url(search_url: str, page: int) -> str: |
|
p = urlparse(search_url) |
|
q = dict(parse_qsl(p.query, keep_blank_values=True)) |
|
q["pagina"] = str(page) |
|
return urlunparse((p.scheme, p.netloc, p.path, p.params, urlencode(q, doseq=True), "")) |
|
|
|
|
|
def listing_id_from_url(url: str) -> str | None: |
|
m = NUMERIC_ID_RE.search(urlparse(url).path) |
|
return m.group(1) if m else None |
|
|
|
|
|
def sha256_bytes(data: bytes) -> str: |
|
return hashlib.sha256(data).hexdigest() |
|
|
|
|
|
def safe_name(value: str) -> str: |
|
return re.sub(r"[^a-zA-Z0-9._-]+", "_", value).strip("_")[:120] or "unnamed" |
|
|
|
|
|
@dataclasses.dataclass(slots=True) |
|
class Config: |
|
searches: list[str] |
|
output_dir: Path |
|
database: Path |
|
max_pages: int = 0 |
|
concurrency: int = 2 |
|
photo_concurrency: int = 2 |
|
delay_min: float = 3.0 |
|
delay_max: float = 6.0 |
|
cooldown_base: float = 30.0 |
|
cooldown_max: float = 300.0 |
|
timeout: float = 30.0 |
|
retries: int = 4 |
|
download_photos: bool = True |
|
save_raw_html: bool = True |
|
use_playwright: bool = True |
|
playwright_headless: bool = False |
|
user_agent: str = DEFAULT_USER_AGENT |
|
browser_user_agent: str | None = None |
|
browser_channel: str = "chrome" |
|
attach_chrome: bool = False |
|
cdp_url: str = "http://localhost:9222" |
|
cloudflare_fallback: bool = True |
|
challenge_timeout: float = 180.0 |
|
|
|
|
|
class Database: |
|
def __init__(self, path: Path) -> None: |
|
path.parent.mkdir(parents=True, exist_ok=True) |
|
self.conn = sqlite3.connect(path) |
|
self.conn.row_factory = sqlite3.Row |
|
self.conn.execute("PRAGMA journal_mode=WAL") |
|
self.conn.execute("PRAGMA foreign_keys=ON") |
|
self._init_schema() |
|
|
|
def _init_schema(self) -> None: |
|
self.conn.executescript( |
|
""" |
|
CREATE TABLE IF NOT EXISTS listings ( |
|
listing_id TEXT PRIMARY KEY, |
|
url TEXT NOT NULL, |
|
title TEXT, |
|
description TEXT, |
|
transaction_type TEXT, |
|
property_type TEXT, |
|
price_brl REAL, |
|
condominium_brl REAL, |
|
iptu_brl REAL, |
|
area_m2 REAL, |
|
bedrooms INTEGER, |
|
suites INTEGER, |
|
parking_spaces INTEGER, |
|
address TEXT, |
|
neighborhood TEXT, |
|
city TEXT, |
|
state TEXT, |
|
advertiser_name TEXT, |
|
advertiser_code TEXT, |
|
creci TEXT, |
|
latitude REAL, |
|
longitude REAL, |
|
published_at TEXT, |
|
first_seen_at TEXT NOT NULL, |
|
last_seen_at TEXT NOT NULL, |
|
inactive_at TEXT, |
|
content_hash TEXT, |
|
raw_html_path TEXT, |
|
extra_json TEXT NOT NULL DEFAULT '{}' |
|
); |
|
CREATE TABLE IF NOT EXISTS listing_searches ( |
|
listing_id TEXT NOT NULL, |
|
search_url TEXT NOT NULL, |
|
first_seen_at TEXT NOT NULL, |
|
last_seen_at TEXT NOT NULL, |
|
PRIMARY KEY (listing_id, search_url), |
|
FOREIGN KEY (listing_id) REFERENCES listings(listing_id) |
|
); |
|
CREATE TABLE IF NOT EXISTS listing_history ( |
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
listing_id TEXT NOT NULL, |
|
captured_at TEXT NOT NULL, |
|
content_hash TEXT NOT NULL, |
|
data_json TEXT NOT NULL, |
|
UNIQUE(listing_id, content_hash), |
|
FOREIGN KEY (listing_id) REFERENCES listings(listing_id) |
|
); |
|
CREATE TABLE IF NOT EXISTS photos ( |
|
listing_id TEXT NOT NULL, |
|
ordinal INTEGER NOT NULL, |
|
source_url TEXT NOT NULL, |
|
sha256 TEXT, |
|
mime_type TEXT, |
|
bytes INTEGER, |
|
local_path TEXT, |
|
first_seen_at TEXT NOT NULL, |
|
last_seen_at TEXT NOT NULL, |
|
PRIMARY KEY (listing_id, source_url), |
|
FOREIGN KEY (listing_id) REFERENCES listings(listing_id) |
|
); |
|
CREATE TABLE IF NOT EXISTS crawl_runs ( |
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
started_at TEXT NOT NULL, |
|
finished_at TEXT, |
|
status TEXT NOT NULL, |
|
stats_json TEXT NOT NULL DEFAULT '{}' |
|
); |
|
""" |
|
) |
|
self.conn.commit() |
|
|
|
def start_run(self) -> int: |
|
cur = self.conn.execute( |
|
"INSERT INTO crawl_runs(started_at,status) VALUES(?,?)", (now_utc(), "running") |
|
) |
|
self.conn.commit() |
|
return int(cur.lastrowid) |
|
|
|
def finish_run(self, run_id: int, status: str, stats: dict[str, Any]) -> None: |
|
self.conn.execute( |
|
"UPDATE crawl_runs SET finished_at=?, status=?, stats_json=? WHERE id=?", |
|
(now_utc(), status, json.dumps(stats, ensure_ascii=False), run_id), |
|
) |
|
self.conn.commit() |
|
|
|
def upsert_listing(self, data: dict[str, Any], search_url: str) -> bool: |
|
ts = now_utc() |
|
existing = self.conn.execute( |
|
"SELECT content_hash FROM listings WHERE listing_id=?", (data["listing_id"],) |
|
).fetchone() |
|
changed = not existing or existing["content_hash"] != data["content_hash"] |
|
cols = [ |
|
"listing_id", "url", "title", "description", "transaction_type", "property_type", |
|
"price_brl", "condominium_brl", "iptu_brl", "area_m2", "bedrooms", "suites", |
|
"parking_spaces", "address", "neighborhood", "city", "state", "advertiser_name", |
|
"advertiser_code", "creci", "latitude", "longitude", "published_at", "content_hash", |
|
"raw_html_path", "extra_json" |
|
] |
|
values = [data.get(c) for c in cols] |
|
self.conn.execute( |
|
f""" |
|
INSERT INTO listings ({','.join(cols)}, first_seen_at, last_seen_at, inactive_at) |
|
VALUES ({','.join('?' for _ in cols)}, ?, ?, NULL) |
|
ON CONFLICT(listing_id) DO UPDATE SET |
|
{','.join(f'{c}=excluded.{c}' for c in cols if c != 'listing_id')}, |
|
last_seen_at=excluded.last_seen_at, |
|
inactive_at=NULL |
|
""", |
|
(*values, ts, ts), |
|
) |
|
self.conn.execute( |
|
""" |
|
INSERT INTO listing_searches(listing_id,search_url,first_seen_at,last_seen_at) |
|
VALUES(?,?,?,?) |
|
ON CONFLICT(listing_id,search_url) DO UPDATE SET last_seen_at=excluded.last_seen_at |
|
""", |
|
(data["listing_id"], search_url, ts, ts), |
|
) |
|
if changed: |
|
snapshot = {k: v for k, v in data.items() if k not in {"raw_html_path"}} |
|
self.conn.execute( |
|
"INSERT OR IGNORE INTO listing_history(listing_id,captured_at,content_hash,data_json) VALUES(?,?,?,?)", |
|
(data["listing_id"], ts, data["content_hash"], json.dumps(snapshot, ensure_ascii=False)), |
|
) |
|
self.conn.commit() |
|
return changed |
|
|
|
def upsert_photo(self, listing_id: str, ordinal: int, url: str, **meta: Any) -> None: |
|
ts = now_utc() |
|
self.conn.execute( |
|
""" |
|
INSERT INTO photos(listing_id,ordinal,source_url,sha256,mime_type,bytes,local_path,first_seen_at,last_seen_at) |
|
VALUES(?,?,?,?,?,?,?,?,?) |
|
ON CONFLICT(listing_id,source_url) DO UPDATE SET |
|
ordinal=excluded.ordinal, sha256=COALESCE(excluded.sha256,photos.sha256), |
|
mime_type=COALESCE(excluded.mime_type,photos.mime_type), |
|
bytes=COALESCE(excluded.bytes,photos.bytes), |
|
local_path=COALESCE(excluded.local_path,photos.local_path), last_seen_at=excluded.last_seen_at |
|
""", |
|
(listing_id, ordinal, url, meta.get("sha256"), meta.get("mime_type"), meta.get("bytes"), meta.get("local_path"), ts, ts), |
|
) |
|
self.conn.commit() |
|
|
|
def get_photo(self, listing_id: str, url: str) -> sqlite3.Row | None: |
|
return self.conn.execute( |
|
"SELECT sha256, local_path FROM photos WHERE listing_id=? AND source_url=?", |
|
(listing_id, url), |
|
).fetchone() |
|
|
|
def mark_inactive(self, search_url: str, seen_ids: set[str], run_started_at: str) -> int: |
|
rows = self.conn.execute( |
|
"SELECT listing_id FROM listing_searches WHERE search_url=? AND last_seen_at < ?", |
|
(search_url, run_started_at), |
|
).fetchall() |
|
stale = [r[0] for r in rows if r[0] not in seen_ids] |
|
if stale: |
|
self.conn.executemany( |
|
"UPDATE listings SET inactive_at=COALESCE(inactive_at, ?) WHERE listing_id=?", |
|
[(now_utc(), x) for x in stale], |
|
) |
|
self.conn.commit() |
|
return len(stale) |
|
|
|
def close(self) -> None: |
|
self.conn.close() |
|
|
|
|
|
@dataclasses.dataclass(slots=True) |
|
class _BrowserResponse: |
|
"""Duck-types the subset of requests.Response used elsewhere in this file, |
|
so a fetch made through the Playwright browser context (see |
|
Fetcher._resolve_via_browser) can be returned interchangeably with a plain |
|
requests.Session response.""" |
|
|
|
status_code: int |
|
headers: requests.structures.CaseInsensitiveDict |
|
text: str |
|
content: bytes |
|
url: str |
|
|
|
def raise_for_status(self) -> None: |
|
if self.status_code >= 400: |
|
raise requests.exceptions.HTTPError(f"HTTP {self.status_code}", response=self) |
|
|
|
|
|
class Fetcher: |
|
def __init__(self, cfg: Config) -> None: |
|
self.cfg = cfg |
|
self.sem = asyncio.Semaphore(cfg.concurrency) |
|
self.photo_sem = asyncio.Semaphore(cfg.photo_concurrency) |
|
self._playwright = None |
|
self._browser_context = None |
|
self._owns_browser_context = True |
|
self._page = None |
|
self._browser_lock = asyncio.Lock() |
|
self._rate_lock = asyncio.Lock() |
|
self._next_request_at = 0.0 |
|
self._cooldown_until = 0.0 |
|
self._consecutive_429 = 0 |
|
# requests.Session is synchronous, so blocking calls are pushed onto |
|
# worker threads with asyncio.to_thread below. |
|
self.session = requests.Session() |
|
self.session.headers.update( |
|
{ |
|
"User-Agent": cfg.user_agent, |
|
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.7", |
|
"Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", |
|
# A closer match to a real browser's request fingerprint makes |
|
# Cloudflare's heuristic (non-challenge) checks less likely to fire. |
|
"Upgrade-Insecure-Requests": "1", |
|
"Sec-Fetch-Dest": "document", |
|
"Sec-Fetch-Mode": "navigate", |
|
"Sec-Fetch-Site": "none", |
|
"Sec-Fetch-User": "?1", |
|
} |
|
) |
|
|
|
async def close(self) -> None: |
|
if self._browser_context is not None: |
|
if self._owns_browser_context: |
|
try: |
|
await self._browser_context.close() |
|
except Exception as exc: |
|
# The operator may close Chrome while handling a Cloudflare |
|
# challenge, or the browser process may already have exited. |
|
# Cleanup must not hide the exception that caused the scraper |
|
# to stop. |
|
logging.debug("Browser context was already closed: %s", exc) |
|
else: |
|
# Attached via --attach-chrome to a Chrome window the operator |
|
# started themselves; closing it here would be surprising and |
|
# unwanted, so just detach the Playwright client. |
|
logging.info("Leaving the attached Chrome window open.") |
|
self._browser_context = None |
|
self._page = None |
|
if self._playwright is not None: |
|
try: |
|
await self._playwright.stop() |
|
except Exception as exc: |
|
logging.debug("Playwright driver was already stopped: %s", exc) |
|
self._playwright = None |
|
await asyncio.to_thread(self.session.close) |
|
|
|
async def _wait_for_request_slot(self) -> None: |
|
"""Space request starts globally, including concurrent photo downloads.""" |
|
loop = asyncio.get_running_loop() |
|
while True: |
|
async with self._rate_lock: |
|
now = loop.time() |
|
wait = max(self._next_request_at, self._cooldown_until) - now |
|
if wait <= 0: |
|
self._next_request_at = now + random.uniform( |
|
self.cfg.delay_min, self.cfg.delay_max |
|
) |
|
return |
|
await asyncio.sleep(wait) |
|
|
|
async def _register_429(self, retry_after: float) -> float: |
|
"""Apply one shared exponential cooldown so queued tasks also slow down.""" |
|
async with self._rate_lock: |
|
self._consecutive_429 += 1 |
|
exponential = self.cfg.cooldown_base * (2 ** min(self._consecutive_429 - 1, 3)) |
|
cooldown = max( |
|
retry_after, |
|
min( |
|
self.cfg.cooldown_max, |
|
exponential + random.uniform(0, self.cfg.cooldown_base), |
|
), |
|
) |
|
self._cooldown_until = max( |
|
self._cooldown_until, asyncio.get_running_loop().time() + cooldown |
|
) |
|
return cooldown |
|
|
|
async def _register_success(self) -> None: |
|
async with self._rate_lock: |
|
if asyncio.get_running_loop().time() >= self._cooldown_until: |
|
self._consecutive_429 = max(0, self._consecutive_429 - 1) |
|
|
|
async def get(self, url: str, *, photo: bool = False) -> requests.Response: |
|
sem = self.photo_sem if photo else self.sem |
|
async with sem: |
|
for attempt in range(self.cfg.retries + 1): |
|
try: |
|
await self._wait_for_request_slot() |
|
response = await asyncio.to_thread( |
|
self.session.get, url, timeout=self.cfg.timeout |
|
) |
|
if self._looks_like_cloudflare_challenge( |
|
response.status_code, response.text, response.headers |
|
): |
|
response = await self._resolve_via_browser(url) |
|
if response.status_code in {429, 500, 502, 503, 504}: |
|
raise requests.exceptions.HTTPError("retryable status", response=response) |
|
response.raise_for_status() |
|
await self._register_success() |
|
return response |
|
except ( |
|
requests.exceptions.Timeout, |
|
requests.exceptions.ConnectionError, |
|
requests.exceptions.HTTPError, |
|
) as exc: |
|
status_code = exc.response.status_code if exc.response is not None else None |
|
retryable = status_code is None or status_code in {429, 500, 502, 503, 504} |
|
if not retryable or attempt >= self.cfg.retries: |
|
raise |
|
retry_after = 0.0 |
|
if exc.response is not None: |
|
with contextlib.suppress(ValueError, TypeError): |
|
retry_after = float(exc.response.headers.get("Retry-After", 0)) |
|
if status_code == 429: |
|
cooldown = await self._register_429(retry_after) |
|
logging.warning( |
|
"HTTP 429; pausing all requests for %.1f seconds (attempt %d/%d)", |
|
cooldown, |
|
attempt + 1, |
|
self.cfg.retries + 1, |
|
) |
|
continue |
|
await asyncio.sleep(max(retry_after, min(60.0, (2**attempt) + random.random()))) |
|
raise RuntimeError("unreachable") |
|
|
|
@staticmethod |
|
def _looks_like_cloudflare_challenge( |
|
status_code: int, text: str, headers: requests.structures.CaseInsensitiveDict |
|
) -> bool: |
|
lowered = text.lower() |
|
markers = ( |
|
"challenges.cloudflare.com", |
|
"just a moment", |
|
"cf-chl-", |
|
"cf-browser-verification", |
|
"turnstile", |
|
"attention required", |
|
"verify you are human", |
|
"checking your browser before accessing", |
|
) |
|
return ( |
|
status_code in {403, 503} |
|
or headers.get("cf-mitigated", "").lower() == "challenge" |
|
or any(marker in lowered for marker in markers) |
|
) |
|
|
|
async def _resolve_via_browser(self, url: str) -> "_BrowserResponse": |
|
"""Reuse the persistent Edge session to satisfy a Cloudflare challenge. |
|
|
|
The browser context keeps its own cf_clearance cookies across runs (it is a |
|
persistent profile), and once a human has solved a challenge in it, plain |
|
API-style requests issued through that same context (no page navigation, |
|
no JS execution needed) inherit the clearance. This lets lightweight |
|
HTTP-only fetches -- including photo downloads -- ride along on a browser |
|
session that has already passed Cloudflare's check, instead of failing |
|
outright the moment a challenge is served. |
|
""" |
|
if not self.cfg.cloudflare_fallback: |
|
raise RuntimeError( |
|
f"Cloudflare challenge detected fetching {url}. Run without " |
|
"--http-only, or drop --no-cloudflare-fallback, to let the browser " |
|
"session handle it." |
|
) |
|
logging.warning("Cloudflare challenge on %s; retrying through the browser session", url) |
|
async with self._browser_lock: |
|
context = await self._ensure_browser_context() |
|
try: |
|
api_response = await context.request.get(url, timeout=self.cfg.timeout * 1000) |
|
body = await api_response.body() |
|
except Exception as exc: |
|
raise RuntimeError( |
|
f"Cloudflare challenge fallback failed for {url}: {exc}. If this " |
|
"is the first request of the run, open the visible Edge window " |
|
"and complete the challenge there, then retry." |
|
) from exc |
|
headers = requests.structures.CaseInsensitiveDict(api_response.headers) |
|
await self._sync_session_from_browser(context) |
|
response = _BrowserResponse( |
|
status_code=api_response.status, |
|
headers=headers, |
|
text=body.decode("utf-8", "replace"), |
|
content=body, |
|
url=api_response.url, |
|
) |
|
if self._looks_like_cloudflare_challenge(response.status_code, response.text, response.headers): |
|
raise RuntimeError( |
|
f"Cloudflare challenge persisted for {url} even through the browser " |
|
"session. Complete it manually in the visible Microsoft Edge window " |
|
"(disable --playwright-headless if it's on) and rerun." |
|
) |
|
return response |
|
|
|
async def _sync_session_from_browser(self, context) -> None: |
|
"""Mirror the browser's cookies (and real UA) onto the plain HTTP session |
|
so subsequent lightweight requests are less likely to need the fallback.""" |
|
try: |
|
cookies = await context.cookies() |
|
except Exception as exc: |
|
logging.debug("Could not read browser cookies: %s", exc) |
|
return |
|
for cookie in cookies: |
|
with contextlib.suppress(Exception): |
|
self.session.cookies.set( |
|
cookie["name"], |
|
cookie["value"], |
|
domain=cookie.get("domain", "") or urlparse(BASE_URL).netloc, |
|
path=cookie.get("path", "/"), |
|
) |
|
page = context.pages[0] if context.pages else None |
|
if page is not None: |
|
with contextlib.suppress(Exception): |
|
real_ua = await page.evaluate("() => navigator.userAgent") |
|
if real_ua: |
|
self.session.headers["User-Agent"] = real_ua |
|
logging.debug("Synced %d browser cookies onto the HTTP session", len(cookies)) |
|
|
|
async def get_html(self, url: str) -> tuple[str, bytes, str]: |
|
if self.cfg.use_playwright: |
|
rendered, final_url = await self.rendered_html(url) |
|
return rendered, rendered.encode("utf-8"), final_url |
|
response = await self.get(url) |
|
return response.text, response.content, str(response.url) |
|
|
|
async def _ensure_browser_context(self): |
|
if self._browser_context is not None: |
|
return self._browser_context |
|
using_patchright = True |
|
try: |
|
# Patchright is a maintained, drop-in fork of Playwright's async API. |
|
# Plain Playwright issues the CDP command Runtime.enable to manage JS |
|
# execution contexts; Cloudflare's bot management specifically checks |
|
# for that call, which is why a challenge can clear once and then |
|
# start looping again on a later page. Patchright avoids that command |
|
# entirely (it runs JS in isolated contexts instead), which is what |
|
# closes that gap. Falls back to stock Playwright if it isn't installed. |
|
try: |
|
from patchright.async_api import async_playwright |
|
except ImportError: |
|
using_patchright = False |
|
logging.warning( |
|
"patchright not installed; falling back to plain Playwright, " |
|
"which is more likely to trip Cloudflare's CDP-leak detection. " |
|
"Install with: pip install patchright" |
|
) |
|
from playwright.async_api import async_playwright |
|
except ImportError as exc: |
|
raise RuntimeError( |
|
"Install a Playwright-compatible driver: pip install patchright " |
|
"(preferred, avoids Cloudflare's CDP-leak detection) or pip install " |
|
"playwright." |
|
) from exc |
|
|
|
self._playwright = await async_playwright().start() |
|
|
|
if self.cfg.attach_chrome: |
|
# Attaches to a Chrome window the operator started themselves (e.g. |
|
# `chrome.exe --remote-debugging-port=9222 --user-data-dir=...`) |
|
# instead of one Playwright/Patchright spawns. Cloudflare's current |
|
# Turnstile check can flag a browser process for having been *launched* |
|
# by an automation framework at all, independent of anything done |
|
# inside it afterwards -- so even a genuine manual click on the |
|
# checkbox won't validate in a Playwright-launched Chrome. Attaching |
|
# over CDP to a normally-started Chrome sidesteps that specific signal |
|
# (though CDP attachment itself can still be detected by other means). |
|
try: |
|
browser = await self._playwright.chromium.connect_over_cdp(self.cfg.cdp_url) |
|
except Exception as exc: |
|
port = urlparse(self.cfg.cdp_url).port or 9222 |
|
raise RuntimeError( |
|
f"Could not connect to Chrome at {self.cfg.cdp_url}. Start Chrome " |
|
"yourself first with a remote debugging port open, e.g.:\n" |
|
f' "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" ' |
|
f'--remote-debugging-port={port} --user-data-dir="C:\\chrome-scrape-profile"\n' |
|
"(use a separate --user-data-dir, not your everyday profile, since " |
|
"Chrome won't let a second instance share one already in use) " |
|
"then re-run with --attach-chrome." |
|
) from exc |
|
self._owns_browser_context = False |
|
context = browser.contexts[0] if browser.contexts else await browser.new_context() |
|
self._browser_context = context |
|
await self._sync_session_from_browser(context) |
|
return context |
|
|
|
profile_dir = self.cfg.output_dir / "browser-profile" |
|
profile_dir.mkdir(parents=True, exist_ok=True) |
|
browser_options: dict[str, Any] = { |
|
"user_data_dir": str(profile_dir), |
|
"channel": self.cfg.browser_channel, |
|
"headless": self.cfg.playwright_headless, |
|
"locale": "pt-BR", |
|
"timezone_id": "America/Sao_Paulo", |
|
# Patchright's own guidance: a fixed viewport size is itself a |
|
# fingerprintable tell (it never matches a real, resized browser |
|
# window). no_viewport lets the actual window size drive it instead. |
|
"no_viewport": True, |
|
} |
|
if using_patchright: |
|
# Patchright's docs are explicit: don't add custom UA/headers or extra |
|
# flags on top of it -- real Chrome's own defaults are more convincing |
|
# than anything we'd hardcode, and layering our own JS patches on top |
|
# of Patchright's isolated-context patches can reintroduce the very |
|
# inconsistencies it's designed to avoid. |
|
if self.cfg.browser_user_agent: |
|
logging.warning( |
|
"--browser-user-agent is set but patchright is active; patchright " |
|
"recommends leaving Chrome's own user agent untouched for the " |
|
"most convincing fingerprint." |
|
) |
|
browser_options["user_agent"] = self.cfg.browser_user_agent |
|
else: |
|
# No Patchright available: fall back to the manual mitigations, since |
|
# stock Playwright needs them (imperfect, but better than nothing). |
|
browser_options["args"] = ["--disable-blink-features=AutomationControlled"] |
|
browser_options["ignore_default_args"] = ["--enable-automation"] |
|
if self.cfg.browser_user_agent: |
|
browser_options["user_agent"] = self.cfg.browser_user_agent |
|
|
|
self._browser_context = await self._playwright.chromium.launch_persistent_context( |
|
**browser_options, |
|
) |
|
if not using_patchright: |
|
# Belt-and-braces JS patch for the handful of properties bot-detection |
|
# scripts check most often. Only needed without Patchright's deeper, |
|
# driver-level patches -- redundant (and possibly counter-productive) |
|
# alongside them. |
|
await self._browser_context.add_init_script( |
|
""" |
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); |
|
window.chrome = window.chrome || { runtime: {} }; |
|
Object.defineProperty(navigator, 'languages', { get: () => ['pt-BR', 'pt', 'en-US', 'en'] }); |
|
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); |
|
const originalQuery = window.navigator.permissions.query; |
|
window.navigator.permissions.query = (parameters) => ( |
|
parameters.name === 'notifications' |
|
? Promise.resolve({ state: Notification.permission }) |
|
: originalQuery(parameters) |
|
); |
|
""" |
|
) |
|
# The persistent profile may already carry a cf_clearance cookie from an |
|
# earlier run; make it available to the plain HTTP session right away. |
|
await self._sync_session_from_browser(self._browser_context) |
|
return self._browser_context |
|
|
|
async def rendered_html(self, url: str) -> tuple[str, str]: |
|
# Serialize browser navigation so one persistent Cloudflare session is reused safely. |
|
async with self._browser_lock: |
|
context = await self._ensure_browser_context() |
|
if self._page is None or self._page.is_closed(): |
|
# Reuse the persistent context's first (blank) tab if we launched |
|
# it ourselves; open a fresh tab if we attached to a Chrome window |
|
# the operator already had open, so we don't hijack one of their tabs. |
|
if self._owns_browser_context and context.pages: |
|
self._page = context.pages[0] |
|
else: |
|
self._page = await context.new_page() |
|
page = self._page |
|
# Page navigations previously bypassed the rate limiter entirely, so |
|
# they fired back-to-back with no pacing -- a strong behavioral tell |
|
# on top of anything fingerprint-related, and a likely reason a |
|
# challenge would clear once and then reappear on the very next page. |
|
await self._wait_for_request_slot() |
|
await page.goto( |
|
url, |
|
wait_until="domcontentloaded", |
|
timeout=int(self.cfg.timeout * 1000), |
|
) |
|
try: |
|
await page.wait_for_load_state( |
|
"networkidle", timeout=int(self.cfg.timeout * 1000) |
|
) |
|
except Exception: |
|
logging.debug("Browser did not reach networkidle for %s", url) |
|
with contextlib.suppress(Exception): |
|
# A small human-like gesture; real visitors rarely leave the |
|
# mouse at (0, 0) the instant a page finishes loading. |
|
await page.mouse.move( |
|
random.uniform(100, 800), random.uniform(100, 600), steps=random.randint(5, 15) |
|
) |
|
|
|
# A visible browser lets the operator solve a Cloudflare challenge manually. |
|
challenge_markers = ( |
|
"challenges.cloudflare.com", |
|
"just a moment", |
|
"cf-chl-", |
|
"cf-browser-verification", |
|
"turnstile", |
|
"attention required", |
|
"verify you are human", |
|
"checking your browser before accessing", |
|
) |
|
deadline = asyncio.get_running_loop().time() + self.cfg.challenge_timeout |
|
while True: |
|
try: |
|
content = await page.content() |
|
except Exception as exc: |
|
if exc.__class__.__name__ in {"TargetClosedError", "Error"}: |
|
raise RuntimeError( |
|
"Microsoft Edge closed while the scraper was waiting for the " |
|
"Cloudflare verification. Keep the browser window open, complete " |
|
"the verification, and let the scraper close Edge when it finishes." |
|
) from exc |
|
raise |
|
lowered = content.lower() |
|
if not any(marker in lowered for marker in challenge_markers): |
|
# Solved (or no challenge was served) -- let plain HTTP requests |
|
# (search pages in --http-only mode, photo downloads) ride on |
|
# this session's clearance too. |
|
await self._sync_session_from_browser(context) |
|
return content, page.url |
|
if self.cfg.playwright_headless: |
|
raise RuntimeError( |
|
"Cloudflare challenge persisted in headless mode. " |
|
"Run without --playwright-headless and solve it in the browser window." |
|
) |
|
if asyncio.get_running_loop().time() >= deadline: |
|
raise RuntimeError( |
|
f"Cloudflare challenge was not completed in the browser window within " |
|
f"{self.cfg.challenge_timeout:.0f}s. Re-run, or raise --challenge-timeout " |
|
"if you need more time to solve it manually." |
|
) |
|
logging.warning("Waiting for Cloudflare challenge to be completed in the browser...") |
|
await page.wait_for_timeout(2000) |
|
|
|
|
|
class Parser: |
|
@staticmethod |
|
def search_links(page_html: str, page_base: str) -> list[str]: |
|
soup = BeautifulSoup(page_html, "lxml") |
|
out: dict[str, None] = {} |
|
for tag in soup.select("a[href]"): |
|
href = tag.get("href", "") |
|
absolute = canonical_url(urljoin(page_base, href)) |
|
if listing_id_from_url(absolute) and "/imovel/" in urlparse(absolute).path: |
|
out[absolute] = None |
|
for match in ABS_LISTING_RE.finditer(page_html): |
|
out[canonical_url(match.group(0))] = None |
|
return list(out) |
|
|
|
@staticmethod |
|
def json_ld(soup: BeautifulSoup) -> list[dict[str, Any]]: |
|
objects: list[dict[str, Any]] = [] |
|
for node in soup.select('script[type="application/ld+json"]'): |
|
raw = node.string or node.get_text() |
|
try: |
|
parsed = json.loads(raw) |
|
except (json.JSONDecodeError, TypeError): |
|
continue |
|
candidates = parsed if isinstance(parsed, list) else [parsed] |
|
for item in candidates: |
|
if isinstance(item, dict) and isinstance(item.get("@graph"), list): |
|
candidates.extend(x for x in item["@graph"] if isinstance(x, dict)) |
|
if isinstance(item, dict): |
|
objects.append(item) |
|
return objects |
|
|
|
@staticmethod |
|
def _meta(soup: BeautifulSoup, *names: str) -> str | None: |
|
for name in names: |
|
node = soup.find("meta", attrs={"property": name}) or soup.find("meta", attrs={"name": name}) |
|
if node and node.get("content"): |
|
return clean_text(node["content"]) |
|
return None |
|
|
|
@classmethod |
|
def detail(cls, url: str, page_html: str, raw_path: str | None = None) -> tuple[dict[str, Any], list[str]]: |
|
soup = BeautifulSoup(page_html, "lxml") |
|
full_text = clean_text(soup.get_text(" ", strip=True)) or "" |
|
listing_id = listing_id_from_url(url) |
|
if not listing_id: |
|
raise ValueError(f"Cannot determine listing id from {url}") |
|
|
|
ld = cls.json_ld(soup) |
|
primary = next((x for x in ld if str(x.get("@type", "")).lower() in { |
|
"product", "realestatelisting", "apartment", "house", "residence", "singlefamilyresidence" |
|
}), ld[0] if ld else {}) |
|
|
|
title = clean_text(primary.get("name")) or cls._meta(soup, "og:title", "twitter:title") |
|
if not title and soup.title: |
|
title = clean_text(soup.title.get_text()) |
|
description = clean_text(primary.get("description")) or cls._meta(soup, "og:description", "description") |
|
|
|
offers = primary.get("offers") if isinstance(primary.get("offers"), dict) else {} |
|
price = parse_brl(str(offers.get("price", ""))) |
|
if price is None: |
|
price = parse_brl(cls._meta(soup, "product:price:amount")) |
|
if price is None: |
|
# Prefer visible price blocks, then first BRL occurrence. |
|
price_nodes = soup.select('[class*="preco" i], [class*="price" i], [itemprop="price"]') |
|
for node in price_nodes: |
|
price = parse_brl(node.get("content") or node.get_text(" ", strip=True)) |
|
if price is not None: |
|
break |
|
|
|
address_obj = primary.get("address") if isinstance(primary.get("address"), dict) else {} |
|
geo = primary.get("geo") if isinstance(primary.get("geo"), dict) else {} |
|
address = clean_text(address_obj.get("streetAddress")) |
|
city = clean_text(address_obj.get("addressLocality")) |
|
state = clean_text(address_obj.get("addressRegion")) |
|
|
|
area = None |
|
for pattern in [r"(?:área(?:\s+privativa|\s+útil)?|area)\s*[:\-]?\s*([\d.,]+)\s*m[²2]", r"([\d.,]+)\s*m[²2]"]: |
|
m = re.search(pattern, full_text, re.I) |
|
if m: |
|
area = parse_number(m.group(1)) |
|
break |
|
|
|
def label_money(*labels: str) -> float | None: |
|
for label in labels: |
|
m = re.search(rf"{label}\s*[:\-]?\s*(R\$\s*[\d.]+(?:,\d{{1,2}})?)", full_text, re.I) |
|
if m: |
|
return parse_brl(m.group(1)) |
|
return None |
|
|
|
def label_text(*labels: str) -> str | None: |
|
for label in labels: |
|
m = re.search(rf"{label}\s*[:\-]?\s*([^|•]+?)(?=\s{{2,}}|\b(?:Código|Creci|Condomínio|IPTU)\b|$)", full_text, re.I) |
|
if m: |
|
return clean_text(m.group(1)) |
|
return None |
|
|
|
path_parts = [x for x in urlparse(url).path.split("/") if x] |
|
slug = path_parts[-1] if path_parts else "" |
|
transaction_type = "venda" if "venda" in slug else "aluguel" if "aluguel" in slug else None |
|
property_type = next((x for x in ["apartamento", "casa", "cobertura", "terreno", "loja", "sala", "kitnet", "galpao"] if x in slug), None) |
|
|
|
image_urls: dict[str, None] = {} |
|
candidates: list[Any] = [primary.get("image"), cls._meta(soup, "og:image", "twitter:image")] |
|
for obj in ld: |
|
candidates.append(obj.get("image")) |
|
for candidate in candidates: |
|
vals = candidate if isinstance(candidate, list) else [candidate] |
|
for val in vals: |
|
if isinstance(val, dict): |
|
val = val.get("url") or val.get("contentUrl") |
|
if isinstance(val, str) and val.startswith(("http://", "https://")): |
|
image_urls[html.unescape(val)] = None |
|
for tag in soup.select("img, source"): |
|
for attr in ("src", "data-src", "data-lazy", "data-original", "data-zoom-image", "srcset", "data-srcset"): |
|
raw = tag.get(attr) |
|
if not raw: |
|
continue |
|
for part in str(raw).split(","): |
|
candidate = part.strip().split(" ")[0] |
|
if candidate and not candidate.startswith("data:"): |
|
absolute = urljoin(url, candidate) |
|
if re.search(r"\.(?:jpe?g|png|webp|avif)(?:\?|$)", absolute, re.I): |
|
image_urls[absolute] = None |
|
for match in IMAGE_URL_RE.finditer(page_html.replace("\\/", "/")): |
|
image_urls[html.unescape(match.group(0))] = None |
|
|
|
# Exclude common site chrome and tiny assets based on URL hints. |
|
photos = [u for u in image_urls if not re.search(r"logo|favicon|sprite|icon|avatar|banner", u, re.I)] |
|
|
|
data = { |
|
"listing_id": listing_id, |
|
"url": canonical_url(url), |
|
"title": title, |
|
"description": description, |
|
"transaction_type": transaction_type, |
|
"property_type": property_type, |
|
"price_brl": price, |
|
"condominium_brl": label_money("Condomínio", "Condominio"), |
|
"iptu_brl": label_money("IPTU"), |
|
"area_m2": area, |
|
"bedrooms": int_near_label(full_text, ["quartos?", "dormitórios?"]), |
|
"suites": int_near_label(full_text, ["suítes?", "suites?"]), |
|
"parking_spaces": int_near_label(full_text, ["vagas?"]), |
|
"address": address or label_text("Endereço", "Endereco"), |
|
"neighborhood": clean_text(address_obj.get("addressSubregion")) or label_text("Bairro"), |
|
"city": city, |
|
"state": state, |
|
"advertiser_name": clean_text(primary.get("seller", {}).get("name")) if isinstance(primary.get("seller"), dict) else None, |
|
"advertiser_code": label_text("Código", "Codigo"), |
|
"creci": label_text("CRECI"), |
|
"latitude": parse_number(str(geo.get("latitude"))) if geo.get("latitude") is not None else None, |
|
"longitude": parse_number(str(geo.get("longitude"))) if geo.get("longitude") is not None else None, |
|
"published_at": clean_text(primary.get("datePosted") or primary.get("datePublished")), |
|
"raw_html_path": raw_path, |
|
"extra_json": json.dumps({"json_ld": ld}, ensure_ascii=False), |
|
} |
|
stable = {k: v for k, v in data.items() if k not in {"content_hash", "raw_html_path"}} |
|
data["content_hash"] = hashlib.sha256(json.dumps(stable, sort_keys=True, ensure_ascii=False).encode()).hexdigest() |
|
return data, photos |
|
|
|
|
|
class Scraper: |
|
def __init__(self, cfg: Config) -> None: |
|
self.cfg = cfg |
|
self.db = Database(cfg.database) |
|
self.fetcher = Fetcher(cfg) |
|
self.stats = {"search_pages": 0, "listing_urls": 0, "details_ok": 0, "details_failed": 0, "changed": 0, "photos_ok": 0, "photos_skipped": 0, "photos_failed": 0, "inactive": 0} |
|
self.run_started_at = now_utc() |
|
|
|
def save_raw(self, kind: str, name: str, content: bytes) -> str: |
|
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") |
|
path = self.cfg.output_dir / "raw" / kind / safe_name(name) / f"{stamp}.html.gz" |
|
path.parent.mkdir(parents=True, exist_ok=True) |
|
with gzip.open(path, "wb", compresslevel=6) as f: |
|
f.write(content) |
|
return str(path.relative_to(self.cfg.output_dir)) |
|
|
|
async def discover(self, search_url: str) -> list[str]: |
|
found: dict[str, None] = {} |
|
page = 1 |
|
consecutive_empty = 0 |
|
while True: |
|
if self.cfg.max_pages and page > self.cfg.max_pages: |
|
break |
|
url = page_url(search_url, page) |
|
logging.info("Search page %d: %s", page, url) |
|
page_html, body, final_url = await self.fetcher.get_html(url) |
|
links = Parser.search_links(page_html, final_url) |
|
self.stats["search_pages"] += 1 |
|
if self.cfg.save_raw_html: |
|
self.save_raw("search", f"{hashlib.sha1(search_url.encode()).hexdigest()[:12]}_p{page}", body) |
|
new = [x for x in links if x not in found] |
|
logging.info("Found %d links (%d new)", len(links), len(new)) |
|
for x in new: |
|
found[x] = None |
|
consecutive_empty = consecutive_empty + 1 if not new else 0 |
|
if not links or consecutive_empty >= 2: |
|
break |
|
page += 1 |
|
self.stats["listing_urls"] += len(found) |
|
return list(found) |
|
|
|
async def scrape_detail(self, url: str, search_url: str) -> None: |
|
try: |
|
page_html, body, final_url = await self.fetcher.get_html(url) |
|
raw_path = self.save_raw("listing", listing_id_from_url(url) or "unknown", body) if self.cfg.save_raw_html else None |
|
data, photos = Parser.detail(final_url, page_html, raw_path) |
|
changed = self.db.upsert_listing(data, search_url) |
|
self.stats["details_ok"] += 1 |
|
self.stats["changed"] += int(changed) |
|
logging.info("Listing %s: %s; %d photos", data["listing_id"], "changed" if changed else "unchanged", len(photos)) |
|
for ordinal, photo_url in enumerate(photos, start=1): |
|
self.db.upsert_photo(data["listing_id"], ordinal, photo_url) |
|
if self.cfg.download_photos: |
|
await asyncio.gather(*(self.download_photo(data["listing_id"], i, u) for i, u in enumerate(photos, 1))) |
|
except Exception: |
|
self.stats["details_failed"] += 1 |
|
logging.exception("Failed listing: %s", url) |
|
|
|
async def download_photo(self, listing_id: str, ordinal: int, url: str) -> None: |
|
try: |
|
existing = self.db.get_photo(listing_id, url) |
|
if existing and existing["sha256"] and existing["local_path"]: |
|
local_path = self.cfg.output_dir / existing["local_path"] |
|
if local_path.is_file(): |
|
self.stats["photos_skipped"] += 1 |
|
logging.debug("Reusing photo for %s: %s", listing_id, local_path) |
|
return |
|
|
|
response = await self.fetcher.get(url, photo=True) |
|
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower() |
|
if not content_type.startswith("image/"): |
|
raise ValueError(f"Not an image: {content_type}") |
|
content = response.content |
|
digest = sha256_bytes(content) |
|
suffix = mimetypes.guess_extension(content_type) or Path(urlparse(url).path).suffix or ".img" |
|
if suffix == ".jpe": |
|
suffix = ".jpg" |
|
path = self.cfg.output_dir / "photos" / listing_id / f"{ordinal:03d}_{digest[:16]}{suffix}" |
|
path.parent.mkdir(parents=True, exist_ok=True) |
|
if not path.exists(): |
|
path.write_bytes(content) |
|
self.db.upsert_photo(listing_id, ordinal, url, sha256=digest, mime_type=content_type, bytes=len(content), local_path=str(path.relative_to(self.cfg.output_dir))) |
|
self.stats["photos_ok"] += 1 |
|
except Exception: |
|
self.stats["photos_failed"] += 1 |
|
logging.exception("Failed photo for %s: %s", listing_id, url) |
|
|
|
async def run(self) -> dict[str, Any]: |
|
run_id = self.db.start_run() |
|
status = "ok" |
|
try: |
|
for search_url in self.cfg.searches: |
|
urls = await self.discover(search_url) |
|
seen_ids = {x for u in urls if (x := listing_id_from_url(u))} |
|
# Detail concurrency remains bounded inside Fetcher. |
|
await asyncio.gather(*(self.scrape_detail(url, search_url) for url in urls)) |
|
self.stats["inactive"] += self.db.mark_inactive(search_url, seen_ids, self.run_started_at) |
|
except Exception: |
|
status = "failed" |
|
raise |
|
finally: |
|
self.db.finish_run(run_id, status, self.stats) |
|
await self.fetcher.close() |
|
self.db.close() |
|
return self.stats |
|
|
|
|
|
def load_searches(args: argparse.Namespace) -> list[str]: |
|
searches = list(args.search or []) |
|
if args.search_file: |
|
for line in Path(args.search_file).read_text(encoding="utf-8").splitlines(): |
|
line = line.strip() |
|
if line and not line.startswith("#"): |
|
searches.append(line) |
|
normalized = [] |
|
for url in searches: |
|
if not urlparse(url).netloc: |
|
url = urljoin(BASE_URL, url) |
|
normalized.append(canonical_url(url)) |
|
return list(dict.fromkeys(normalized)) |
|
|
|
|
|
def build_arg_parser() -> argparse.ArgumentParser: |
|
p = argparse.ArgumentParser(description="Authorized DFImoveis scraper") |
|
p.add_argument("--search", action="append", help="Search URL; repeat for multiple searches") |
|
p.add_argument("--search-file", help="Text file containing one search URL per line") |
|
p.add_argument("--output", default="dfimoveis_data", help="Output directory") |
|
p.add_argument("--database", help="SQLite path; defaults to OUTPUT/dfimoveis.sqlite3") |
|
p.add_argument("--max-pages", type=int, default=0, help="0 means continue until exhausted") |
|
p.add_argument("--concurrency", type=int, default=2) |
|
p.add_argument("--photo-concurrency", type=int, default=2) |
|
p.add_argument("--delay-min", type=float, default=3.0) |
|
p.add_argument("--delay-max", type=float, default=6.0) |
|
p.add_argument("--cooldown-base", type=float, default=30.0) |
|
p.add_argument("--cooldown-max", type=float, default=300.0) |
|
p.add_argument("--timeout", type=float, default=30.0) |
|
p.add_argument("--retries", type=int, default=4) |
|
p.add_argument("--no-photos", action="store_true") |
|
p.add_argument("--no-raw-html", action="store_true") |
|
p.add_argument( |
|
"--http-only", |
|
action="store_true", |
|
help="Use direct HTTP requests instead of the default local Microsoft Edge browser", |
|
) |
|
p.add_argument( |
|
"--playwright-headless", |
|
action="store_true", |
|
help="Run Playwright without a visible browser (not recommended for Cloudflare challenges)", |
|
) |
|
p.add_argument( |
|
"--no-cloudflare-fallback", |
|
action="store_true", |
|
help=( |
|
"Disable the automatic fallback that retries a challenged HTTP-only or " |
|
"photo request through the persistent browser session" |
|
), |
|
) |
|
p.add_argument( |
|
"--challenge-timeout", |
|
type=float, |
|
default=180.0, |
|
help="Seconds to wait for a Cloudflare challenge to be solved in the visible browser window", |
|
) |
|
p.add_argument( |
|
"--attach-chrome", |
|
action="store_true", |
|
help=( |
|
"Attach to a Chrome window you started yourself (via --remote-debugging-port) " |
|
"instead of one this script launches. Needed when Cloudflare's Turnstile flags " |
|
"automation-launched Chrome even after a genuine manual click; see --cdp-url." |
|
), |
|
) |
|
p.add_argument( |
|
"--cdp-url", |
|
default=os.getenv("DFIMOVEIS_CDP_URL", "http://localhost:9222"), |
|
help="CDP endpoint of the already-running Chrome (used with --attach-chrome)", |
|
) |
|
p.add_argument("--user-agent", default=os.getenv("DFIMOVEIS_USER_AGENT", DEFAULT_USER_AGENT)) |
|
p.add_argument( |
|
"--browser-channel", |
|
default=os.getenv("DFIMOVEIS_BROWSER_CHANNEL", "chrome"), |
|
choices=["chrome", "msedge", "chromium", "chrome-beta", "msedge-beta", "msedge-dev"], |
|
help=( |
|
"Which installed browser Playwright/Patchright should drive. 'chrome' " |
|
"(the default) is what Patchright's own guidance recommends -- the " |
|
"bundled 'chromium' binary is a stealth downgrade, not an upgrade." |
|
), |
|
) |
|
p.add_argument( |
|
"--browser-user-agent", |
|
default=os.getenv("DFIMOVEIS_BROWSER_USER_AGENT"), |
|
help=( |
|
"Override the browser's native user agent. Not recommended when " |
|
"patchright is installed -- its own default is more convincing than " |
|
"any override." |
|
), |
|
) |
|
p.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]) |
|
return p |
|
|
|
|
|
async def async_main() -> int: |
|
args = build_arg_parser().parse_args() |
|
logging.basicConfig(level=getattr(logging, args.log_level), format="%(asctime)s %(levelname)s %(message)s") |
|
searches = load_searches(args) |
|
if not searches: |
|
print("Provide at least one --search URL or --search-file", file=sys.stderr) |
|
return 2 |
|
output = Path(args.output).expanduser().resolve() |
|
output.mkdir(parents=True, exist_ok=True) |
|
cfg = Config( |
|
searches=searches, |
|
output_dir=output, |
|
database=Path(args.database).expanduser().resolve() if args.database else output / "dfimoveis.sqlite3", |
|
max_pages=max(0, args.max_pages), |
|
concurrency=max(1, args.concurrency), |
|
photo_concurrency=max(1, args.photo_concurrency), |
|
delay_min=max(0, args.delay_min), |
|
delay_max=max(0, args.delay_min, args.delay_max), |
|
cooldown_base=max(1, args.cooldown_base), |
|
cooldown_max=max(1, args.cooldown_base, args.cooldown_max), |
|
timeout=max(1, args.timeout), |
|
retries=max(0, args.retries), |
|
download_photos=not args.no_photos, |
|
save_raw_html=not args.no_raw_html, |
|
use_playwright=not args.http_only, |
|
playwright_headless=args.playwright_headless, |
|
user_agent=args.user_agent, |
|
browser_user_agent=args.browser_user_agent, |
|
browser_channel=args.browser_channel, |
|
attach_chrome=args.attach_chrome, |
|
cdp_url=args.cdp_url, |
|
cloudflare_fallback=not args.no_cloudflare_fallback, |
|
challenge_timeout=max(10.0, args.challenge_timeout), |
|
) |
|
started = time.monotonic() |
|
scraper = Scraper(cfg) |
|
stats = await scraper.run() |
|
stats["elapsed_seconds"] = round(time.monotonic() - started, 2) |
|
print(json.dumps(stats, ensure_ascii=False, indent=2)) |
|
return 0 |
|
|
|
|
|
def main() -> None: |
|
try: |
|
raise SystemExit(asyncio.run(async_main())) |
|
except KeyboardInterrupt: |
|
raise SystemExit(130) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |