diff --git a/моя/sales-finder/salesfinder/egrul.py b/моя/sales-finder/salesfinder/egrul.py index 3ebc7c89..716ff78e 100644 --- a/моя/sales-finder/salesfinder/egrul.py +++ b/моя/sales-finder/salesfinder/egrul.py @@ -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 diff --git a/моя/sales-finder/tests/test_egrul.py b/моя/sales-finder/tests/test_egrul.py index 9f454d1c..0aa215f2 100644 --- a/моя/sales-finder/tests/test_egrul.py +++ b/моя/sales-finder/tests/test_egrul.py @@ -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