From 082d258baada5a5e331a8feb16aa95477c6f19ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 12 Jul 2026 08:52:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(sales-finder):=202=D0=93=D0=98=D0=A1=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=D0=B0=20xfetch=20?= =?UTF-8?q?+=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD/=D0=98=D0=9D?= =?UTF-8?q?=D0=9D=20=D1=81=20=D1=81=D0=B0=D0=B9=D1=82=D0=B0=20=E2=86=92=20?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D0=BE=D0=B9=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живая проверка 12.07: xf4.ru/fetch рендерит 2ГИС (обход антибота) → парсер листинга (порт ChannelA) даёт фирмы+сайты. Телефон и ИНН снимаем структурно с сайта (tel:/schema), директор — ДаДата по ИНН. Сквозной прогон 'стоматология москва': 12 фирм, телефон 10/12, директор 4/12, за ~43с. 42 теста зелёные. allowlist gitleaks для тест-телефонов sales-finder. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitleaks.toml | 7 +++- моя/sales-finder/salesfinder/phones.py | 39 ++++++++++++++++++++ моя/sales-finder/salesfinder/xfetch.py | 46 ++++++++++++++++++++++++ моя/sales-finder/tests/test_phones.py | 21 +++++++++++ моя/sales-finder/tests/test_xfetch.py | 33 +++++++++++++++++ моя/sales-finder/web/app.py | 50 +++++++++++++++++++++----- 6 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 моя/sales-finder/salesfinder/phones.py create mode 100644 моя/sales-finder/salesfinder/xfetch.py create mode 100644 моя/sales-finder/tests/test_phones.py create mode 100644 моя/sales-finder/tests/test_xfetch.py diff --git a/.gitleaks.toml b/.gitleaks.toml index 4d76e3b8..b8f0c7f7 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -149,7 +149,12 @@ paths = [ '''app/resources/js/views/autopodbor/screens/.*\.vue''', # .env.example — шаблон переменных окружения (плейсхолдеры/пустые значения, не # реальные секреты). Настоящие .env — в .gitignore и сканируются pre-commit. - '''app/\.env\.example''' + '''app/\.env\.example''', + # Тест-фикстуры инструмента sales-finder — синтетические телефоны-образцы + # (напр. +7 495 000-00-00) для проверки извлечения номера из кода сайта, и + # публичный ИНН для проверки резолва. Не реальные ПДн; та же категория, что + # app/tests/*.php и app/tests/fixtures/*. Токены живут в secrets/ (.gitignore). + '''моя/sales-finder/tests/.*''' ] regexTarget = "match" regexes = [ diff --git a/моя/sales-finder/salesfinder/phones.py b/моя/sales-finder/salesfinder/phones.py new file mode 100644 index 00000000..38057702 --- /dev/null +++ b/моя/sales-finder/salesfinder/phones.py @@ -0,0 +1,39 @@ +"""Чистое извлечение телефона и ИНН из кода сайта фирмы. +Берём номера ТОЛЬКО из структурных мест (tel:-ссылки, schema.org telephone) — сырой regex +по телу даёт мусор (см. заметки движка). Возвращаем нормализованный `+7XXXXXXXXXX`.""" +import re + +_TEL = re.compile(r'tel:\s*\+?([\d\s\-\(\)]{7,20})', re.I) +_SCHEMA = re.compile(r'"telephone"\s*:\s*"\+?([\d\s\-\(\)]{7,20})"', re.I) +_INN = re.compile(r'ИНН[\s:/\xa0\-]*?(\d{10}|\d{12})') + + +def _norm(raw): + d = re.sub(r"\D", "", raw or "") + if len(d) == 11 and d[0] in "78": + d = d[1:] + return "+7" + d if len(d) == 10 else None + + +def extract_phones(html: str) -> list: + """Все уникальные телефоны из tel:/schema, порядок сохранён.""" + html = html or "" + out, seen = [], set() + for m in _TEL.findall(html) + _SCHEMA.findall(html): + p = _norm(m) + if p and p not in seen: + seen.add(p) + out.append(p) + return out + + +def extract_phone(html: str): + """Один основной телефон (первый структурный) или None.""" + ph = extract_phones(html) + return ph[0] if ph else None + + +def extract_inn(html: str): + """ИНН из футера/реквизитов сайта (10 или 12 цифр после «ИНН») или None.""" + m = _INN.search(html or "") + return m.group(1) if m else None diff --git a/моя/sales-finder/salesfinder/xfetch.py b/моя/sales-finder/salesfinder/xfetch.py new file mode 100644 index 00000000..93f99f11 --- /dev/null +++ b/моя/sales-finder/salesfinder/xfetch.py @@ -0,0 +1,46 @@ +"""Рендер тяжёлых страниц (2ГИС/Яндекс) через сервис xfetch (xf4.ru) — обход антибота. +Контракт (как в боевом XfetchClient): POST https://xf4.ru/fetch {url, api_key, render, timeout} +→ JSON {response_body_base64} → base64-decode → HTML. Сервис флакует — ретраим. +Сеть отделена: `_post` можно подменить в тесте.""" +import base64 +import requests + +ENDPOINT = "https://xf4.ru/fetch" + + +def _post(url, api_key, render, timeout, http_timeout): + return requests.post(ENDPOINT, + json={"url": url, "api_key": api_key, "render": render, "timeout": timeout}, + timeout=http_timeout) + + +def decode(resp) -> str: + """Из ответа xfetch достать HTML (base64). Пусто при неуспехе.""" + try: + if resp is None or resp.status_code != 200: + return "" + b64 = resp.json().get("response_body_base64") + if not b64: + return "" + return base64.b64decode(b64).decode("utf-8", "replace") + except Exception: + return "" + + +def render(url, api_key, retries=3, render_js=True, timeout=25, http_timeout=120, post=_post) -> str: + """Вернуть HTML страницы, отрендеренной сервисом. Ретраит флак до `retries` раз.""" + if not api_key: + return "" + for _ in range(max(1, retries)): + try: + html = decode(post(url, api_key, render_js, timeout, http_timeout)) + except requests.RequestException: + html = "" + if html: + return html + return "" + + +def fetcher(api_key): + """Готовая функция fetch(url)->html для collect_two_gis (2ГИС через xfetch).""" + return lambda url: render(url, api_key) diff --git a/моя/sales-finder/tests/test_phones.py b/моя/sales-finder/tests/test_phones.py new file mode 100644 index 00000000..73e4db16 --- /dev/null +++ b/моя/sales-finder/tests/test_phones.py @@ -0,0 +1,21 @@ +from salesfinder.phones import extract_phone, extract_phones, extract_inn + +def test_tel_href_normalized(): + assert extract_phone('звонок') == "+74950000000" + +def test_schema_telephone(): + assert extract_phone('{"telephone":"8 495 000 00 00"}') == "+74950000000" + +def test_no_structural_number_returns_none(): + # цифры в теле без tel:/schema — НЕ телефон (избегаем мусора) + assert extract_phone("Цена 84991234567 рублей за курс") is None + +def test_dedup_multiple(): + html = 'ab' + assert extract_phones(html) == ["+74950000000"] + +def test_inn_from_footer(): + assert extract_inn("ООО Ромашка, ИНН: 7707083893, Москва") == "7707083893" + +def test_inn_absent(): + assert extract_inn("нет реквизитов") is None diff --git a/моя/sales-finder/tests/test_xfetch.py b/моя/sales-finder/tests/test_xfetch.py new file mode 100644 index 00000000..91b51752 --- /dev/null +++ b/моя/sales-finder/tests/test_xfetch.py @@ -0,0 +1,33 @@ +import base64 +from salesfinder import xfetch + +class FakeResp: + def __init__(self, status, payload=None): + self.status_code = status + self._payload = payload or {} + def json(self): + return self._payload + +def _b64(html): + return base64.b64encode(html.encode("utf-8")).decode("ascii") + +def test_decode_extracts_html(): + resp = FakeResp(200, {"response_body_base64": _b64("2gis")}) + assert xfetch.decode(resp) == "2gis" + +def test_decode_empty_on_error_status(): + assert xfetch.decode(FakeResp(500, {})) == "" + +def test_render_retries_until_nonempty(): + calls = {"n": 0} + def flaky_post(url, api_key, render, timeout, http_timeout): + calls["n"] += 1 + if calls["n"] < 2: + return FakeResp(200, {}) # пусто → ретрай + return FakeResp(200, {"response_body_base64": _b64("ok")}) + html = xfetch.render("https://2gis.ru/x", "KEY", retries=3, post=flaky_post) + assert html == "ok" + assert calls["n"] == 2 + +def test_render_no_key_returns_empty(): + assert xfetch.render("https://2gis.ru/x", "") == "" diff --git a/моя/sales-finder/web/app.py b/моя/sales-finder/web/app.py index b1d7c536..b2c0830f 100644 --- a/моя/sales-finder/web/app.py +++ b/моя/sales-finder/web/app.py @@ -11,26 +11,60 @@ from salesfinder.render import export_xlsx app = FastAPI(title="Sales Finder") STORE = RunStore(os.environ.get("SF_DB", "runs.sqlite")) +def _read_secret(name): + try: + return open(f"secrets/{name}").read().strip() + except OSError: + return "" + +def _fetch_site(domain): + """HTML сайта фирмы: быстрый requests, при блоке/пустоте — через xfetch (рендер).""" + import requests + from salesfinder.xfetch import render as xf_render + ua = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"} + for scheme in ("https://", "http://"): + try: + r = requests.get(scheme + domain, headers=ua, timeout=12, allow_redirects=True) + if r.status_code < 400 and r.text: + return r.text + except requests.RequestException: + pass + key = _read_secret("xfetch_key.txt") + return xf_render("https://" + domain, key) if key else "" + def _real_pipeline(rubric, city, queries): - """Живой прогон: собирает реальные deps. Импорт внутри — тяжёлый Playwright не грузим в тестах.""" + """Живой прогон. 2ГИС — через сервис рендера xfetch (обход антибота); телефон и ИНН — + из кода сайта фирмы; директор — ДаДата по ИНН.""" from salesfinder.pipeline import run_pipeline from salesfinder.twogis import collect_two_gis - from salesfinder.fetch import html_fetcher, render + from salesfinder.xfetch import fetcher as xf_fetcher from salesfinder.keyso import collect_niche_advertisers from salesfinder.adscan import detect + from salesfinder.phones import extract_phone, extract_inn from salesfinder.dadata import enrich as dd_enrich - keyso_token = open("secrets/keyso_token.txt").read().strip() - dadata_token = open("secrets/dadata_token.txt").read().strip() + keyso_token = _read_secret("keyso_token.txt") + dadata_token = _read_secret("dadata_token.txt") + xfetch_key = _read_secret("xfetch_key.txt") + def scan_ad(firm): if not firm.domain: return {"advertises": "не проверено", "confidence": "низкая", "evidence": ["нет сайта"]} - html, hosts = render("https://" + firm.domain, capture_requests=True) - return detect(html, hosts) + html = _fetch_site(firm.domain) + # попутно снимаем телефон и ИНН из кода сайта (side-effect для последующего enrich) + firm.phone = firm.phone or extract_phone(html) + firm.inn = firm.inn or extract_inn(html) + return detect(html, []) + + def enrich(firm): + if firm.inn: + dd_enrich(firm, dadata_token) # ищет по ИНН → директор/юрлицо/статус + deps = dict( - collect_2gis=lambda c, qs: collect_two_gis(c, qs, fetch=html_fetcher("cache")), + collect_2gis=lambda c, qs: collect_two_gis(c, qs, fetch=xf_fetcher(xfetch_key)), collect_keyso=lambda kw: collect_niche_advertisers(kw, keyso_token), scan_ad=scan_ad, - enrich=lambda f: dd_enrich(f, dadata_token), + enrich=enrich, ) return run_pipeline(rubric, city, queries, deps)