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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user