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)