init: Parser v1 — Lead Generation Engine
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Phase 0 — Research артефакты
|
||||
|
||||
Тестовые скрипты, которыми проверяли возможности Botasaurus на Яндекс.Картах
|
||||
до написания боевого парсера. Сохранены для истории / возможного re-run.
|
||||
|
||||
| Файл | Что делает |
|
||||
|------|------------|
|
||||
| `test_yandex.py` | Минимальный тест — открыть страницу, проверить captcha, посчитать карточки |
|
||||
| `test_yandex_v2.py` | Со скроллом — добавить логику подгрузки списка организаций |
|
||||
| `test_yandex_v3.py` | Deep dive — клик по карточке, парсинг полей в боковой панели |
|
||||
|
||||
Закрывают **Phase 0** Research. Полный отчёт о результатах — в:
|
||||
`../../sessions/2026-05-01_phase0_research.md`
|
||||
|
||||
Карта селекторов и решения D2/D3/D4 (стек/БД/anti-bot) — в:
|
||||
`../../DECISIONS.md`
|
||||
|
||||
---
|
||||
|
||||
*Перенесено в архив 2026-05-03. Тесты больше не нужны для повседневной работы,
|
||||
но могут пригодиться при изменении вёрстки Яндекс.Карт.*
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Phase 0 — разведочный тест Botasaurus на Яндекс.Картах.
|
||||
|
||||
Цель: убедиться, что Botasaurus открывает Яндекс.Карты, видит список
|
||||
организаций и не получает captcha при базовом сценарии.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
УСТАНОВКА (один раз, в свежем venv):
|
||||
|
||||
python -m venv venv
|
||||
venv\\Scripts\\activate # Windows
|
||||
# source venv/bin/activate # Linux/Mac
|
||||
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
|
||||
ЗАПУСК:
|
||||
|
||||
python test_yandex.py
|
||||
|
||||
ОЖИДАЕМЫЙ РЕЗУЛЬТАТ:
|
||||
- Откроется headless Chrome
|
||||
- В консоли: "Найдено карточек: N" (где N >= 5)
|
||||
- Никакой captcha
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
"""
|
||||
|
||||
from botasaurus.browser import browser, Driver
|
||||
|
||||
|
||||
# Селекторы Яндекс.Карт меняются — пробуем несколько вариантов подряд.
|
||||
# Первый рабочий — фиксируем для прода.
|
||||
SELECTORS_TO_TRY = [
|
||||
".search-snippet-view",
|
||||
'[class*="search-snippet-view"]',
|
||||
'li[class*="search-snippet"]',
|
||||
'[data-id="serp"]',
|
||||
]
|
||||
|
||||
|
||||
@browser(
|
||||
headless=False, # Phase 0: видим что происходит. На VPS поставим True
|
||||
block_images=True, # быстрее без картинок
|
||||
reuse_driver=True, # один браузер на все вызовы (не пересоздаём окно)
|
||||
)
|
||||
def test_yandex_maps(driver: Driver, data: dict):
|
||||
"""Открывает Яндекс.Карты с поисковым запросом и считает карточки."""
|
||||
query = data.get("query", "кафе")
|
||||
city_id = data.get("city_id", 213) # 213 = Москва в Яндекс.Картах
|
||||
|
||||
url = f"https://yandex.ru/maps/{city_id}/moscow/search/{query}/"
|
||||
print(f"\n→ Открываю: {url}")
|
||||
driver.get(url)
|
||||
|
||||
# Даём JS отрендерить карту и список
|
||||
driver.sleep(5)
|
||||
|
||||
# Проверка на CAPTCHA — у Botasaurus есть встроенный детектор
|
||||
detected_by = driver.get_bot_detected_by()
|
||||
if detected_by:
|
||||
print(f"⚠️ Детектор бота сработал: {detected_by}")
|
||||
return {"status": "bot_detected", "detected_by": detected_by, "cards": 0}
|
||||
|
||||
# Дополнительная проверка по URL и DOM
|
||||
if "showcaptcha" in driver.current_url or driver.select(".CheckboxCaptcha"):
|
||||
print("⚠️ Получили CAPTCHA. IP/UA засветился.")
|
||||
return {"status": "captcha", "cards": 0}
|
||||
|
||||
# Перебираем селекторы — ищем тот, который сработал
|
||||
found_selector = None
|
||||
cards_count = 0
|
||||
for sel in SELECTORS_TO_TRY:
|
||||
count = driver.count(sel)
|
||||
if count > 0:
|
||||
found_selector = sel
|
||||
cards_count = count
|
||||
break
|
||||
|
||||
if not found_selector:
|
||||
print("⚠️ Список организаций не найден. Возможно, селекторы изменились.")
|
||||
print(f" URL после загрузки: {driver.current_url}")
|
||||
return {"status": "no_results", "cards": 0}
|
||||
|
||||
print(f"✓ Сработал селектор: {found_selector}")
|
||||
print(f"✓ Найдено карточек: {cards_count}")
|
||||
|
||||
# Тест считается успешным, если карточек 5+
|
||||
status = "ok" if cards_count >= 5 else "partial"
|
||||
return {
|
||||
"status": status,
|
||||
"cards": cards_count,
|
||||
"selector_used": found_selector,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Тестируем 1 категорию. После успеха — добавим больше.
|
||||
result = test_yandex_maps({"query": "кафе", "city_id": 213})
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"РЕЗУЛЬТАТ: {result}")
|
||||
print("=" * 60)
|
||||
|
||||
# Что записать в sessions/2026-05-01_phase0_research.md:
|
||||
# - status: ok / partial / captcha / bot_detected / no_results
|
||||
# - cards: сколько найдено
|
||||
# - selector_used: какой селектор сработал (для прода)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Phase 0 — расширенный тест Яндекс.Карт со скроллом и извлечением полей.
|
||||
|
||||
ЦЕЛЬ:
|
||||
1. Скроллить список организаций → получить 20+ карточек (а не 5 как было)
|
||||
2. Из первых 5 карточек вытащить: название, рейтинг, категорию
|
||||
3. Понять структуру карточки → подготовить почву для боевого парсера
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
ЗАПУСК (venv уже настроен, библиотеки установлены):
|
||||
|
||||
python test_yandex_v2.py
|
||||
|
||||
ОЖИДАЕМЫЙ РЕЗУЛЬТАТ:
|
||||
- Карточек: 20-30 (после скролла)
|
||||
- У каждой выводится: name + rating + category
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
"""
|
||||
|
||||
from botasaurus.browser import browser, Driver
|
||||
|
||||
|
||||
# Контейнер с прокручиваемым списком — пробуем несколько селекторов
|
||||
LIST_CONTAINER_SELECTORS = [
|
||||
".scroll__container",
|
||||
".search-list-view__list",
|
||||
'[class*="search-list-view"]',
|
||||
".results",
|
||||
]
|
||||
|
||||
CARD_SELECTOR = ".search-snippet-view"
|
||||
|
||||
# Селекторы внутри одной карточки (тоже подбираем эмпирически)
|
||||
TITLE_SELECTORS = [
|
||||
".search-business-snippet-view__title",
|
||||
'[class*="snippet-view__title"]',
|
||||
'[class*="title"]',
|
||||
]
|
||||
RATING_SELECTORS = [
|
||||
".business-rating-badge-view__rating-text",
|
||||
".business-rating-badge-view__rating",
|
||||
'[class*="rating-badge"] [class*="rating"]',
|
||||
]
|
||||
CATEGORY_SELECTORS = [
|
||||
".search-business-snippet-view__category",
|
||||
'[class*="snippet-view__category"]',
|
||||
'[class*="category"]',
|
||||
]
|
||||
|
||||
|
||||
def find_first_text(element, selectors):
|
||||
"""Перебор селекторов внутри карточки → текст первого найденного, либо None."""
|
||||
for sel in selectors:
|
||||
sub = element.select(sel)
|
||||
if sub:
|
||||
text = sub.text
|
||||
if text:
|
||||
return text.strip()
|
||||
return None
|
||||
|
||||
|
||||
@browser(
|
||||
headless=False,
|
||||
block_images=True,
|
||||
reuse_driver=True,
|
||||
)
|
||||
def test_yandex_maps_v2(driver: Driver, data: dict):
|
||||
query = data.get("query", "кафе")
|
||||
city_id = data.get("city_id", 213)
|
||||
|
||||
url = f"https://yandex.ru/maps/{city_id}/moscow/search/{query}/"
|
||||
print(f"\n→ Открываю: {url}")
|
||||
driver.get(url)
|
||||
driver.sleep(5)
|
||||
|
||||
# Проверка на бот-детектор / капчу
|
||||
detected = driver.get_bot_detected_by()
|
||||
if detected:
|
||||
print(f"⚠️ Бот-детектор: {detected}")
|
||||
return {"status": "bot_detected", "detected_by": detected}
|
||||
|
||||
if "showcaptcha" in driver.current_url or driver.select(".CheckboxCaptcha"):
|
||||
print("⚠️ CAPTCHA")
|
||||
return {"status": "captcha"}
|
||||
|
||||
# Найти контейнер прокручиваемого списка
|
||||
list_container = None
|
||||
for sel in LIST_CONTAINER_SELECTORS:
|
||||
if driver.count(sel) > 0:
|
||||
list_container = sel
|
||||
print(f"✓ Контейнер списка найден: {sel}")
|
||||
break
|
||||
|
||||
# Цикл скролла — каждый раз даём время подгрузить карточки
|
||||
print("\n⏬ Скроллим список...")
|
||||
cards_before = driver.count(CARD_SELECTOR)
|
||||
print(f" Стартовое количество карточек: {cards_before}")
|
||||
|
||||
MAX_SCROLLS = 10
|
||||
for i in range(1, MAX_SCROLLS + 1):
|
||||
try:
|
||||
if list_container:
|
||||
# Скроллим именно панель списка (не окно)
|
||||
driver.scroll(selector=list_container, by=2000, smooth_scroll=False)
|
||||
else:
|
||||
# fallback: общий скролл окна
|
||||
driver.scroll(by=2000, smooth_scroll=False)
|
||||
except Exception as e:
|
||||
print(f" Скролл #{i} упал: {e}")
|
||||
break
|
||||
|
||||
driver.sleep(1.5)
|
||||
cards_now = driver.count(CARD_SELECTOR)
|
||||
print(f" Скролл #{i}: карточек {cards_now}")
|
||||
|
||||
# Если за последний скролл ничего нового не появилось — выходим
|
||||
if cards_now == cards_before and i >= 3:
|
||||
print(" Новые карточки не появляются → завершаем скролл")
|
||||
break
|
||||
cards_before = cards_now
|
||||
|
||||
total_cards = driver.count(CARD_SELECTOR)
|
||||
print(f"\n✓ Итого карточек после скролла: {total_cards}")
|
||||
|
||||
# Извлечь поля из первых 5 карточек
|
||||
print("\n📋 Парсим первые 5 карточек:")
|
||||
cards = driver.select_all(CARD_SELECTOR)[:5]
|
||||
parsed = []
|
||||
for idx, card in enumerate(cards, start=1):
|
||||
name = find_first_text(card, TITLE_SELECTORS)
|
||||
rating = find_first_text(card, RATING_SELECTORS)
|
||||
category = find_first_text(card, CATEGORY_SELECTORS)
|
||||
parsed.append({"name": name, "rating": rating, "category": category})
|
||||
print(f" [{idx}] {name!r} | rating={rating!r} | category={category!r}")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"total_cards": total_cards,
|
||||
"list_container_used": list_container,
|
||||
"sample": parsed,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = test_yandex_maps_v2({"query": "кафе", "city_id": 213})
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("РЕЗУЛЬТАТ:")
|
||||
print(f" status: {result.get('status')}")
|
||||
print(f" total_cards: {result.get('total_cards')}")
|
||||
print(f" list_container_used: {result.get('list_container_used')}")
|
||||
print(f" sample (первые 5):")
|
||||
for s in result.get("sample", []):
|
||||
print(f" - {s}")
|
||||
print("=" * 60)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Phase 0 — углублённый тест: клик по карточке, парсинг боковой панели.
|
||||
|
||||
ЦЕЛЬ:
|
||||
1. Открыть карты → кликнуть первую карточку
|
||||
2. Из боковой панели вытащить: телефон, адрес, сайт, отзывы, соцсети
|
||||
3. Зафиксировать рабочие селекторы для боевого парсера
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
ЗАПУСК:
|
||||
python test_yandex_v3.py
|
||||
|
||||
ОЖИДАЕМЫЙ РЕЗУЛЬТАТ:
|
||||
- Из 3 карточек подряд извлекаются контактные поля
|
||||
- Телефон в формате "+7 ..." виден в выводе
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
"""
|
||||
|
||||
from botasaurus.browser import browser, Driver
|
||||
|
||||
|
||||
CARD_SELECTOR = ".search-snippet-view"
|
||||
|
||||
# Поля в боковой панели — пробуем несколько селекторов на каждое поле.
|
||||
# Самые надёжные — стандартные HTML атрибуты (tel:, http://) и itemprop микроразметка.
|
||||
FIELD_SELECTORS = {
|
||||
"title": [
|
||||
"h1.orgpage-header-view__header",
|
||||
"h1",
|
||||
".card-title-view__title",
|
||||
],
|
||||
"phone": [
|
||||
# tel:-ссылка — самый надёжный способ
|
||||
'a[href^="tel:"]',
|
||||
".orgpage-phones-view__phone-number",
|
||||
".card-phones-view__phone-number",
|
||||
'[class*="phones-view__phone"]',
|
||||
],
|
||||
"address": [
|
||||
".orgpage-header-view__address",
|
||||
".business-contacts-view__address",
|
||||
'[class*="address-view"]',
|
||||
'[itemprop="address"]',
|
||||
],
|
||||
"website": [
|
||||
# http(s)-ссылки кроме самого яндекса
|
||||
'.business-urls-view__link',
|
||||
'[class*="urls-view"] a',
|
||||
],
|
||||
"category": [
|
||||
".orgpage-header-view__category",
|
||||
".business-card-title-view__categories",
|
||||
'[class*="header-view__category"]',
|
||||
],
|
||||
"rating": [
|
||||
".business-rating-badge-view__rating-text",
|
||||
'[class*="rating-badge-view__rating"]',
|
||||
],
|
||||
"reviews_count": [
|
||||
".business-header-rating-view__text",
|
||||
".card-section-header__title",
|
||||
'[class*="reviews-count"]',
|
||||
'[class*="rating-amount"]',
|
||||
],
|
||||
"working_hours": [
|
||||
".business-working-status",
|
||||
'[class*="working-status"]',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def find_first(element_or_driver, selectors):
|
||||
"""Перебирает селекторы, возвращает Element первого найденного либо None."""
|
||||
for sel in selectors:
|
||||
result = element_or_driver.select(sel)
|
||||
if result:
|
||||
return result, sel
|
||||
return None, None
|
||||
|
||||
|
||||
def find_first_text(element_or_driver, selectors):
|
||||
el, sel = find_first(element_or_driver, selectors)
|
||||
if el and el.text:
|
||||
return el.text.strip(), sel
|
||||
return None, None
|
||||
|
||||
|
||||
def parse_card_panel(driver: Driver) -> dict:
|
||||
"""Парсит открытую боковую панель карточки.
|
||||
Возвращает словарь со всеми полями + лог сработавших селекторов."""
|
||||
result = {"fields": {}, "selectors_used": {}}
|
||||
|
||||
# Простые текстовые поля
|
||||
for field, selectors in FIELD_SELECTORS.items():
|
||||
if field in ("phone", "website"):
|
||||
continue # они со ссылками — обработаем отдельно
|
||||
text, sel = find_first_text(driver, selectors)
|
||||
result["fields"][field] = text
|
||||
result["selectors_used"][field] = sel
|
||||
|
||||
# Телефоны: собираем все a[href^="tel:"] и текстовые элементы
|
||||
phones = []
|
||||
tel_links = driver.select_all('a[href^="tel:"]')
|
||||
for link in tel_links:
|
||||
href = link.get_attribute("href")
|
||||
if href:
|
||||
phones.append(href.replace("tel:", "").strip())
|
||||
if not phones:
|
||||
# fallback: текст из специфических селекторов
|
||||
for sel in FIELD_SELECTORS["phone"][1:]: # пропускаем tel: — уже пробовали
|
||||
elements = driver.select_all(sel)
|
||||
for el in elements:
|
||||
if el.text:
|
||||
phones.append(el.text.strip())
|
||||
if phones:
|
||||
result["selectors_used"]["phone"] = sel
|
||||
break
|
||||
else:
|
||||
result["selectors_used"]["phone"] = 'a[href^="tel:"]'
|
||||
result["fields"]["phones"] = list(dict.fromkeys(phones)) # уникальные, порядок сохраняется
|
||||
|
||||
# Сайт: ищем ссылку на сторонний домен
|
||||
website = None
|
||||
web_sel_used = None
|
||||
for sel in FIELD_SELECTORS["website"]:
|
||||
link = driver.select(sel)
|
||||
if link:
|
||||
href = link.get_attribute("href")
|
||||
if href and "yandex" not in href.lower():
|
||||
website = href
|
||||
web_sel_used = sel
|
||||
break
|
||||
result["fields"]["website"] = website
|
||||
result["selectors_used"]["website"] = web_sel_used
|
||||
|
||||
# Соцсети — что найдём
|
||||
socials = {}
|
||||
for net, pattern in [("vk", "vk.com"), ("telegram", "t.me"), ("instagram", "instagram.com"), ("youtube", "youtube.com")]:
|
||||
link = driver.select(f'a[href*="{pattern}"]')
|
||||
if link:
|
||||
socials[net] = link.get_attribute("href")
|
||||
result["fields"]["socials"] = socials
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@browser(
|
||||
headless=False,
|
||||
block_images=True,
|
||||
reuse_driver=True,
|
||||
)
|
||||
def test_card_deep_dive(driver: Driver, data: dict):
|
||||
query = data.get("query", "кафе")
|
||||
city_id = data.get("city_id", 213)
|
||||
cards_to_parse = data.get("cards_to_parse", 3)
|
||||
|
||||
url = f"https://yandex.ru/maps/{city_id}/moscow/search/{query}/"
|
||||
print(f"\n→ Открываю: {url}")
|
||||
driver.get(url)
|
||||
driver.sleep(5)
|
||||
|
||||
# Проверки на блокировку
|
||||
detected = driver.get_bot_detected_by()
|
||||
if detected:
|
||||
return {"status": "bot_detected", "detected_by": detected}
|
||||
|
||||
# Берём ссылки на детальные карточки заранее (чтобы не зависеть от состояния DOM)
|
||||
print(f"\n🔗 Собираю ссылки первых {cards_to_parse} карточек...")
|
||||
cards = driver.select_all(CARD_SELECTOR)[:cards_to_parse]
|
||||
if not cards:
|
||||
return {"status": "no_cards"}
|
||||
|
||||
card_links = []
|
||||
for card in cards:
|
||||
link_el = card.select("a")
|
||||
if link_el:
|
||||
href = link_el.get_attribute("href")
|
||||
if href:
|
||||
# Яндекс отдаёт относительные ссылки — приводим к абсолютным
|
||||
if href.startswith("/"):
|
||||
href = "https://yandex.ru" + href
|
||||
card_links.append(href)
|
||||
print(f" Получено ссылок: {len(card_links)}")
|
||||
for i, link in enumerate(card_links, 1):
|
||||
print(f" [{i}] {link[:100]}...")
|
||||
|
||||
# Идём по каждой ссылке и парсим
|
||||
results = []
|
||||
for idx, link in enumerate(card_links, 1):
|
||||
print(f"\n══════ Карточка [{idx}/{len(card_links)}] ══════")
|
||||
driver.get(link)
|
||||
driver.sleep(4) # ждём рендер боковой панели
|
||||
|
||||
parsed = parse_card_panel(driver)
|
||||
results.append(parsed)
|
||||
|
||||
print(" Поля:")
|
||||
for k, v in parsed["fields"].items():
|
||||
if isinstance(v, (list, dict)) and not v:
|
||||
continue # не показываем пустые списки/словари
|
||||
print(f" {k}: {v}")
|
||||
|
||||
# Итог: какие селекторы стабильно срабатывают
|
||||
print("\n" + "=" * 60)
|
||||
print("СТАТИСТИКА ПО СЕЛЕКТОРАМ (что сработало для каждого поля):")
|
||||
selector_stats = {}
|
||||
for r in results:
|
||||
for field, sel in r["selectors_used"].items():
|
||||
if sel:
|
||||
selector_stats.setdefault(field, []).append(sel)
|
||||
for field, sels in selector_stats.items():
|
||||
unique_sels = set(sels)
|
||||
print(f" {field}: {unique_sels} (срабатывало в {len(sels)}/{len(results)} карточках)")
|
||||
print("=" * 60)
|
||||
|
||||
return {"status": "ok", "parsed": results, "selector_stats": selector_stats}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = test_card_deep_dive({
|
||||
"query": "кафе",
|
||||
"city_id": 213,
|
||||
"cards_to_parse": 3,
|
||||
})
|
||||
|
||||
print(f"\n→ Финальный статус: {result.get('status')}")
|
||||
Reference in New Issue
Block a user