Files
research-agent-plugin/scripts/deep_research.py
T

1180 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.3"))
# Игнорировать 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 <url> --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
# Извлечь текст из <body> или <article>
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'<think>.*?</think>', '', 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.2)
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())