fix(sales-finder): ФНС теряла директоров — параллельный долбёж + кэширование неудач

Симптом (нашёл владелец): у 7 из 15 фирм ИНН фирмы есть, а директора нет.

Причина 1: ходили к ФНС в 8 потоков — она отбивается и отдаёт пустоту.
Проверено: те же ИНН по одному отдаются нормально (АСТРЕЯ → Коржова Т.С.,
ИНН 246403602419; Клиника практ. стом. → Джаватханов Д.И., ИНН 246508334975).

Причина 2 (хуже): пустой ответ КЭШИРОВАЛСЯ — фирма навсегда оставалась без
директора даже при пересборе.

Фикс:
 - семафор MAX_CONCURRENT=1 + пауза 0.7с: к ФНС ходим по одному, вежливо;
 - кэшируем ТОЛЬКО удачу; осевшие пустышки выбрасываем при чтении;
 - из кэша вычищено 16 отравленных записей.

129 тестов зелёных.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-12 12:09:04 +03:00
parent 1ee0e94da4
commit cab8f5f51e
2 changed files with 73 additions and 6 deletions
+28 -6
View File
@@ -8,9 +8,16 @@
"""
import re
import time
import threading
import requests
BASE = "https://egrul.nalog.ru"
# ФНС не терпит, когда её долбят пачкой: при параллельных запросах она отдаёт пустоту
# (инцидент 12.07.2026 — 7 из 15 фирм остались без директора). Ходим по одному, с паузой.
MAX_CONCURRENT = 1
PAUSE_SEC = 0.7
_SEM = threading.Semaphore(MAX_CONCURRENT)
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36",
"Accept": "application/json, text/javascript, */*; q=0.01",
@@ -63,9 +70,15 @@ def pdf_text(data: bytes) -> str:
def fetch_vypiska(company_inn: str, session=None, sleep=time.sleep, tries: int = 12) -> bytes:
"""Скачать PDF-выписку ЕГРЮЛ по ИНН фирмы. b"" если капча/не нашлось/не успели."""
"""Скачать PDF-выписку ЕГРЮЛ по ИНН фирмы. b"" если капча/не нашлось/не успели.
Ходим к ФНС по одному (семафор) — пачкой она отдаёт пустоту."""
if not company_inn:
return b""
with _SEM:
return _fetch_vypiska_locked(company_inn, session, sleep, tries)
def _fetch_vypiska_locked(company_inn, session, sleep, tries):
s = session or requests.Session()
s.headers.update(UA)
try:
@@ -92,7 +105,9 @@ def fetch_vypiska(company_inn: str, session=None, sleep=time.sleep, tries: int =
for _ in range(tries):
st = s.get(f"{BASE}/vyp-status/{rt}", timeout=20).json()
if st.get("status") == "ready":
return s.get(f"{BASE}/vyp-download/{rt}", timeout=40).content
pdf = s.get(f"{BASE}/vyp-download/{rt}", timeout=40).content
sleep(PAUSE_SEC) # вежливая пауза перед следующей фирмой
return pdf
sleep(2)
return b""
except (requests.RequestException, ValueError):
@@ -101,7 +116,10 @@ def fetch_vypiska(company_inn: str, session=None, sleep=time.sleep, tries: int =
def fetch_director(company_inn: str, session=None, sleep=time.sleep,
cache_dir="cache/egrul") -> dict:
"""ИНН/ФИО/должность руководителя по ИНН фирмы (с кэшем — ФНС дёргаем один раз)."""
"""ИНН/ФИО/должность руководителя по ИНН фирмы (с кэшем — ФНС дёргаем один раз).
Кэшируем ТОЛЬКО удачу. Пустой ответ (ФНС отбилась/капча/таймаут) не кэшируем —
иначе фирма навсегда останется без директора даже при пересборе (инцидент 12.07.2026)."""
if not company_inn:
return dict(EMPTY)
import json, os
@@ -109,11 +127,15 @@ def fetch_director(company_inn: str, session=None, sleep=time.sleep,
cpath = os.path.join(cache_dir, f"{company_inn}.json")
if os.path.exists(cpath):
try:
return json.load(open(cpath, encoding="utf-8"))
except ValueError:
cached = json.load(open(cpath, encoding="utf-8"))
if any(cached.get(k) for k in ("fio", "inn", "post")):
return cached
os.remove(cpath) # отравленный пустой кэш — выбрасываем
except (ValueError, OSError):
pass
d = parse_director(pdf_text(fetch_vypiska(company_inn, session=session, sleep=sleep)))
json.dump(d, open(cpath, "w", encoding="utf-8"), ensure_ascii=False)
if any(d.get(k) for k in ("fio", "inn", "post")):
json.dump(d, open(cpath, "w", encoding="utf-8"), ensure_ascii=False)
return d
+45
View File
@@ -97,3 +97,48 @@ def test_enrich_without_company_inn_does_nothing(tmp_path):
f = Firm(name="Без ИНН")
enrich_director(f, cache_dir=str(tmp_path))
assert f.director_inn is None
def test_failure_is_never_cached(tmp_path, monkeypatch):
"""Инцидент 12.07.2026: пустой ответ ФНС кэшировался навсегда, и фирма больше
НИКОГДА не получала директора. Неудачу кэшировать нельзя."""
import salesfinder.egrul as eg
calls = {"n": 0}
def flaky(inn, session=None, sleep=None, tries=12):
calls["n"] += 1
if calls["n"] == 1:
return b"" # ФНС отбилась
return b"%PDF-fake" # со второго раза дала
monkeypatch.setattr(eg, "fetch_vypiska", flaky)
monkeypatch.setattr(eg, "pdf_text", lambda b: (
"без доверенности 15 Фамилия Имя Отчество ИВАНОВ ИВАН ИВАНОВИЧ "
"16 ИНН 123456789012 18 Должность ДИРЕКТОР" if b else ""))
d1 = eg.fetch_director("7700000001", cache_dir=str(tmp_path))
assert d1["inn"] is None # первый раз — пусто
assert not list(tmp_path.glob("*.json")) # и НЕ закэшировано
d2 = eg.fetch_director("7700000001", cache_dir=str(tmp_path))
assert d2["inn"] == "123456789012" # повтор — получилось
assert list(tmp_path.glob("*.json")) # удачу кэшируем
def test_poisoned_empty_cache_is_thrown_away(tmp_path, monkeypatch):
"""Пустышки, уже осевшие в кэше, должны выбрасываться, а не возвращаться."""
import salesfinder.egrul as eg
(tmp_path / "7700000002.json").write_text('{"fio": null, "inn": null, "post": null}',
encoding="utf-8")
monkeypatch.setattr(eg, "fetch_vypiska", lambda *a, **k: b"%PDF")
monkeypatch.setattr(eg, "pdf_text", lambda b:
"без доверенности 15 Фамилия Имя Отчество ПЕТРОВ ПЁТР ПЕТРОВИЧ "
"16 ИНН 999988887777 18 Должность ДИРЕКТОР")
d = eg.fetch_director("7700000002", cache_dir=str(tmp_path))
assert d["inn"] == "999988887777" # сходили заново, а не вернули пустышку
def test_fns_is_not_hammered_in_parallel():
"""ФНС при параллельных запросах отдаёт пустоту — ходим по одному."""
from salesfinder.egrul import MAX_CONCURRENT
assert MAX_CONCURRENT == 1