fix: устранены все найденные аудитом баги и тихие падения
- SQL injection паттерн → параметризованные запросы во всех местах - except: pass/continue → logger.warning() везде, ничего не тонет молча - WAL mode + индекс domain_dedup_key в database.py - try/finally для conn в main.py, утечка соединения устранена - backoff 30с при 403/429 от Rusprofile/ЕГРЮЛ - ликвидированные компании → egrul_status="liquidated" - max_candidates в contacts_finder считает только реальных кандидатов - DB_PATH абсолютный (Path(__file__).parent), HH_PAUSE_BETWEEN_QUERIES в config - HH_SIGNAL_QUERIES дубль убран из launcher.py → импорт из config - path traversal защита в egrul_enricher debug_dump_html Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ Streamlit перезапускает скрипт на каждое действ
|
||||
держать не имеет смысла.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -15,6 +16,8 @@ from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _conn(db_path: Path | str) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
@@ -311,10 +314,17 @@ def add_lead_manual(db_path, data: dict) -> int:
|
||||
conn.rollback()
|
||||
msg = str(e).lower()
|
||||
if "inn" in msg:
|
||||
logger.warning(f"[db_layer] add_lead_manual: дубль по ИНН ({data.get('inn')}): {e}")
|
||||
raise ValueError(f"Компания с таким ИНН уже есть в базе ({data.get('inn')}).") from e
|
||||
if "phone" in msg:
|
||||
logger.warning(f"[db_layer] add_lead_manual: дубль по телефону: {e}")
|
||||
raise ValueError(f"Компания с таким телефоном уже есть в базе.") from e
|
||||
logger.warning(f"[db_layer] add_lead_manual: IntegrityError: {e}")
|
||||
raise ValueError(f"Не удалось добавить (дубль): {e}") from e
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.warning(f"[db_layer] add_lead_manual: неожиданная ошибка: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Все настройки в одном месте. Меняем здесь — отражается во всех парсерах.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Города и их Yandex.Maps ID
|
||||
@@ -113,9 +114,12 @@ MAX_CARDS_PER_CATEGORY = 100 # сколько карточек открыват
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Пути
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
DB_PATH = "leads.db"
|
||||
DB_PATH = Path(__file__).parent / "leads.db"
|
||||
EXPORT_DIR = "exports"
|
||||
|
||||
# Пауза между HH-запросами (секунды) — задаётся здесь и используется в run_hh
|
||||
HH_PAUSE_BETWEEN_QUERIES = 3
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Скоринг лидов v5 — «решаемая нами боль» (шкала 0-10)
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
+9
-3
@@ -4,6 +4,7 @@
|
||||
Сама занимается дедупликацией: ИНН > телефон > домен.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -12,6 +13,8 @@ from typing import Optional
|
||||
import config
|
||||
from normalization import phone_dedup_key, normalize_domain
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Схема таблиц
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
@@ -119,6 +122,7 @@ CREATE INDEX IF NOT EXISTS idx_leads_score ON leads(score DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_leads_source ON leads(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_leads_outreach ON leads(outreach_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_leads_has_website ON leads(has_website);
|
||||
CREATE INDEX IF NOT EXISTS idx_leads_domain ON leads(domain_dedup_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sources_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -208,6 +212,7 @@ def get_connection(db_path: str = "leads.db") -> sqlite3.Connection:
|
||||
"""Открыть соединение с включённым row_factory (sqlite3.Row для dict-доступа)."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
@@ -748,8 +753,9 @@ def cleanup_bad_director_names(conn: sqlite3.Connection) -> int:
|
||||
"Производство", "Услуги", "Работ",
|
||||
]
|
||||
# Совпадение если первое слово в director_name — должность
|
||||
where_parts = [f"director_name LIKE '{m}%'" for m in bad_markers]
|
||||
where_parts = ["director_name LIKE ?"] * len(bad_markers)
|
||||
where_clause = " OR ".join(where_parts)
|
||||
params = [f"{m}%" for m in bad_markers]
|
||||
sql = f"""
|
||||
UPDATE leads
|
||||
SET director_name = NULL,
|
||||
@@ -757,7 +763,7 @@ def cleanup_bad_director_names(conn: sqlite3.Connection) -> int:
|
||||
egrul_status = NULL
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
cursor = conn.execute(sql)
|
||||
cursor = conn.execute(sql, params)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@@ -878,7 +884,7 @@ def get_stats(conn: sqlite3.Connection) -> dict:
|
||||
).fetchall())
|
||||
threshold = config.HOT_LEAD_THRESHOLD
|
||||
hot = conn.execute(
|
||||
f"SELECT COUNT(*) FROM leads WHERE score >= {int(threshold)}"
|
||||
"SELECT COUNT(*) FROM leads WHERE score >= ?", (int(threshold),)
|
||||
).fetchone()[0]
|
||||
with_phone = conn.execute(
|
||||
"SELECT COUNT(*) FROM leads WHERE phone_primary IS NOT NULL"
|
||||
|
||||
@@ -303,7 +303,8 @@ def find_company_website(
|
||||
}
|
||||
|
||||
seen: set[str] = set()
|
||||
max_candidates = 6 # проверим не больше 6 разных сайтов
|
||||
max_candidates = 6 # проверим не больше 6 реальных (не агрегаторных) кандидатов
|
||||
real_candidates = 0
|
||||
|
||||
for query in queries:
|
||||
try:
|
||||
@@ -324,7 +325,8 @@ def find_company_website(
|
||||
seen.add(host)
|
||||
if not _is_company_site(found_url):
|
||||
continue
|
||||
if len(seen) > max_candidates:
|
||||
real_candidates += 1
|
||||
if real_candidates > max_candidates:
|
||||
break
|
||||
|
||||
root = f"{urlparse(found_url).scheme}://{host}"
|
||||
|
||||
@@ -52,7 +52,7 @@ def _api_key() -> Optional[str]:
|
||||
load_dotenv()
|
||||
key = os.environ.get("DADATA_API_KEY")
|
||||
except ImportError:
|
||||
pass
|
||||
logger.warning("[dadata] python-dotenv не установлен, .env не загружен")
|
||||
return key
|
||||
|
||||
|
||||
@@ -282,8 +282,12 @@ def enrich_via_dadata(
|
||||
best = d
|
||||
break
|
||||
if not best:
|
||||
# Все ликвидированы — берём первого, не записываем в БД
|
||||
# Все ликвидированы — берём первого, логируем, выставляем статус
|
||||
logger.warning(f"[dadata] Все совпадения ликвидированы для '{name}'")
|
||||
best = suggestions[0].get("data") or {}
|
||||
parsed = _parse_party(best)
|
||||
parsed["egrul_status"] = "liquidated"
|
||||
return parsed
|
||||
|
||||
parsed = _parse_party(best)
|
||||
return parsed
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
Стратегия запросов:
|
||||
- Случайный User-Agent
|
||||
- timeout 10 сек
|
||||
- При 403/429 — пропускаем (логируем как 'error')
|
||||
- При 403/429 — пауза 30 сек + пропускаем (логируем как 'error')
|
||||
- Пауза между лидами в run_egrul_enrichment, не здесь
|
||||
|
||||
Ограничения:
|
||||
@@ -18,6 +18,7 @@
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
@@ -379,7 +380,8 @@ def enrich_egrul_by_inn(
|
||||
allow_redirects=True,
|
||||
)
|
||||
if resp.status_code in (403, 429):
|
||||
logger.warning(f" Rusprofile blocked us ({resp.status_code}) для ИНН {inn}")
|
||||
logger.warning(f"[egrul] Rusprofile вернул {resp.status_code}, пауза 30 сек (ИНН {inn})")
|
||||
time.sleep(30)
|
||||
return result
|
||||
if resp.status_code != 200:
|
||||
return result
|
||||
@@ -610,10 +612,11 @@ def enrich_egrul(
|
||||
)
|
||||
|
||||
if resp.status_code in (403, 429):
|
||||
logger.warning(f" Rusprofile blocked us ({resp.status_code}) для '{name}'")
|
||||
logger.warning(f"[egrul] Rusprofile вернул {resp.status_code}, пауза 30 сек ('{name}')")
|
||||
time.sleep(30)
|
||||
return result
|
||||
if resp.status_code != 200:
|
||||
logger.debug(f" Rusprofile вернул {resp.status_code} для '{name}'")
|
||||
logger.warning(f"[egrul] Rusprofile вернул {resp.status_code} для '{name}'")
|
||||
return result
|
||||
|
||||
# Если редирект на /id/N или /ip/N — Rusprofile уверен в матче.
|
||||
@@ -657,10 +660,18 @@ def enrich_egrul(
|
||||
|
||||
# Debug: сохранить HTML для отладки regex'ов
|
||||
if debug_dump_html:
|
||||
from pathlib import Path
|
||||
dump_path = Path(debug_dump_html).resolve()
|
||||
try:
|
||||
with open(debug_dump_html, "w", encoding="utf-8") as f:
|
||||
dump_path.relative_to(Path.cwd())
|
||||
except ValueError:
|
||||
logger.warning(f"[egrul] debug_dump_html путь вне CWD, пропускаю: {debug_dump_html}")
|
||||
debug_dump_html = None
|
||||
if debug_dump_html:
|
||||
try:
|
||||
with open(dump_path, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
logger.info(f" [debug] HTML сохранён в {debug_dump_html}")
|
||||
logger.info(f" [debug] HTML сохранён в {dump_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f" [debug] Не удалось сохранить HTML: {e}")
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ False positives возможны (например, упоминание "yclien
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -273,6 +274,7 @@ def analyze_website(url: str, timeout: float = 6.0) -> dict:
|
||||
timeout=(timeout, timeout), verify=False, stream=False,
|
||||
)
|
||||
if sub_resp.status_code != 200:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
sub_html = sub_resp.text[:MAX_HTML_BYTES]
|
||||
sub_inn, sub_ogrn, sub_kpp = _extract_inn_ogrn_kpp(sub_html)
|
||||
@@ -281,8 +283,11 @@ def analyze_website(url: str, timeout: float = 6.0) -> dict:
|
||||
ogrn_val = ogrn_val or sub_ogrn
|
||||
kpp_val = kpp_val or sub_kpp
|
||||
logger.debug(f" ИНН найден на {path}: {inn_val}")
|
||||
time.sleep(0.5)
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
logger.warning(f"[website_analyzer] подстраница {path} сайта {url}: {e}")
|
||||
continue
|
||||
if inn_val:
|
||||
result["inn"] = inn_val
|
||||
|
||||
+1
-18
@@ -7,6 +7,7 @@ import subprocess
|
||||
import sys
|
||||
|
||||
import config # для синхронизации дефолтных категорий (фокус-ЦА) с CLI
|
||||
from config import HH_SIGNAL_QUERIES
|
||||
|
||||
try:
|
||||
import questionary
|
||||
@@ -224,24 +225,6 @@ MOSCOW_DISTRICTS = [
|
||||
]
|
||||
|
||||
|
||||
# ── HH signal-запросы (из config) ────────────────────────────────────
|
||||
HH_SIGNAL_QUERIES = [
|
||||
"оператор ПК",
|
||||
"оператор колл-центра",
|
||||
"оператор технической поддержки",
|
||||
"менеджер чата",
|
||||
"менеджер по продажам без CRM",
|
||||
"помощник руководителя",
|
||||
"ассистент руководителя",
|
||||
"офис-менеджер",
|
||||
"администратор записи",
|
||||
"администратор салона красоты",
|
||||
"администратор клиники",
|
||||
"ресепшн",
|
||||
"бухгалтер 1С",
|
||||
"помощник бухгалтера",
|
||||
]
|
||||
|
||||
|
||||
def ask(fn, *args, **kwargs):
|
||||
result = fn(*args, **kwargs, style=STYLE).ask()
|
||||
|
||||
@@ -205,8 +205,7 @@ def run_hh(conn, queries: list[str], city: str, max_pages: int = None) -> list[i
|
||||
|
||||
# Пауза между запросами (anti-rate-limit)
|
||||
if idx < len(queries):
|
||||
import time
|
||||
time.sleep(3)
|
||||
time.sleep(config.HH_PAUSE_BETWEEN_QUERIES)
|
||||
|
||||
return run_ids
|
||||
|
||||
@@ -272,7 +271,7 @@ def run_enrichment(conn, limit: int | None = None) -> int:
|
||||
conn.execute(f"UPDATE leads SET {sets} WHERE id = ?", vals)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.debug(f" ⚠ update inn/ogrn: {e}")
|
||||
logger.warning(f" ⚠ update inn/ogrn: {e}")
|
||||
|
||||
# Если нашли ИНН на сайте, сразу идём в Rusprofile by-inn за директором
|
||||
rusprofile_note = ""
|
||||
@@ -302,7 +301,7 @@ def run_enrichment(conn, limit: int | None = None) -> int:
|
||||
director = egrul_info.get("director_name") or "—"
|
||||
rusprofile_note = f" +ЕГРЮЛ:директор={director[:30]}"
|
||||
except Exception as e:
|
||||
logger.debug(f" ⚠ Rusprofile by-inn: {e}")
|
||||
logger.warning(f" ⚠ Rusprofile by-inn: {e}")
|
||||
|
||||
# Пересчитать score (теперь с Tier 2 данными + ИНН с сайта)
|
||||
full_lead = dict(conn.execute("SELECT * FROM leads WHERE id = ?", (lead_id,)).fetchone())
|
||||
@@ -888,7 +887,7 @@ def main():
|
||||
# Раньше флаг ставился всегда → `--full --source yandex` гонял HH-обогащение по
|
||||
# ВСЕЙ базе HH-лидов без сайта (~час впустую). Теперь — только при source hh/all
|
||||
# (как и обещает README: «+ --hh-enrich-websites если source=hh»).
|
||||
args.hh_enrich_websites = args.source in ("hh", "all")
|
||||
args.hh_enrich_websites = args.hh_enrich_websites or args.source in ("hh", "all")
|
||||
args.find_sites = True # ⬅ ищем сайты для лидов где website пуст (DDG fallback)
|
||||
args.enrich = True # → website-analyzer достаёт email/phone со страниц
|
||||
args.enrich_egrul = True
|
||||
@@ -914,14 +913,13 @@ def main():
|
||||
# Инициализация БД
|
||||
init_db(config.DB_PATH)
|
||||
conn = get_connection(config.DB_PATH)
|
||||
|
||||
try:
|
||||
# Только статистика — выходим сразу
|
||||
if args.stats:
|
||||
stats = get_stats(conn)
|
||||
print("\n📊 СТАТИСТИКА:")
|
||||
for k, v in stats.items():
|
||||
print(f" {k}: {v}")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Cleanup кривых ФИО директоров (одноразовая операция)
|
||||
@@ -960,7 +958,6 @@ def main():
|
||||
or args.export_run is not None
|
||||
):
|
||||
parser.print_help()
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
# Собираем run_id всех прогонов этой сессии — для auto-export CSV по прогонам
|
||||
@@ -1062,7 +1059,7 @@ def main():
|
||||
# Шаг 7: пересоздать CSV конкретного прогона (--export-run N)
|
||||
if args.export_run is not None:
|
||||
export_run(config.DB_PATH, args.export_run)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user