Spaces:
Sleeping
Sleeping
| """ | |
| Cookie Harvester using nodriver | |
| ================================ | |
| Dùng nodriver (Chrome CDP thuần, bypass Cloudflare + DataDome) để lấy session | |
| cookies cho TripAdvisor, Agoda, Booking.com — sau đó tái sử dụng trong httpx | |
| cho bulk requests mà không cần mở browser mỗi lần. | |
| Cơ chế: | |
| 1. Mở real Chrome (headless=False), visit từng site | |
| 2. Chờ JS challenges giải quyết (Cloudflare 5s, DataDome auto-solve) | |
| 3. Extract cookies: datadome (TA), cf_clearance + agoda_* (Agoda), cf_clearance (Booking) | |
| 4. Lưu vào _COOKIE_STORE dict + file cache | |
| 5. Auto-refresh khi cookies hết hạn (TTL: 20 phút) | |
| Usage: | |
| from scripts._cookie_harvester import get_cookies, get_headers | |
| ta_headers = get_headers("tripadvisor") # dict with Cookie header | |
| agoda_cookies = get_cookies("agoda") # raw cookie string | |
| """ | |
| import asyncio | |
| import base64 | |
| import json | |
| import logging | |
| import os | |
| import select | |
| import socket | |
| import threading | |
| import time | |
| from pathlib import Path | |
| from typing import Optional | |
| logger = logging.getLogger("cookie_harvester") | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| COOKIE_TTL = 20 * 60 # 20 minutes before re-harvest | |
| COOKIE_CACHE_FILE = Path(__file__).parent.parent / "scraped_data" / "raw_cache" / "_cookies.json" | |
| COOKIE_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| # Pages to harvest from each site (must trigger their bot-protection challenge) | |
| _HARVEST_TARGETS = { | |
| "tripadvisor": "https://www.tripadvisor.com/Restaurants-g293924-Hanoi.html", | |
| "agoda": "https://www.agoda.com/", | |
| "booking": "https://www.booking.com/", | |
| "traveloka": "https://www.traveloka.com/en-en/hotel", | |
| } | |
| # How long to wait after page load for JS challenges to resolve | |
| _WAIT_AFTER_LOAD = { | |
| "tripadvisor": 10, | |
| "agoda": 15, # CF Bot Management can take up to 10s | |
| "booking": 15, | |
| "traveloka": 8, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Internal state | |
| # --------------------------------------------------------------------------- | |
| _COOKIE_STORE: dict[str, dict] = {} # domain → {"cookie_str": "...", "harvested_at": ts} | |
| _HARVEST_LOCK = threading.Lock() | |
| _IS_HARVESTING = False | |
| # User-Agent to use in harvested sessions | |
| _UA = ( | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " | |
| "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" | |
| ) | |
| _PROXIES = [ | |
| "http://nguyegqsY1:qZlSBJ1E@51.79.191.62:8161", # RID ZP75087_18889 exp 08/05/2026 | |
| "http://nguyeaYYeK:7QlkNk4y@103.187.5.219:8775", # RID ZP75087_18888 exp 08/05/2026 | |
| ] | |
| # --------------------------------------------------------------------------- | |
| # Cookie cache (disk) | |
| # --------------------------------------------------------------------------- | |
| def _load_cached_cookies() -> None: | |
| """Load previously harvested cookies from disk if still fresh.""" | |
| global _COOKIE_STORE | |
| if not COOKIE_CACHE_FILE.exists(): | |
| return | |
| try: | |
| data: dict = json.loads(COOKIE_CACHE_FILE.read_text(encoding="utf-8")) | |
| now = time.time() | |
| loaded = 0 | |
| for domain, entry in data.items(): | |
| age = now - entry.get("harvested_at", 0) | |
| if age < COOKIE_TTL and entry.get("cookie_str"): | |
| _COOKIE_STORE[domain] = entry | |
| loaded += 1 | |
| if loaded: | |
| logger.info(f"[cookie_harvester] Loaded {loaded} cached cookie sets from disk") | |
| except Exception as e: | |
| logger.debug(f"[cookie_harvester] Could not load cache: {e}") | |
| def _save_cookie_cache() -> None: | |
| """Persist current cookies to disk.""" | |
| try: | |
| COOKIE_CACHE_FILE.write_text( | |
| json.dumps(_COOKIE_STORE, ensure_ascii=False, indent=2), | |
| encoding="utf-8", | |
| ) | |
| except Exception as e: | |
| logger.debug(f"[cookie_harvester] Could not save cache: {e}") | |
| # --------------------------------------------------------------------------- | |
| # nodriver async harvest | |
| # --------------------------------------------------------------------------- | |
| # --------------------------------------------------------------------------- | |
| # Local auth-forwarding proxy (lets Chrome use an auth proxy without dialogs) | |
| # --------------------------------------------------------------------------- | |
| class _LocalAuthProxy: | |
| """ | |
| Minimal HTTP/CONNECT tunnel proxy that listens on localhost and forwards | |
| all traffic to an authenticated upstream proxy, injecting Proxy-Authorization | |
| automatically. Chrome can then use --proxy-server=http://127.0.0.1:PORT | |
| without needing to handle any auth dialog. | |
| """ | |
| def __init__(self, upstream_host: str, upstream_port: int, | |
| username: str, password: str, local_port: int = 18889): | |
| self.upstream_host = upstream_host | |
| self.upstream_port = upstream_port | |
| self._auth_header = ( | |
| "Proxy-Authorization: Basic " | |
| + base64.b64encode(f"{username}:{password}".encode()).decode() | |
| ) | |
| self.local_port = local_port | |
| self._server: Optional[socket.socket] = None | |
| self._thread: Optional[threading.Thread] = None | |
| self._running = False | |
| def start(self) -> int: | |
| self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | |
| self._server.bind(("127.0.0.1", self.local_port)) | |
| self._server.listen(20) | |
| self._server.settimeout(1.0) | |
| self._running = True | |
| self._thread = threading.Thread(target=self._accept_loop, daemon=True) | |
| self._thread.start() | |
| logger.debug(f"[local_proxy] Listening on 127.0.0.1:{self.local_port} → {self.upstream_host}:{self.upstream_port}") | |
| return self.local_port | |
| def stop(self): | |
| self._running = False | |
| if self._server: | |
| try: | |
| self._server.close() | |
| except Exception: | |
| pass | |
| def _accept_loop(self): | |
| while self._running: | |
| try: | |
| client, _ = self._server.accept() | |
| threading.Thread(target=self._handle_client, args=(client,), daemon=True).start() | |
| except socket.timeout: | |
| continue | |
| except Exception: | |
| break | |
| def _handle_client(self, client: socket.socket): | |
| try: | |
| raw = b"" | |
| while b"\r\n\r\n" not in raw: | |
| chunk = client.recv(8192) | |
| if not chunk: | |
| break | |
| raw += chunk | |
| if not raw: | |
| return | |
| head, _, body = raw.partition(b"\r\n\r\n") | |
| lines = head.decode(errors="replace").split("\r\n") | |
| first_line = lines[0] | |
| # Inject auth into headers | |
| filtered = [l for l in lines[1:] if not l.lower().startswith("proxy-authorization")] | |
| filtered.append(self._auth_header) | |
| new_head = "\r\n".join([first_line] + filtered) | |
| full_request = (new_head + "\r\n\r\n").encode() + body | |
| upstream = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| upstream.settimeout(30) | |
| upstream.connect((self.upstream_host, self.upstream_port)) | |
| upstream.send(full_request) | |
| if first_line.upper().startswith("CONNECT"): | |
| # Wait for 200 Connection established | |
| resp = b"" | |
| while b"\r\n\r\n" not in resp: | |
| chunk = upstream.recv(4096) | |
| if not chunk: | |
| break | |
| resp += chunk | |
| client.send(resp) | |
| self._tunnel(client, upstream) | |
| except Exception: | |
| pass | |
| finally: | |
| try: | |
| client.close() | |
| except Exception: | |
| pass | |
| def _tunnel(a: socket.socket, b: socket.socket): | |
| socks = [a, b] | |
| try: | |
| while True: | |
| r, _, _ = select.select(socks, [], socks, 10) | |
| if not r: | |
| break | |
| for s in r: | |
| data = s.recv(65536) | |
| if not data: | |
| return | |
| other = b if s is a else a | |
| other.sendall(data) | |
| except Exception: | |
| pass | |
| finally: | |
| for s in socks: | |
| try: | |
| s.close() | |
| except Exception: | |
| pass | |
| def _parse_proxy_url(proxy_url: str): | |
| """Parse 'http://user:pass@host:port' → (host, port, user, pass).""" | |
| import urllib.parse | |
| p = urllib.parse.urlparse(proxy_url) | |
| return p.hostname, p.port, p.username or "", p.password or "" | |
| # --------------------------------------------------------------------------- | |
| # nodriver async harvest | |
| # --------------------------------------------------------------------------- | |
| async def _harvest_async(domains: list[str], proxy: Optional[str] = None) -> dict[str, str]: | |
| """ | |
| Launch a real Chrome browser (via nodriver), visit each site, | |
| wait for JS challenges to resolve, then extract cookies. | |
| Returns dict: domain → cookie_string | |
| """ | |
| import nodriver as uc | |
| # Start local auth-forwarding proxy so Chrome uses the upstream proxy | |
| # without needing a credentials dialog (Chrome can't do user:pass in --proxy-server) | |
| local_proxy: Optional[_LocalAuthProxy] = None | |
| effective_proxy_arg = "" | |
| if proxy: | |
| try: | |
| ph, pp, pu, pw = _parse_proxy_url(proxy) | |
| if ph and pp: | |
| local_proxy = _LocalAuthProxy(ph, pp, pu, pw, local_port=18889) | |
| local_port = local_proxy.start() | |
| effective_proxy_arg = f"http://127.0.0.1:{local_port}" | |
| logger.info(f"[cookie_harvester] Local auth proxy on :{local_port} → {ph}:{pp}") | |
| except Exception as e: | |
| logger.warning(f"[cookie_harvester] Could not start local proxy: {e}") | |
| local_proxy = None | |
| browser_args = [ | |
| "--window-size=1280,900", | |
| "--lang=en-US", | |
| f"--user-agent={_UA}", | |
| ] | |
| if effective_proxy_arg: | |
| browser_args.append(f"--proxy-server={effective_proxy_arg}") | |
| logger.info(f"[cookie_harvester] Chrome will use proxy: {effective_proxy_arg}") | |
| else: | |
| logger.warning("[cookie_harvester] No proxy configured — direct IP may be blocked by DataDome") | |
| logger.info(f"[cookie_harvester] Starting Chrome for domains: {domains}") | |
| browser = await uc.start(headless=False, browser_args=browser_args) | |
| results: dict[str, str] = {} | |
| try: | |
| for domain in domains: | |
| url = _HARVEST_TARGETS.get(domain) | |
| if not url: | |
| continue | |
| wait = _WAIT_AFTER_LOAD.get(domain, 6) | |
| try: | |
| logger.info(f"[cookie_harvester] Visiting {url} ...") | |
| page = await browser.get(url) | |
| # Wait for page + CF challenge to fully resolve | |
| await asyncio.sleep(wait) | |
| # Extra wait if CF "Just a moment" still showing | |
| for _attempt in range(6): | |
| try: | |
| title = str(await page.evaluate("document.title") or "") | |
| if "just a moment" in title.lower(): | |
| logger.info(f"[cookie_harvester] CF still active ({domain}), +3s...") | |
| await asyncio.sleep(3) | |
| else: | |
| break | |
| except Exception: | |
| await asyncio.sleep(2) | |
| # Let nodriver settle | |
| await asyncio.sleep(2) | |
| # --- Cookie extraction --- | |
| # datadome is confirmed httpOnly:False in Chrome sessions — JS can read it. | |
| # We use JS document.cookie as primary (avoids nodriver's broken Cookie.from_json). | |
| # CDP fallback via raw JS JSON trick gets all same-site cookies. | |
| cdp_cookie_str = "" | |
| try: | |
| result = await page.evaluate("document.cookie") | |
| cdp_cookie_str = result if isinstance(result, str) else "" | |
| if cdp_cookie_str: | |
| logger.debug(f"[cookie_harvester] JS document.cookie: {len(cdp_cookie_str)} chars for {domain}") | |
| except Exception as e: | |
| logger.debug(f"[cookie_harvester] JS document.cookie failed ({domain}): {e}") | |
| # Strategy 2: patch Cookie.from_json and use CDP (handles httpOnly if needed) | |
| if not cdp_cookie_str: | |
| try: | |
| import nodriver.cdp.network as _cdp_net | |
| # Capture bound classmethod BEFORE patching | |
| _orig_cfjson = _cdp_net.Cookie.from_json | |
| # type: ignore | |
| def _patched_from_json(cls, json_val): # type: ignore | |
| if isinstance(json_val, dict): | |
| json_val.setdefault('sameParty', False) | |
| json_val.setdefault('samesite', None) | |
| return _orig_cfjson(json_val) | |
| _cdp_net.Cookie.from_json = _patched_from_json | |
| all_cookies = await browser.cookies.get_all() | |
| _cdp_net.Cookie.from_json = _orig_cfjson # restore | |
| pairs = [f"{c.name}={c.value}" for c in all_cookies if getattr(c, 'name', None) and getattr(c, 'value', None) is not None] | |
| cdp_cookie_str = "; ".join(pairs) | |
| logger.debug(f"[cookie_harvester] CDP fallback got {len(all_cookies)} cookies for {domain}") | |
| except Exception as e: | |
| logger.debug(f"[cookie_harvester] CDP fallback failed ({domain}): {e}") | |
| if cdp_cookie_str: | |
| results[domain] = cdp_cookie_str | |
| has_cf = "cf_clearance" in cdp_cookie_str | |
| has_dd = "datadome" in cdp_cookie_str | |
| flags = " ".join(filter(None, [ | |
| "CF✓" if has_cf else "CF✗", | |
| "DD✓" if has_dd else "DD✗", | |
| ])) | |
| logger.info(f"[cookie_harvester] ✓ {domain}: {len(cdp_cookie_str)} chars {flags}") | |
| else: | |
| logger.warning(f"[cookie_harvester] ✗ {domain}: no cookies captured") | |
| except Exception as e: | |
| logger.warning(f"[cookie_harvester] Failed to harvest {domain}: {e}") | |
| finally: | |
| try: | |
| browser.stop() | |
| except Exception: | |
| pass | |
| if local_proxy: | |
| try: | |
| local_proxy.stop() | |
| except Exception: | |
| pass | |
| return results | |
| def _harvest_sync(domains: list[str], proxy: Optional[str] = None) -> dict[str, str]: | |
| """Run async harvest in a new event loop (safe to call from any thread).""" | |
| loop = asyncio.new_event_loop() | |
| asyncio.set_event_loop(loop) | |
| try: | |
| return loop.run_until_complete(_harvest_async(domains, proxy=proxy)) | |
| finally: | |
| loop.close() | |
| # --------------------------------------------------------------------------- | |
| # Public API | |
| # --------------------------------------------------------------------------- | |
| def ensure_cookies(domains: Optional[list[str]] = None, force: bool = False) -> None: | |
| """ | |
| Ensure cookies are available for the requested domains. | |
| Harvests if missing or expired. Thread-safe. | |
| Args: | |
| domains: list of "tripadvisor" | "agoda" | "booking" | |
| defaults to all three | |
| force: re-harvest even if still fresh | |
| """ | |
| global _IS_HARVESTING | |
| if domains is None: | |
| domains = list(_HARVEST_TARGETS.keys()) | |
| # Determine which domains actually need harvesting | |
| now = time.time() | |
| need = [] | |
| for d in domains: | |
| entry = _COOKIE_STORE.get(d, {}) | |
| age = now - entry.get("harvested_at", 0) | |
| if force or not entry.get("cookie_str") or age > COOKIE_TTL: | |
| need.append(d) | |
| if not need: | |
| return # All fresh | |
| with _HARVEST_LOCK: | |
| # Double-check after acquiring lock | |
| need2 = [] | |
| for d in need: | |
| entry = _COOKIE_STORE.get(d, {}) | |
| age = now - entry.get("harvested_at", 0) | |
| if force or not entry.get("cookie_str") or age > COOKIE_TTL: | |
| need2.append(d) | |
| if not need2: | |
| return | |
| logger.info(f"[cookie_harvester] Harvesting cookies for: {need2}") | |
| # Use first proxy for cookie harvesting | |
| proxy = _PROXIES[0] if _PROXIES else None | |
| new_cookies = _harvest_sync(need2, proxy=proxy) | |
| ts = time.time() | |
| for domain, cookie_str in new_cookies.items(): | |
| _COOKIE_STORE[domain] = { | |
| "cookie_str": cookie_str, | |
| "harvested_at": ts, | |
| } | |
| _save_cookie_cache() | |
| logger.info(f"[cookie_harvester] Harvest complete. Got: {list(new_cookies.keys())}") | |
| def get_cookies(domain: str, auto_harvest: bool = True) -> Optional[str]: | |
| """ | |
| Get cookie string for a domain. | |
| Returns cookie string like "cf_clearance=xxx; datadome=yyy" or None. | |
| """ | |
| if auto_harvest: | |
| ensure_cookies([domain]) | |
| entry = _COOKIE_STORE.get(domain, {}) | |
| return entry.get("cookie_str") or None | |
| def get_headers(domain: str, extra: Optional[dict] = None) -> dict: | |
| """ | |
| Build a complete headers dict including cookies for the given domain. | |
| Safe to use with httpx. | |
| Args: | |
| domain: "tripadvisor" | "agoda" | "booking" | |
| extra: additional headers to merge | |
| """ | |
| cookies = get_cookies(domain) | |
| headers = { | |
| "User-Agent": _UA, | |
| "Accept-Language": "en-US,en;q=0.9", | |
| "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", | |
| "Referer": _HARVEST_TARGETS.get(domain, "https://www.google.com/"), | |
| "Sec-Fetch-Dest": "document", | |
| "Sec-Fetch-Mode": "navigate", | |
| "Sec-Fetch-Site": "same-origin", | |
| "Upgrade-Insecure-Requests": "1", | |
| } | |
| if cookies: | |
| headers["Cookie"] = cookies | |
| if extra: | |
| headers.update(extra) | |
| return headers | |
| def invalidate(domain: str) -> None: | |
| """Mark domain cookies as expired — triggers re-harvest on next use.""" | |
| if domain in _COOKIE_STORE: | |
| _COOKIE_STORE[domain]["harvested_at"] = 0 | |
| logger.info(f"[cookie_harvester] Invalidated cookies for {domain}") | |
| # --------------------------------------------------------------------------- | |
| # Init: try loading from disk on import | |
| # --------------------------------------------------------------------------- | |
| _load_cached_cookies() | |
| # --------------------------------------------------------------------------- | |
| # Standalone runner | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| logging.basicConfig(level=logging.INFO) | |
| print("Harvesting cookies for TripAdvisor, Agoda, Booking.com...") | |
| ensure_cookies(force=True) | |
| for domain in ("tripadvisor", "agoda", "booking"): | |
| c = get_cookies(domain, auto_harvest=False) | |
| if c: | |
| print(f"\n[{domain}] ({len(c)} chars):") | |
| # Show only key cookie names for privacy | |
| names = [p.split("=")[0].strip() for p in c.split(";")] | |
| print(f" Cookies: {', '.join(names)}") | |
| else: | |
| print(f"\n[{domain}] FAILED — no cookies") | |