From e1b2485156204bfdc3cbdae470c869c144bb70f4 Mon Sep 17 00:00:00 2001 From: dinlo Date: Sun, 31 May 2026 18:45:40 +0800 Subject: [PATCH] Initial commit Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/settings.local.json | 7 + CHANGES.md | 106 +++ LOCALIZATION.md | 88 ++ README.md | 83 ++ __pycache__/main.cpython-312.pyc | Bin 0 -> 68796 bytes config.json | 6 + lang/README.md | 93 ++ lang/en.json | 89 ++ lang/kz.json | 89 ++ lang/ru.json | 89 ++ main.py | 1376 ++++++++++++++++++++++++++++++ 11 files changed, 2026 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 CHANGES.md create mode 100644 LOCALIZATION.md create mode 100644 README.md create mode 100644 __pycache__/main.cpython-312.pyc create mode 100644 config.json create mode 100644 lang/README.md create mode 100644 lang/en.json create mode 100644 lang/kz.json create mode 100644 lang/ru.json create mode 100644 main.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..cea507e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(python *)" + ] + } +} diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..52c2b69 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,106 @@ +# Сводка изменений: Система локализации + +## ✅ Выполнено + +### 1. Создана структура локализации +``` +lang/ +├── README.md # Инструкция по добавлению языков +├── en.json # Английский язык +├── ru.json # Русский язык +└── kz.json # Казахский язык (пример) +``` + +### 2. Модифицирован бэкенд (main.py) +- ✅ Добавлена константа `LANG_DIR` для папки локализаций +- ✅ Функция `get_available_languages()` — сканирует папку lang +- ✅ Функция `load_language(lang_code)` — загружает JSON локализации +- ✅ API эндпоинт `GET /api/languages` — список доступных языков +- ✅ API эндпоинт `GET /api/language/{lang_code}` — получение переводов +- ✅ Поле `language` добавлено в `config.json` и `SettingsModel` + +### 3. Модифицирован фронтенд (Vue 3) +- ✅ Реактивная система локализации с функцией `t(key, params)` +- ✅ Автоматическая загрузка языка при старте приложения +- ✅ Селектор языка в настройках +- ✅ Мгновенная смена языка без перезагрузки страницы +- ✅ Все текстовые строки заменены на вызовы `t()` +- ✅ Поддержка параметров в строках: `{id}`, `{words}`, `{error}` + +### 4. Документация +- ✅ `lang/README.md` — инструкция по добавлению языков +- ✅ `LOCALIZATION.md` — техническая документация +- ✅ Обновлён основной `README.md` + +## 🎯 Как это работает + +### Добавление нового языка +1. Создайте файл `lang/код.json` (например, `de.json` для немецкого) +2. Скопируйте содержимое из `en.json` или `ru.json` +3. Переведите значения (не меняя ключи) +4. Перезапустите приложение +5. **Готово!** Язык автоматически появится в настройках + +### Смена языка пользователем +1. Открыть настройки (⚙️) +2. Выбрать язык в выпадающем списке "🌍 Язык интерфейса" +3. Интерфейс мгновенно переключится +4. Сохранить настройки + +## 📊 Статистика + +- **Строк кода в main.py:** 1375 (было ~1246) +- **Файлов локализации:** 3 (en, ru, kz) +- **Локализованных строк:** ~50 ключей +- **API эндпоинтов:** +2 новых + +## 🌍 Доступные языки + +- 🇬🇧 **English** (`en.json`) +- 🇷🇺 **Русский** (`ru.json`) +- 🇰🇿 **Қазақша** (`kz.json`) — пример для демонстрации + +## 🔧 Технические особенности + +- **Динамическое обнаружение:** Новые языки обнаруживаются автоматически +- **Горячая замена:** Язык меняется без перезагрузки страницы +- **Параметризация:** Поддержка подстановки значений в строки +- **Fallback:** Если перевод не найден, показывается ключ +- **UTF-8:** Полная поддержка Unicode для всех языков +- **Реактивность:** Vue 3 автоматически обновляет интерфейс + +## 🎨 Пример использования в коде + +### Простая строка +```javascript +{{ t('app_title') }} // → "Readeck Importer" +``` + +### Строка с параметрами +```javascript +{{ t('content_stats', { words: wordCount }) }} // → "симв. · 42 слов" +t('success_bookmark_created', { id: '123' }) // → "Успешно! Закладка создана (ID: 123)" +``` + +## 📝 Структура файла локализации + +```json +{ + "lang_name": "Название языка на этом языке", + "lang_code": "код_языка", + "ключ": "Переведённое значение", + "ключ_с_параметром": "Текст с {параметром}" +} +``` + +## ✨ Преимущества реализации + +1. **Простота** — добавить язык = создать один JSON файл +2. **Автоматизация** — не нужно менять код для добавления языка +3. **Масштабируемость** — можно добавить неограниченное количество языков +4. **UX** — мгновенная смена языка без перезагрузки +5. **Поддержка** — легко обновлять и исправлять переводы + +--- + +**Система готова к использованию!** 🚀 diff --git a/LOCALIZATION.md b/LOCALIZATION.md new file mode 100644 index 0000000..90fc723 --- /dev/null +++ b/LOCALIZATION.md @@ -0,0 +1,88 @@ +# Обновление: Добавлена система локализации + +## Что изменилось + +### 1. Создана папка `lang` с файлами локализации +- `en.json` — английский язык +- `ru.json` — русский язык +- `kz.json` — казахский язык (пример) +- `README.md` — инструкция по добавлению новых языков + +### 2. Изменения в `main.py` + +#### Бэкенд: +- Добавлена константа `LANG_DIR` для папки с локализациями +- Добавлена функция `get_available_languages()` — сканирует папку lang и возвращает список доступных языков +- Добавлена функция `load_language(lang_code)` — загружает файл локализации +- Добавлен эндпоинт `GET /api/languages` — возвращает список доступных языков +- Добавлен эндпоинт `GET /api/language/{lang_code}` — возвращает строки локализации для языка +- В `config.json` добавлено поле `language` (по умолчанию "ru") +- Обновлена модель `SettingsModel` с полем `language` + +#### Фронтенд: +- Добавлены реактивные переменные: + - `availableLanguages` — список доступных языков + - `translations` — текущие строки локализации + - `currentLang` — текущий выбранный язык +- Добавлена функция `t(key, params)` — возвращает локализованную строку с подстановкой параметров +- Добавлена функция `loadLanguage(langCode)` — загружает локализацию +- Добавлена функция `changeLanguage(langCode)` — меняет язык интерфейса +- Все текстовые строки в интерфейсе заменены на вызовы `t('ключ')` +- В настройках добавлен селектор языка интерфейса +- При загрузке приложения автоматически загружается язык из настроек + +### 3. Изменения в `config.json` +Теперь файл содержит дополнительное поле: +```json +{ + "readeck_url": "...", + "readeck_token": "...", + "public_host": "...", + "language": "ru" +} +``` + +## Как использовать + +### Смена языка интерфейса +1. Откройте настройки (кнопка ⚙️) +2. Выберите язык в выпадающем списке "🌍 Язык интерфейса" +3. Интерфейс сразу переключится на выбранный язык +4. Нажмите "Сохранить" чтобы сохранить выбор + +### Добавление нового языка +1. Создайте файл `lang/код_языка.json` (например, `de.json` для немецкого) +2. Скопируйте структуру из `en.json` или `ru.json` +3. Переведите все значения (не меняя ключи) +4. Укажите `lang_name` и `lang_code` +5. Перезапустите приложение +6. Новый язык автоматически появится в настройках! + +## Пример файла локализации + +```json +{ + "lang_name": "Deutsch", + "lang_code": "de", + "app_title": "Readeck Importer", + "app_subtitle": "Lokale Artikel mit Übersetzung in Readeck importieren", + "settings": "Einstellungen", + ... +} +``` + +## Технические детали + +- Локализация загружается асинхронно при старте приложения +- Язык сохраняется в `config.json` и восстанавливается при следующем запуске +- Система поддерживает параметры в строках: `{id}`, `{words}`, `{error}` и т.д. +- Если файл локализации не найден, используются ключи как fallback +- Все файлы должны быть в кодировке UTF-8 + +## Преимущества реализации + +✅ **Простота добавления языков** — просто положите JSON-файл в папку `lang` +✅ **Автоматическое обнаружение** — новые языки появляются в интерфейсе без изменения кода +✅ **Горячая замена** — язык меняется мгновенно без перезагрузки страницы +✅ **Расширяемость** — легко добавить любое количество языков +✅ **Централизация** — все переводы в одном месте diff --git a/README.md b/README.md new file mode 100644 index 0000000..0248eb3 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Readeck Local Importer + +Локальный веб-сервис для импорта статей в [Readeck](https://readeck.org/) (self-hosted сервис «прочитать позже»). Позволяет загрузить текст, файл или статью по ссылке, перевести её, отредактировать метаданные и одной кнопкой создать закладку в Readeck. + +Readeck умеет сохранять закладки только по URL, поэтому приложение поднимает временную ссылку на ваш контент в локальной сети и передаёт её Readeck — так локальный текст попадает в библиотеку как обычная статья. + +## Возможности + +- **Импорт по ссылке** — скачивает страницу и извлекает чистый текст статьи (`trafilatura`). +- **Загрузка файлов** `.txt`, `.html`, `.md` (+ drag & drop), автоопределение кодировки. +- **Форматы контента** — HTML, Markdown, простой текст. +- **Перевод** через Google (22 языка) с учётом лимитов на длину запроса. +- **Автозаполнение метаданных** из HTML-метатегов (заголовок, автор, описание, дата, сайт). +- **Предпросмотр** статьи ровно в том виде, в каком её увидит Readeck. +- **Санитизация HTML** перед публикацией (`bleach`). +- **Тест подключения** к Readeck прямо из настроек. +- **Локализация интерфейса** — поддержка нескольких языков с возможностью добавления новых. +- Тёмная тема, счётчик символов/слов, автосохранение черновика. + +## Требования + +- Python 3.9+ +- Доступный сервер Readeck и API-токен к нему + +## Установка + +```bash +pip install fastapi uvicorn pydantic beautifulsoup4 lxml httpx deep-translator markdown bleach trafilatura +``` + +## Запуск + +```bash +python main.py +``` + +Сервер стартует на `http://0.0.0.0:8142`, браузер откроется автоматически на `http://127.0.0.1:8142`. + +При первом запуске откроется окно настроек — укажите: + +- **Readeck URL** — адрес вашего сервера Readeck (например `http://192.168.1.10:8000`) +- **API Токен** — токен из настроек Readeck (`Bearer`) +- **LAN IP** — IP этой машины в локальной сети (для callback-ссылки, по которой Readeck заберёт контент) + +Нажмите «Проверить подключение», чтобы убедиться, что сервер и токен валидны, затем сохраните. Настройки записываются в `config.json`. + +## Использование + +1. Вставьте текст, загрузите/перетащите файл или импортируйте статью по ссылке. +2. При необходимости переведите контент и выберите его формат. +3. Заполните или автозаполните метаданные, добавьте теги. +4. Посмотрите предпросмотр и нажмите «Создать закладку». + +## Файлы + +- `main.py` — всё приложение (бэкенд FastAPI + фронтенд на Vue 3 / Tailwind). +- `config.json` — настройки подключения к Readeck и выбранный язык интерфейса. +- `lang/` — папка с файлами локализации интерфейса. + +## Локализация + +Приложение поддерживает несколько языков интерфейса. Доступные языки: +- 🇬🇧 English +- 🇷🇺 Русский +- 🇰🇿 Қазақша (Казахский) + +### Смена языка +1. Откройте настройки (⚙️) +2. Выберите язык в списке "🌍 Язык интерфейса" +3. Язык изменится мгновенно + +### Добавление нового языка +1. Создайте файл `lang/код_языка.json` (например, `de.json`) +2. Скопируйте структуру из `lang/en.json` или `lang/ru.json` +3. Переведите все значения (не меняя ключи) +4. Перезапустите приложение — новый язык появится автоматически! + +Подробнее см. `lang/README.md` и `LOCALIZATION.md`. + +## Примечания по безопасности + +- `config.json` хранит API-токен в открытом виде. Не коммитьте файл в git; при необходимости перевыпустите токен. +- Сервис слушает `0.0.0.0:8142` **без аутентификации** и доступен всем в локальной сети. Эндпоинт импорта по URL скачивает произвольные адреса (потенциальный SSRF). Для домашней сети это обычно приемлемо; не выставляйте сервис в интернет без авторизации. diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a4c2fb76e7f55aa4a98a60cd9155f8c34d0a7c5 GIT binary patch literal 68796 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

W9=En^j5v%5I-85} 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 z z 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 C>k!TW literal 0 HcmV?d00001 diff --git a/config.json b/config.json new file mode 100644 index 0000000..45b64fb --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "readeck_url": "http://192.168.1.182:8000", + "readeck_token": "Ra5nCVmru35waFbZE9jDrNnYLbkptHc952qtMFPprJDyJCHZ", + "public_host": "192.168.1.151", + "language": "ru" +} \ No newline at end of file diff --git a/lang/README.md b/lang/README.md new file mode 100644 index 0000000..b89cad4 --- /dev/null +++ b/lang/README.md @@ -0,0 +1,93 @@ +# Локализация / Localization + +Эта папка содержит файлы локализации для интерфейса приложения. + +## Как добавить новый язык + +1. Создайте новый JSON-файл в этой папке с кодом языка в качестве имени файла, например: `de.json` для немецкого языка. + +2. Скопируйте содержимое из `en.json` или `ru.json` в качестве шаблона. + +3. Переведите все значения (правая часть после двоеточия) на ваш язык. **Не изменяйте ключи** (левая часть). + +4. Обязательно укажите: + - `lang_name` — название языка на этом языке (например, "Deutsch" для немецкого) + - `lang_code` — код языка (должен совпадать с именем файла) + +5. Сохраните файл в кодировке UTF-8. + +6. Перезапустите приложение — новый язык автоматически появится в настройках! + +## Структура файла локализации + +```json +{ + "lang_name": "Название языка", + "lang_code": "код", + "ключ": "Переведённое значение", + ... +} +``` + +### Параметры в строках + +Некоторые строки содержат параметры в фигурных скобках, например: +- `{id}` — будет заменён на ID закладки +- `{words}` — будет заменён на количество слов +- `{error}` — будет заменён на текст ошибки + +**Не удаляйте эти параметры** при переводе, просто переместите их в нужное место в предложении. + +## Доступные языки + +- `en.json` — English (Английский) +- `ru.json` — Русский +- `kz.json` — Қазақша (Казахский) + +--- + +# Localization + +This folder contains localization files for the application interface. + +## How to add a new language + +1. Create a new JSON file in this folder with the language code as the filename, e.g., `de.json` for German. + +2. Copy the content from `en.json` or `ru.json` as a template. + +3. Translate all values (right side after the colon) to your language. **Do not change the keys** (left side). + +4. Make sure to specify: + - `lang_name` — the name of the language in that language (e.g., "Deutsch" for German) + - `lang_code` — language code (must match the filename) + +5. Save the file in UTF-8 encoding. + +6. Restart the application — the new language will automatically appear in settings! + +## Localization file structure + +```json +{ + "lang_name": "Language name", + "lang_code": "code", + "key": "Translated value", + ... +} +``` + +### Parameters in strings + +Some strings contain parameters in curly braces, for example: +- `{id}` — will be replaced with bookmark ID +- `{words}` — will be replaced with word count +- `{error}` — will be replaced with error text + +**Do not remove these parameters** when translating, just move them to the appropriate place in the sentence. + +## Available languages + +- `en.json` — English +- `ru.json` — Русский (Russian) +- `kz.json` — Қазақша (Kazakh) diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..2aff780 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,89 @@ +{ + "lang_name": "English", + "lang_code": "en", + "app_title": "Readeck Importer", + "app_subtitle": "Import local articles to Readeck with translation", + "settings": "Settings", + "theme_light": "Light theme", + "theme_dark": "Dark theme", + + "section_upload": "Upload and Content", + "section_metadata": "Metadata", + "section_readeck": "Readeck Options", + + "import_url": "Import from URL", + "import_url_placeholder": "https://example.com/article", + "import_url_button": "⬇️ Download", + "import_url_loading": "Loading...", + + "file_upload": "File (.txt, .html, .md)", + + "content_format": "Content Format", + "format_html": "HTML", + "format_markdown": "Markdown", + "format_text": "Text", + + "content_label": "Content", + "content_placeholder": "Upload a file, drag it here, paste text, or import from URL...", + "content_stats": "chars · {words} words", + + "translation": "Translation:", + "translate_button": "🔄 Translate", + "translating": "Translating...", + + "preview_button": "👁️ Preview", + "preview_title": "Preview", + "preview_new_tab": "↗️ Open in new tab", + "preview_close": "✕ Close", + + "autofill_button": "✨ Autofill", + "autofill_tooltip": "Fill from HTML meta tags", + + "meta_title": "Title *", + "meta_title_placeholder": "My article", + "meta_authors": "Authors", + "meta_authors_placeholder": "John Doe", + "meta_date": "Date (ISO)", + "meta_date_placeholder": "2023-10-01", + "meta_description": "Description", + "meta_description_placeholder": "Brief description...", + "meta_site_name": "Site Name", + "meta_site_name_placeholder": "Local Source", + + "tags_label": "🏷️ Tags", + "tags_placeholder": "tech, news, translated", + + "favorite": "⭐ Add to Favorites", + "archive": "📦 Archive", + + "submit_button": "🚀 Create Bookmark", + "submitting": "⏳ Sending to Readeck...", + + "settings_title": "Settings", + "settings_readeck_url": "🌐 Readeck URL", + "settings_readeck_url_placeholder": "http://192.168.1.10:8000", + "settings_token": "🔑 API Token", + "settings_token_placeholder": "rdk_...", + "settings_lan_ip": "📡 Your LAN IP", + "settings_lan_ip_placeholder": "192.168.x.x", + "settings_language": "🌍 Interface Language", + "settings_test": "🔌 Test Connection", + "settings_testing": "Testing...", + "settings_cancel": "Cancel", + "settings_save": "💾 Save", + + "error_title_required": "Error: Title and Content are required!", + "success_bookmark_created": "Success! Bookmark created (ID: {id})", + "success_article_loaded": "✅ Article loaded and metadata filled", + "error_loading": "Loading error:\\n{error}", + "error_translation": "Translation error:\\n{error}", + "error_settings_save": "Failed to save settings:\\n{error}", + "error_network": "Network error while saving:\\n{error}", + "error_file_read": "Failed to read file:\\n{error}", + "error_metadata": "Failed to extract metadata:\\n{error}", + "error_preview": "Failed to build preview:\\n{error}", + "error_submit": "Error:\\n{error}", + + "test_success": "✅ Connection successful", + "test_error": "❌ {error}" +} diff --git a/lang/kz.json b/lang/kz.json new file mode 100644 index 0000000..538fb1b --- /dev/null +++ b/lang/kz.json @@ -0,0 +1,89 @@ +{ + "lang_name": "Қазақша", + "lang_code": "kz", + "app_title": "Readeck Importer", + "app_subtitle": "Readeck-ке аудармамен жергілікті мақалаларды импорттау", + "settings": "Баптаулар", + "theme_light": "Ашық тақырып", + "theme_dark": "Қараңғы тақырып", + + "section_upload": "Жүктеу және Мазмұн", + "section_metadata": "Метадеректер", + "section_readeck": "Readeck опциялары", + + "import_url": "Сілтеме бойынша импорттау", + "import_url_placeholder": "https://example.com/article", + "import_url_button": "⬇️ Жүктеу", + "import_url_loading": "Жүктелуде...", + + "file_upload": "Файл (.txt, .html, .md)", + + "content_format": "Мазмұн форматы", + "format_html": "HTML", + "format_markdown": "Markdown", + "format_text": "Мәтін", + + "content_label": "Мазмұн", + "content_placeholder": "Файлды жүктеңіз, мұнда апарыңыз, мәтінді қойыңыз немесе сілтеме бойынша импорттаңыз...", + "content_stats": "таңба · {words} сөз", + + "translation": "Аударма:", + "translate_button": "🔄 Аудару", + "translating": "Аударылуда...", + + "preview_button": "👁️ Алдын ала қарау", + "preview_title": "Алдын ала қарау", + "preview_new_tab": "↗️ Жаңа қойындыда ашу", + "preview_close": "✕ Жабу", + + "autofill_button": "✨ Автотолтыру", + "autofill_tooltip": "HTML мета тегтерінен толтыру", + + "meta_title": "Тақырып *", + "meta_title_placeholder": "Менің мақалам", + "meta_authors": "Авторлар", + "meta_authors_placeholder": "Иван Иванов", + "meta_date": "Күні (ISO)", + "meta_date_placeholder": "2023-10-01", + "meta_description": "Сипаттама", + "meta_description_placeholder": "Қысқаша сипаттама...", + "meta_site_name": "Сайт атауы", + "meta_site_name_placeholder": "Жергілікті көз", + + "tags_label": "🏷️ Тегтер", + "tags_placeholder": "tech, news, translated", + + "favorite": "⭐ Таңдаулыларға қосу", + "archive": "📦 Мұрағатқа", + + "submit_button": "🚀 Бетбелгі жасау", + "submitting": "⏳ Readeck-ке жіберілуде...", + + "settings_title": "Баптаулар", + "settings_readeck_url": "🌐 Readeck URL", + "settings_readeck_url_placeholder": "http://192.168.1.10:8000", + "settings_token": "🔑 API токені", + "settings_token_placeholder": "rdk_...", + "settings_lan_ip": "📡 Сіздің LAN IP", + "settings_lan_ip_placeholder": "192.168.x.x", + "settings_language": "🌍 Интерфейс тілі", + "settings_test": "🔌 Қосылымды тексеру", + "settings_testing": "Тексерілуде...", + "settings_cancel": "Болдырмау", + "settings_save": "💾 Сақтау", + + "error_title_required": "Қате: Тақырып және Мазмұн міндетті!", + "success_bookmark_created": "Сәтті! Бетбелгі жасалды (ID: {id})", + "success_article_loaded": "✅ Мақала жүктелді және метадеректер толтырылды", + "error_loading": "Жүктеу қатесі:\\n{error}", + "error_translation": "Аударма қатесі:\\n{error}", + "error_settings_save": "Баптауларды сақтау мүмкін болмады:\\n{error}", + "error_network": "Сақтау кезінде желі қатесі:\\n{error}", + "error_file_read": "Файлды оқу мүмкін болмады:\\n{error}", + "error_metadata": "Метадеректерді алу мүмкін болмады:\\n{error}", + "error_preview": "Алдын ала қарауды құру мүмкін болмады:\\n{error}", + "error_submit": "Қате:\\n{error}", + + "test_success": "✅ Қосылым сәтті", + "test_error": "❌ {error}" +} diff --git a/lang/ru.json b/lang/ru.json new file mode 100644 index 0000000..c6c3f3c --- /dev/null +++ b/lang/ru.json @@ -0,0 +1,89 @@ +{ + "lang_name": "Русский", + "lang_code": "ru", + "app_title": "Readeck Importer", + "app_subtitle": "Импорт локальных статей в Readeck с переводом", + "settings": "Настройки", + "theme_light": "Светлая тема", + "theme_dark": "Тёмная тема", + + "section_upload": "Загрузка и Контент", + "section_metadata": "Метаданные", + "section_readeck": "Опции Readeck", + + "import_url": "Импорт по ссылке", + "import_url_placeholder": "https://example.com/article", + "import_url_button": "⬇️ Загрузить", + "import_url_loading": "Загрузка...", + + "file_upload": "Файл (.txt, .html, .md)", + + "content_format": "Формат контента", + "format_html": "HTML", + "format_markdown": "Markdown", + "format_text": "Текст", + + "content_label": "Контент", + "content_placeholder": "Загрузите файл, перетащите его сюда, вставьте текст или импортируйте по ссылке...", + "content_stats": "симв. · {words} слов", + + "translation": "Перевод:", + "translate_button": "🔄 Перевести", + "translating": "Перевод...", + + "preview_button": "👁️ Предпросмотр", + "preview_title": "Предпросмотр", + "preview_new_tab": "↗️ В новой вкладке", + "preview_close": "✕ Закрыть", + + "autofill_button": "✨ Автозаполнить", + "autofill_tooltip": "Заполнить из HTML-метатегов контента", + + "meta_title": "Заголовок *", + "meta_title_placeholder": "Моя статья", + "meta_authors": "Авторы", + "meta_authors_placeholder": "Иван Иванов", + "meta_date": "Дата (ISO)", + "meta_date_placeholder": "2023-10-01", + "meta_description": "Описание", + "meta_description_placeholder": "Краткое описание...", + "meta_site_name": "Название сайта", + "meta_site_name_placeholder": "Local Source", + + "tags_label": "🏷️ Теги", + "tags_placeholder": "tech, news, translated", + + "favorite": "⭐ В Избранное", + "archive": "📦 В Архив", + + "submit_button": "🚀 Создать закладку", + "submitting": "⏳ Отправка в Readeck...", + + "settings_title": "Настройки", + "settings_readeck_url": "🌐 Readeck URL", + "settings_readeck_url_placeholder": "http://192.168.1.10:8000", + "settings_token": "🔑 API Токен", + "settings_token_placeholder": "rdk_...", + "settings_lan_ip": "📡 Ваш LAN IP", + "settings_lan_ip_placeholder": "192.168.x.x", + "settings_language": "🌍 Язык интерфейса", + "settings_test": "🔌 Проверить подключение", + "settings_testing": "Проверка...", + "settings_cancel": "Отмена", + "settings_save": "💾 Сохранить", + + "error_title_required": "Ошибка: Заголовок и Контент обязательны!", + "success_bookmark_created": "Успешно! Закладка создана (ID: {id})", + "success_article_loaded": "✅ Статья загружена и метаданные заполнены", + "error_loading": "Ошибка загрузки:\\n{error}", + "error_translation": "Ошибка перевода:\\n{error}", + "error_settings_save": "Не удалось сохранить настройки:\\n{error}", + "error_network": "Сетевая ошибка при сохранении:\\n{error}", + "error_file_read": "Не удалось прочитать файл:\\n{error}", + "error_metadata": "Не удалось извлечь метаданные:\\n{error}", + "error_preview": "Не удалось построить предпросмотр:\\n{error}", + "error_submit": "Ошибка:\\n{error}", + + "test_success": "✅ Подключение успешно", + "test_error": "❌ {error}" +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..ad3a54f --- /dev/null +++ b/main.py @@ -0,0 +1,1376 @@ +import os +import re +import json +import time +import uuid +import socket +import threading +import webbrowser +import contextlib +import traceback +import uvicorn +from typing import List + +from fastapi import FastAPI, UploadFile, File, HTTPException +from fastapi.responses import HTMLResponse +from pydantic import BaseModel +from bs4 import BeautifulSoup +from deep_translator import GoogleTranslator +import httpx +import bleach +import markdown as md_lib + +try: + import trafilatura +except Exception: # библиотека опциональна — без неё импорт по URL вернёт ошибку + trafilatura = None + +# ========================================== +# КОНФИГУРАЦИЯ И УТИЛИТЫ +# ========================================== + +PORT = 8142 +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +CONFIG_FILE = os.path.join(BASE_DIR, "config.json") +LANG_DIR = os.path.join(BASE_DIR, "lang") + +# Сколько хранить отданный контент (сек). Readeck забирает ссылку почти сразу, +# но даём запас. Записи старше TTL удаляются, чтобы CONTENT_STORE не рос вечно. +CONTENT_TTL = 3600 +# Лимит Google Translate на один запрос ~5000 символов. Берём с запасом. +TRANSLATE_CHAR_LIMIT = 4500 + +# content_id -> {"html": str, "created": float} +CONTENT_STORE = {} +_store_lock = threading.Lock() + +# Разрешённые при санитизации теги/атрибуты (статейная разметка). +ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [ + "p", "div", "span", "br", "hr", "pre", "h1", "h2", "h3", "h4", "h5", "h6", + "img", "figure", "figcaption", "table", "thead", "tbody", "tfoot", "tr", + "th", "td", "article", "section", "blockquote", "sub", "sup", "u", "s", +] +ALLOWED_ATTRS = { + "*": ["class", "id", "title", "lang"], + "a": ["href", "title", "rel", "target"], + "img": ["src", "alt", "title", "width", "height"], +} + +def get_lan_ip() -> str: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except Exception: + return "127.0.0.1" + +def store_content(html: str) -> str: + """Сохраняет HTML под новым UUID, попутно подчищая протухшие записи.""" + content_id = str(uuid.uuid4()) + now = time.time() + with _store_lock: + expired = [k for k, v in CONTENT_STORE.items() if now - v["created"] > CONTENT_TTL] + for k in expired: + CONTENT_STORE.pop(k, None) + CONTENT_STORE[content_id] = {"html": html, "created": now} + return content_id + +def sanitize_html(html: str) -> str: + """Чистит HTML от потенциально опасных тегов/атрибутов перед публикацией.""" + return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, strip=True) + +def markdown_to_html(text: str) -> str: + """Конвертирует Markdown в HTML (с таблицами и блоками кода).""" + return md_lib.markdown(text, extensions=["extra", "sane_lists", "nl2br"]) + +def chunk_text(text: str, limit: int = TRANSLATE_CHAR_LIMIT) -> List[str]: + """Режет длинный текст на куски <= limit символов, по возможности по границам + предложений/слов, чтобы не упереться в лимит Google на один запрос.""" + if len(text) <= limit: + return [text] + parts = re.split(r"(?<=[.!?。…\n])\s+", text) + chunks, buf = [], "" + for part in parts: + # Одно «предложение» само длиннее лимита — режем жёстко по символам. + while len(part) > limit: + if buf: + chunks.append(buf) + buf = "" + chunks.append(part[:limit]) + part = part[limit:] + if len(buf) + len(part) + 1 <= limit: + buf = f"{buf} {part}".strip() + else: + if buf: + chunks.append(buf) + buf = part + if buf: + chunks.append(buf) + return chunks + +def translate_long(text: str, target_lang: str) -> str: + """Переводит произвольно длинный текст, разбивая его на куски под лимит Google.""" + if not text or not text.strip(): + return text + translator = GoogleTranslator(source="auto", target=target_lang) + out = [] + for chunk in chunk_text(text): + try: + res = translator.translate(chunk) + out.append(res if res else chunk) + except Exception: + out.append(chunk) + return " ".join(out) + +def extract_metadata_from_html(html: str) -> dict: + """Достаёт title/author/description/site_name/date из HTML-метатегов.""" + soup = BeautifulSoup(html, "html.parser") + + def meta(*, name=None, prop=None): + if name: + tag = soup.find("meta", attrs={"name": name}) + else: + tag = soup.find("meta", attrs={"property": prop}) + return tag.get("content", "").strip() if tag and tag.get("content") else "" + + title = "" + if soup.title and soup.title.string: + title = soup.title.string.strip() + title = meta(prop="og:title") or title + + return { + "title": title, + "authors": meta(name="author") or meta(prop="article:author"), + "description": meta(name="description") or meta(prop="og:description"), + "site_name": meta(prop="og:site_name"), + "date": meta(prop="article:published_time") or meta(name="date"), + } + +def get_available_languages() -> list: + """Сканирует папку lang и возвращает список доступных языков.""" + if not os.path.exists(LANG_DIR): + return [] + + languages = [] + try: + for filename in os.listdir(LANG_DIR): + if filename.endswith('.json'): + lang_code = filename[:-5] # убираем .json + filepath = os.path.join(LANG_DIR, filename) + try: + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + languages.append({ + 'code': lang_code, + 'name': data.get('lang_name', lang_code), + 'native_name': data.get('lang_name', lang_code) + }) + except Exception as e: + print(f"[WARNING] Не удалось загрузить {filename}: {e}") + except Exception as e: + print(f"[WARNING] Ошибка при сканировании папки lang: {e}") + + return languages + +def load_language(lang_code: str) -> dict: + """Загружает файл локализации для указанного языка.""" + filepath = os.path.join(LANG_DIR, f"{lang_code}.json") + if not os.path.exists(filepath): + return {} + + try: + with open(filepath, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"[WARNING] Не удалось загрузить локализацию {lang_code}: {e}") + return {} + +def load_config() -> dict: + default_config = { + "readeck_url": "", + "readeck_token": "", + "public_host": get_lan_ip(), + "language": "ru" + } + + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + default_config.update(data) + except json.JSONDecodeError as e: + print(f"\n[WARNING] ОШИБКА В config.json! Файл содержит неверный формат JSON: {e}") + print("[WARNING] Проверьте, нет ли там лишних запятых или пропущенных кавычек.\n") + except Exception as e: + print(f"\n[WARNING] Не удалось прочитать config.json: {e}\n") + else: + print(f"\n[INFO] Файл {CONFIG_FILE} не найден. Используются пустые настройки.\n") + + return default_config + +def save_config(config: dict): + try: + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4) + except Exception as e: + print(f"\n[ERROR] Не удалось сохранить config.json: {e}\n") + raise HTTPException(status_code=500, detail=f"Ошибка записи в файл настроек: {e}") + +# ========================================== +# ИНИЦИАЛИЗАЦИЯ ПРИЛОЖЕНИЯ +# ========================================== + +@contextlib.asynccontextmanager +async def lifespan(app: FastAPI): + def open_browser(): + webbrowser.open(f"http://127.0.0.1:{PORT}") + threading.Timer(1.5, open_browser).start() + yield + +app = FastAPI(title="Readeck Local Importer", lifespan=lifespan) + +# ========================================== +# МОДЕЛИ ДАННЫХ +# ========================================== + +class SettingsModel(BaseModel): + readeck_url: str = "" + readeck_token: str = "" + public_host: str = "" + language: str = "ru" + +class TranslateRequest(BaseModel): + content: str + target_lang: str = "ru" + +class FetchUrlRequest(BaseModel): + url: str + +class ExtractMetaRequest(BaseModel): + content: str + +class MarkdownRequest(BaseModel): + content: str + +class SubmitRequest(BaseModel): + content: str + title: str = "" + description: str = "" + authors: str = "" + site_name: str = "" + date: str = "" + language: str = "ru" + tags: List[str] = [] + favorite: bool = False + archive: bool = False + content_format: str = "html" # html | markdown | text + +# ========================================== +# ФРОНТЕНД (HTML / JS) +# ========================================== + +HTML_TEMPLATE = """ + + + + + + Readeck Local Importer + + + + + +

+
+
+

+ 📚 {{ t('app_title') }} +

+

{{ t('app_subtitle') }}

+
+
+ + +
+
+ +
+ +
+
+
+ 1 +
+

{{ t('section_upload') }}

+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + +
+ +
+ + + +
+
+ +
+
+ + {{ charCount }} {{ t('content_stats', { words: wordCount }) }} +
+ +
+ +
+ 🌐 {{ t('translation') }} + + +
+ + +
+ + +
+
+
+
+ 2 +
+

{{ t('section_metadata') }}

+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ 3 +
+

{{ t('section_readeck') }}

+
+ +
+ + +
+ +
+ + +
+ + + +
+ {{ resultMessage }} +
+
+
+ + +
+
+
+
+ ⚙️ +
+

{{ t('settings_title') }}

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ {{ testMessage }} +
+ +
+ + +
+
+
+ + +
+
+
+
+
👁️
+

{{ t('preview_title') }}

+
+
+ {{ t('preview_new_tab') }} + +
+
+
+ +
+
+
+
+ + + + +""" + +# ========================================== +# БЭКЕНД: ЭНДПОИНТЫ API +# ========================================== + +@app.get("/", response_class=HTMLResponse) +def index(): + return HTMLResponse(HTML_TEMPLATE) + +@app.get("/api/settings") +def get_settings(): + return load_config() + +@app.post("/api/settings") +def update_settings(settings: SettingsModel): + config_dict = settings.model_dump() if hasattr(settings, "model_dump") else settings.dict() + save_config(config_dict) + return {"status": "ok"} + +@app.get("/api/languages") +def get_languages(): + """Возвращает список доступных языков интерфейса.""" + return {"languages": get_available_languages()} + +@app.get("/api/language/{lang_code}") +def get_language(lang_code: str): + """Возвращает строки локализации для указанного языка.""" + translations = load_language(lang_code) + if not translations: + raise HTTPException(404, f"Язык {lang_code} не найден") + return translations + +@app.post("/api/upload") +async def upload_file(file: UploadFile = File(...)): + content = await file.read() + for enc in ["utf-8", "windows-1251", "latin-1"]: + try: + text = content.decode(enc) + return {"content": text} + except UnicodeDecodeError: + continue + raise HTTPException(400, "Невозможно прочитать кодировку файла.") + +@app.post("/api/translate") +def translate_api(req: TranslateRequest): + soup = BeautifulSoup(req.content, "html.parser") + + # Простой текст без тегов — переводим целиком с учётом лимита Google. + if not soup.find(): + return {"translated": translate_long(req.content, req.target_lang)} + + translator = GoogleTranslator(source='auto', target=req.target_lang) + nodes_to_translate = [] + texts = [] + + for node in soup.find_all(string=True): + if node.parent.name not in ['style', 'script', 'head'] and node.strip(): + nodes_to_translate.append(node) + texts.append(node.strip()) + + if not texts: + return {"translated": req.content} + + # Группируем текстовые узлы в батчи так, чтобы суммарная длина батча + # не превышала лимит Google на один запрос. Длинные узлы переводим + # по отдельности через translate_long (он сам режет на части). + translated_texts = [] + batch, batch_len = [], 0 + + def flush_batch(): + nonlocal batch, batch_len + if not batch: + return + try: + res = translator.translate_batch(batch) + translated_texts.extend(res) + except Exception: + for text in batch: + try: + translated_texts.append(translator.translate(text)) + except Exception: + translated_texts.append(text) + batch, batch_len = [], 0 + + for text in texts: + if len(text) > TRANSLATE_CHAR_LIMIT: + flush_batch() + translated_texts.append(translate_long(text, req.target_lang)) + continue + # +1 на разделитель; держим батч под лимитом и не длиннее 20 узлов. + if batch_len + len(text) + 1 > TRANSLATE_CHAR_LIMIT or len(batch) >= 20: + flush_batch() + batch.append(text) + batch_len += len(text) + 1 + flush_batch() + + for i, node in enumerate(nodes_to_translate): + if i < len(translated_texts) and translated_texts[i]: + node.replace_with(translated_texts[i]) + + return {"translated": str(soup)} + +@app.post("/api/fetch-url") +async def fetch_url(req: FetchUrlRequest): + """Скачивает страницу по URL и извлекает из неё чистый текст статьи + метаданные.""" + url = req.url.strip() + if not url: + raise HTTPException(400, "URL не указан.") + if not url.startswith(("http://", "https://")): + url = "https://" + url + if trafilatura is None: + raise HTTPException(500, "Библиотека trafilatura не установлена (pip install trafilatura).") + + headers = {"User-Agent": "Mozilla/5.0 (compatible; ReadeckImporter/1.0)"} + try: + async with httpx.AsyncClient(follow_redirects=True, timeout=30.0, headers=headers) as client: + resp = await client.get(url) + resp.raise_for_status() + raw_html = resp.text + except Exception as e: + raise HTTPException(400, f"Не удалось загрузить страницу: {type(e).__name__}: {e}") + + # Извлекаем основной контент как HTML (с заголовками/ссылками/картинками). + extracted = trafilatura.extract( + raw_html, output_format="html", include_links=True, + include_images=True, include_formatting=True, url=url, + ) + content = sanitize_html(extracted) if extracted else "" + + meta = extract_metadata_from_html(raw_html) + if not content: + # Фолбэк: если ничего не извлеклось — отдадим хотя бы текст body. + soup = BeautifulSoup(raw_html, "html.parser") + body = soup.body + content = sanitize_html(str(body)) if body else "" + + return {"content": content, "meta": meta} + +@app.post("/api/extract-meta") +def extract_meta(req: ExtractMetaRequest): + """Возвращает метаданные, найденные в переданном HTML.""" + return {"meta": extract_metadata_from_html(req.content)} + +@app.post("/api/render-markdown") +def render_markdown(req: MarkdownRequest): + """Конвертирует Markdown в HTML для предпросмотра/отправки.""" + return {"html": markdown_to_html(req.content)} + +@app.post("/api/test-connection") +async def test_connection(settings: SettingsModel): + """Проверяет доступность Readeck и валидность токена.""" + readeck_url = settings.readeck_url.strip().strip("/") + readeck_token = settings.readeck_token.strip() + if not readeck_url or not readeck_token: + raise HTTPException(400, "Заполните URL и токен.") + + headers = {"Authorization": f"Bearer {readeck_token}"} + try: + async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client: + resp = await client.get(f"{readeck_url}/api/bookmarks?limit=1", headers=headers) + except Exception as e: + raise HTTPException(400, f"Нет связи с сервером: {type(e).__name__}: {e}") + + if resp.status_code in (401, 403): + raise HTTPException(401, "Сервер доступен, но токен отклонён (401/403).") + if resp.status_code >= 400: + raise HTTPException(400, f"Сервер ответил кодом {resp.status_code}.") + return {"ok": True, "message": "Подключение успешно: сервер и токен валидны."} + +def prepare_content(raw: str, content_format: str) -> str: + """Готовит контент к публикации: markdown->html, затем санитизация.""" + fmt = (content_format or "html").lower() + if fmt == "markdown": + html = markdown_to_html(raw) + elif fmt == "text": + # Экранируем и сохраняем переводы строк как параграфы. + escaped = bleach.clean(raw, tags=[], strip=True) + paragraphs = [p.strip() for p in escaped.split("\n\n") if p.strip()] + html = "".join(f"

{p}

" for p in paragraphs) or f"

{escaped}

" + else: + html = raw + return sanitize_html(html) + +def inject_metadata(html_content: str, meta: dict) -> str: + soup = BeautifulSoup(html_content, "html.parser") + + if not soup.html: + wrapper = BeautifulSoup("", "html.parser") + wrapper.body.append(soup) + soup = wrapper + elif not soup.head: + head = soup.new_tag("head") + soup.html.insert(0, head) + + soup.html["lang"] = meta.get("language", "ru") + + def set_meta(attrs: dict): + search_attrs = {k: v for k, v in attrs.items() if k != "content"} + tag = soup.head.find("meta", attrs=search_attrs) + if tag: + tag["content"] = attrs["content"] + else: + new_tag = soup.new_tag("meta") + new_tag.attrs.update(attrs) + soup.head.append(new_tag) + + if meta.get("title"): + if soup.head.title: + soup.head.title.string = meta["title"] + else: + t_tag = soup.new_tag("title") + t_tag.string = meta["title"] + soup.head.append(t_tag) + + if meta.get("description"): + set_meta({"name": "description", "content": meta["description"]}) + if meta.get("authors"): + set_meta({"name": "author", "content": meta["authors"]}) + if meta.get("site_name"): + set_meta({"property": "og:site_name", "content": meta["site_name"]}) + if meta.get("date"): + set_meta({"name": "article:published_time", "content": meta["date"]}) + + return str(soup) + +@app.post("/api/submit") +async def submit_bookmark(req: SubmitRequest): + config = load_config() + readeck_url = config.get("readeck_url", "").strip("/") + readeck_token = config.get("readeck_token", "") + public_host = config.get("public_host", "").strip() or get_lan_ip() + + if not readeck_url or not readeck_token: + raise HTTPException(400, "URL и Токен Readeck не настроены. Откройте настройки и сохраните их.") + + prepared = prepare_content(req.content, req.content_format) + final_html = inject_metadata(prepared, { + "title": req.title, + "description": req.description, + "authors": req.authors, + "site_name": req.site_name, + "date": req.date, + "language": req.language + }) + + content_id = store_content(final_html) + + callback_url = f"http://{public_host}:{PORT}/content/{content_id}" + + payload = { + "url": callback_url, + "labels": req.tags, + "favorite": req.favorite, + "archived": req.archive + } + + headers = { + "Authorization": f"Bearer {readeck_token}", + "Content-Type": "application/json" + } + + print(f"\n[DEBUG] --- НАЧАЛО ОТПРАВКИ ---") + print(f"[DEBUG] URL: {readeck_url}/api/bookmarks") + + async with httpx.AsyncClient() as client: + try: + resp = await client.post( + f"{readeck_url}/api/bookmarks", + json=payload, + headers=headers, + timeout=45.0, + follow_redirects=True + ) + + print(f"[DEBUG] Статус ответа: {resp.status_code}") + + if resp.status_code >= 400: + raise Exception(f"Readeck отклонил запрос (Код {resp.status_code}). Ответ: {resp.text}") + + try: + data = resp.json() + except Exception: + data = {"id": "Успешно, но сервер не вернул JSON"} + + print(f"[DEBUG] --- УСПЕШНО --- \n") + return {"success": True, "bookmark": data} + + except Exception as e: + print("\n!!! ОШИБКА READECK API !!!") + traceback.print_exc() + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!\n") + raise HTTPException(500, detail=f"{type(e).__name__}: {str(e)}") + +class PreviewRequest(BaseModel): + content: str + title: str = "" + description: str = "" + authors: str = "" + site_name: str = "" + date: str = "" + language: str = "ru" + content_format: str = "html" + +@app.post("/api/preview") +def preview(req: PreviewRequest): + """Готовит финальный HTML (как при отправке) и кладёт во временное хранилище + для просмотра через /content/{id} — точно так же, как его увидит Readeck.""" + prepared = prepare_content(req.content, req.content_format) + final_html = inject_metadata(prepared, { + "title": req.title, + "description": req.description, + "authors": req.authors, + "site_name": req.site_name, + "date": req.date, + "language": req.language, + }) + content_id = store_content(final_html) + return {"id": content_id, "url": f"/content/{content_id}"} + +@app.get("/content/{content_id}") +def get_content(content_id: str): + entry = CONTENT_STORE.get(content_id) + if not entry or time.time() - entry["created"] > CONTENT_TTL: + CONTENT_STORE.pop(content_id, None) + raise HTTPException(404, "Content not found or expired") + return HTMLResponse(content=entry["html"], media_type="text/html; charset=utf-8") + + +if __name__ == "__main__": + print(f"[*] Starting Readeck Local Importer on http://0.0.0.0:{PORT}") + print(f"[*] Your LAN IP (for firewall/callback) is: {get_lan_ip()}") + uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info", reload=False) \ No newline at end of file