-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathui.py
More file actions
482 lines (421 loc) · 22.8 KB
/
Copy pathui.py
File metadata and controls
482 lines (421 loc) · 22.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
"""
Web UI for testing the scraper.
Run: python ui.py
Opens at http://localhost:7860
"""
import asyncio
import json
import gradio as gr
from pathlib import Path
# ── helpers ──────────────────────────────────────────────────────────────────
def _run(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def _make_browser_ctx(pw, headless: bool):
"""Return (browser, ctx) coroutine — shared stealth setup."""
return _make_browser_ctx_async(pw, headless)
async def _make_browser_ctx_async(pw, headless: bool):
browser = await pw.chromium.launch(
headless=headless,
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
)
ctx = await browser.new_context(
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
locale="ru-RU",
timezone_id="Europe/Moscow",
viewport={"width": 1440, "height": 900},
)
await ctx.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
return browser, ctx
# ── tab 1: scrape a single card URL ──────────────────────────────────────────
def scrape_card(
source: str, url: str, headless: bool,
neg_threshold: float,
problem_desc: str,
lm_host: str, lm_model: str,
):
if not url.strip():
return "Введите URL карточки", "{}"
url = url.strip()
result = {} # card info
result_reviews = [] # filtered (and optionally AI-ranked) reviews
async def run():
from playwright.async_api import async_playwright
async with async_playwright() as pw:
browser, ctx = await _make_browser_ctx_async(pw, headless)
page = await ctx.new_page()
if source == "2gis":
from scraper_2gis import parse_card as _parse
elif source == "yandex":
from scraper_yandex import parse_card as _parse
else:
from scraper_google import parse_card as _parse
from card_cache import CardCache
cache = CardCache()
cached = cache.get(url)
if cached:
card, reviews = cached
else:
card, reviews = await _parse(page, url, "тест", "тест")
if card:
cache.put(url, card, reviews)
await browser.close()
if not card:
return
result.update(card.to_csv_row())
result["phones_list"] = [p.raw for p in card.phones]
# step 3: filter by star threshold
neg = [r for r in reviews if r.rating is not None and r.rating <= neg_threshold]
unrated = [r for r in reviews if r.rating is None]
filtered = neg if neg else unrated # fallback: unrated if none have stars
if not problem_desc.strip():
# step 5: no AI — return filtered reviews as-is
result_reviews.extend([
{"author": r.author, "date": r.date, "rating": r.rating, "text": r.text}
for r in filtered
])
else:
# step 4: AI filter — one LM call for the whole batch
from ollama_client import filter_reviews
texts = [r.text for r in filtered if r.text.strip()]
matches = filter_reviews(
problem_desc.strip(), texts,
host=lm_host.strip(),
model=lm_model.strip() or "local-model",
)
matched_indices = {m["index"]: m["reason"] for m in matches}
for i, r in enumerate(filtered):
if i in matched_indices:
result_reviews.append({
"author": r.author,
"date": r.date,
"rating": r.rating,
"text": r.text,
"ai_reason": matched_indices[i],
})
try:
_run(run())
except Exception as e:
return f"Ошибка: {e}", "{}"
if not result:
return "❌ Не удалось получить данные", "{}"
ai_note = f" 🤖 ИИ отфильтровал: {len(result_reviews)} отзывов совпадают с описанием" if problem_desc.strip() else ""
summary = (
f"✅ {result.get('name', '?')}\n"
f" Рейтинг: {result.get('rating')} ★ | Всего отзывов: {result.get('review_count')}\n"
f" Телефоны: {result.get('phones')}\n"
f" WA: {result.get('whatsapp')} TG: {result.get('telegram')}\n"
f" Букинг: {result.get('has_booking')}\n"
f" Негативных (≤{neg_threshold:.0f}★): {len(result_reviews) if not problem_desc.strip() else '?'} собрано\n"
+ ai_note
)
return summary, json.dumps(
{"card": result, "filtered_reviews": result_reviews},
ensure_ascii=False, indent=2,
)
# ── tab 2: search (collect URLs) ─────────────────────────────────────────────
def search_urls(
source: str, cities_raw: str, category: str, headless: bool,
rating_min: float, rating_max: float,
limit_per_city: int,
):
cities = [c.strip() for c in cities_raw.splitlines() if c.strip()]
if not cities or not category.strip():
return "Введите хотя бы один город и категорию", "[]"
cards_out = []
skipped_count = 0
log = []
async def run():
nonlocal skipped_count
import asyncio, random
from playwright.async_api import async_playwright
async with async_playwright() as pw:
browser, ctx = await _make_browser_ctx_async(pw, headless)
page = await ctx.new_page()
if source == "2gis":
from scraper_2gis import search_cards, parse_card as _parse
elif source == "yandex":
from scraper_yandex import search_cards, parse_card as _parse
else:
from scraper_google import search_cards, parse_card as _parse
for city in cities:
log.append(f"🔍 {city}…")
urls = await search_cards(page, city, category.strip(), limit_per_city * 3, 1, 2)
city_found = 0
for url in urls:
if city_found >= limit_per_city:
break
await asyncio.sleep(random.uniform(0.5, 1.5))
card, _ = await _parse(page, url, city, category.strip())
if card is None:
continue
if card.rating is not None and not (rating_min <= card.rating <= rating_max):
skipped_count += 1
continue
cards_out.append({
"city": city,
"name": card.name,
"rating": card.rating,
"address": card.address,
"phones": card.phones_str,
"whatsapp": card.whatsapp or "",
"telegram": card.telegram or "",
"has_booking": card.has_booking,
"card_url": card.card_url,
})
city_found += 1
log.append(f" ✅ {city}: {city_found} компаний")
await browser.close()
try:
_run(run())
except Exception as e:
return f"Ошибка: {e}", "[]", [], gr.update(choices=[], value=None)
choices = [f"{i}: ★{c['rating']} [{c['city']}] {c['name']}" for i, c in enumerate(cards_out)]
summary = (
f"Фильтр: {rating_min}–{rating_max}★ | Категория: {category}\n"
f"Итого найдено: {len(cards_out)} | Пропущено (вне диапазона): {skipped_count}\n\n"
+ "\n".join(log) + "\n\n"
+ "\n".join(f"★{c['rating']} [{c['city']}] {c['name']} {c['card_url']}" for c in cards_out)
)
return summary, json.dumps(cards_out, ensure_ascii=False, indent=2), cards_out, gr.update(choices=choices, value=choices[0] if choices else None)
# ── tab 3: AI анализ (Claude / LM Studio) ────────────────────────────────────
def run_ai(name: str, address: str, reviews_text: str, backend: str, lm_url: str, lm_model: str):
import os
from models import BusinessCard, Review
from analyzer import analyse
if backend == "Anthropic (Claude)" and not os.environ.get("ANTHROPIC_API_KEY"):
return "❌ ANTHROPIC_API_KEY не задан в окружении"
card = BusinessCard(
card_id="ui_test", name=name or "Тест", address=address or "",
city="Тест", category="тест", phones=[], rating=3.5, review_count=10,
working_hours="", has_website=False, website_url=None,
card_url="https://example.com", lat=None, lon=None,
)
reviews = []
for i, line in enumerate(reviews_text.strip().splitlines()):
if line.strip():
reviews.append(Review(card_id="ui_test", author=f"Автор {i+1}",
date="", rating=None, text=line.strip()))
endpoint = lm_url.strip() if backend == "LM Studio (локальная)" else ""
model = lm_model.strip() or "local-model"
try:
summary, msg = analyse(card, reviews, local_endpoint=endpoint, local_model=model)
return f"**Резюме отзывов:**\n{summary}\n\n**Первое сообщение:**\n{msg}"
except Exception as e:
return f"Ошибка: {e}"
# ── tab 4: CSV viewer ─────────────────────────────────────────────────────────
def load_csv(path: str, only_pain: bool):
import csv
p = Path(path.strip())
if not p.exists():
return f"Файл не найден: {p}", []
with open(p, encoding="utf-8") as f:
rows = list(csv.DictReader(f))
if only_pain:
rows = [r for r in rows if str(r.get("pain_point_match", "")).lower() == "true"]
# sort: pain_point_match=True first
rows.sort(key=lambda r: str(r.get("pain_point_match", "")).lower() != "true")
return f"Строк: {len(rows)} (всего после фильтра)", rows
# ── build UI ──────────────────────────────────────────────────────────────────
_SOURCES = ["2gis", "yandex", "google"]
def _make_card(name, city, source, rating, pain_match, evidence):
from models import BusinessCard
return BusinessCard(
card_id="ui_dialog", name=name or "Салон", address="",
city=city or "", category="", phones=[], rating=float(rating or 0),
review_count=None, working_hours="", has_website=False, website_url=None,
card_url="", lat=None, lon=None, source=source or "yandex",
pain_point_match=bool(pain_match),
pain_point_evidence=(evidence or "").strip(),
)
with gr.Blocks(title="Scraper UI", theme=gr.themes.Soft()) as app:
gr.Markdown("# 2GIS / Yandex / Google Maps Scraper")
# shared state: list of cards from Tab 2 search
t2_state = gr.State([])
# ── Tab 1: парсинг одной карточки ────────────────────────────────────────
with gr.Tab("🔍 Парсинг карточки"):
gr.Markdown("Вставьте URL карточки и нажмите «Парсить». Рейтинг показывается информационно.")
with gr.Row():
t1_source = gr.Dropdown(_SOURCES, value="yandex", label="Источник")
t1_url = gr.Textbox(label="URL карточки", placeholder="https://yandex.com/maps/org/...")
t1_headless = gr.Checkbox(value=True, label="Headless")
t1_neg_thr = gr.Slider(1, 4, value=3, step=1, label="Показывать отзывы с оценкой ≤ N звёзд")
gr.Markdown("**Фильтр по описанию проблемы через LM Studio** *(оставьте пустым — все негативные без ИИ)*")
t1_problem = gr.Textbox(
label="Описание проблемы",
placeholder="Клиентам сложно дозвониться, долго ждут ответа, путаница с записью...",
lines=2,
)
with gr.Row():
t1_lm_host = gr.Textbox(label="LM Studio URL", value="http://localhost:1234")
t1_lm_model = gr.Textbox(label="Модель", value="local-model")
t1_btn = gr.Button("Парсить", variant="primary")
t1_summary = gr.Textbox(label="Результат", lines=6)
t1_json = gr.Code(label="JSON (карточка + отзывы)", language="json", lines=20)
t1_send = gr.Button("Отправить в Диалог →", variant="secondary")
t1_btn.click(
scrape_card,
[t1_source, t1_url, t1_headless, t1_neg_thr, t1_problem, t1_lm_host, t1_lm_model],
[t1_summary, t1_json],
)
# ── Tab 2: поиск по городам ───────────────────────────────────────────────
with gr.Tab("🗺️ Поиск компаний"):
gr.Markdown("Ищет по нескольким городам, парсит карточки и фильтрует по рейтингу.")
with gr.Row():
t2_source = gr.Dropdown(_SOURCES, value="yandex", label="Источник")
t2_cat = gr.Textbox(label="Категория", value="маникюр")
t2_headless = gr.Checkbox(value=True, label="Headless")
t2_cities = gr.Textbox(
label="Города (каждый с новой строки)",
value="Воронеж\nКраснодар\nТюмень\nЧелябинск\nИркутск",
lines=6,
)
with gr.Row():
t2_rmin = gr.Slider(0, 5, value=3.0, step=0.1, label="Мин. рейтинг")
t2_rmax = gr.Slider(0, 5, value=4.5, step=0.1, label="Макс. рейтинг")
t2_limit = gr.Slider(1, 30, value=5, step=1, label="Компаний на город")
t2_btn = gr.Button("Искать", variant="primary")
t2_summary = gr.Textbox(label="Результат", lines=12)
t2_json = gr.Code(label="JSON", language="json", lines=15)
gr.Markdown("**Выберите компанию и отправьте в Диалог:**")
t2_select = gr.Dropdown(choices=[], label="Компания из результатов", interactive=True)
t2_send = gr.Button("Отправить выбранную в Диалог →", variant="secondary")
t2_btn.click(
search_urls,
[t2_source, t2_cities, t2_cat, t2_headless, t2_rmin, t2_rmax, t2_limit],
[t2_summary, t2_json, t2_state, t2_select],
)
# ── Tab 3: AI анализ ──────────────────────────────────────────────────────
with gr.Tab("🤖 AI анализ"):
gr.Markdown("Тест `analyzer.py` — отзывы + резюме + первое сообщение.")
with gr.Row():
t3_name = gr.Textbox(label="Название салона", value="Студия красоты Анны")
t3_addr = gr.Textbox(label="Адрес", value="Воронеж, ул. Ленина 5")
t3_reviews = gr.Textbox(
label="Отзывы (каждый с новой строки)", lines=5,
value="Очень долго ждала записи, пришлось звонить несколько раз.\nМастер отличный, но запись по телефону неудобная."
)
with gr.Row():
t3_backend = gr.Radio(
["Anthropic (Claude)", "LM Studio (локальная)"],
value="LM Studio (локальная)", label="Нейросеть",
)
with gr.Row():
t3_lm_url = gr.Textbox(label="LM Studio URL", value="http://localhost:1234/v1")
t3_lm_model = gr.Textbox(label="Модель", value="local-model")
t3_btn = gr.Button("Анализировать", variant="primary")
t3_out = gr.Markdown()
t3_btn.click(run_ai, [t3_name, t3_addr, t3_reviews, t3_backend, t3_lm_url, t3_lm_model], t3_out)
# ── Tab 4: диалог ─────────────────────────────────────────────────────────
with gr.Tab("💬 Диалог"):
gr.Markdown(
"Заполните данные вручную или перенесите лид из вкладки **Парсинг** / **Поиск**.\n\n"
"Сообщения **1 и 3** — LM Studio (персонализированные). "
"Сообщения **2, 4, 5** — шаблоны."
)
with gr.Row():
td_name = gr.Textbox(label="Название компании")
td_city = gr.Textbox(label="Город")
td_source = gr.Dropdown(_SOURCES, value="yandex", label="Источник")
td_rating = gr.Number(label="Рейтинг", value=0)
td_evidence = gr.Textbox(
label="Болевая точка (pain_point_evidence) — главная цитата",
placeholder="Пришел без записи — мастера нет, попросили зайти через час...",
lines=2,
)
td_reviews = gr.Textbox(
label="Отзывы клиентов — каждый с новой строки (можно вставить несколько)",
placeholder="Записался на 19:30, ждал 40 минут...\nНе берут трубку, записаться невозможно...",
lines=6,
)
td_pain_match = gr.Checkbox(label="Есть подтверждённая болевая точка", value=False)
with gr.Row():
td_lm_host = gr.Textbox(label="LM Studio URL", value="http://localhost:1234")
td_lm_model = gr.Textbox(label="Модель", value="local-model")
td_btn = gr.Button("Сгенерировать диалог", variant="primary")
td_out = gr.Textbox(label="Готовый скрипт", lines=22)
def gen_dialog(name, city, source, rating, pain_match, evidence, reviews_raw, lm_host, lm_model):
from dialog_gen import generate_dialog
card = _make_card(name, city, source, rating, pain_match, evidence)
reviews = [l.strip() for l in (reviews_raw or "").splitlines() if l.strip()]
try:
return generate_dialog(
card, reviews=reviews,
endpoint=(lm_host or "").strip(),
model=(lm_model or "local-model").strip(),
)
except Exception as e:
return f"Ошибка: {e}"
td_btn.click(
gen_dialog,
[td_name, td_city, td_source, td_rating, td_pain_match, td_evidence, td_reviews, td_lm_host, td_lm_model],
td_out,
)
# ── Tab 5: CSV ────────────────────────────────────────────────────────────
with gr.Tab("📊 CSV"):
gr.Markdown("Просмотр собранных данных. Компании с AI-болью — сверху.")
with gr.Row():
t4_path = gr.Textbox(label="Путь к файлу", value="output/businesses.csv")
t4_only_pain = gr.Checkbox(label="Только pain_point_match = true", value=False)
t4_btn = gr.Button("Загрузить")
t4_info = gr.Textbox(label="Инфо", lines=1)
t4_table = gr.Dataframe(label="Данные", wrap=True)
t4_btn.click(load_csv, [t4_path, t4_only_pain], [t4_info, t4_table])
# ── cross-tab wiring (after all components declared) ──────────────────────
# Tab 1 → Диалог: читает JSON карточки + отфильтрованные отзывы
def _from_tab1(json_str):
try:
data = json.loads(json_str or "{}")
except Exception:
return ("",) * 8 + (False,)
card = data.get("card", {})
rvs = data.get("filtered_reviews", [])
rv_text = "\n".join(r.get("text", "") for r in rvs if r.get("text", "").strip())
pain_evidence = card.get("pain_point_evidence") or card.get("pain_point_quote") or ""
return (
card.get("name", ""),
card.get("city", ""),
card.get("source", "yandex"),
card.get("rating") or 0,
pain_evidence,
rv_text,
bool(card.get("pain_point_match", False)),
)
t1_send.click(
_from_tab1,
[t1_json],
[td_name, td_city, td_source, td_rating, td_evidence, td_reviews, td_pain_match],
)
# Tab 2 → Диалог: берёт выбранную строку дропдауна из t2_state
def _from_tab2(selection, cards):
if not selection or not cards:
return ("",) * 7
try:
idx = int(str(selection).split(":")[0])
c = cards[idx]
except Exception:
return ("",) * 7
return (
c.get("name", ""),
c.get("city", ""),
c.get("source", "yandex"),
c.get("rating") or 0,
"", # evidence — нет без полного парсинга
"", # reviews — нет без полного парсинга
False,
)
t2_send.click(
_from_tab2,
[t2_select, t2_state],
[td_name, td_city, td_source, td_rating, td_evidence, td_reviews, td_pain_match],
)
if __name__ == "__main__":
app.launch(server_port=7860, inbrowser=True)