feat(sales-finder): 2ГИС через сервис рендера xfetch + телефон/ИНН с сайта → живой список

Живая проверка 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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-12 08:52:44 +03:00
parent 5c6b7280d7
commit 082d258baa
6 changed files with 187 additions and 9 deletions
+6 -1
View File
@@ -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 = [
+39
View File
@@ -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
+46
View File
@@ -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)
+21
View File
@@ -0,0 +1,21 @@
from salesfinder.phones import extract_phone, extract_phones, extract_inn
def test_tel_href_normalized():
assert extract_phone('<a href="tel:+7 (495) 000-00-00">звонок</a>') == "+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 = '<a href="tel:+74950000000">a</a><a href="tel:8(495)0000000">b</a>'
assert extract_phones(html) == ["+74950000000"]
def test_inn_from_footer():
assert extract_inn("ООО Ромашка, ИНН: 7707083893, Москва") == "7707083893"
def test_inn_absent():
assert extract_inn("нет реквизитов") is None
+33
View File
@@ -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("<html>2gis</html>")})
assert xfetch.decode(resp) == "<html>2gis</html>"
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("<html>ok</html>")})
html = xfetch.render("https://2gis.ru/x", "KEY", retries=3, post=flaky_post)
assert html == "<html>ok</html>"
assert calls["n"] == 2
def test_render_no_key_returns_empty():
assert xfetch.render("https://2gis.ru/x", "") == ""
+42 -8
View File
@@ -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)