#!/usr/bin/env python3 """ Deep Research mode — многошаговое исследование как Perplexity Deep Research. Архитектура: 1. Генерация 3-4 связанных поисковых запросов по теме (LLM) 2. SearXNG → сбор результатов по каждому запросу 3. Извлечение полного текста топ-статей (aiohttp → curl → Obscura → Playwright → Google Cache) 4. LLM-анализ: gaps, follow-up вопросы 5. Второй раунд поиска по follow-up 6. Финальный LLM-синтез → structured report с [N] цитатами Использование: python3 scripts/deep_research.py "тема исследования" [--lang ru] [--output report.md] Опции: --lang ru (default) | en --output путь к файлу для сохранения отчёта (по умолчанию stdout) --ignore-robots игнорировать robots.txt при загрузке страниц (по умолчанию False) Зависимости: requests, beautifulsoup4, obscura (AUR: obscura-browser) """ import argparse import asyncio import hashlib import json import os import random import re import sqlite3 import subprocess import sys import time import urllib.parse from collections import defaultdict from datetime import datetime from pathlib import Path import aiohttp import requests from bs4 import BeautifulSoup # Импорт валидатора — ищем в scripts/ плагина или в ~/workspace/ import importlib.util _validator_paths = [ os.path.join(os.path.dirname(os.path.abspath(__file__)), "research_validator.py"), "/home/neo/workspace/research_validator.py", ] _validator = None for _vp in _validator_paths: if os.path.exists(_vp): _validator_spec = importlib.util.spec_from_file_location("research_validator", _vp) _validator = importlib.util.module_from_spec(_validator_spec) _validator_spec.loader.exec_module(_validator) break if _validator is None: raise ImportError("research_validator.py не найден. Установите плагин research-agent полностью.") validate_report = _validator.validate_report filter_valid_urls = _validator.filter_valid_urls build_fix_prompt = _validator.build_fix_prompt # ─── Конфигурация ───────────────────────────────────────────────────────────── SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://localhost:8888") LLM_URL = os.environ.get("LLM_URL", "http://192.168.1.152:8080/v1/chat/completions") LLM_MODEL = os.environ.get("LLM_MODEL", "Qwen3.6-35B-A3B-UD-IQ4_NL.gguf") LLM_TEMPERATURE = float(os.environ.get("LLM_TEMPERATURE", "0.7")) # Игнорировать robots.txt (по умолчанию False) IGNORE_ROBOTS = False MAX_ARTICLES_PER_PHASE = 10 MAX_RESULTS_PER_QUERY = 10 MAX_ARTICLE_CHARS = 6000 SOURCE_COUNTER = [0] # mutable counter for [N] references # ─── Anti-blocking: User-Agent ротация ──────────────────────────────────────── USER_AGENTS = [ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", ] def random_ua(): return random.choice(USER_AGENTS) def build_headers(referer=None): """Собрать реалистичные заголовки для HTTP-запроса.""" h = { "User-Agent": random_ua(), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7", "Accept-Encoding": "gzip, deflate", "DNT": "1", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", } if referer: h["Referer"] = referer return h # ─── Anti-blocking: Domain Rate Limiter (с jitter) ──────────────────────────── class DomainRateLimiter: """Минимум 1.5 сек между запросами к одному домену ± случайный jitter.""" def __init__(self, min_interval=1.5, jitter=0.5): self._last_request = defaultdict(float) self._min_interval = min_interval self._jitter = jitter async def acquire(self, domain): """Ждать с jitter: 1.5s ± 0.5s — неотличимо от человека.""" elapsed = time.monotonic() - self._last_request[domain] base_wait = max(0, self._min_interval - elapsed) jittered = base_wait + random.uniform(-self._jitter, self._jitter) wait = max(0, jittered) if wait > 0: await asyncio.sleep(wait) self._last_request[domain] = time.monotonic() _limiter = DomainRateLimiter(min_interval=1.5, jitter=0.5) # ─── Anti-blocking: Circuit Breaker ──────────────────────────────────────────── class DomainCircuitBreaker: """После 3 ошибок подряд — домен отключается на 5 минут.""" def __init__(self, threshold=3, cooldown=300): self._failures = defaultdict(int) self._cooldown_until = {} self._threshold = threshold self._cooldown = cooldown def is_open(self, domain): """True = домен отключён, не ходить.""" if domain in self._cooldown_until: if time.monotonic() < self._cooldown_until[domain]: return True # cooldown истёк, сброс del self._cooldown_until[domain] self._failures[domain] = 0 return False def record_failure(self, domain): self._failures[domain] += 1 if self._failures[domain] >= self._threshold: self._cooldown_until[domain] = time.monotonic() + self._cooldown log(f"⚠ Circuit breaker OPEN for {domain} ({self._cooldown}s cooldown)") def record_success(self, domain): self._failures[domain] = 0 _circuit_breaker = DomainCircuitBreaker(threshold=3, cooldown=300) # ─── Статистика доменов ──────────────────────────────────────────────────────── _stats = defaultdict(lambda: {"requests": 0, "ok": 0, "blocked": 0, "timeout": 0}) def _record_result(domain, outcome): """Записать исход запроса для статистики.""" _stats[domain]["requests"] += 1 _stats[domain][outcome] = _stats[domain].get(outcome, 0) + 1 def dump_stats(): """Вывести статистику по доменам (проблемные первые).""" if not _stats: return log("=== Domain stats ===") for domain, s in sorted(_stats.items(), key=lambda x: -(x[1]["blocked"] + x[1]["timeout"])): if s["blocked"] > 0 or s["timeout"] > 0: log(f" {domain}: {s['requests']} req, " f"ok={s['ok']}, blocked={s['blocked']}, timeout={s['timeout']}") # ─── Anti-blocking: SQLite-кэш URL ───────────────────────────────────────────── CACHE_DB_PATH = Path.home() / ".hermes" / "scraper_cache.db" def _cache_init(): """Создать таблицу кэша если нет.""" CACHE_DB_PATH.parent.mkdir(parents=True, exist_ok=True) with sqlite3.connect(str(CACHE_DB_PATH)) as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS url_cache ( url_hash TEXT PRIMARY KEY, url TEXT NOT NULL, content TEXT NOT NULL, fetched_at REAL NOT NULL ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_fetched_at ON url_cache(fetched_at)") def _cache_get(url): """Получить закэшированный контент (если свежее 24ч).""" url_hash = hashlib.sha256(url.encode()).hexdigest() try: with sqlite3.connect(str(CACHE_DB_PATH)) as conn: row = conn.execute( "SELECT content, fetched_at FROM url_cache WHERE url_hash = ?", (url_hash,), ).fetchone() if row: content, fetched_at = row age_hours = (time.time() - fetched_at) / 3600 if age_hours <= 24: return content except Exception: pass return None def _cache_put(url, content): """Сохранить контент в кэш.""" url_hash = hashlib.sha256(url.encode()).hexdigest() try: with sqlite3.connect(str(CACHE_DB_PATH)) as conn: conn.execute( "INSERT OR REPLACE INTO url_cache (url_hash, url, content, fetched_at) VALUES (?, ?, ?, ?)", (url_hash, url, content, time.time()), ) except Exception: pass def _cache_cleanup_if_needed(): """Раз в ~20 запусков — удалить записи старше 30 дней и vacuum.""" if random.randint(1, 20) != 1: return try: with sqlite3.connect(str(CACHE_DB_PATH)) as conn: deleted = conn.execute( "DELETE FROM url_cache WHERE fetched_at < ?", (time.time() - 30 * 86400,), ).rowcount if deleted > 0: conn.execute("VACUUM") log(f"Cache cleanup: removed {deleted} entries older than 30 days") except Exception: pass _cache_init() # при импорте # ─── Advanced: Headless Browser (Playwright) ────────────────────────────────── BROWSER_ENABLED = True # всегда используем browser как fallback HYSTERIA2_PROXY = os.environ.get("HYSTERIA2_PROXY", "socks5://192.168.1.1:1080") # Домены, заблокированные или ограниченные в РФ — через hysteria2 БЛОКИРОВАННЫЕ_В_РФ = { "wikipedia.org", "en.wikipedia.org", "ru.wikipedia.org", "iaea.org", "www.iaea.org", "arxiv.org", "export.arxiv.org", "medium.com", "www.medium.com", "twitter.com", "x.com", "instagram.com", "www.instagram.com", "facebook.com", "www.facebook.com", "reuters.com", "www.reuters.com", "apnews.com", "www.apnews.com", "bbc.com", "www.bbc.com", "bbc.co.uk", "theguardian.com", "www.theguardian.com", "bloomberg.com", "www.bloomberg.com", "nytimes.com", "www.nytimes.com", "forbes.com", "www.forbes.com", "ft.com", "www.ft.com", "independent.co.uk", "www.independent.co.uk", "politico.com", "www.politico.com", "newsweek.com", "www.newsweek.com", "dw.com", "www.dw.com", "eurointegration.com.ua", "pravda.com.ua", "reddit.com", "www.reddit.com", "discord.com", "discord.gg", "telegram.org", "telegram.me", "signal.org", "change.org", "www.change.org", "opensea.io", "crunchbase.com", "www.crunchbase.com", "producthunt.com", "www.producthunt.com", "techcrunch.com", "www.techcrunch.com", "venturebeat.com", "www.venturebeat.com", "wired.com", "www.wired.com", "theverge.com", "www.theverge.com", } def _нужен_прокси(url): """Проверить, нужен ли hysteria2 прокси для этого URL.""" from urllib.parse import urlparse domain = urlparse(url).hostname or "" for blocked in БЛОКИРОВАННЫЕ_В_РФ: if domain == blocked or domain.endswith("." + blocked): return True return False # robots.txt кэш (на 6 часов) — домен → set запрещённых путей _robots_cache = {} # {domain: (timestamp, set_of_disallowed_paths)} async def _check_robots(session, domain): """Проверить robots.txt домена. Возвращает set запрещённых путей. Кэширует результат на 6 часов.""" now = time.time() if domain in _robots_cache: ts, paths = _robots_cache[domain] if now - ts < 21600: # 6 часов return paths disallowed = set() try: robots_url = f"https://{domain}/robots.txt" async with session.get(robots_url, headers=build_headers(), timeout=aiohttp.ClientTimeout(total=5)) as resp: if resp.status == 200: text = await resp.text() for line in text.split("\n"): line = line.strip() if line.lower().startswith("disallow:"): path = line.split(":", 1)[1].strip() if path: disallowed.add(path) except Exception: pass _robots_cache[domain] = (now, disallowed) return disallowed def _is_url_allowed(url, disallowed_paths): """Проверить, разрешён ли URL согласно robots.txt.""" if not disallowed_paths: return True path = urllib.parse.urlparse(url).path for pattern in disallowed_paths: if pattern == "/": return False if pattern.endswith("*"): if path.startswith(pattern[:-1]): return False elif path.startswith(pattern): return False return True # Stealth-скрипт для маскировки под обычный браузер _STEALTH_JS = """ // Скрыть webdriver-флаг Object.defineProperty(navigator, 'webdriver', {get: () => false}); // Подменить plugins Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]}); // Подменить languages Object.defineProperty(navigator, 'languages', {get: () => ['ru-RU', 'ru', 'en-US', 'en']}); // Скрыть chrome.runtime window.chrome = {runtime: {}}; // Подменить permissions const originalQuery = window.navigator.permissions.query; window.navigator.permissions.query = (parameters) => ( parameters.name === 'notifications' ? Promise.resolve({state: Notification.permission}) : originalQuery(parameters) ); """ class ObscuraFetcher: """Fetch через Obscura (Rust headless browser) для JS-страниц. Быстрее Playwright: ~85ms vs ~500ms, ~30MB vs ~200MB. Использует obscura CLI: fetch --dump markdown. """ def __init__(self, timeout=20): self._available = None # None = не проверено, True/False self._timeout = timeout def _is_available(self): """Проверить, установлен ли obscura.""" if self._available is None: self._available = ( subprocess.run( ["which", "obscura"], capture_output=True, timeout=5 ).returncode == 0 ) if not self._available: log(" ⚠ obscura not found in PATH (skip)") return self._available async def fetch(self, url): """Загрузить страницу через Obscura CLI и извлечь markdown. Возвращает None если не удалось.""" if not self._is_available(): return None proxy_flag = "" if _нужен_прокси(url): proxy_flag = f"--proxy {HYSTERIA2_PROXY}" cmd = [ "obscura", "fetch", url, "--dump", "markdown", "--wait-until", "networkidle", "--timeout", str(self._timeout), "--wait", "10", "--quiet", ] if proxy_flag: cmd.extend(proxy_flag.split()) try: result = await asyncio.to_thread( subprocess.run, cmd, capture_output=True, text=True, timeout=self._timeout + 5 ) if result.returncode != 0: return None text = result.stdout.strip() if not text or len(text) < 300: return None # Obscura уже возвращает чистый markdown — обрезка if len(text) > MAX_ARTICLE_CHARS: text = text[:MAX_ARTICLE_CHARS] return text except (subprocess.TimeoutExpired, FileNotFoundError, OSError): self._available = False log(" ⚠ Obscura fetch failed — marking unavailable") return None except Exception as e: log(f" ✗ Obscura error: {e}") return None class BrowserFetcher: """Headless Chromium через Playwright для JavaScript-сайтов. Используется как последний fallback после aiohttp + curl + obscura.""" def __init__(self, timeout=20000): self._playwright = None self._browser = None self._timeout = timeout self._launched = False async def _ensure_browser(self): """Ленивый запуск браузера (один раз за сессию).""" if self._launched: return try: from playwright.async_api import async_playwright except ImportError: log("⚠ Playwright not installed. Install: pip install playwright && playwright install chromium") return self._playwright = await async_playwright().start() self._browser = await self._playwright.chromium.launch( headless=True, args=[ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-blink-features=AutomationControlled", "--disable-infobars", ], ) self._launched = True log(" ✓ Browser launched (Playwright Chromium)") async def fetch(self, url): """Загрузить страницу через браузер и извлечь текст. Возвращает None если не удалось.""" await self._ensure_browser() if not self._browser: return None context = None try: proxy_cfg = {} if _нужен_прокси(url): proxy_cfg["server"] = HYSTERIA2_PROXY context = await self._browser.new_context( user_agent=random_ua(), java_script_enabled=True, viewport={"width": 1920, "height": 1080}, locale="ru-RU", proxy=proxy_cfg if proxy_cfg else None, ) page = await context.new_page() # Антидетект await page.add_init_script(_STEALTH_JS) # Загрузка страницы await page.goto(url, wait_until="networkidle", timeout=self._timeout + 10000) # Дождаться отрисовки JS-контента (SPA) try: await page.wait_for_timeout(3000) await page.wait_for_load_state("networkidle", timeout=10000) except Exception: pass # не все сайты доходят до networkidle # Извлечь текст из или
try: text = await page.evaluate(""" () => { const article = document.querySelector('article'); const main = document.querySelector('main'); const target = article || main || document.body; return target ? target.innerText : document.body.innerText; } """) except Exception: text = await page.evaluate("() => document.body.innerText") # Очистка text = re.sub(r"\s+", " ", text).strip() if text else "" if len(text) >= 300: return text[:MAX_ARTICLE_CHARS] except Exception as e: log(f" ✗ Browser fetch failed: {type(e).__name__}: {str(e)[:100]}") finally: if context: try: await context.close() except Exception: pass return None async def close(self): """Закрыть браузер (вызывается в конце сессии).""" if self._browser: try: await self._browser.close() except Exception: pass if self._playwright: try: await self._playwright.stop() except Exception: pass self._launched = False def detect_waf_protection(html): """Детектит QRator, Cloudflare, Akamai и другие WAF/captcha страницы. Возвращает строку с названием защиты или None если не обнаружено. """ if not html: return None html_lower = html.lower() # Cloudflare — только реальный WAF challenge (не email-decode) if 'cdn-cgi/challenge/' in html_lower or 'cf-browser-verification' in html_lower or 'cdn-cgi/images' in html_lower: return 'Cloudflare' # QRator if 'qrator' in html_lower or '__qrator' in html_lower: return 'QRator WAF' # Generic captcha/challenge if any(kw in html_lower for kw in ['captcha', 'captcha-img', 'challenge-page', 'verify you are human', 'enable javascript', 'turn on javascript', 'permission denied', 'access denied', 'sucuri', 'perimeterx', 'imperva', 'incapsula', 'botguard', 'akamai', 'f5 apm', 'f5 big-ip']): return 'WAF/Captcha' return None def log(msg): print(f"[deep-research] {msg}", file=sys.stderr) def llm_call(system, user, max_tokens=2048, temperature=None): """Вызвать LLM и вернуть текст ответа (использует completions для скорости).""" if temperature is None: temperature = LLM_TEMPERATURE # Completions endpoint — быстрее (без thinking на Qwen3.6) prompt = f"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{user}<|im_end|>\n<|im_start|>assistant\n" payload = { "prompt": prompt, "temperature": temperature, "max_tokens": max_tokens, "stop": ["<|im_end|>", "<|im_start|>"], "stream": False, } # Используем completions endpoint (быстрее без thinking) completions_url = LLM_URL.replace("/chat/completions", "/completions") try: resp = requests.post(completions_url, json=payload, timeout=300) resp.raise_for_status() data = resp.json() text = data["choices"][0]["text"].strip() # Убираем thinking-теги если есть import re text = re.sub(r'.*?', '', text, flags=re.DOTALL).strip() if not text: log("LLM error: empty response (all thinking, no content)") return None return text except requests.exceptions.Timeout: log("LLM error: timeout after 300s") return None except requests.exceptions.HTTPError as e: log(f"LLM error: HTTP {resp.status_code} — {resp.text[:300]}") return None except (KeyError, json.JSONDecodeError) as e: log(f"LLM error: parse failed — {e}") log(f"Response preview: {resp.text[:500]}") return None except Exception as e: log(f"LLM error: {type(e).__name__}: {e}") return None async def searxng_search(session, query, lang="ru", category="general", pageno=1): """Поиск через SearXNG (async). Возвращает список результатов.""" params = { "q": query, "format": "json", "categories": category, "language": lang, "pageno": pageno, } # Для русского языка — только Yandex (лучший RU-индекс, меньше WAF) if lang == "ru": params["engines"] = "yandex" else: params["engines"] = "duckduckgo,bing,google,startpage,qwant" headers = build_headers() try: async with session.get( SEARXNG_URL, params=params, headers=headers, timeout=aiohttp.ClientTimeout(total=30) ) as resp: resp.raise_for_status() data = await resp.json() results = data.get("results", []) # фильтр: только с нормальными URL valid = filter_valid_urls(results)[:MAX_RESULTS_PER_QUERY] except Exception as e: log(f"SearXNG error: {e}") return [] return valid async def fetch_article_text(session, url, use_cache=True, browser_fetcher=None, obscura_fetcher=None): """Скачать и извлечь чистый текст статьи (async). Приоритет: кэш → circuit breaker → rate limiter → aiohttp (с retry) → curl → obscura (JS) → browser (Playwright) → Google Cache. """ # 0. Проверить кэш if use_cache: cached = _cache_get(url) if cached: return cached domain = urllib.parse.urlparse(url).hostname # 1. Circuit breaker — пропустить проблемный домен if _circuit_breaker.is_open(domain): log(f" ⚠ SKIP {domain} (circuit breaker open)") return None # 1.5 Проверить robots.txt (если не отключено) if not IGNORE_ROBOTS: disallowed = await _check_robots(session, domain) if not _is_url_allowed(url, disallowed): log(f" ⚠ SKIP {domain} (robots.txt disallow: {urllib.parse.urlparse(url).path})") return None # 2. Rate limiting с jitter await _limiter.acquire(domain) headers = build_headers(referer="https://www.google.com/") # 2.5 Определить, нужен ли прокси для этого домена use_proxy = _нужен_прокси(url) # 3. aiohttp с экспоненциальным backoff (до 3 попыток) last_error = None for attempt in range(3): try: if use_proxy: # Используем hysteria2 SOCKS5 для заблокированных доменов from aiohttp_socks import ProxyConnector proxy_connector = ProxyConnector.from_url(HYSTERIA2_PROXY) async with session.get( url, headers=headers, timeout=aiohttp.ClientTimeout(total=30), connector=proxy_connector ) as resp: if resp.status == 429: wait = 2 ** attempt + random.uniform(0, 1) log(f" ⚠ 429 from {domain}, waiting {wait:.1f}s (attempt {attempt+1}/3)") await asyncio.sleep(wait) continue if resp.status == 401 or resp.status == 403: _circuit_breaker.record_failure(domain) _record_result(domain, "blocked") log(f" ✗ {resp.status} from {domain} (failures: {_circuit_breaker._failures[domain]})") return None resp.raise_for_status() html = await resp.text() waf = detect_waf_protection(html) if waf: log(f" ✗ {domain} protected by {waf}, skipping") _record_result(domain, "blocked") return None else: async with session.get( url, headers=headers, timeout=aiohttp.ClientTimeout(total=15) ) as resp: if resp.status == 429: wait = 2 ** attempt + random.uniform(0, 1) log(f" ⚠ 429 from {domain}, waiting {wait:.1f}s (attempt {attempt+1}/3)") await asyncio.sleep(wait) continue if resp.status == 401 or resp.status == 403: _circuit_breaker.record_failure(domain) _record_result(domain, "blocked") log(f" ✗ {resp.status} from {domain} (failures: {_circuit_breaker._failures[domain]})") return None resp.raise_for_status() html = await resp.text() waf = detect_waf_protection(html) if waf: log(f" ✗ {domain} protected by {waf}, skipping") _record_result(domain, "blocked") return None text = _extract_text(html) if text: _cache_put(url, text) _circuit_breaker.record_success(domain) _record_result(domain, "ok") return text # Текст извлёкся, но меньше 300 символов — не считаем успехом _record_result(domain, "ok") return None except aiohttp.ClientTimeout: last_error = "timeout" _record_result(domain, "timeout") _circuit_breaker.record_failure(domain) if attempt < 2: wait = 2 ** attempt + random.uniform(0, 1) log(f" ⚠ timeout from {domain}, waiting {wait:.1f}s (attempt {attempt+1}/3)") await asyncio.sleep(wait) except aiohttp.ClientError as e: last_error = str(e)[:100] _circuit_breaker.record_failure(domain) if attempt < 2: wait = 2 ** attempt + random.uniform(0, 1) await asyncio.sleep(wait) except Exception as e: last_error = str(e)[:100] break # неожиданная ошибка — не ретраить # 4. Curl fallback (один раз, если aiohttp не сработал) try: curl_cmd = ["curl", "-sL", "--max-time", "15", url, "-H", f"User-Agent: {headers['User-Agent']}", "-H", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "-H", "Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7"] if use_proxy: # Через hysteria2 SOCKS5 socks_addr = HYSTERIA2_PROXY.replace("socks5://", "") curl_cmd.extend(["--socks5-hostname", socks_addr, "--max-time", "30"]) result = await asyncio.to_thread( subprocess.run, curl_cmd, capture_output=True, text=True, timeout=30 ) if result.returncode == 0 and len(result.stdout) >= 300: # Проверим на WAF до парсинга waf = detect_waf_protection(result.stdout) if waf: log(f" ✗ {domain} protected by {waf} (curl), skipping browser") _record_result(domain, "blocked") return None text = _extract_text(result.stdout) if text: _cache_put(url, text) _circuit_breaker.record_success(domain) _record_result(domain, "ok") return text # Если curl вернул WAF-страницу или мало текста — не будем тратить browser на капчу if result.returncode == 0 and len(result.stdout) < 5000: waf = detect_waf_protection(result.stdout) if waf: log(f" ✗ {domain} protected by {waf} (short page), skipping browser") _record_result(domain, "blocked") return None except Exception: pass # 5. Obscura (JS-рендер, Markdown) — быстрый Rust-browser fallback if obscura_fetcher: try: log(f" 🔍 Trying Obscura for {domain}...") text = await obscura_fetcher.fetch(url) if text: _cache_put(url, text) _circuit_breaker.record_success(domain) _record_result(domain, "ok") return text except Exception as e: log(f" ✗ Obscura fetch failed: {type(e).__name__}: {str(e)[:80]}") # 6. Browser fallback (Playwright) — для JS-сайтов и заблокированных if browser_fetcher: try: log(f" 🔍 Trying Playwright browser for {domain}...") text = await browser_fetcher.fetch(url) if text: _cache_put(url, text) _circuit_breaker.record_success(domain) _record_result(domain, "ok") return text except Exception as e: log(f" ✗ Browser fallback failed: {type(e).__name__}: {str(e)[:80]}") # 7. Google Cache fallback — для WAF-защищённых страниц try: cache_url = f"https://webcache.googleusercontent.com/search?q=cache:{url}" headers = build_headers(referer="https://www.google.com/") async with session.get(cache_url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status == 200: html = await resp.text() text = _extract_text(html) if text and len(text) >= 500: log(f" ✓ Cache: {domain}") _cache_put(url, text) _circuit_breaker.record_success(domain) _record_result(domain, "ok") return text except Exception: pass _record_result(domain, "blocked") return None def _extract_text(html): """Извлечь чистый текст из HTML через BeautifulSoup.""" try: soup = BeautifulSoup(html, "html.parser") for tag in soup(["script", "style", "nav", "footer", "header"]): tag.decompose() text = soup.get_text(separator=" ") text = re.sub(r"\s+", " ", text).strip() if len(text) < 300: return None return text[:MAX_ARTICLE_CHARS] except Exception: return None def generate_search_queries(topic, lang="ru"): """Фаза 1: сгенерировать 3-4 поисковых запроса по теме.""" system = ( "Ты — аналитик, который подбирает поисковые запросы для глубокого исследования темы. " "Верни ТОЛЬКО список запросов, каждый на отдельной строке, без нумерации." ) lang_hint = "на русском языке" if lang == "ru" else "in English" prompt = ( f"Тема: {topic}\n\n" f"Составь 4 различных поисковых запроса {lang_hint}, которые раскрывают тему с разных сторон. " f"Запросы должны покрывать: основные факты, свежие новости/события, " f"ключевые персоны/организации, смежные темы.\n" f"Только запросы, каждый на новой строке." ) result = llm_call(system, prompt) if not result: return [topic] queries = [q.strip().strip('"').strip("'") for q in result.split("\n") if q.strip()] # Убрать нумерацию queries_clean = [] for q in queries: q = re.sub(r"^\d+[\.\)]\s*", "", q).strip() if q: queries_clean.append(q) return queries_clean[:4] async def collect_articles(session, queries, lang="ru"): """Собрать результаты по всем запросам ПАРАЛЛЕЛЬНО. Возвращает список словарей.""" all_results = [] seen_urls = set() tasks = [searxng_search(session, q, lang) for q in queries] results_per_query = await asyncio.gather(*tasks, return_exceptions=True) for query, result in zip(queries, results_per_query): if isinstance(result, Exception): log(f" ✗ {query[:60]}: {type(result).__name__}: {result}") continue log(f" ✓ {query[:60]}: {len(result)} результатов") for r in result: url = r.get("url", "") if url in seen_urls: continue seen_urls.add(url) all_results.append({ "title": r.get("title", "?"), "url": url, "snippet": r.get("content", ""), "engine": r.get("engine", "?"), }) return all_results async def read_articles(session, results, limit=MAX_ARTICLES_PER_PHASE, browser_fetcher=None, obscura_fetcher=None): """Прочитать полный текст топ-N статей ПАРАЛЛЕЛЬНО.""" articles = [] to_read = results[:limit] if not to_read: return articles tasks = [fetch_article_text(session, r["url"], browser_fetcher=browser_fetcher, obscura_fetcher=obscura_fetcher) for r in to_read] texts = await asyncio.gather(*tasks, return_exceptions=True) for i, (r, text) in enumerate(zip(to_read, texts)): if isinstance(text, Exception) or text is None: log(f" ✗ {r['title'][:60]}: не удалось прочитать") else: SOURCE_COUNTER[0] += 1 n = SOURCE_COUNTER[0] articles.append({ "ref": n, "title": r["title"], "url": r["url"], "text": text, }) log(f" ✓ [{n}] {r['title'][:60]}") # Сохраняем порядок по ref articles.sort(key=lambda a: a["ref"]) return articles def analyze_gaps(topic, articles_text, lang="ru"): """Фаза 4: LLM анализирует gaps и генерирует follow-up вопросы.""" system = ( "Ты — исследователь. Ты прочитал несколько статей по теме. " "Проанализируй gaps: какие аспекты не раскрыты, какие вопросы остались без ответа. " "Верни ТОЛЬКО 2-3 follow-up поисковых запроса, каждый на новой строке." ) lang_hint = "на русском языке" if lang == "ru" else "in English" prompt = ( f"Тема: {topic}\n\n" f"Прочитанные статьи (кратко):\n{articles_text[:3000]}\n\n" f"Какие аспекты темы не раскрыты? Сформулируй 2-3 follow-up поисковых запроса " f"{lang_hint}, чтобы заполнить пробелы. Только запросы, каждый на новой строке." ) result = llm_call(system, prompt) if not result: return [] queries = [q.strip().strip('"').strip("'") for q in result.split("\n") if q.strip()] queries_clean = [] for q in queries: q = re.sub(r"^\d+[\.\)]\s*", "", q).strip() if q: queries_clean.append(q) return queries_clean[:3] def synthesize_report(topic, all_articles, lang="ru"): """Фаза 6: финальный LLM-синтез → structured report.""" # Подготовка контекста из статей articles_ctx = "\n\n".join( f"[{a['ref']}] {a['title']}\nURL: {a['url']}\n{_truncate(a['text'], 3000)}" for a in all_articles ) system = ( "Ты — аналитик. Составь структурированный отчёт по результатам исследования.\n" "Формат отчёта СТРОГО:\n\n" "## [Заголовок темы]\n\n" "**Ключевой вывод:** [1-2 предложения]\n\n" "### Обзор\n[Синтез с цитатами [1][2][3]...]\n\n" "### [Подтема 1]\n[Детали с цитатами [N]]\n\n" "### [Подтема 2]\n[Детали с цитатами [N]]\n\n" "### Дополнительные вопросы\n- [Вопрос 1]?\n- [Вопрос 2]?\n\n" "### Источники\n[1] Заголовок — URL\n[2] Заголовок — URL\n..." ) lang_instruction = ( "Отчёт на русском языке. Все inline-цитаты в формате [N]. " "Каждый абзац содержит минимум одну цитату. " "Не используй фразы 'по данным', 'согласно' — только [N]." if lang == "ru" else "Report in English. All inline citations as [N]. " "Every paragraph has at least one citation. " "Don't use phrases like 'according to' — only [N]." ) prompt = ( f"Тема исследования: {topic}\n{lang_instruction}\n\n" f"Прочитанные источники:\n\n{articles_ctx}\n\n" f"Составь структурированный отчёт. Все факты подкрепляй цитатами [N]." ) report = llm_call(system, prompt, max_tokens=4096, temperature=0.7) if not report: report = "# Ошибка синтеза отчёта\n\nНе удалось сгенерировать отчёт." # Формируем полный блок источников из реальных данных — всегда, # не полагаемся на LLM (локальные модели часто теряют URL). source_lines = [] for a in all_articles: source_lines.append(f"[{a['ref']}] {a['title']} — {a['url']}") # Удаляем возможный кривой блок источников от LLM и вставляем правильный report = re.sub(r'\n###?\s*(?:Источники|Sources).*', '', report, flags=re.DOTALL) report += "\n\n### Источники\n" + "\n".join(source_lines) return report def _truncate(text, max_chars): if len(text) <= max_chars: return text return text[:max_chars] + "..." # ─── Main ──────────────────────────────────────────────────────────────────── async def main_async(): parser = argparse.ArgumentParser( description="Deep Research — многошаговое исследование как Perplexity" ) parser.add_argument("topic", help="Тема исследования") parser.add_argument("--lang", default="ru", choices=["ru", "en"]) parser.add_argument("--model", help="Имя LLM модели (env: LLM_MODEL)") parser.add_argument("--temperature", type=float, help="Temperature (env: LLM_TEMPERATURE)") parser.add_argument("--output", "-o", help="Сохранить отчёт в файл") parser.add_argument("--concurrency", type=int, default=10, help="Лимит одновременных HTTP-запросов (по умолчанию 10)") parser.add_argument("--ignore-robots", action="store_true", help="Игнорировать robots.txt при загрузке страниц") args = parser.parse_args() if args.model: global LLM_MODEL LLM_MODEL = args.model if args.temperature is not None: global LLM_TEMPERATURE LLM_TEMPERATURE = args.temperature if args.ignore_robots: global IGNORE_ROBOTS IGNORE_ROBOTS = True topic = args.topic lang = args.lang log(f"Deep Research: {topic} (lang={lang})") start_time = time.time() # Fetchers — ленивый запуск при первой необходимости browser_fetcher = BrowserFetcher() obscura_fetcher = ObscuraFetcher() # Создаём одну сессию на всё исследование — connection pooling connector = aiohttp.TCPConnector(limit=args.concurrency, ttl_dns_cache=300) async with aiohttp.ClientSession(connector=connector) as session: # ─── Фаза 1: генерация запросов ─────────────────────────────────────── log("Фаза 1: генерация поисковых запросов...") queries = generate_search_queries(topic, lang) if not queries: queries = [topic] log(f" Запросы: {queries}") # ─── Фаза 2: первичный сбор ──────────────────────────────────────────── log(f"Фаза 2: сбор результатов по {len(queries)} запросам...") results = await collect_articles(session, queries, lang) log(f" Найдено {len(results)} уникальных результатов") # ─── Фаза 3: чтение статей ───────────────────────────────────────────── log(f"Фаза 3: чтение статей (топ {MAX_ARTICLES_PER_PHASE})...") all_articles = await read_articles(session, results, MAX_ARTICLES_PER_PHASE, browser_fetcher=browser_fetcher, obscura_fetcher=obscura_fetcher) log(f" Прочитано {len(all_articles)} статей") # ─── Фаза 4: анализ gaps и follow-up ─────────────────────────────────── if all_articles: articles_summary = "\n\n".join( f"[{a['ref']}] {a['title']}: {_truncate(a['text'], 500)}" for a in all_articles ) log("Фаза 4: анализ gaps и генерация follow-up...") followup_queries = analyze_gaps(topic, articles_summary, lang) if followup_queries: log(f" Follow-up: {followup_queries}") # ─── Фаза 5: второй раунд поиска ──────────────────────────────── log("Фаза 5: второй раунд поиска...") more_results = await collect_articles(session, followup_queries, lang) log(f" Найдено ещё {len(more_results)} результатов") # Читаем ещё 3 статьи more_articles = await read_articles(session, more_results, 3, browser_fetcher=browser_fetcher, obscura_fetcher=obscura_fetcher) all_articles.extend(more_articles) log(f" Всего прочитано: {len(all_articles)} статей") # ─── Фаза 6: финальный синтез ────────────────────────────────────────── log("Фаза 6: синтез отчёта...") report = synthesize_report(topic, all_articles, lang) # ─── Валидация отчёта ──────────────────────────────────────────────── log("Проверка качества отчёта...") ok, errors = validate_report(report, all_articles) if not ok: log(f" ⚠ Найдено проблем: {len(errors)}") for e in errors: log(f" - {e}") # Пробуем переспросить LLM с конкретными ошибками if all_articles: fix_prompt = build_fix_prompt(report, errors, all_articles) fixed_report = llm_call( "Ты — редактор. Исправь отчёт по замечаниям. Верни ПОЛНЫЙ исправленный отчёт.", fix_prompt, max_tokens=4096, temperature=0.1, ) if fixed_report: # Перепроверяем ok2, errors2 = validate_report(fixed_report, all_articles) if ok2 or len(errors2) < len(errors): report = fixed_report log(" ✓ Отчёт исправлен") else: log(f" ⚠ Исправление не помогло: {len(errors2)} проблем осталось") else: log(" ⚠ LLM не ответил на запрос исправления") else: log(" ✓ Отчёт прошёл валидацию") elapsed = time.time() - start_time report += f"\n\n---\n*Исследование выполнено за {elapsed:.0f} сек, прочитано {len(all_articles)} источников*" # Статистика и обслуживание dump_stats() _cache_cleanup_if_needed() # Закрыть браузер если был запущен if browser_fetcher: await browser_fetcher.close() if args.output: with open(args.output, "w", encoding="utf-8") as f: f.write(report) log(f"Отчёт сохранён: {args.output}") else: print("\n" + report) if __name__ == "__main__": asyncio.run(main_async())