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("

🌐 Категоризатор сайтов

") with gr.Row(): # СВЕРХ-КОМПАКТНАЯ ЛЕВАЯ ПАНЕЛЬ (scale=1, max_width=280px) with gr.Column(scale=1, max_width=280, elem_classes="sidebar"): # Подключение gr.HTML("

📊 Подключение

") 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("
") # Навигация gr.HTML("

🧭 Навигация

") 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("
") # Категория gr.HTML("

🏷️ Категория

") category_dropdown = gr.Dropdown( choices=app.categories, label="", show_label=False ) save_status = gr.HTML("", elem_classes="compact-status") gr.HTML("
") # Экспорт gr.HTML("

💾 Экспорт

") export_btn = gr.Button("📥 CSV", variant="secondary", size="sm") export_file = gr.File(visible=False) # Мини-инструкция gr.HTML("""
💡 Быстрая помощь:
1. Публичная Google таблица
2. A: URL, B: категории
3. Навигация ⬅️➡️
4. Выбор категории, экспорт
""") # МАКСИМАЛЬНОЕ ОКНО ПРОСМОТРА (scale=6) with gr.Column(scale=6): # Очень большое окно - 900px высота, во всю ширину website_viewer = gr.HTML( value="""

🚀 Добро пожаловать!

Подключите Google таблицу

Максимальное окно для удобного просмотра

900px высота × полная ширина экрана

""", 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'' else: iframe_html = "

❌ URL не найден или некорректен

" info_html = f'{info}' return ( gr.HTML(f'
✅ Готово!
'), iframe_html, url_display, category, info_html ) else: # Ошибка return ( gr.HTML(f'
❌ Ошибка
'), 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'' else: iframe_html = "

❌ URL не найден

" info_html = f'{info}' 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'
{status}
' else: status_html = f'
{status}
' 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()