diff --git a/README.md b/README.md index 0248eb3..1b3bc75 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,124 @@ -# Readeck Local Importer +# Readeck Importer -Локальный веб-сервис для импорта статей в [Readeck](https://readeck.org/) (self-hosted сервис «прочитать позже»). Позволяет загрузить текст, файл или статью по ссылке, перевести её, отредактировать метаданные и одной кнопкой создать закладку в Readeck. +Локальное веб-приложение для добавления статей и файлов в [Readeck](https://readeck.org/) — self-hosted сервис «прочитать позже». -Readeck умеет сохранять закладки только по URL, поэтому приложение поднимает временную ссылку на ваш контент в локальной сети и передаёт её Readeck — так локальный текст попадает в библиотеку как обычная статья. +Readeck импортирует материалы по URL. Приложение подготавливает контент, временно публикует его по доступному в локальной сети адресу и создаёт закладку через Readeck API. Поэтому в библиотеку можно отправлять и локальные файлы. ## Возможности -- **Импорт по ссылке** — скачивает страницу и извлекает чистый текст статьи (`trafilatura`). -- **Загрузка файлов** `.txt`, `.html`, `.md` (+ drag & drop), автоопределение кодировки. -- **Форматы контента** — HTML, Markdown, простой текст. -- **Перевод** через Google (22 языка) с учётом лимитов на длину запроса. -- **Автозаполнение метаданных** из HTML-метатегов (заголовок, автор, описание, дата, сайт). -- **Предпросмотр** статьи ровно в том виде, в каком её увидит Readeck. -- **Санитизация HTML** перед публикацией (`bleach`). -- **Тест подключения** к Readeck прямо из настроек. -- **Локализация интерфейса** — поддержка нескольких языков с возможностью добавления новых. -- Тёмная тема, счётчик символов/слов, автосохранение черновика. +- Импорт статьи по URL с извлечением основного содержания (`trafilatura`). +- Загрузка файлов `.txt`, `.html`, `.htm`, `.md`, `.markdown`, включая drag & drop. +- Автоопределение UTF-8 и Windows-1251 для локальных файлов. +- Форматы содержимого: HTML, Markdown и простой текст. +- Перевод через Google Translate с разбиением длинного текста на безопасные части. +- Автозаполнение метаданных: заголовок, автор, описание, дата и сайт. +- Предпросмотр итоговой статьи перед отправкой. +- Санитизация HTML перед публикацией (`bleach`). +- Проверка соединения с Readeck в настройках. +- Локальная библиотека: отдельное окно для просмотра файлов из выбранной папки. +- Отправка открытого в Локальной библиотеке файла в основное окно для последующей отправки в Readeck. +- Светлая и тёмная темы, автосохранение черновика, счётчики символов и слов. +- Интерфейс на русском, английском и казахском языках. ## Требования -- Python 3.9+ -- Доступный сервер Readeck и API-токен к нему +- Python 3.9+ (проект проверялся на Python 3.12). +- Доступный сервер Readeck и API-токен. +- Сетевой доступ Readeck к машине с этим приложением, если Readeck расположен на другом хосте. ## Установка -```bash -pip install fastapi uvicorn pydantic beautifulsoup4 lxml httpx deep-translator markdown bleach trafilatura +### Windows: быстрый запуск + +В каталоге проекта есть `run.bat`. Он запускает приложение через `.venv\Scripts\python.exe` и очищает унаследованные `PYTHONPATH` / `PYTHONHOME`. + +Перед первым запуском создайте виртуальное окружение и установите зависимости: + +```bat +py -3.12 -m venv .venv +.venv\Scripts\python -m pip install fastapi uvicorn pydantic beautifulsoup4 lxml httpx deep-translator markdown bleach trafilatura python-multipart +run.bat ``` -## Запуск +### Универсальный запуск ```bash +python -m pip install fastapi uvicorn pydantic beautifulsoup4 lxml httpx deep-translator markdown bleach trafilatura python-multipart python main.py ``` -Сервер стартует на `http://0.0.0.0:8142`, браузер откроется автоматически на `http://127.0.0.1:8142`. +Сервис слушает `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`. +- **Readeck URL** — адрес Readeck, например `http://192.168.1.10:8000`. +- **API Токен** — токен Readeck; приложение передаёт его в заголовке `Authorization: Bearer …`. +- **Ваш LAN IP** — IP машины с приложением, доступный для Readeck. Он используется в callback-ссылке с материалом. +- **Папка локальной библиотеки** — папка, в которой нужно искать файлы для чтения. +- **Язык интерфейса**. -## Использование +Нажмите **Проверить подключение**, затем **Сохранить**. Настройки записываются в `config.json`. -1. Вставьте текст, загрузите/перетащите файл или импортируйте статью по ссылке. -2. При необходимости переведите контент и выберите его формат. -3. Заполните или автозаполните метаданные, добавьте теги. -4. Посмотрите предпросмотр и нажмите «Создать закладку». +## Работа с материалами -## Файлы +1. В основном окне вставьте текст, загрузите файл или введите URL статьи. +2. При необходимости выберите формат и переведите содержимое. +3. Заполните метаданные вручную либо используйте **Автозаполнение**. +4. Добавьте теги и нужные флаги Readeck. +5. Откройте предпросмотр и нажмите **Создать закладку**. -- `main.py` — всё приложение (бэкенд FastAPI + фронтенд на Vue 3 / Tailwind). -- `config.json` — настройки подключения к Readeck и выбранный язык интерфейса. -- `lang/` — папка с файлами локализации интерфейса. +### Локальная библиотека + +1. Откройте **Локальная библиотека** в шапке основного окна. +2. В отдельном окне появится список поддерживаемых файлов из выбранной папки и всех её подпапок. +3. Нажмите **Открыть**, чтобы перейти в режим чтения. Файлы не загружаются целиком на странице списка, поэтому библиотека остаётся отзывчивой и с крупными каталогами. +4. На странице чтения используйте **Отправить в Readeck**. Содержимое и заголовок будут переданы в главное окно приложения. +5. Проверьте метаданные и создайте закладку обычным способом. + +Поддерживаются `.txt`, `.md`, `.markdown`, `.html` и `.htm`. Переход за пределы выбранной директории заблокирован. + +## Тестирование + +Регрессионные тесты не требуют сети или настоящего Readeck: + +```bash +# Windows Git Bash / POSIX shell +PYTHONPATH= PYTHONHOME= .venv/Scripts/python.exe -m unittest tests.test_reader -v + +# Windows cmd.exe +set PYTHONPATH= +set PYTHONHOME= +.venv\Scripts\python.exe -m unittest tests.test_reader -v +``` + +Проверяются настройки Локальной библиотеки, безопасное открытие файлов, передача текста в основное окно и наличие общей дизайн-системы интерфейса. + +## Структура проекта + +- `main.py` — FastAPI-бэкенд, API и встроенный интерфейс Vue 3 / Tailwind. +- `run.bat` — запуск приложения в Windows. +- `config.json` — локальные настройки подключения и Локальной библиотеки. **Не храните здесь рабочие токены в репозитории.** +- `lang/` — локализации интерфейса. +- `tests/test_reader.py` — автоматические тесты Локальной библиотеки и UI. +- `LOCALIZATION.md` — сведения о локализации. ## Локализация -Приложение поддерживает несколько языков интерфейса. Доступные языки: -- 🇬🇧 English -- 🇷🇺 Русский -- 🇰🇿 Қазақша (Казахский) +Доступны: -### Смена языка -1. Откройте настройки (⚙️) -2. Выберите язык в списке "🌍 Язык интерфейса" -3. Язык изменится мгновенно +- English +- Русский +- Қазақша -### Добавление нового языка -1. Создайте файл `lang/код_языка.json` (например, `de.json`) -2. Скопируйте структуру из `lang/en.json` или `lang/ru.json` -3. Переведите все значения (не меняя ключи) -4. Перезапустите приложение — новый язык появится автоматически! +Чтобы добавить язык, создайте `lang/<код>.json`, скопируйте структуру `lang/en.json` или `lang/ru.json`, переведите значения без изменения ключей и перезапустите приложение. -Подробнее см. `lang/README.md` и `LOCALIZATION.md`. +Подробнее: `lang/README.md` и `LOCALIZATION.md`. -## Примечания по безопасности +## Безопасность -- `config.json` хранит API-токен в открытом виде. Не коммитьте файл в git; при необходимости перевыпустите токен. -- Сервис слушает `0.0.0.0:8142` **без аутентификации** и доступен всем в локальной сети. Эндпоинт импорта по URL скачивает произвольные адреса (потенциальный SSRF). Для домашней сети это обычно приемлемо; не выставляйте сервис в интернет без авторизации. +- `config.json` содержит API-токен в открытом виде. Не публикуйте реальный токен и перевыпустите его при утечке. +- Сервис доступен в локальной сети на порту `8142`, без собственной аутентификации. +- Импорт по URL загружает произвольный адрес, то есть потенциально допускает SSRF. Не публикуйте приложение в интернете без ограничения доступа и дополнительной защиты. +- Readeck должен иметь возможность обратиться по callback-адресу к указанному LAN IP и порту `8142`. diff --git a/main.py b/main.py index ad3a54f..fb457db 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,8 @@ import threading import webbrowser import contextlib import traceback +import html +from pathlib import Path import uvicorn from typing import List @@ -190,9 +192,11 @@ def load_config() -> dict: "readeck_url": "", "readeck_token": "", "public_host": get_lan_ip(), - "language": "ru" + "language": "ru", + # Папка, которую просматривает отдельное окно «Локальная библиотека». + "reader_directory": os.path.join(BASE_DIR, "pages") } - + if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: @@ -239,6 +243,7 @@ class SettingsModel(BaseModel): readeck_token: str = "" public_host: str = "" language: str = "ru" + reader_directory: str = "" class TranslateRequest(BaseModel): content: str @@ -266,6 +271,81 @@ class SubmitRequest(BaseModel): archive: bool = False content_format: str = "html" # html | markdown | text +# ========================================== +# ЛОКАЛЬНАЯ БИБЛИОТЕКА +# ========================================== + +def get_reader_directory() -> Path: + configured = load_config().get("reader_directory", "") + return Path(configured or os.path.join(BASE_DIR, "pages")).expanduser().resolve() + + +def get_reader_file(relative_path: str) -> Path: + """Возвращает файл из библиотеки и запрещает выход за выбранный каталог.""" + directory = get_reader_directory() + target = (directory / relative_path).resolve() + if not target.is_relative_to(directory) or not target.is_file(): + raise HTTPException(404, "Файл не найден.") + if target.suffix.lower() not in {".txt", ".md", ".markdown", ".html", ".htm"}: + raise HTTPException(400, "Этот тип файла не поддерживается.") + return target + + +def read_reader_file(path: Path) -> str: + for encoding in ("utf-8", "windows-1251"): + try: + return path.read_text(encoding=encoding) + except UnicodeDecodeError: + continue + except OSError as exc: + raise HTTPException(500, f"Не удалось прочитать файл: {exc}") + raise HTTPException(400, "Не удалось определить кодировку файла.") + + +def build_reader_window() -> str: + """Строит лёгкий список файлов — их содержимое загружается только при открытии.""" + directory = get_reader_directory() + files = [] + error = "" + if not directory.is_dir(): + error = f"Каталог не найден: {directory}" + else: + try: + files = sorted( + (path for path in directory.rglob("*") if path.is_file() and path.suffix.lower() in {".txt", ".md", ".markdown", ".html", ".htm"}), + key=lambda path: str(path.relative_to(directory)).lower(), + ) + except OSError as exc: + error = f"Не удалось прочитать каталог: {exc}" + + rows = [] + for path in files: + relative_name = str(path.relative_to(directory)).replace("\\", "/") + modified = time.strftime("%Y-%m-%d %H:%M", time.localtime(path.stat().st_mtime)) + rows.append(f''' + {'🌐' if path.suffix.lower() in {'.html', '.htm'} else '📄'} {html.escape(relative_name)} + {html.escape(path.suffix.upper())} · {modified} + Открыть → + ''') + + listing = "".join(rows) or "

В выбранном каталоге нет поддерживаемых файлов.

" + notice = f"

{html.escape(error)}

" if error else "" + return f''' + Локальная библиотека
+

📄 Локальная библиотека

Текущая папка: {html.escape(str(directory))}

{notice}
{listing}
''' + + +def build_reader_file_page(relative_path: str) -> str: + path = get_reader_file(relative_path) + content = read_reader_file(path) + directory = get_reader_directory() + is_html = path.suffix.lower() in {".html", ".htm"} + displayed = content if is_html else f"
{html.escape(content)}
" + return f'''{html.escape(path.name)}
← Назад к библиотеке

{html.escape(path.name)}


{displayed}
''' + + # ========================================== # ФРОНТЕНД (HTML / JS) # ========================================== @@ -285,8 +365,7 @@ HTML_TEMPLATE = """ * { font-family: 'Inter', sans-serif; } body { - background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); - background-attachment: fixed; + background: #f5f7f5; } .glass { @@ -302,15 +381,12 @@ HTML_TEMPLATE = """ } .gradient-text { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; + color: #17211b; } .btn-gradient { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - transition: all 0.3s ease; + background: #176b45; + } .btn-gradient:hover:not(:disabled) { @@ -422,32 +498,66 @@ HTML_TEMPLATE = """ animation: spin 0.7s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } + + /* Claude-design workspace system: Operate surface */ + :root { --workspace:#f5f7f5; --surface:#ffffff; --surface-muted:#f0f3f0; --ink:#17211b; --muted:#617066; --line:#d9e0da; --accent:#176b45; --accent-strong:#0e5133; --focus:#8bc7a8; --danger:#b42318; --radius:14px; } + * { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } + body { background:var(--workspace); color:var(--ink); } + .app-shell { max-width:1440px; padding:28px 32px 48px; } + .app-header { padding-bottom:22px; border-bottom:1px solid var(--line); margin-bottom:24px; } + .app-title { color:var(--ink)!important; text-shadow:none!important; filter:none!important; font-size:clamp(1.7rem,3vw,2.35rem)!important; letter-spacing:-.04em; } + .app-subtitle { color:var(--muted)!important; } + .workspace-grid { gap:20px!important; align-items:start; } + .workspace-card { background:var(--surface)!important; border:1px solid var(--line)!important; border-radius:var(--radius)!important; box-shadow:none!important; padding:26px!important; } + .workspace-card:hover { transform:none!important; box-shadow:0 7px 22px rgba(23,33,27,.06)!important; } + .workspace-card h2 { color:var(--ink)!important; -webkit-text-fill-color:var(--ink)!important; background:none!important; font-size:1.25rem!important; letter-spacing:-.02em; } + .step-number { background:var(--surface-muted)!important; color:var(--accent)!important; box-shadow:none!important; border:1px solid var(--line); border-radius:50%!important; width:34px!important; height:34px!important; font-size:.85rem!important; } + .glass, .glass-dark { background:var(--surface-muted)!important; border:1px solid var(--line)!important; backdrop-filter:none!important; box-shadow:none!important; color:var(--ink)!important; } + .top-action { min-height:42px; padding:0 14px!important; border-radius:9px!important; font-size:.875rem; } + .input-modern { background:var(--surface)!important; color:var(--ink)!important; border:1px solid var(--line)!important; border-radius:9px!important; box-shadow:none!important; } + .input-modern:focus { border-color:var(--accent)!important; box-shadow:0 0 0 3px rgba(23,107,69,.16)!important; } + .btn-gradient, .btn-success { background:var(--accent)!important; color:#fff!important; box-shadow:none!important; border-radius:9px!important; } + .btn-gradient:hover:not(:disabled), .btn-success:hover:not(:disabled) { background:var(--accent-strong)!important; transform:none!important; box-shadow:none!important; } + button:disabled { opacity:.45!important; } + .editor-area { min-height:360px!important; resize:vertical!important; line-height:1.55; } + .format-control { border-radius:8px!important; box-shadow:none!important; } + .quiet-action { border-radius:9px!important; } + .settings-dialog, .preview-dialog { background:var(--surface)!important; border:1px solid var(--line); border-radius:16px!important; box-shadow:0 24px 70px rgba(23,33,27,.22)!important; } + .settings-dialog h2, .preview-dialog h2 { color:var(--ink)!important; -webkit-text-fill-color:var(--ink)!important; background:none!important; } + .settings-label { color:var(--ink)!important; font-size:.875rem!important; letter-spacing:0!important; text-transform:none!important; } + html.dark { --workspace:#111714; --surface:#18201b; --surface-muted:#202a23; --ink:#edf5ef; --muted:#a4b4a8; --line:#324036; --accent:#48a875; --accent-strong:#66bc8e; --focus:#91d4ad; } + html.dark .text-gray-700, html.dark .text-gray-800, html.dark .text-gray-900 { color:var(--ink)!important; } + html.dark .text-gray-500 { color:var(--muted)!important; } + @media (max-width: 640px) { .app-shell { padding:18px 14px 30px; } .workspace-card { padding:18px!important; } .app-header { align-items:stretch!important; } .top-actions { width:100%; } .top-action { flex:1; } } -
-
+
+
-

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

+ {{ t('app_title') }}

-

{{ t('app_subtitle') }}

+

{{ t('app_subtitle') }}

-
- - +
-
+
-
+
-
+
1

{{ t('section_upload') }}

@@ -500,7 +610,7 @@ HTML_TEMPLATE = """
@@ -522,10 +632,10 @@ HTML_TEMPLATE = """
-
+
-
+
2

{{ t('section_metadata') }}

@@ -560,7 +670,7 @@ HTML_TEMPLATE = """
-
+
3

{{ t('section_readeck') }}

@@ -593,10 +703,10 @@ HTML_TEMPLATE = """
-
-
+
+
-
+
⚙️

{{ t('settings_title') }}

@@ -614,6 +724,11 @@ HTML_TEMPLATE = """
+
+ + +

Файлы .txt, .md и .html из этой папки будут показаны в отдельном окне.

+