commit e1b2485156204bfdc3cbdae470c869c144bb70f4 Author: dinlo Date: Sun May 31 18:45:40 2026 +0800 Initial commit Co-Authored-By: Claude Opus 4.8 (1M context) 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 0000000..8a4c2fb Binary files /dev/null and b/__pycache__/main.cpython-312.pyc differ 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