Spaces:
Sleeping
Sleeping
import gradio as gr | |
import pandas as pd | |
import io | |
import re | |
from typing import List, Optional, Tuple | |
import logging | |
# Настройка логирования | |
logging.basicConfig(level=logging.INFO) | |
logger = logging.getLogger(__name__) | |
class WebsiteCategorizerApp: | |
def __init__(self): | |
self.sheet_url = "" | |
self.sheet_data = [] | |
self.current_index = 0 | |
self.categories = ["новости", "коммерция", "другое"] | |
self.results_data = [] # Для сохранения результатов | |
def convert_google_sheet_url(self, sheet_url: str) -> str: | |
"""Конвертирует URL Google таблицы в CSV экспорт URL""" | |
try: | |
# Различные форматы URL Google таблиц | |
if "/edit#gid=" in sheet_url: | |
csv_url = sheet_url.replace("/edit#gid=", "/export?format=csv&gid=") | |
elif "/edit?usp=sharing" in sheet_url: | |
csv_url = sheet_url.replace("/edit?usp=sharing", "/export?format=csv") | |
elif "/edit" in sheet_url: | |
csv_url = sheet_url.replace("/edit", "/export?format=csv") | |
else: | |
# Если URL уже в формате экспорта | |
csv_url = sheet_url | |
return csv_url | |
except Exception as e: | |
logger.error(f"Ошибка конвертации URL: {e}") | |
return "" | |
def connect_to_sheet(self, sheet_url: str) -> Tuple[str, str]: | |
"""Подключается к Google таблице через CSV экспорт""" | |
try: | |
if not sheet_url: | |
return "❌ Ошибка: Введите URL Google таблицы", "" | |
# Конвертируем URL в CSV формат | |
csv_url = self.convert_google_sheet_url(sheet_url) | |
if not csv_url: | |
return "❌ Ошибка: Неверный формат URL", "" | |
# Загружаем данные через pandas | |
df = pd.read_csv(csv_url) | |
if df.empty: | |
return "❌ Ошибка: Таблица пуста", "" | |
# Убедимся что есть минимум 2 столбца | |
if len(df.columns) < 2: | |
return "❌ Ошибка: Нужно минимум 2 столбца (URL и категория)", "" | |
# Сохранение данных | |
self.sheet_data = [] | |
self.results_data = [] | |
# Получаем названия столбцов | |
url_column = df.columns[0] | |
category_column = df.columns[1] | |
for index, row in df.iterrows(): | |
url = str(row[url_column]).strip() if pd.notna(row[url_column]) else "" | |
category = str(row[category_column]).strip() if pd.notna(row[category_column]) else "" | |
if url and url.lower() not in ['url', 'nan']: | |
self.sheet_data.append({ | |
"index": index, | |
"url": url, | |
"category": category if category.lower() != 'nan' else "" | |
}) | |
# Добавляем в результаты | |
self.results_data.append({ | |
"url": url, | |
"category": category if category.lower() != 'nan' else "" | |
}) | |
if not self.sheet_data: | |
return "❌ Ошибка: Не найдены валидные URL", "" | |
self.current_index = 0 | |
self.sheet_url = sheet_url | |
success_msg = f"✅ Подключено успешно! Найдено {len(self.sheet_data)} записей" | |
first_url = self.get_current_url_for_display() | |
return success_msg, first_url | |
except Exception as e: | |
logger.error(f"Ошибка подключения к таблице: {e}") | |
error_msg = f"❌ Ошибка: {str(e)}" | |
error_msg += "\n\nУбедитесь что:\n- Таблица публичная (доступна всем по ссылке)\n- URL корректный" | |
return error_msg, "" | |
def get_current_url_for_display(self) -> str: | |
"""Возвращает текущий URL для отображения в iframe""" | |
if not self.sheet_data or self.current_index >= len(self.sheet_data): | |
return "" | |
url = self.sheet_data[self.current_index]["url"] | |
# Добавляем http:// если протокол не указан | |
if url and not url.startswith(("http://", "https://")): | |
url = "http://" + url | |
return url | |
def get_current_info(self) -> Tuple[str, str, str]: | |
"""Возвращает информацию о текущей записи""" | |
if not self.sheet_data: | |
return "", "", "Нет данных" | |
if self.current_index >= len(self.sheet_data): | |
self.current_index = 0 | |
current = self.sheet_data[self.current_index] | |
url = current["url"] | |
category = current["category"] | |
info = f"{self.current_index + 1}/{len(self.sheet_data)}" | |
return url, category, info | |
def navigate_to_index(self, index: int) -> Tuple[str, str, str, str]: | |
"""Переходит к записи по индексу""" | |
if not self.sheet_data: | |
return "", "", "", "Нет данных" | |
# Ограничиваем индекс допустимыми значениями | |
index = max(0, min(index, len(self.sheet_data) - 1)) | |
self.current_index = index | |
url, category, info = self.get_current_info() | |
iframe_url = self.get_current_url_for_display() | |
return url, category, info, iframe_url | |
def previous_record(self) -> Tuple[str, str, str, str]: | |
"""Переход к предыдущей записи""" | |
if not self.sheet_data: | |
return "", "", "", "Нет данных" | |
if self.current_index > 0: | |
self.current_index -= 1 | |
else: | |
self.current_index = len(self.sheet_data) - 1 | |
return self.navigate_to_index(self.current_index) | |
def next_record(self) -> Tuple[str, str, str, str]: | |
"""Переход к следующей записи""" | |
if not self.sheet_data: | |
return "", "", "", "Нет данных" | |
if self.current_index < len(self.sheet_data) - 1: | |
self.current_index += 1 | |
else: | |
self.current_index = 0 | |
return self.navigate_to_index(self.current_index) | |
def save_category(self, category: str) -> Tuple[str, str]: | |
"""Сохраняет категорию локально и возвращает CSV для скачивания""" | |
if not self.sheet_data: | |
return "❌ Нет данных для сохранения", "" | |
try: | |
# Обновляем локальные данные | |
self.sheet_data[self.current_index]["category"] = category | |
self.results_data[self.current_index]["category"] = category | |
# Создаем CSV файл с результатами | |
df_results = pd.DataFrame(self.results_data) | |
csv_buffer = io.StringIO() | |
df_results.to_csv(csv_buffer, index=False, encoding='utf-8') | |
csv_content = csv_buffer.getvalue() | |
status_msg = f"✅ '{category}' сохранено" | |
return status_msg, csv_content | |
except Exception as e: | |
logger.error(f"Ошибка сохранения категории: {e}") | |
return f"❌ Ошибка: {str(e)}", "" | |
def export_results(self) -> str: | |
"""Экспортирует все результаты в CSV""" | |
if not self.results_data: | |
return "" | |
df_results = pd.DataFrame(self.results_data) | |
csv_buffer = io.StringIO() | |
df_results.to_csv(csv_buffer, index=False, encoding='utf-8') | |
return csv_buffer.getvalue() | |
# Создаем экземпляр приложения | |
app = WebsiteCategorizerApp() | |
# CSS для ультра-компактного дизайна | |
ultra_compact_css = """ | |
/* Минимальная левая панель */ | |
.sidebar { | |
background: #f8f9fa; | |
border-radius: 6px; | |
padding: 8px; | |
font-size: 12px; | |
} | |
.sidebar h4 { | |
font-size: 13px; | |
margin: 4px 0 2px 0; | |
color: #495057; | |
} | |
.sidebar .gradio-textbox, | |
.sidebar .gradio-dropdown { | |
font-size: 12px; | |
} | |
.sidebar button { | |
padding: 4px 8px; | |
font-size: 11px; | |
margin: 2px 0; | |
} | |
/* Большое окно просмотра */ | |
.main-viewer { | |
border: 1px solid #dee2e6; | |
border-radius: 8px; | |
overflow: hidden; | |
box-shadow: 0 4px 12px rgba(0,0,0,0.15); | |
} | |
.compact-status { | |
font-size: 11px; | |
padding: 4px 6px; | |
margin: 2px 0; | |
border-radius: 3px; | |
} | |
/* Убираем лишние отступы */ | |
.gradio-row { | |
gap: 8px; | |
} | |
.gradio-column { | |
gap: 6px; | |
} | |
""" | |
# Создание ультра-компактного интерфейса | |
with gr.Blocks( | |
title="Категоризатор сайтов", | |
css=ultra_compact_css, | |
theme=gr.themes.Soft() | |
) as demo: | |
# Минимальный заголовок | |
gr.HTML("<h2 style='text-align: center; margin: 8px 0; color: #2d5aa0;'>🌐 Категоризатор сайтов</h2>") | |
with gr.Row(): | |
# СВЕРХ-КОМПАКТНАЯ ЛЕВАЯ ПАНЕЛЬ (scale=1, max_width=280px) | |
with gr.Column(scale=1, max_width=280, elem_classes="sidebar"): | |
# Подключение | |
gr.HTML("<h4>📊 Подключение</h4>") | |
sheet_url_input = gr.Textbox( | |
label="", | |
placeholder="URL Google таблицы", | |
lines=1, | |
show_label=False | |
) | |
connect_btn = gr.Button("🔗 Подключить", variant="primary", size="sm") | |
connection_status = gr.HTML("", elem_classes="compact-status") | |
gr.HTML("<br>") | |
# Навигация | |
gr.HTML("<h4>🧭 Навигация</h4>") | |
with gr.Row(): | |
prev_btn = gr.Button("⬅️", size="sm", scale=1) | |
record_info = gr.HTML("", scale=2, elem_classes="compact-status") | |
next_btn = gr.Button("➡️", size="sm", scale=1) | |
current_url_display = gr.Textbox( | |
label="", | |
placeholder="URL", | |
interactive=False, | |
lines=1, | |
show_label=False | |
) | |
gr.HTML("<br>") | |
# Категория | |
gr.HTML("<h4>🏷️ Категория</h4>") | |
category_dropdown = gr.Dropdown( | |
choices=app.categories, | |
label="", | |
show_label=False | |
) | |
save_status = gr.HTML("", elem_classes="compact-status") | |
gr.HTML("<br>") | |
# Экспорт | |
gr.HTML("<h4>💾 Экспорт</h4>") | |
export_btn = gr.Button("📥 CSV", variant="secondary", size="sm") | |
export_file = gr.File(visible=False) | |
# Мини-инструкция | |
gr.HTML(""" | |
<div style='font-size: 10px; color: #6c757d; margin-top: 8px; padding: 4px; background: #e9ecef; border-radius: 3px;'> | |
💡 <b>Быстрая помощь:</b><br> | |
1. Публичная Google таблица<br> | |
2. A: URL, B: категории<br> | |
3. Навигация ⬅️➡️<br> | |
4. Выбор категории, экспорт | |
</div> | |
""") | |
# МАКСИМАЛЬНОЕ ОКНО ПРОСМОТРА (scale=6) | |
with gr.Column(scale=6): | |
# Очень большое окно - 900px высота, во всю ширину | |
website_viewer = gr.HTML( | |
value=""" | |
<div style='height: 900px; display: flex; flex-direction: column; align-items: center; justify-content: center; | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
border-radius: 8px; color: white; text-align: center;'> | |
<h1 style='margin-bottom: 24px; font-size: 32px;'>🚀 Добро пожаловать!</h1> | |
<p style='font-size: 24px; margin-bottom: 16px;'>Подключите Google таблицу</p> | |
<p style='font-size: 18px; opacity: 0.8;'>Максимальное окно для удобного просмотра</p> | |
<p style='font-size: 16px; opacity: 0.6; margin-top: 24px;'>900px высота × полная ширина экрана</p> | |
</div> | |
""", | |
elem_classes="main-viewer" | |
) | |
# Скрытое состояние для CSV данных | |
csv_data = gr.State("") | |
# Обработчики событий (адаптированные под компактный интерфейс) | |
def handle_connect(url): | |
"""Обработчик подключения к таблице""" | |
status, iframe_url = app.connect_to_sheet(url) | |
if "✅" in status: # Успешное подключение | |
url_display, category, info = app.get_current_info() | |
if iframe_url: | |
# Максимальный iframe - 900px высота | |
iframe_html = f'<iframe src="{iframe_url}" width="100%" height="900px" frameborder="0" style="border-radius: 8px;"></iframe>' | |
else: | |
iframe_html = "<div style='height: 900px; display: flex; align-items: center; justify-content: center; background: #f8d7da; border: 2px dashed #dc3545; border-radius: 8px; color: #721c24;'><p style='font-size: 18px;'>❌ URL не найден или некорректен</p></div>" | |
info_html = f'<span style="color: green; font-weight: bold;">{info}</span>' | |
return ( | |
gr.HTML(f'<div class="compact-status" style="color: green; background: #d4edda; border: 1px solid #c3e6cb;">✅ Готово!</div>'), | |
iframe_html, | |
url_display, | |
category, | |
info_html | |
) | |
else: # Ошибка | |
return ( | |
gr.HTML(f'<div class="compact-status" style="color: #dc3545; background: #f8d7da; border: 1px solid #f5c6cb;">❌ Ошибка</div>'), | |
website_viewer.value, | |
"", | |
"", | |
gr.HTML("") | |
) | |
def handle_navigation(direction): | |
"""Обработчик навигации""" | |
if direction == "next": | |
url_display, category, info, iframe_url = app.next_record() | |
else: | |
url_display, category, info, iframe_url = app.previous_record() | |
if iframe_url: | |
iframe_html = f'<iframe src="{iframe_url}" width="100%" height="900px" frameborder="0" style="border-radius: 8px;"></iframe>' | |
else: | |
iframe_html = "<div style='height: 900px; display: flex; align-items: center; justify-content: center; background: #f8d7da; border: 2px dashed #dc3545; border-radius: 8px; color: #721c24;'><p style='font-size: 18px;'>❌ URL не найден</p></div>" | |
info_html = f'<span style="color: green; font-size: 11px; font-weight: bold;">{info}</span>' | |
return ( | |
iframe_html, | |
url_display, | |
category, | |
info_html | |
) | |
def handle_category_change(category): | |
"""Обработчик изменения категории""" | |
if category: | |
status, csv_content = app.save_category(category) | |
if "✅" in status: | |
status_html = f'<div class="compact-status" style="color: green; background: #d4edda; border: 1px solid #c3e6cb;">{status}</div>' | |
else: | |
status_html = f'<div class="compact-status" style="color: #dc3545; background: #f8d7da; border: 1px solid #f5c6cb;">{status}</div>' | |
return status_html, csv_content | |
return gr.HTML(""), "" | |
def handle_export(): | |
"""Обработчик экспорта результатов""" | |
csv_content = app.export_results() | |
if csv_content: | |
# Сохраняем во временный файл | |
with open("results.csv", "w", encoding="utf-8") as f: | |
f.write(csv_content) | |
return gr.File(value="results.csv", visible=True) | |
return gr.File(visible=False) | |
# Привязка событий | |
connect_btn.click( | |
handle_connect, | |
inputs=[sheet_url_input], | |
outputs=[connection_status, website_viewer, current_url_display, category_dropdown, record_info] | |
) | |
next_btn.click( | |
lambda: handle_navigation("next"), | |
outputs=[website_viewer, current_url_display, category_dropdown, record_info] | |
) | |
prev_btn.click( | |
lambda: handle_navigation("previous"), | |
outputs=[website_viewer, current_url_display, category_dropdown, record_info] | |
) | |
category_dropdown.change( | |
handle_category_change, | |
inputs=[category_dropdown], | |
outputs=[save_status, csv_data] | |
) | |
export_btn.click( | |
handle_export, | |
outputs=[export_file] | |
) | |
# Запуск приложения | |
if __name__ == "__main__": | |
demo.launch() | |