Url_Categorize_Manual / app_compact.py
limitedonly41's picture
Rename app.py to app_compact.py
5a17118 verified
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()