wrapper.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import asyncio
  2. import base64
  3. import shutil
  4. import sys
  5. import time
  6. from typing import Any, Literal, Optional, Union
  7. from urllib.parse import urlparse
  8. from camoufox.async_api import AsyncCamoufox
  9. from app.config import config
  10. from app.libs.log import log
  11. def _detect_headless_mode() -> Union[bool, Literal["virtual"]]:
  12. if sys.platform.startswith("linux") and shutil.which("Xvfb"):
  13. return "virtual"
  14. return True
  15. def _resolve_headless_mode() -> Union[bool, Literal["virtual"]]:
  16. env_mode = config.HEADLESS_MODE
  17. if env_mode == "virtual":
  18. return "virtual"
  19. if env_mode in ("true", "True", "1"):
  20. return True
  21. if env_mode in ("false", "False", "0"):
  22. return False
  23. return _detect_headless_mode()
  24. class CamoufoxWrapper:
  25. _instance: Optional["CamoufoxWrapper"] = None
  26. def __new__(cls) -> "CamoufoxWrapper":
  27. if cls._instance is None:
  28. cls._instance = super().__new__(cls)
  29. return cls._instance
  30. def _build_proxy_config(
  31. self, proxy: str, proxy_auth: Optional[dict[str, Any]] = None
  32. ) -> dict[str, Any]:
  33. proxy_url = urlparse(proxy if proxy.startswith("http") else f"http://{proxy}")
  34. config_proxy: dict[str, Any] = {
  35. "server": f"{proxy_url.scheme}://{proxy_url.hostname}:{proxy_url.port}",
  36. }
  37. if proxy_auth:
  38. if proxy_auth.get("username"):
  39. config_proxy["username"] = proxy_auth["username"]
  40. if proxy_auth.get("password"):
  41. config_proxy["password"] = proxy_auth["password"]
  42. return config_proxy
  43. async def _handle_mercadolibre(self, page, url: str, browser) -> str:
  44. """Hacks específicos para el portal de Mercado Libre."""
  45. html = await page.content()
  46. # 1. Detectar captcha "Para continuar"
  47. if "Para continuar" in html:
  48. log.logger.debug("MeLi captcha detected. Reloading in new page...")
  49. page = await browser.new_page()
  50. page.set_default_navigation_timeout(60000)
  51. await page.goto(url, timeout=60000)
  52. html = await page.content()
  53. # 2. Espera adicional de network idle
  54. await page.wait_for_load_state("networkidle", timeout=2000)
  55. # 3. Rate limit detection (429)
  56. html = await page.content()
  57. rate_limit_pattern = '{"message":"local_rate_limited","status":429}'
  58. if rate_limit_pattern in html:
  59. log.logger.debug("Rate limit detected for MeLi. Reloading page...")
  60. await page.reload()
  61. html = await page.content()
  62. # 4. Remover banner de cookies
  63. try:
  64. await page.wait_for_selector("div.cookie-consent-banner-opt-out", timeout=1000)
  65. await page.evaluate(
  66. "() => { const banner = document.querySelector('div.cookie-consent-banner-opt-out'); if (banner) banner.remove(); }"
  67. )
  68. except Exception:
  69. pass
  70. # 5. Click "todas las características"
  71. try:
  72. await page.wait_for_selector(
  73. "button.ui-pdp-collapsable__action.ui-vpp-highlighted-specs__striped-collapsed__action[data-testid='action-collapsable-target']",
  74. timeout=1000,
  75. )
  76. await page.click(
  77. "button.ui-pdp-collapsable__action.ui-vpp-highlighted-specs__striped-collapsed__action[data-testid='action-collapsable-target']"
  78. )
  79. except Exception:
  80. pass
  81. # 6. Click "toda la descripción"
  82. try:
  83. await page.wait_for_selector(
  84. "button.ui-pdp-collapsable__action.ui-pdp-description-collapse[data-testid='action-collapsable-target']",
  85. timeout=1000,
  86. )
  87. await page.click(
  88. "button.ui-pdp-collapsable__action.ui-pdp-description-collapse[data-testid='action-collapsable-target']"
  89. )
  90. except Exception:
  91. pass
  92. # HTML final actualizado
  93. html = await page.content()
  94. return html
  95. async def visit(self, url: str, options: dict[str, Any]) -> dict[str, Any]:
  96. start_time = time.time()
  97. headless = _resolve_headless_mode()
  98. log.logger.info(f"Using headless mode: {headless}")
  99. camoufox_options: dict[str, Any] = {
  100. "headless": headless,
  101. }
  102. proxy = options.get("proxy")
  103. if proxy:
  104. log.logger.debug(f"Using proxy: {proxy}")
  105. camoufox_options["proxy"] = self._build_proxy_config(
  106. proxy, options.get("proxyAuth")
  107. )
  108. camoufox_options["geoip"] = True
  109. headers = options.get("headers")
  110. if headers:
  111. if "host" in headers:
  112. del headers["host"]
  113. if "content-length" in headers:
  114. del headers["content-length"]
  115. log.logger.debug(f"Using headers: {headers}")
  116. # Intentar inicializar AsyncCamoufox (con fallback si es virtual)
  117. try:
  118. browser_cls = AsyncCamoufox(**camoufox_options)
  119. except Exception as launch_exc:
  120. if headless == "virtual":
  121. log.logger.warning(
  122. f"Failed to launch with virtual display: {launch_exc}. Falling back to headless=True"
  123. )
  124. camoufox_options["headless"] = True
  125. browser_cls = AsyncCamoufox(**camoufox_options)
  126. else:
  127. raise
  128. # Usar context manager para toda la lógica del browser
  129. async with browser_cls as browser:
  130. page = await browser.new_page()
  131. if headers:
  132. await page.set_extra_http_headers(headers)
  133. await page.goto(url, timeout=120000, wait_until=options.get("waitUntil", "load"))
  134. await page.wait_for_load_state("networkidle", timeout=120000)
  135. # Extra wait after networkidle (for SPAs that need hydration time)
  136. extra_wait = options.get("waitTime", 0) or 0
  137. if extra_wait > 0:
  138. log.logger.info(f"Extra wait: {extra_wait}s after networkidle")
  139. await asyncio.sleep(extra_wait)
  140. # Hacks específicos para Mercado Libre
  141. is_meli = "mercadolibre.cl" in url
  142. if is_meli:
  143. log.logger.debug("Applying MeLi-specific hacks")
  144. html = await self._handle_mercadolibre(page, url, browser)
  145. else:
  146. html = await page.content()
  147. screenshot: Optional[str] = None
  148. if options.get("screenshot"):
  149. screenshot_bytes = await page.screenshot(
  150. full_page=True,
  151. type="jpeg",
  152. quality=config.SCREENSHOT_QUALITY,
  153. )
  154. screenshot = base64.b64encode(screenshot_bytes).decode("utf-8")
  155. duration = time.time() - start_time
  156. log.logger.info(f"HTML Size: {len(html) / 1000:.2f} KB from '{url}'")
  157. log.logger.info(f"Execution time: {duration:.2f}s")
  158. return {"html": html, "screenshot": screenshot}