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:
+6
-1
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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", "") == ""
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user