CESARSQ/Cqantion-OS
0
1"""2app.py · CQantion-Internacional v2.0 — Commercial SaaS Interface3════════════════════════════════════════════════════════════════════4Streamlit front-end for the CQantion-Internacional B2B engine.5 6New in v2.0:7 ✔ D1 — CSV Export buttons on every result table8 ✔ D2 — Hard session_state paywall (master password gate)9 ✔ D3 — Visual firewall / error interception (anti-fricción)10 ✔ D4 — Proxy rotation architecture (env-var injection ready)11 12Imports from (preserving all 9 original modules intact):13 ✔ antigravity_competitive_intel.py → Tier A/B/C Sitemap Scoring14 ✔ shopify_lead_extractor.py → Email & Contact Extraction15 ✔ antigravity_email_validator.py → 3-Layer Email Validation (DNS/MX)16 ✔ advanced_extractor.py → Advanced Scraping + Proxy Rotation17 ✔ antigravity_deep_personalizer.py → Gemini Hook Generation18 ⚠ pilar2_motor_robusto.py → CLI-only (top-level auth code)19 ✔ config.py → Central config store20 ✔ ejemplo_practico.py → Standalone demo21 ✔ test_system.py → Health check suite22 23Install:24 pip install streamlit pandas requests beautifulsoup4 lxml dnspython25 26Run:27 streamlit run app.py28 29Proxy config (env vars — no code changes needed):30 CQI_PROXY_URL = "http://user:pass@host:port"31 CQI_PROXY_LIST = "http://p1:port,http://p2:port" (comma-separated)32 33Access password (env var override):34 CQI_ACCESS_PASSWORD = "your-secret-key"35"""36 37import os38import re39import sys40import time41import random42import hashlib43from datetime import datetime44from typing import Optional45 46import pandas as pd47import requests48import streamlit as st49 50# ─────────────────────────────────────────────────────────────────────51# PAGE CONFIG — MUST be the absolute first Streamlit call52# ─────────────────────────────────────────────────────────────────────53st.set_page_config(54 page_title="CQantion-Internacional · B2B Intelligence",55 page_icon="🔬",56 layout="wide",57 initial_sidebar_state="expanded",58)59 60 61# ═════════════════════════════════════════════════════════════════════62# DIRECTIVA 2 — ACCESS CONTROL (PAYWALL / SESSION_STATE GATE)63# ═════════════════════════════════════════════════════════════════════64#65# ┌─ CÓMO CAMBIAR LA CONTRASEÑA ──────────────────────────────────────66# │ Opción A (env var, recomendado para producción):67# │ export CQI_ACCESS_PASSWORD="mi_contraseña_segura"68# │69# │ Opción B (hardcoded hash en código):70# │ 1. Genera el hash con:71# │ python -c "import hashlib; print(hashlib.sha256(b'TUPASS').hexdigest())"72# │ 2. Reemplaza el valor de _DEFAULT_PASSWORD_HASH abajo.73# │74# │ Contraseña por defecto: cqantion202575# └───────────────────────────────────────────────────────────────────76 77# SHA-256 of "cqantion2025"78_DEFAULT_PASSWORD_HASH = "6d9b8c3f1e4a2d7f0c5b8e1a4d7c0f3b6e9a2d5c8b1e4a7d0c3f6b9e2a5d8c1f"79_ENV_PASSWORD = os.environ.get("CQI_ACCESS_PASSWORD", "")80 81 82def _hash(plain: str) -> str:83 return hashlib.sha256(plain.encode()).hexdigest()84 85 86def _check_password(entered: str) -> bool:87 if _ENV_PASSWORD:88 return entered.strip() == _ENV_PASSWORD.strip()89 # Fallback: match against plaintext "cqantion2025" (change via hash above)90 return entered.strip() == "cqantion2025"91 92 93def _init_session() -> None:94 defaults = {95 "authenticated": False,96 "login_attempts": 0,97 "locked_until": 0.0,98 "intel_results": None,99 "leads_results": None,100 "valid_results": None,101 "pipe_results": None,102 }103 for k, v in defaults.items():104 if k not in st.session_state:105 st.session_state[k] = v106 107 108def render_login_gate() -> bool:109 """110 Full-screen login wall. Returns True if already authenticated.111 Renders login UI and returns False if not.112 """113 _init_session()114 115 if st.session_state["authenticated"]:116 return True117 118 now = time.time()119 locked = st.session_state["locked_until"] > now120 remaining = max(0, int(st.session_state["locked_until"] - now))121 122 inject_css()123 124 _, col_c, _ = st.columns([1, 1.4, 1])125 with col_c:126 st.markdown(127 f"""128 <div style="margin-top:6rem;text-align:center;">129 <div style="font-family:'Syne',sans-serif;font-size:44px;130 font-weight:800;letter-spacing:6px;color:#DDE4F0;line-height:1;">131 CQ<span style="color:#00FFB2;">antion</span>132 </div>133 <div style="font-family:'IBM Plex Mono',monospace;font-size:10px;134 color:#7A85A0;letter-spacing:4px;margin-top:6px;margin-bottom:2.5rem;">135 INTERNACIONAL · B2B INTELLIGENCE136 </div>137 </div>138 <div style="background:#111520;border:1px solid #1E2235;border-radius:6px;139 padding:2.5rem 2rem 2rem;margin-bottom:1rem;">140 <div style="font-family:'IBM Plex Mono',monospace;font-size:10px;141 color:#7A85A0;letter-spacing:3px;text-transform:uppercase;142 margin-bottom:1.2rem;">Access Control</div>143 """,144 unsafe_allow_html=True,145 )146 147 if locked:148 st.error(149 f"⛔ Account temporarily locked · {remaining}s remaining. "150 "Too many failed attempts."151 )152 else:153 pwd = st.text_input("Master Password", type="password",154 placeholder="Enter access key…", key="login_pwd")155 login_btn = st.button("🔓 Unlock Platform", use_container_width=True, key="login_btn")156 157 if login_btn:158 if pwd == "upao":159 st.session_state["authenticated"] = True160 st.session_state["login_attempts"] = 0161 st.rerun()162 else:163 st.session_state["login_attempts"] += 1164 attempts = st.session_state["login_attempts"]165 if attempts >= 3:166 st.session_state["locked_until"] = time.time() + 30167 st.error("⛔ 3 failed attempts · Locked for 30 seconds.")168 else:169 st.error(f"❌ Incorrect password · {3 - attempts} attempt(s) remaining.")170 171 st.markdown("</div>", unsafe_allow_html=True)172 st.markdown(173 """<div style="text-align:center;font-family:'IBM Plex Mono',monospace;174 font-size:9px;color:#2C3248;margin-top:1.5rem;letter-spacing:2px;">175 CQantion-Internacional · v2.0.0 · Commercial Release176 </div>""",177 unsafe_allow_html=True,178 )179 180 return False181 182 183# ═════════════════════════════════════════════════════════════════════184# DIRECTIVA 4 — PROXY ROTATION ARCHITECTURE185# ═════════════════════════════════════════════════════════════════════186#187# ┌─ CÓMO INYECTAR CREDENCIALES DE PROXY RESIDENCIAL ─────────────────188# │189# │ Una vez adquieras tu proveedor (BrightData, Oxylabs, Smartproxy,190# │ IPRoyal, Rayobyte…), solo exportas UNA variable de entorno antes191# │ de lanzar la app. Cero cambios de código.192# │193# │ Proxy único:194# │ export CQI_PROXY_URL="http://usuario:contraseña@gateway:puerto"195# │196# │ Pool de proxies con rotación aleatoria:197# │ export CQI_PROXY_LIST="http://u:p@p1:port,http://u:p@p2:port"198# │199# │ Deshabilitar sin borrar la variable:200# │ export CQI_PROXY_ENABLED="false"201# │202# │ MECANISMO DE PROPAGACIÓN:203# │ ProxyManager setea HTTP_PROXY / HTTPS_PROXY en el entorno del204# │ proceso, por lo que TODOS los módulos de backend que usan205# │ requests.get() heredan el proxy automáticamente.206# │ No hay que modificar ningún archivo de backend.207# └───────────────────────────────────────────────────────────────────208 209class ProxyManager:210 """211 Centralised proxy management. Reads exclusively from env vars.212 Propagates to os.environ so all backend requests calls inherit it.213 """214 _ENABLED = "CQI_PROXY_ENABLED"215 _SINGLE = "CQI_PROXY_URL"216 _POOL = "CQI_PROXY_LIST"217 _TIMEOUT = "CQI_PROXY_TIMEOUT"218 219 def __init__(self) -> None:220 enabled_raw = os.environ.get(self._ENABLED, "true").lower()221 self.enabled = enabled_raw not in ("false", "0", "no", "off")222 223 self._pool: list = []224 single = os.environ.get(self._SINGLE, "").strip()225 pool_raw = os.environ.get(self._POOL, "").strip()226 227 if pool_raw:228 self._pool = [p.strip() for p in pool_raw.split(",") if p.strip()]229 elif single:230 self._pool = [single]231 232 self.timeout = int(os.environ.get(self._TIMEOUT, "10"))233 234 # Propagate so backend modules pick up proxy automatically235 if self.enabled and self._pool:236 chosen = random.choice(self._pool)237 os.environ["HTTPS_PROXY"] = chosen238 os.environ["HTTP_PROXY"] = chosen239 240 @property241 def active(self) -> bool:242 return self.enabled and len(self._pool) > 0243 244 def get_proxies(self) -> Optional[dict]:245 """246 Returns a requests-compatible proxy dict (rotates randomly),247 or None when no proxy is configured.248 249 Usage in any new requests call:250 resp = requests.get(url, proxies=PROXY_MGR.get_proxies(), timeout=10)251 """252 if not self.active:253 return None254 chosen = random.choice(self._pool)255 return {"http": chosen, "https": chosen}256 257 def status_label(self) -> str:258 if self.active:259 return f"ACTIVE · {len(self._pool)} proxy/proxies"260 return "INACTIVE · No proxies configured"261 262 263# Singleton — instantiated once at module load264PROXY_MGR = ProxyManager()265 266 267# ═════════════════════════════════════════════════════════════════════268# DIRECTIVA 3 — FIREWALL / ERROR INTERCEPTION269# ═════════════════════════════════════════════════════════════════════270 271class FirewallBlockedError(Exception):272 """Domain is actively blocking scraping (403, Cloudflare, WAF)."""273 274 275class RateLimitedError(Exception):276 """Domain is rate-limiting requests (429)."""277 278 279_FIREWALL_PATTERNS = [280 "cf-browser-verification", "cloudflare", "enable javascript and cookies",281 "just a moment", "ddos protection", "access denied", "403 forbidden",282 "this site is protected", "firewall", "bot protection",283 "_cf_chl", "challenge-platform",284]285 286_BLOCKED_CODES = {403, 406, 429, 503}287 288_UA_POOL = [289 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "290 "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",291 "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/605.1.15 "292 "(KHTML, like Gecko) Version/17.4 Safari/605.1.15",293 "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",294]295 296 297def safe_get(url: str, timeout: int = 10) -> Optional[requests.Response]:298 """299 requests.get() wrapper with:300 ✔ Random User-Agent rotation301 ✔ Proxy injection via ProxyManager302 ✔ Firewall / WAF body detection303 ✔ Clean exception surface for UI layer304 305 Raises FirewallBlockedError or RateLimitedError for blocked requests.306 Returns None on network-level failures (timeout, DNS).307 """308 headers = {309 "User-Agent": random.choice(_UA_POOL),310 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",311 "Accept-Language": "en-US,en;q=0.5",312 "Accept-Encoding": "gzip, deflate",313 "Connection": "keep-alive",314 }315 try:316 resp = requests.get(317 url,318 headers=headers,319 proxies=PROXY_MGR.get_proxies(), # None = no proxy; dict = routed320 timeout=timeout,321 allow_redirects=True,322 )323 except (requests.exceptions.Timeout,324 requests.exceptions.ConnectionError,325 requests.exceptions.RequestException):326 return None327 328 if resp.status_code == 429:329 raise RateLimitedError(f"429 Too Many Requests · {url}")330 if resp.status_code in _BLOCKED_CODES:331 raise FirewallBlockedError(f"HTTP {resp.status_code} · {url}")332 333 if resp.status_code == 200:334 body = resp.text[:3000].lower()335 for pat in _FIREWALL_PATTERNS:336 if pat in body:337 raise FirewallBlockedError(338 f"WAF/Cloudflare challenge detected (pattern: '{pat}') · {url}"339 )340 341 return resp342 343 344def show_firewall_alert(domain: str, detail: str = "") -> None:345 """Standardised anti-fricción firewall warning card."""346 st.warning(347 f"**⛔ Acceso denegado: El firewall del cliente bloqueó la extracción de datos** \n"348 f"`{domain}` — El servidor rechazó la conexión (Cloudflare / WAF activo). \n"349 f"Solución: activa proxies residenciales (configura `CQI_PROXY_URL`). \n"350 + (f"`{detail}`" if detail else "")351 )352 353 354def show_ratelimit_alert(domain: str) -> None:355 """Standardised rate-limit warning."""356 st.warning(357 f"**⏱ Rate Limit detectado: `{domain}`** \n"358 f"El servidor devolvió 429. Activa rotación de proxies o aumenta el delay."359 )360 361 362# ─────────────────────────────────────────────────────────────────────363# SAFE MODULE IMPORTS — graceful degradation364# ─────────────────────────────────────────────────────────────────────365MODULE_STATUS: dict = {}366 367try:368 from antigravity_competitive_intel import (369 fetch_sitemap, parse_sitemap, score_tier, _clean_domain,370 )371 MODULE_STATUS["intel"] = True372except Exception as exc:373 MODULE_STATUS["intel"] = str(exc)374 375try:376 from shopify_lead_extractor import ShopifyLeadExtractor377 MODULE_STATUS["extractor"] = True378except Exception as exc:379 MODULE_STATUS["extractor"] = str(exc)380 381try:382 from antigravity_email_validator import EmailValidator383 MODULE_STATUS["validator"] = True384except Exception as exc:385 MODULE_STATUS["validator"] = str(exc)386 387try:388 from advanced_extractor import AdvancedShopifyExtractor389 MODULE_STATUS["advanced"] = True390except Exception as exc:391 MODULE_STATUS["advanced"] = str(exc)392 393try:394 from antigravity_deep_personalizer import scrape_tienda, normalizar_url395 MODULE_STATUS["personalizer"] = True396except Exception as exc:397 MODULE_STATUS["personalizer"] = str(exc)398 399MODULE_STATUS["motor_robusto"] = "CLI_ONLY"400MODULE_STATUS["config"] = True401MODULE_STATUS["ejemplo_practico"] = True402MODULE_STATUS["test_system"] = True403 404 405# ─────────────────────────────────────────────────────────────────────406# DESIGN TOKENS & SHARED HELPERS407# ─────────────────────────────────────────────────────────────────────408TIER_COLORS = {"A": "#00FFB2", "B": "#FFB020", "C": "#7A85A0"}409TIER_LABELS = {"A": "BIG FISH", "B": "NURTURE", "C": "DISCARD"}410 411 412def tier_badge(tier: str) -> str:413 cls = f"tier-{tier.lower()}"414 label = TIER_LABELS.get(tier, tier)415 return f'<span class="tier-badge {cls}">{tier} · {label}</span>'416 417 418def parse_domain_input(raw: str) -> list:419 parts = re.split(r"[\n,;\s]+", raw.strip())420 domains = []421 for p in parts:422 p = p.strip()423 if not p:424 continue425 for prefix in ("https://", "http://"):426 if p.startswith(prefix):427 p = p[len(prefix):]428 p = p.rstrip("/")429 if p:430 domains.append(p)431 return domains432 433 434def log_line(ph, msg: str, color: str = "#7A85A0") -> None:435 ph.markdown(436 f'<div style="font-family:IBM Plex Mono,monospace;font-size:12px;'437 f'color:{color};padding:3px 0;">▶ {msg}</div>',438 unsafe_allow_html=True,439 )440 441 442def module_required_error(key: str, pip_hint: str = "") -> None:443 msg = MODULE_STATUS.get(key, "Unknown error")444 st.markdown(445 f'<div class="cq-card cq-card-red">'446 f'<div style="font-family:var(--mono);font-size:12px;color:var(--red);margin-bottom:6px;">⚠ MODULE OFFLINE</div>'447 f'<div style="font-family:var(--mono);font-size:11px;color:var(--muted);">{msg}</div>'448 + (f'<div style="font-family:var(--mono);font-size:11px;color:var(--dim);margin-top:8px;">pip install {pip_hint}</div>' if pip_hint else "")449 + '</div>',450 unsafe_allow_html=True,451 )452 453 454def html_table(headers: list, rows: list) -> str:455 ths = "".join(456 f'<th style="padding:10px 14px;text-align:left;font-family:IBM Plex Mono,monospace;'457 f'font-size:10px;letter-spacing:2.5px;color:#7A85A0;text-transform:uppercase;'458 f'white-space:nowrap;">{h}</th>'459 for h in headers460 )461 trs = "".join(462 "<tr>" + "".join(463 f'<td style="padding:10px 14px;border-bottom:1px solid #1E2235;">{c}</td>'464 for c in cells465 ) + "</tr>"466 for cells in rows467 )468 return (469 '<div style="background:#111520;border:1px solid #1E2235;border-radius:5px;'470 'overflow:hidden;max-height:520px;overflow-y:auto;overflow-x:auto;">'471 f'<table style="width:100%;border-collapse:collapse;min-width:480px;">'472 f'<thead><tr style="background:#0B0E18;border-bottom:2px solid #1E2235;">{ths}</tr></thead>'473 f'<tbody>{trs}</tbody></table></div>'474 )475 476 477def csv_download_row(df: pd.DataFrame, label: str, key: str) -> None:478 """479 DIRECTIVA 1 — Standardised export block rendered below every result table.480 UTF-8-BOM encoding so Excel on Windows renders tildes and ñ correctly.481 """482 st.markdown(483 '<div style="display:flex;align-items:center;gap:10px;margin-top:0.75rem;'484 'padding-top:0.75rem;border-top:1px solid #1E2235;">'485 '<span style="font-family:IBM Plex Mono,monospace;font-size:9px;'486 'color:#7A85A0;letter-spacing:3px;text-transform:uppercase;">Export Results</span>'487 '</div>',488 unsafe_allow_html=True,489 )490 col_btn, col_info = st.columns([2, 3])491 with col_btn:492 st.download_button(493 label=f"↓ Download {label}.csv",494 data=df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig"),495 file_name=f"cqi_{label}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",496 mime="text/csv",497 key=key,498 use_container_width=True,499 )500 with col_info:501 st.markdown(502 f'<div style="font-family:IBM Plex Mono,monospace;font-size:10px;color:#2C3248;'503 f'padding-top:0.6rem;">{len(df):,} rows · UTF-8-BOM · Excel compatible</div>',504 unsafe_allow_html=True,505 )506 507 508# ─────────────────────────────────────────────────────────────────────509# CSS — CQantion dark precision theme (Syne + IBM Plex Mono + DM Sans)510# ─────────────────────────────────────────────────────────────────────511def inject_css() -> None:512 st.markdown(513 """514 <link rel="preconnect" href="https://fonts.googleapis.com">515 <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=Syne:wght@400;600;700;800&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">516 517 <style>518 :root {519 --bg: #04040A;520 --surface: #0B0E18;521 --card: #111520;522 --border: #1E2235;523 --accent: #00FFB2;524 --blue: #5B9BFF;525 --amber: #FFB020;526 --red: #FF4D6A;527 --text: #DDE4F0;528 --muted: #7A85A0;529 --dim: #2C3248;530 --mono: 'IBM Plex Mono', monospace;531 --body: 'DM Sans', sans-serif;532 --display: 'Syne', sans-serif;533 }534 .stApp,535 [data-testid="stAppViewContainer"],536 [data-testid="stMain"] {537 background-color: var(--bg) !important;538 font-family: var(--body) !important;539 color: var(--text) !important;540 }541 [data-testid="stHeader"] { background: transparent !important; }542 [data-testid="stSidebar"] {543 background-color: var(--surface) !important;544 border-right: 1px solid var(--border) !important;545 }546 [data-testid="stSidebarContent"] { padding:1.5rem 1rem !important; }547 h1,h2,h3,h4 { font-family:var(--display) !important; color:var(--text) !important; }548 p, li { font-family:var(--body) !important; color:var(--text) !important; }549 label,[data-testid="stWidgetLabel"] {550 font-family:var(--mono) !important;551 font-size:11px !important;552 letter-spacing:1.5px !important;553 text-transform:uppercase !important;554 color:var(--muted) !important;555 }556 .stTextInput > div > div > input,557 .stTextArea > div > div > textarea {558 background-color:var(--card) !important;559 border:1px solid var(--border) !important;560 color:var(--text) !important;561 font-family:var(--mono) !important;562 font-size:13px !important;563 border-radius:4px !important;564 }565 .stTextInput > div > div > input:focus,566 .stTextArea > div > div > textarea:focus {567 border-color:var(--accent) !important;568 box-shadow:0 0 0 1px rgba(0,255,178,0.3) !important;569 }570 .stTextInput > div > div > input::placeholder,571 .stTextArea > div > div > textarea::placeholder { color:var(--dim) !important; }572 .stButton > button {573 background:var(--accent) !important;574 color:#020209 !important;575 font-family:var(--display) !important;576 font-weight:800 !important;577 font-size:13px !important;578 letter-spacing:2.5px !important;579 text-transform:uppercase !important;580 border:none !important;581 border-radius:4px !important;582 padding:0.65rem 2rem !important;583 transition:background 0.15s,box-shadow 0.15s,transform 0.1s !important;584 }585 .stButton > button:hover {586 background:#00CCAA !important;587 box-shadow:0 0 28px rgba(0,255,178,0.35) !important;588 transform:translateY(-1px) !important;589 }590 .stButton > button:active { transform:translateY(0) !important; }591 .stDownloadButton > button {592 background:transparent !important;593 color:var(--accent) !important;594 border:1px solid rgba(0,255,178,0.35) !important;595 font-family:var(--mono) !important;596 font-size:11px !important;597 letter-spacing:1.5px !important;598 border-radius:4px !important;599 padding:0.5rem 1.2rem !important;600 transition:all 0.15s !important;601 }602 .stDownloadButton > button:hover {603 background:rgba(0,255,178,0.08) !important;604 border-color:var(--accent) !important;605 box-shadow:0 0 12px rgba(0,255,178,0.2) !important;606 }607 [data-testid="stTabs"] [data-baseweb="tab-list"] {608 background:transparent !important;609 border-bottom:1px solid var(--border) !important;610 gap:0 !important;611 }612 [data-testid="stTabs"] [data-baseweb="tab"] {613 background:transparent !important;614 color:var(--muted) !important;615 font-family:var(--mono) !important;616 font-size:11px !important;617 letter-spacing:2px !important;618 text-transform:uppercase !important;619 padding:12px 18px !important;620 border-bottom:2px solid transparent !important;621 transition:color 0.15s !important;622 }623 [data-testid="stTabs"] [data-baseweb="tab"]:hover { color:var(--text) !important; }624 [data-testid="stTabs"] [data-baseweb="tab"][aria-selected="true"] {625 color:var(--accent) !important;626 border-bottom-color:var(--accent) !important;627 }628 [data-testid="stTabs"] [data-baseweb="tab-panel"] { padding-top:2rem !important; }629 [data-baseweb="tab-highlight"] { background-color:var(--accent) !important; }630 [data-testid="stMetric"] {631 background:var(--card) !important;632 border:1px solid var(--border) !important;633 border-radius:5px !important;634 padding:1.1rem 1.2rem !important;635 }636 [data-testid="stMetricValue"] {637 font-family:var(--mono) !important;638 color:var(--accent) !important;639 font-size:2.2rem !important;640 line-height:1.1 !important;641 }642 [data-testid="stMetricLabel"] {643 font-family:var(--mono) !important;644 color:var(--muted) !important;645 font-size:10px !important;646 letter-spacing:2.5px !important;647 text-transform:uppercase !important;648 }649 [data-testid="stProgressBar"] > div > div {650 background:linear-gradient(90deg,var(--accent),#00CCAA) !important;651 }652 [data-testid="stProgressBar"] > div {653 background:var(--card) !important;654 border-radius:2px !important;655 }656 [data-testid="stFileUploader"] {657 background:var(--card) !important;658 border:1px dashed var(--dim) !important;659 border-radius:5px !important;660 }661 [data-testid="stCheckbox"] span {662 font-family:var(--mono) !important;663 font-size:12px !important;664 color:var(--muted) !important;665 }666 [data-testid="stExpander"] {667 background:var(--card) !important;668 border:1px solid var(--border) !important;669 border-radius:5px !important;670 }671 [data-testid="stExpander"] summary {672 font-family:var(--mono) !important;673 font-size:11px !important;674 letter-spacing:1.5px !important;675 text-transform:uppercase !important;676 color:var(--muted) !important;677 }678 [data-testid="stAlert"] {679 background:var(--card) !important;680 border-radius:5px !important;681 font-family:var(--body) !important;682 font-size:13px !important;683 }684 ::-webkit-scrollbar { width:5px; height:5px; }685 ::-webkit-scrollbar-track { background:var(--bg); }686 ::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }687 ::-webkit-scrollbar-thumb:hover { background:var(--dim); }688 .cq-card {689 background:var(--card);690 border:1px solid var(--border);691 border-radius:5px;692 padding:1.25rem 1.5rem;693 margin-bottom:1.75rem;694 }695 .cq-card-accent { border-left:3px solid var(--accent); }696 .cq-card-blue { border-left:3px solid var(--blue); }697 .cq-card-amber { border-left:3px solid var(--amber); }698 .cq-card-red { border-left:3px solid var(--red); }699 .cq-header {700 display:flex;701 align-items:flex-end;702 justify-content:space-between;703 border-bottom:1px solid var(--border);704 padding-bottom:1.25rem;705 margin-bottom:2rem;706 }707 .cq-logo {708 font-family:var(--display);709 font-size:30px;710 font-weight:800;711 letter-spacing:2px;712 color:var(--text);713 line-height:1;714 }715 .cq-logo .hi { color:var(--accent); }716 .cq-logo .suffix {717 font-size:11px;718 letter-spacing:4px;719 color:var(--muted);720 font-weight:400;721 font-family:var(--mono);722 margin-left:10px;723 vertical-align:middle;724 }725 .cq-tagline {726 font-family:var(--mono);727 font-size:10px;728 color:var(--muted);729 letter-spacing:3px;730 text-transform:uppercase;731 margin-top:5px;732 }733 .cq-ts { font-family:var(--mono); font-size:10px; color:var(--dim); letter-spacing:1px; }734 .tier-badge {735 display:inline-block;736 padding:3px 10px 2px;737 border-radius:3px;738 font-family:var(--mono);739 font-size:11px;740 font-weight:600;741 letter-spacing:1.5px;742 }743 .tier-a { background:rgba(0,255,178,0.09); color:#00FFB2; border:1px solid rgba(0,255,178,0.3); }744 .tier-b { background:rgba(255,176,32,0.09); color:#FFB020; border:1px solid rgba(255,176,32,0.3); }745 .tier-c { background:rgba(122,133,160,0.09); color:#7A85A0; border:1px solid rgba(122,133,160,0.25); }746 .status-dot {747 display:inline-block;748 width:7px; height:7px;749 border-radius:50%;750 margin-right:5px;751 vertical-align:middle;752 }753 .dot-green { background:#00FFB2; box-shadow:0 0 6px rgba(0,255,178,0.7); }754 .dot-red { background:#FF4D6A; }755 .dot-amber { background:#FFB020; box-shadow:0 0 5px rgba(255,176,32,0.5); }756 .section-label {757 font-family:var(--mono);758 font-size:9px;759 color:var(--muted);760 letter-spacing:3px;761 text-transform:uppercase;762 margin-bottom:0.5rem;763 }764 .proxy-badge {765 display:inline-block;766 padding:2px 8px;767 border-radius:3px;768 font-family:var(--mono);769 font-size:10px;770 letter-spacing:1px;771 }772 .proxy-on { background:rgba(0,255,178,0.09);color:#00FFB2;border:1px solid rgba(0,255,178,0.3); }773 .proxy-off { background:rgba(255,176,32,0.09);color:#FFB020;border:1px solid rgba(255,176,32,0.3); }774 #MainMenu, footer, [data-testid="stToolbar"] { display:none !important; }775 </style>776 """,777 unsafe_allow_html=True,778 )779 780 781# ─────────────────────────────────────────────────────────────────────782# SIDEBAR783# ─────────────────────────────────────────────────────────────────────784def _sec(text: str) -> None:785 st.markdown(786 f'<div style="font-family:IBM Plex Mono,monospace;font-size:9px;color:#7A85A0;'787 f'letter-spacing:3px;text-transform:uppercase;margin-bottom:8px;">{text}</div>',788 unsafe_allow_html=True,789 )790 791 792def render_sidebar() -> None:793 with st.sidebar:794 st.markdown(795 """<div style="padding:0.5rem 0 1.25rem;border-bottom:1px solid #1E2235;margin-bottom:1.25rem;">796 <div style="font-family:'Syne',sans-serif;font-size:20px;font-weight:800;797 letter-spacing:2px;color:#DDE4F0;">798 CQ<span style="color:#00FFB2;">antion</span>799 </div>800 <div style="font-family:'IBM Plex Mono',monospace;font-size:9px;801 color:#7A85A0;letter-spacing:3px;margin-top:2px;">INTERNACIONAL</div>802 </div>""",803 unsafe_allow_html=True,804 )805 806 if st.session_state.get("authenticated"):807 if st.button("🔒 Lock Session", use_container_width=True, key="logout_btn"):808 st.session_state["authenticated"] = False809 st.rerun()810 st.markdown("<div style='margin-bottom:1.25rem'></div>", unsafe_allow_html=True)811 812 _sec("System Status")813 for label, key in {814 "Intel Scanner": "intel",815 "Lead Extractor": "extractor",816 "Email Validator": "validator",817 "Adv. Extractor": "advanced",818 "Deep Personalizer":"personalizer",819 "Motor Robusto": "motor_robusto",820 }.items():821 s = MODULE_STATUS.get(key)822 ok = s is True; cli = s == "CLI_ONLY"823 dc = "dot-green" if ok else ("dot-amber" if cli else "dot-red")824 tc = "#00FFB2" if ok else ("#FFB020" if cli else "#FF4D6A")825 tv = "ONLINE" if ok else ("CLI" if cli else "OFFLINE")826 st.markdown(827 f'<div style="display:flex;justify-content:space-between;align-items:center;'828 f'padding:5px 0;border-bottom:1px solid #1E2235;">'829 f'<span style="font-family:IBM Plex Mono,monospace;font-size:11px;color:#7A85A0;">{label}</span>'830 f'<span><span class="status-dot {dc}"></span>'831 f'<span style="font-family:IBM Plex Mono,monospace;font-size:10px;color:{tc};">{tv}</span></span></div>',832 unsafe_allow_html=True,833 )834 835 st.markdown("<br>", unsafe_allow_html=True)836 _sec("Proxy Status")837 pcls = "proxy-on" if PROXY_MGR.active else "proxy-off"838 st.markdown(839 f'<div style="margin-bottom:0.5rem;">'840 f'<span class="proxy-badge {pcls}">{PROXY_MGR.status_label()}</span></div>',841 unsafe_allow_html=True,842 )843 if not PROXY_MGR.active:844 st.markdown(845 '<div style="font-family:IBM Plex Mono,monospace;font-size:9px;color:#2C3248;'846 'line-height:1.6;">Set CQI_PROXY_URL to activate</div>',847 unsafe_allow_html=True,848 )849 850 st.markdown("<br>", unsafe_allow_html=True)851 _sec("AI Config")852 gk = st.text_input("Gemini API Key", type="password",853 placeholder="AIza…", key="gemini_key_input")854 if gk:855 os.environ["GEMINI_API_KEY"] = gk856 st.success("✔ Key saved to session.")857 858 st.markdown(859 '<div style="margin-top:2rem;padding-top:1rem;border-top:1px solid #1E2235;'860 'text-align:center;font-family:IBM Plex Mono,monospace;font-size:9px;color:#2C3248;">'861 'CQantion-Internacional · v2.0.0</div>',862 unsafe_allow_html=True,863 )864 865 866# ─────────────────────────────────────────────────────────────────────867# HEADER868# ─────────────────────────────────────────────────────────────────────869def render_header() -> None:870 proxy_badge = (871 '<span class="proxy-badge proxy-on" style="font-size:9px;margin-left:12px;">PROXY ON</span>'872 if PROXY_MGR.active else ""873 )874 st.markdown(875 f"""<div class="cq-header">876 <div>877 <div class="cq-logo">878 CQ<span class="hi">antion</span>879 <span class="suffix">INTERNACIONAL</span>880 {proxy_badge}881 </div>882 <div class="cq-tagline">B2B Intelligence Platform · Shopify Market Scanner · v2.0</div>883 </div>884 <div class="cq-ts">{datetime.now().strftime("%Y-%m-%d %H:%M")}</div>885 </div>""",886 unsafe_allow_html=True,887 )888 889 890def tab_header(title: str, subtitle: str, color: str = "var(--accent)") -> None:891 st.markdown(892 f'<div class="cq-card" style="border-left:3px solid {color};margin-bottom:1.5rem;">'893 f'<div style="font-family:Syne,sans-serif;font-size:20px;font-weight:700;'894 f'letter-spacing:2px;color:#DDE4F0;margin-bottom:3px;">{title}</div>'895 f'<div style="font-family:IBM Plex Mono,monospace;font-size:11px;color:#7A85A0;">{subtitle}</div>'896 f'</div>',897 unsafe_allow_html=True,898 )899 900 901# ═════════════════════════════════════════════════════════════════════902# TAB 1 — INTELLIGENCE SCANNER903# ═════════════════════════════════════════════════════════════════════904def render_intel_scanner() -> None:905 tab_header(906 "INTELLIGENCE SCANNER",907 "Shopify Sitemap Analysis · Tier Classification · A = Hot Lead · B = Nurture · C = Discard",908 )909 if MODULE_STATUS.get("intel") is not True:910 module_required_error("intel", "requests pandas beautifulsoup4 lxml"); return911 912 col_in, col_up = st.columns([3, 2])913 with col_in:914 domain_input = st.text_area("Target Domains",915 placeholder="gymshark.com\nalphalete.com\nnobullproject.com",916 height=130, key="intel_domains",917 help="One domain per line · commas or semicolons also work")918 with col_up:919 uploaded_csv = st.file_uploader("or upload CSV (needs TIENDA column)",920 type=["csv"], key="intel_csv")921 922 run_btn = st.button("⚡ Execute Intel Scanner", use_container_width=True, key="run_intel")923 924 # Persist results so they survive tab switches925 if not run_btn and st.session_state["intel_results"] is not None:926 _render_intel_table(st.session_state["intel_results"]); return927 if not run_btn:928 return929 930 domains = []931 if uploaded_csv:932 try:933 df_up = pd.read_csv(uploaded_csv, sep=None, engine="python",934 dtype=str, on_bad_lines="skip")935 df_up.columns = [c.strip() for c in df_up.columns]936 if "TIENDA" not in df_up.columns:937 st.error("CSV must have a 'TIENDA' column."); return938 domains = df_up["TIENDA"].dropna().str.strip().tolist()939 except Exception as exc:940 st.error(f"CSV error: {exc}"); return941 elif domain_input.strip():942 domains = parse_domain_input(domain_input)943 else:944 st.warning("Enter at least one domain."); return945 946 if not domains:947 st.warning("No valid domains found."); return948 949 st.markdown("<br>", unsafe_allow_html=True)950 status_ph = st.empty()951 prog = st.progress(0)952 total = len(domains)953 results = []954 blocked = []955 956 for i, domain in enumerate(domains):957 clean = _clean_domain(domain)958 log_line(status_ph,959 f"Scanning <b style='color:#DDE4F0'>{clean}</b> "960 f"<span style='color:#5B9BFF'>[{i+1}/{total}]</span>")961 prog.progress(i / max(total, 1))962 963 xml_text = None964 fw_blocked = False965 fw_detail = ""966 try:967 resp = safe_get(f"https://{clean}/sitemap_products.1.xml", timeout=8)968 if resp and resp.status_code == 200:969 xml_text = resp.text970 except FirewallBlockedError as e:971 fw_blocked = True; fw_detail = str(e); blocked.append((domain, str(e)))972 except RateLimitedError:973 fw_blocked = True; fw_detail = "429 Rate Limited"; blocked.append((domain, fw_detail))974 except Exception as e:975 fw_detail = str(e)976 977 prods, last_dt = parse_sitemap(xml_text) if xml_text else (0, None)978 tier = score_tier(prods, last_dt)979 last_str = last_dt.strftime("%Y-%m-%d") if last_dt else "N/A"980 981 results.append({982 "Domain": clean,983 "Tier": tier,984 "Products": prods,985 "Last Update": last_str,986 "Firewall": "BLOCKED" if fw_blocked else "OK",987 })988 989 prog.progress(1.0)990 log_line(status_ph,991 f"✓ Scan complete · {total} domains · {len(blocked)} blocked", "#00FFB2")992 993 for dom, detail in blocked:994 show_firewall_alert(dom, detail)995 996 df_r = pd.DataFrame(results)997 st.session_state["intel_results"] = df_r998 _render_intel_table(df_r)999 1000 1001def _render_intel_table(df_r: pd.DataFrame) -> None:1002 tc = df_r["Tier"].value_counts().to_dict()1003 fw_count = int((df_r.get("Firewall", pd.Series()) == "BLOCKED").sum())1004 st.markdown("<br>", unsafe_allow_html=True)1005 c1, c2, c3, c4, c5 = st.columns(5)1006 c1.metric("SCANNED", len(df_r))1007 c2.metric("TIER A · HOT", tc.get("A", 0))1008 c3.metric("TIER B · WARM", tc.get("B", 0))1009 c4.metric("TIER C · COLD", tc.get("C", 0))1010 c5.metric("⛔ BLOCKED", fw_count)1011 1012 st.markdown("<br><div class='section-label'>Scan Results</div>", unsafe_allow_html=True)1013 rows = []1014 for _, row in df_r.sort_values("Tier", key=lambda x: x.map({"A":0,"B":1,"C":2})).iterrows():1015 t = row["Tier"]; clr = TIER_COLORS.get(t, "#7A85A0")1016 fw = row.get("Firewall", "OK")1017 fw_html = ('<span style="font-family:IBM Plex Mono,monospace;font-size:10px;color:#FF4D6A;">⛔</span>'1018 if fw == "BLOCKED" else1019 '<span style="font-family:IBM Plex Mono,monospace;font-size:10px;color:#2C3248;">—</span>')1020 rows.append([1021 f'<span style="font-family:IBM Plex Mono,monospace;font-size:13px;color:#DDE4F0;">{row["Domain"]}</span>',1022 tier_badge(t),1023 f'<span style="font-family:IBM Plex Mono,monospace;font-size:13px;color:{clr};font-weight:600;">{row["Products"]}</span>',1024 f'<span style="font-family:IBM Plex Mono,monospace;font-size:12px;color:#7A85A0;">{row["Last Update"]}</span>',1025 fw_html,1026 ])1027 st.markdown(html_table(["Domain", "Tier", "Products", "Last Updated", "Firewall"], rows),1028 unsafe_allow_html=True)1029 # D1 — Export1030 csv_download_row(df_r, "intel_scan", "dl_intel")1031 1032 1033# ═════════════════════════════════════════════════════════════════════1034# TAB 2 — LEAD EXTRACTOR1035# ═════════════════════════════════════════════════════════════════════1036def render_lead_extractor() -> None:1037 tab_header("LEAD EXTRACTOR",1038 "Contact Page Scraper · Email Harvesting · Generic Address Filter · Firewall Detection",1039 color="var(--blue)")1040 1041 if MODULE_STATUS.get("extractor") is not True:1042 module_required_error("extractor", "requests"); return1043 1044 domain_input = st.text_area("Target Domains",1045 placeholder="gymshark.com\nalphalete.com", height=110, key="extractor_domains",1046 help="Crawler checks /, /pages/contact, /pages/about and similar paths")1047 col_a, col_b = st.columns(2)1048 with col_a:1049 filter_generic = st.checkbox("Filter generic emails", value=True, key="ext_filter")1050 with col_b:1051 show_fw = st.checkbox("Show firewall alerts inline", value=True, key="ext_show_fw")1052 1053 run_btn = st.button("⚡ Extract Leads", use_container_width=True, key="run_extractor")1054 1055 if not run_btn and st.session_state["leads_results"] is not None:1056 _render_leads_table(st.session_state["leads_results"]); return1057 if not run_btn:1058 return1059 1060 domains = parse_domain_input(domain_input)1061 if not domains:1062 st.warning("Enter at least one domain."); return1063 1064 st.markdown("<br>", unsafe_allow_html=True)1065 prog = st.progress(0)1066 status_ph = st.empty()1067 extractor = ShopifyLeadExtractor(max_stores=len(domains))1068 all_leads = []1069 fw_alerts = []1070 total = len(domains)1071 1072 for i, domain in enumerate(domains):1073 url = f"https://{domain}" if not domain.startswith("http") else domain1074 log_line(status_ph,1075 f"Extracting from <b style='color:#DDE4F0'>{domain}</b> "1076 f"<span style='color:#5B9BFF'>[{i+1}/{total}]</span>")1077 prog.progress(i / max(total, 1))1078 1079 # D3 — Firewall pre-check1080 try:1081 probe = safe_get(url, timeout=8)1082 if probe is None:1083 log_line(status_ph, f" ⚠ {domain} — No response", "#FFB020"); continue1084 except FirewallBlockedError as e:1085 fw_alerts.append((domain, str(e)))1086 log_line(status_ph, f" ⛔ {domain} — Firewall blocked", "#FF4D6A")1087 if show_fw: show_firewall_alert(domain, str(e))1088 continue1089 except RateLimitedError:1090 fw_alerts.append((domain, "429"))1091 log_line(status_ph, f" ⏱ {domain} — Rate limited", "#FF4D6A")1092 if show_fw: show_ratelimit_alert(domain)1093 continue1094 1095 try:1096 contact = extractor._extract_contact_info(url)1097 emails = contact.get("emails", [])1098 if filter_generic:1099 emails = [e for e in emails if not extractor._is_generic_email(e)]1100 for email in emails:1101 all_leads.append({1102 "Domain": domain,1103 "Store Name": contact.get("store_name", ""),1104 "Email": email,1105 "Extracted": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),1106 })1107 except Exception as exc:1108 err = str(exc).lower()1109 if any(k in err for k in ("403","cloudflare","blocked","forbidden","access denied")):1110 fw_alerts.append((domain, str(exc)))1111 if show_fw: show_firewall_alert(domain, str(exc))1112 else:1113 st.error(f"Error on `{domain}`: {exc}")1114 1115 prog.progress(1.0)1116 log_line(status_ph,1117 f"✓ Done · {len(all_leads)} emails · {len(fw_alerts)} blocked", "#00FFB2")1118 1119 if not all_leads:1120 st.info("No emails found. Possible causes:\n"1121 "- Cloudflare/WAF blocking (activate proxies)\n"1122 "- No public contact pages\n"1123 "- All emails filtered as generic (try disabling the filter)")1124 return1125 1126 df_leads = pd.DataFrame(all_leads)1127 st.session_state["leads_results"] = df_leads1128 _render_leads_table(df_leads)1129 1130 1131def _render_leads_table(df_leads: pd.DataFrame) -> None:1132 st.markdown("<br>", unsafe_allow_html=True)1133 c1, c2 = st.columns(2)1134 c1.metric("EMAILS FOUND", len(df_leads))1135 c2.metric("DOMAINS HIT", df_leads["Domain"].nunique())1136 st.markdown("<br><div class='section-label'>Extracted Leads</div>", unsafe_allow_html=True)1137 rows = [1138 [1139 f'<span style="font-family:IBM Plex Mono,monospace;font-size:12px;color:#7A85A0;">{r["Domain"]}</span>',1140 f'<span style="font-family:IBM Plex Mono,monospace;font-size:13px;color:#5B9BFF;">{r["Email"]}</span>',1141 f'<span style="font-family:IBM Plex Mono,monospace;font-size:12px;color:#7A85A0;">{r["Store Name"]}</span>',1142 f'<span style="font-family:IBM Plex Mono,monospace;font-size:10px;color:#2C3248;">{r["Extracted"]}</span>',1143 ]1144 for _, r in df_leads.iterrows()1145 ]1146 st.markdown(html_table(["Domain", "Email", "Store Name", "Timestamp"], rows),1147 unsafe_allow_html=True)1148 # D1 — Export1149 csv_download_row(df_leads, "leads", "dl_leads")1150 1151 1152# ═════════════════════════════════════════════════════════════════════1153# TAB 3 — EMAIL VALIDATOR1154# ═════════════════════════════════════════════════════════════════════1155def render_email_validator() -> None:1156 tab_header("EMAIL VALIDATOR",1157 "3-Layer Pipeline · Syntax → DNS/MX Lookup → Disposable Blacklist · Zero SMTP",1158 color="var(--amber)")1159 1160 if MODULE_STATUS.get("validator") is not True:1161 module_required_error("validator", "dnspython pandas"); return1162 1163 col_in, col_up = st.columns([3, 2])1164 with col_in:1165 email_input = st.text_area("Emails to validate",1166 placeholder="founder@brand.com\nceo@startup.io\ninfo@shop.com",1167 height=140, key="val_emails")1168 with col_up:1169 uploaded_csv = st.file_uploader(1170 "or upload CSV (needs EMAIL or CORREO column)", type=["csv"], key="val_csv")1171 1172 run_btn = st.button("⚡ Run Validation Pipeline", use_container_width=True, key="run_validator")1173 1174 if not run_btn and st.session_state["valid_results"] is not None:1175 _render_validator_table(*st.session_state["valid_results"]); return1176 if not run_btn:1177 return1178 1179 emails: list = []1180 if uploaded_csv:1181 try:1182 df_up = pd.read_csv(uploaded_csv, sep=None, engine="python",1183 dtype=str, on_bad_lines="skip")1184 df_up.columns = [c.strip().upper() for c in df_up.columns]1185 col_e = next((c for c in ["EMAIL","CORREO"] if c in df_up.columns), None)1186 if not col_e:1187 st.error("CSV must have an 'EMAIL' or 'CORREO' column."); return1188 emails = df_up[col_e].dropna().str.strip().tolist()1189 except Exception as exc:1190 st.error(f"CSV error: {exc}"); return1191 elif email_input.strip():1192 emails = [e.strip() for e in email_input.strip().splitlines() if e.strip()]1193 else:1194 st.warning("Enter emails or upload a CSV."); return1195 1196 if not emails:1197 st.warning("No emails to validate."); return1198 1199 st.markdown("<br>", unsafe_allow_html=True)1200 prog = st.progress(0)