Files
toreadeck/main.py
T

1517 lines
77 KiB
Python
Raw Normal View History

2026-05-31 18:45:40 +08:00
import os
import re
import json
import time
import uuid
import socket
import threading
import webbrowser
import contextlib
import traceback
import html
from pathlib import Path
2026-05-31 18:45:40 +08:00
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",
# Папка, которую просматривает отдельное окно «Локальная библиотека».
"reader_directory": os.path.join(BASE_DIR, "pages")
2026-05-31 18:45:40 +08:00
}
2026-05-31 18:45:40 +08:00
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"
reader_directory: str = ""
2026-05-31 18:45:40 +08:00
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
# ==========================================
# ЛОКАЛЬНАЯ БИБЛИОТЕКА
# ==========================================
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'''<a class="list-item" href="/library/file/{relative_name}">
<span class="item-name">{'🌐' if path.suffix.lower() in {'.html', '.htm'} else '📄'} {html.escape(relative_name)}</span>
<span class="item-meta">{html.escape(path.suffix.upper())} · {modified}</span>
<span class="open-hint">Открыть →</span>
</a>''')
listing = "".join(rows) or "<p class='empty'>В выбранном каталоге нет поддерживаемых файлов.</p>"
notice = f"<p class='error'>{html.escape(error)}</p>" if error else ""
return f'''<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Локальная библиотека</title><style>
:root{{--workspace:#f5f7f5;--surface:#fff;--surface-muted:#f0f3f0;--ink:#17211b;--muted:#617066;--line:#d9e0da;--accent:#176b45;--bg:var(--workspace);--card:var(--surface);--text:var(--ink);--border:var(--line);--shadow:0 4px 12px rgb(23 33 27 / .06)}} @media(prefers-color-scheme:dark){{:root{{--workspace:#111714;--surface:#18201b;--surface-muted:#202a23;--ink:#edf5ef;--muted:#a4b4a8;--line:#324036;--accent:#48a875;--bg:var(--workspace);--card:var(--surface);--text:var(--ink);--border:var(--line);--shadow:0 8px 18px rgb(0 0 0 / .2)}}}} body{{font-family:Inter,system-ui,sans-serif;background:var(--bg);color:var(--text);margin:0;line-height:1.6}} .container{{max-width:900px;margin:0 auto;padding:40px 20px}} h1{{font-weight:800;letter-spacing:-.025em}} .subtitle{{color:var(--muted);margin-bottom:2rem}} .list-view{{display:flex;flex-direction:column;gap:10px}} .list-item{{background:var(--card);padding:16px 20px;border-radius:12px;border:1px solid var(--border);box-shadow:var(--shadow);text-decoration:none;color:inherit;display:flex;align-items:center;justify-content:space-between;gap:16px;transition:transform .2s,border-color .2s}} .list-item:hover{{transform:translateY(-2px);border-color:var(--accent)}} .item-name{{font-weight:700;word-break:break-all}} .item-meta{{color:var(--muted);font-size:.85rem;white-space:nowrap}} .open-hint{{color:var(--accent);font-size:.9rem;font-weight:600;white-space:nowrap}} .footer{{margin-top:60px;padding:20px;text-align:center;font-size:.85rem;color:var(--muted);border-top:1px solid var(--border)}} code{{background:rgb(0 0 0 / .1);padding:2px 4px;border-radius:4px}} .error{{color:#dc2626;font-weight:600}} .empty{{color:var(--muted)}}</style></head><body><div class="container">
<h1>📄 Локальная библиотека</h1><p class="subtitle">Текущая папка: <code>{html.escape(str(directory))}</code></p>{notice}<div class="list-view">{listing}</div><div class="footer">Выберите файл, чтобы открыть его для чтения.</div></div></body></html>'''
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"<pre>{html.escape(content)}</pre>"
return f'''<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{html.escape(path.name)}</title><style>
:root{{--workspace:#f5f7f5;--surface:#fff;--surface-muted:#f0f3f0;--ink:#17211b;--muted:#617066;--line:#d9e0da;--accent:#176b45;--bg:var(--workspace);--card:var(--surface);--text:var(--ink);--border:var(--line);--shadow:0 4px 12px rgb(23 33 27 / .06)}} @media(prefers-color-scheme:dark){{:root{{--workspace:#111714;--surface:#18201b;--surface-muted:#202a23;--ink:#edf5ef;--muted:#a4b4a8;--line:#324036;--accent:#48a875;--bg:var(--workspace);--card:var(--surface);--text:var(--ink);--border:var(--line);--shadow:0 8px 18px rgb(0 0 0 / .2)}}}} body{{font-family:Inter,system-ui,sans-serif;background:var(--bg);color:var(--text);margin:0;line-height:1.6}} .container{{max-width:900px;margin:0 auto;padding:40px 20px}} .reader-topbar{{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:20px}} .content-wrapper{{background:var(--card);padding:40px;border-radius:24px;border:1px solid var(--border);box-shadow:var(--shadow)}} .back-btn{{display:inline-block;color:var(--accent);text-decoration:none;font-weight:600}} pre{{white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;font-size:.95rem;background:rgb(0 0 0 / .03);padding:20px;border-radius:12px;border:1px solid var(--border)}} .send{{background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 14px;font-weight:650;cursor:pointer}}</style></head><body><div class="container"><div class="reader-topbar"><a href="/library" class="back-btn">← Назад к библиотеке</a><button class="send" id="send">Отправить в Readeck</button></div><div class="content-wrapper"><h1>{html.escape(path.name)}</h1><hr>{displayed}</div></div><script>document.getElementById('send').addEventListener('click', () => {{if (!window.opener || window.opener.closed) {{alert('Главное окно Readeck не найдено.');return;}} window.opener.postMessage({{type:'local-library-content',title:{json.dumps(path.stem)},content:{json.dumps(content)},content_format:{json.dumps('html' if is_html else 'text')} }},window.location.origin); document.getElementById('send').textContent='Отправлено';}});</script></body></html>'''
2026-05-31 18:45:40 +08:00
# ==========================================
# ФРОНТЕНД (HTML / JS)
# ==========================================
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Readeck Local Importer</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
* { font-family: 'Inter', sans-serif; }
body {
background: #f5f7f5;
2026-05-31 18:45:40 +08:00
}
.glass {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.glass-dark {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(30px);
border: 1px solid rgba(255, 255, 255, 0.4);
}
.gradient-text {
color: #17211b;
2026-05-31 18:45:40 +08:00
}
.btn-gradient {
background: #176b45;
2026-05-31 18:45:40 +08:00
}
.btn-gradient:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(102, 126, 234, 0.5);
}
.btn-success {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
.btn-success:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(17, 153, 142, 0.5);
}
.input-modern {
transition: all 0.3s ease;
border: 2px solid rgba(102, 126, 234, 0.2);
}
.input-modern:focus {
border-color: #667eea;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
outline: none;
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.15);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
}
/* ---------- Тёмная тема ---------- */
html.dark body {
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #312e3a 100%);
}
html.dark .glass {
background: rgba(30, 41, 59, 0.7);
border: 1px solid rgba(148, 163, 184, 0.15);
}
html.dark .glass-dark {
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(148, 163, 184, 0.2);
}
html.dark .input-modern {
background: rgba(15, 23, 42, 0.6);
border-color: rgba(129, 140, 248, 0.3);
color: #e2e8f0;
}
html.dark .input-modern::placeholder { color: #64748b; }
html.dark .text-gray-700,
html.dark .text-gray-800,
html.dark .text-gray-900 { color: #cbd5e1 !important; }
html.dark .gradient-text {
background: linear-gradient(135deg, #818cf8 0%, #c084fc 100%);
-webkit-background-clip: text;
background-clip: text;
}
html.dark .bg-green-50 { background: rgba(16, 185, 129, 0.15) !important; }
html.dark .bg-red-50 { background: rgba(239, 68, 68, 0.15) !important; }
/* ---------- Предпросмотр ---------- */
.preview-body {
line-height: 1.7;
color: #1f2937;
}
.preview-body h1, .preview-body h2, .preview-body h3 { font-weight: 700; margin: 1rem 0 0.5rem; }
.preview-body p { margin: 0.75rem 0; }
.preview-body img { max-width: 100%; border-radius: 0.5rem; }
.preview-body a { color: #6366f1; text-decoration: underline; }
.preview-body pre { background: #f1f5f9; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
html.dark .preview-body { color: #e2e8f0; }
html.dark .preview-body pre { background: #1e293b; }
.drag-active {
border-color: #667eea !important;
background: rgba(102, 126, 234, 0.08) !important;
}
.spinner {
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
width: 14px; height: 14px;
display: inline-block;
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; } }
2026-05-31 18:45:40 +08:00
</style>
</head>
<body class="min-h-screen">
<div id="app" class="app-shell mx-auto">
<div class="app-header flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
2026-05-31 18:45:40 +08:00
<div>
<h1 class="app-title font-extrabold mb-2">
{{ t('app_title') }}
2026-05-31 18:45:40 +08:00
</h1>
<p class="app-subtitle text-sm md:text-base">{{ t('app_subtitle') }}</p>
2026-05-31 18:45:40 +08:00
</div>
<div class="top-actions flex gap-2 flex-wrap">
<button @click="toggleDark" class="top-action glass font-semibold" :title="isDark ? t('theme_light') : t('theme_dark')">
{{ isDark ? 'Светлая тема' : 'Тёмная тема' }}
</button>
<button @click="openLocalLibrary" class="top-action glass font-semibold">
Локальная библиотека
2026-05-31 18:45:40 +08:00
</button>
<button @click="showSettings = true" class="top-action glass font-semibold">
{{ t('settings') }}
2026-05-31 18:45:40 +08:00
</button>
</div>
</div>
<div class="workspace-grid grid grid-cols-1 lg:grid-cols-2">
2026-05-31 18:45:40 +08:00
<!-- Левая панель: Загрузка и контент -->
<div class="workspace-card">
2026-05-31 18:45:40 +08:00
<div class="flex items-center gap-3 mb-6">
<div class="step-number flex items-center justify-center font-bold">
2026-05-31 18:45:40 +08:00
1
</div>
<h2 class="text-2xl font-bold gradient-text">{{ t('section_upload') }}</h2>
</div>
<!-- Импорт по URL -->
<div class="mb-5">
<label class="block text-sm font-semibold mb-2 text-gray-700">🔗 {{ t('import_url') }}</label>
<div class="flex flex-col sm:flex-row gap-2">
<input v-model="urlInput" type="text" @keyup.enter="fetchUrl" class="flex-1 input-modern rounded-xl p-3 text-sm" :placeholder="t('import_url_placeholder')">
<button @click="fetchUrl" :disabled="isFetching || !urlInput" class="btn-gradient text-white px-5 py-3 rounded-xl text-sm font-semibold disabled:opacity-50 disabled:cursor-not-allowed shadow-lg flex-shrink-0 flex items-center justify-center gap-2">
<span v-if="isFetching" class="spinner"></span>
{{ isFetching ? t('import_url_loading') : t('import_url_button') }}
</button>
</div>
</div>
<div class="mb-5">
<label class="block text-sm font-semibold mb-2 text-gray-700">📄 {{ t('file_upload') }}</label>
<input type="file" accept=".txt,.html,.md,.markdown" @change="uploadFile" class="block w-full text-sm file:mr-4 file:py-3 file:px-6 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-gradient-to-r file:from-blue-500 file:to-purple-600 file:text-white hover:file:from-blue-600 hover:file:to-purple-700 file:cursor-pointer file:transition-all file:shadow-lg cursor-pointer"/>
</div>
<!-- Формат контента -->
<div class="mb-5">
<label class="block text-sm font-semibold mb-2 text-gray-700">📐 {{ t('content_format') }}</label>
<div class="flex gap-2 flex-wrap">
<button @click="form.content_format = 'html'"
:class="form.content_format === 'html' ? 'btn-gradient text-white shadow-lg' : 'glass text-gray-700'"
class="px-4 py-2 rounded-xl text-sm font-semibold transition-all">
{{ t('format_html') }}
</button>
<button @click="form.content_format = 'markdown'"
:class="form.content_format === 'markdown' ? 'btn-gradient text-white shadow-lg' : 'glass text-gray-700'"
class="px-4 py-2 rounded-xl text-sm font-semibold transition-all">
{{ t('format_markdown') }}
</button>
<button @click="form.content_format = 'text'"
:class="form.content_format === 'text' ? 'btn-gradient text-white shadow-lg' : 'glass text-gray-700'"
class="px-4 py-2 rounded-xl text-sm font-semibold transition-all">
{{ t('format_text') }}
</button>
</div>
</div>
<div class="mb-3">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-semibold text-gray-700">✍️ {{ t('content_label') }}</label>
<span class="text-xs text-gray-500 font-medium">{{ charCount }} {{ t('content_stats', { words: wordCount }) }}</span>
</div>
<textarea v-model="form.content" rows="14"
@dragover.prevent="dragActive = true" @dragleave.prevent="dragActive = false" @drop.prevent="onDrop"
:class="{ 'drag-active': dragActive }"
class="editor-area w-full input-modern p-4 font-mono text-sm transition-all"
2026-05-31 18:45:40 +08:00
:placeholder="t('content_placeholder')"></textarea>
</div>
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 p-5 glass rounded-2xl shadow-lg mb-3">
<span class="text-sm font-semibold text-gray-700">🌐 {{ t('translation') }}</span>
<select v-model="targetLang" class="input-modern rounded-xl p-2.5 text-sm font-medium flex-1">
<option v-for="lang in languages" :key="lang.code" :value="lang.code">{{ lang.label }}</option>
</select>
<button @click="translateContent" :disabled="isTranslating || !form.content" class="btn-gradient text-white px-6 py-2.5 rounded-xl text-sm font-semibold disabled:opacity-50 disabled:cursor-not-allowed shadow-lg flex-shrink-0 flex items-center justify-center gap-2">
<span v-if="isTranslating" class="spinner"></span>
{{ isTranslating ? t('translating') : t('translate_button') }}
</button>
</div>
<button @click="openPreview" :disabled="!form.content || isPreviewing" class="w-full glass text-gray-700 font-semibold py-3 px-4 rounded-2xl shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
<span v-if="isPreviewing" class="spinner" style="border-top-color:#667eea;"></span>
{{ t('preview_button') }}
</button>
</div>
<!-- Правая панель: Метаданные и отправка -->
<div class="workspace-card">
2026-05-31 18:45:40 +08:00
<div class="flex items-center justify-between gap-3 mb-6">
<div class="flex items-center gap-3">
<div class="step-number flex items-center justify-center font-bold">
2026-05-31 18:45:40 +08:00
2
</div>
<h2 class="text-2xl font-bold gradient-text">{{ t('section_metadata') }}</h2>
</div>
<button @click="autofillMeta" :disabled="!form.content || isExtracting" class="glass text-gray-700 px-4 py-2 rounded-xl text-xs font-semibold shadow hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" :title="t('autofill_tooltip')">
<span v-if="isExtracting" class="spinner" style="border-top-color:#667eea;"></span>
{{ t('autofill_button') }}
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="sm:col-span-2">
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('meta_title') }}</label>
<input v-model="form.title" type="text" class="w-full input-modern rounded-xl p-3 text-sm font-medium" :placeholder="t('meta_title_placeholder')">
</div>
<div>
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('meta_authors') }}</label>
<input v-model="form.authors" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('meta_authors_placeholder')">
</div>
<div>
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('meta_date') }}</label>
<input v-model="form.date" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('meta_date_placeholder')">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('meta_description') }}</label>
<input v-model="form.description" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('meta_description_placeholder')">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('meta_site_name') }}</label>
<input v-model="form.site_name" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('meta_site_name_placeholder')">
</div>
</div>
<div class="flex items-center gap-3 mb-6">
<div class="step-number flex items-center justify-center font-bold">
2026-05-31 18:45:40 +08:00
3
</div>
<h2 class="text-2xl font-bold gradient-text">{{ t('section_readeck') }}</h2>
</div>
<div class="mb-5">
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('tags_label') }}</label>
<input v-model="tagsInput" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('tags_placeholder')">
</div>
<div class="flex gap-4 mb-6 flex-wrap">
<label class="flex items-center gap-2 text-sm font-semibold cursor-pointer glass px-4 py-2.5 rounded-xl hover:shadow-lg transition-all">
<input type="checkbox" v-model="form.favorite" class="w-5 h-5 rounded-lg text-purple-600 focus:ring-purple-500 focus:ring-2 cursor-pointer">
{{ t('favorite') }}
</label>
<label class="flex items-center gap-2 text-sm font-semibold cursor-pointer glass px-4 py-2.5 rounded-xl hover:shadow-lg transition-all">
<input type="checkbox" v-model="form.archive" class="w-5 h-5 rounded-lg text-purple-600 focus:ring-purple-500 focus:ring-2 cursor-pointer">
{{ t('archive') }}
</label>
</div>
<button @click="submitBookmark" :disabled="isSubmitting" class="w-full btn-success text-white font-bold py-4 px-6 rounded-2xl shadow-2xl disabled:opacity-50 disabled:cursor-not-allowed text-lg">
{{ isSubmitting ? t('submitting') : t('submit_button') }}
</button>
<div v-if="resultMessage" :class="resultIsError ? 'bg-red-50 text-red-700 border-red-200' : 'bg-green-50 text-green-700 border-green-200'" class="mt-5 p-4 rounded-2xl border-2 text-sm font-semibold whitespace-pre-wrap shadow-lg fade-in">
{{ resultMessage }}
</div>
</div>
</div>
<!-- Модальное окно настроек -->
<div v-if="showSettings" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" @click.self="showSettings = false">
<div class="settings-dialog p-6 md:p-8 w-full max-w-md">
2026-05-31 18:45:40 +08:00
<div class="flex items-center gap-3 mb-6">
<div class="step-number flex items-center justify-center font-bold">
2026-05-31 18:45:40 +08:00
⚙️
</div>
<h2 class="text-2xl font-bold gradient-text">{{ t('settings_title') }}</h2>
</div>
<div class="mb-4">
<label class="block text-sm font-bold mb-2 text-gray-700">{{ t('settings_readeck_url') }}</label>
<input v-model="settings.readeck_url" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('settings_readeck_url_placeholder')">
</div>
<div class="mb-4">
<label class="block text-sm font-bold mb-2 text-gray-700">{{ t('settings_token') }}</label>
<input v-model="settings.readeck_token" type="password" class="w-full input-modern rounded-xl p-3 text-sm font-mono" :placeholder="t('settings_token_placeholder')">
</div>
<div class="mb-4">
<label class="block text-sm font-bold mb-2 text-gray-700">{{ t('settings_lan_ip') }}</label>
<input v-model="settings.public_host" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('settings_lan_ip_placeholder')">
</div>
<div class="mb-4">
<label class="block text-sm font-bold mb-2 text-gray-700">Папка локальной библиотеки</label>
<input v-model="settings.reader_directory" type="text" class="w-full input-modern rounded-xl p-3 text-sm" placeholder="C:\\путь\\к\\файлам">
<p class="mt-1 text-xs text-gray-500">Файлы .txt, .md и .html из этой папки будут показаны в отдельном окне.</p>
</div>
2026-05-31 18:45:40 +08:00
<div class="mb-6">
<label class="block text-sm font-bold mb-2 text-gray-700">{{ t('settings_language') }}</label>
<select v-model="settings.language" @change="changeLanguage(settings.language)" class="w-full input-modern rounded-xl p-3 text-sm">
<option v-for="lang in availableLanguages" :key="lang.code" :value="lang.code">{{ lang.name }}</option>
</select>
</div>
<button @click="testConnection" :disabled="isTesting" class="w-full glass text-gray-700 px-6 py-3 rounded-xl font-semibold shadow hover:shadow-lg transition-all mb-3 disabled:opacity-50 flex items-center justify-center gap-2">
<span v-if="isTesting" class="spinner" style="border-top-color:#667eea;"></span>
{{ isTesting ? t('settings_testing') : t('settings_test') }}
</button>
<div v-if="testMessage" :class="testIsError ? 'bg-red-50 text-red-700 border-red-200' : 'bg-green-50 text-green-700 border-green-200'" class="mb-4 p-3 rounded-xl border-2 text-sm font-semibold fade-in">
{{ testMessage }}
</div>
<div class="flex gap-3">
<button @click="showSettings = false" class="flex-1 px-6 py-3 glass rounded-xl font-semibold hover:shadow-lg transition-all">
{{ t('settings_cancel') }}
</button>
<button @click="saveSettings" class="flex-1 btn-gradient text-white px-6 py-3 rounded-xl font-semibold shadow-lg">
{{ t('settings_save') }}
</button>
</div>
</div>
</div>
<!-- Модальное окно предпросмотра -->
<div v-if="showPreview" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 fade-in" @click.self="showPreview = false">
<div class="glass-dark rounded-3xl w-full max-w-3xl max-h-[90vh] shadow-2xl flex flex-col overflow-hidden">
<div class="flex items-center justify-between gap-3 p-6 border-b border-gray-200/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xl shadow-lg">👁️</div>
<h2 class="text-2xl font-bold gradient-text">{{ t('preview_title') }}</h2>
</div>
<div class="flex gap-2">
<a v-if="previewUrl" :href="previewUrl" target="_blank" class="glass text-gray-700 px-4 py-2 rounded-xl text-sm font-semibold shadow hover:shadow-lg transition-all">{{ t('preview_new_tab') }}</a>
<button @click="showPreview = false" class="glass text-gray-700 px-4 py-2 rounded-xl text-sm font-semibold shadow hover:shadow-lg transition-all">{{ t('preview_close') }}</button>
</div>
</div>
<div class="p-6 overflow-y-auto">
<iframe v-if="previewUrl" :src="previewUrl" class="w-full rounded-2xl bg-white border-0" style="height:65vh;"></iframe>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted, reactive, watch } = Vue;
createApp({
setup() {
const showSettings = ref(false);
const showPreview = ref(false);
const isTranslating = ref(false);
const isSubmitting = ref(false);
const isFetching = ref(false);
const isExtracting = ref(false);
const isPreviewing = ref(false);
const isTesting = ref(false);
const resultMessage = ref("");
const resultIsError = ref(false);
const testMessage = ref("");
const testIsError = ref(false);
const targetLang = ref("ru");
const tagsInput = ref("");
const urlInput = ref("");
const previewUrl = ref("");
const dragActive = ref(false);
const isDark = ref(false);
// Языки перевода (поддерживаются deep_translator / Google).
const languages = [
{ code: "ru", label: "🇷🇺 Русский" },
{ code: "en", label: "🇬🇧 Английский" },
{ code: "es", label: "🇪🇸 Испанский" },
{ code: "de", label: "🇩🇪 Немецкий" },
{ code: "fr", label: "🇫🇷 Французский" },
{ code: "it", label: "🇮🇹 Итальянский" },
{ code: "pt", label: "🇵🇹 Португальский" },
{ code: "pl", label: "🇵🇱 Польский" },
{ code: "uk", label: "🇺🇦 Украинский" },
{ code: "nl", label: "🇳🇱 Нидерландский" },
{ code: "tr", label: "🇹🇷 Турецкий" },
{ code: "zh-CN", label: "🇨🇳 Китайский (упр.)" },
{ code: "ja", label: "🇯🇵 Японский" },
{ code: "ko", label: "🇰🇷 Корейский" },
{ code: "ar", label: "🇸🇦 Арабский" },
{ code: "hi", label: "🇮🇳 Хинди" },
{ code: "cs", label: "🇨🇿 Чешский" },
{ code: "sv", label: "🇸🇪 Шведский" },
{ code: "fi", label: "🇫🇮 Финский" },
{ code: "el", label: "🇬🇷 Греческий" },
{ code: "he", label: "🇮🇱 Иврит" },
{ code: "vi", label: "🇻🇳 Вьетнамский" },
];
const formats = [
{ value: "html", label: "HTML" },
{ value: "markdown", label: "Markdown" },
{ value: "text", label: "Текст" },
];
const settings = reactive({ readeck_url: "", readeck_token: "", public_host: "", language: "ru", reader_directory: "" });
2026-05-31 18:45:40 +08:00
const form = reactive({
content: "", title: "", description: "", authors: "",
site_name: "Local Importer", date: new Date().toISOString().split('T')[0],
favorite: false, archive: false, content_format: "html"
});
// Локализация
const availableLanguages = ref([]);
const translations = ref({});
const currentLang = ref("ru");
// Функция перевода
const t = (key, params = {}) => {
let text = translations.value[key] || key;
// Подстановка параметров {param}
Object.keys(params).forEach(k => {
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), params[k]);
});
return text;
};
// Загрузка языка
const loadLanguage = async (langCode) => {
try {
const res = await fetch(`/api/language/${langCode}`);
if (res.ok) {
translations.value = await res.json();
currentLang.value = langCode;
document.documentElement.lang = langCode;
}
} catch (e) {
console.error("Ошибка загрузки локализации:", e);
}
};
// Смена языка
const changeLanguage = async (langCode) => {
await loadLanguage(langCode);
settings.language = langCode;
};
// Счётчик символов/слов.
const charCount = computed(() => form.content.length);
const wordCount = computed(() => {
const t = form.content.trim();
return t ? t.split(/\\s+/).length : 0;
});
const DRAFT_KEY = "readeck_importer_draft";
// --- Тёмная тема ---
const applyDark = (val) => {
isDark.value = val;
document.documentElement.classList.toggle("dark", val);
};
const toggleDark = () => {
applyDark(!isDark.value);
localStorage.setItem("readeck_theme", isDark.value ? "dark" : "light");
};
const openLocalLibrary = () => {
const popup = window.open("/library", "local-library", "width=1100,height=800,resizable=yes,scrollbars=yes");
if (!popup) alert("Браузер заблокировал новое окно. Разрешите всплывающие окна для этого приложения.");
};
window.addEventListener("message", (event) => {
if (event.origin !== window.location.origin || !event.data || event.data.type !== "local-library-content") return;
form.content = event.data.content || "";
form.content_format = event.data.content_format || "text";
if (event.data.title) form.title = event.data.title;
resultIsError.value = false;
resultMessage.value = "Текст получен из Локальной библиотеки.";
});
2026-05-31 18:45:40 +08:00
onMounted(async () => {
// Загрузка доступных языков
try {
const res = await fetch("/api/languages");
if (res.ok) {
const data = await res.json();
availableLanguages.value = data.languages || [];
}
} catch (e) {
console.error("Ошибка загрузки списка языков:", e);
}
// Загрузка настроек
try {
const res = await fetch("/api/settings");
if (res.ok) {
const data = await res.json();
Object.assign(settings, data);
// Загружаем язык из настроек
await loadLanguage(settings.language || "ru");
}
} catch (e) {
console.error("Ошибка загрузки настроек при старте:", e);
// Загружаем язык по умолчанию
await loadLanguage("ru");
}
// Тема: сохранённая или системная.
const savedTheme = localStorage.getItem("readeck_theme");
if (savedTheme) applyDark(savedTheme === "dark");
else applyDark(window.matchMedia("(prefers-color-scheme: dark)").matches);
// Восстановление черновика.
try {
const draft = localStorage.getItem(DRAFT_KEY);
if (draft) Object.assign(form, JSON.parse(draft));
} catch (e) { /* ignore */ }
if (!settings.readeck_token || !settings.readeck_url) {
showSettings.value = true;
}
});
// Автосохранение черновика в localStorage.
watch(form, (val) => {
try { localStorage.setItem(DRAFT_KEY, JSON.stringify(val)); } catch (e) {}
}, { deep: true });
const parseError = async (res) => {
try {
const d = await res.json();
return typeof d.detail === "string" ? d.detail : JSON.stringify(d.detail);
} catch (e) {
return `Ошибка сервера (${res.status})`;
}
};
const saveSettings = async () => {
try {
const res = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings)
});
if (!res.ok) {
alert(t('error_settings_save', { error: await parseError(res) }));
return;
}
showSettings.value = false;
testMessage.value = "";
} catch (e) {
alert(t('error_network', { error: e.message }));
}
};
const testConnection = async () => {
isTesting.value = true;
testMessage.value = "";
try {
const res = await fetch("/api/test-connection", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || `Код ${res.status}`);
testIsError.value = false;
testMessage.value = t('test_success');
} catch (e) {
testIsError.value = true;
testMessage.value = t('test_error', { error: e.message });
} finally {
isTesting.value = false;
}
};
const detectFormat = (name) => {
const n = (name || "").toLowerCase();
if (n.endsWith(".md") || n.endsWith(".markdown")) return "markdown";
if (n.endsWith(".html") || n.endsWith(".htm")) return "html";
return "text";
};
const loadFile = async (file) => {
if (!file) return;
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: fd });
if (!res.ok) {
alert(t('error_file_read', { error: await parseError(res) }));
return;
}
const data = await res.json();
form.content = data.content;
form.content_format = detectFormat(file.name);
if (!form.title) form.title = file.name.replace(/\\.[^.]+$/, "");
};
const uploadFile = async (e) => {
await loadFile(e.target.files[0]);
};
const onDrop = async (e) => {
dragActive.value = false;
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if (file) await loadFile(file);
};
const fetchUrl = async () => {
if (!urlInput.value) return;
isFetching.value = true;
resultMessage.value = "";
try {
const res = await fetch("/api/fetch-url", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: urlInput.value })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Не удалось загрузить");
form.content = data.content;
form.content_format = "html";
// Подставляем метаданные, не затирая уже заполненные поля.
const m = data.meta || {};
if (m.title && !form.title) form.title = m.title;
if (m.authors && !form.authors) form.authors = m.authors;
if (m.description && !form.description) form.description = m.description;
if (m.site_name) form.site_name = m.site_name;
if (m.date) form.date = (m.date || "").split("T")[0];
resultIsError.value = false;
resultMessage.value = t('success_article_loaded');
} catch (e) {
resultIsError.value = true;
resultMessage.value = t('error_loading', { error: e.message });
} finally {
isFetching.value = false;
}
};
const autofillMeta = async () => {
isExtracting.value = true;
try {
const res = await fetch("/api/extract-meta", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: form.content })
});
const data = await res.json();
const m = data.meta || {};
if (m.title) form.title = m.title;
if (m.authors) form.authors = m.authors;
if (m.description) form.description = m.description;
if (m.site_name) form.site_name = m.site_name;
if (m.date) form.date = (m.date || "").split("T")[0];
} catch (e) {
alert(t('error_metadata', { error: e.message }));
} finally {
isExtracting.value = false;
}
};
const translateContent = async () => {
isTranslating.value = true;
try {
const res = await fetch("/api/translate", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: form.content, target_lang: targetLang.value })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Ошибка перевода");
form.content = data.translated;
} catch (e) {
resultIsError.value = true;
resultMessage.value = t('error_translation', { error: e.message });
} finally {
isTranslating.value = false;
}
};
const openPreview = async () => {
isPreviewing.value = true;
try {
const res = await fetch("/api/preview", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: form.content, title: form.title, description: form.description,
authors: form.authors, site_name: form.site_name, date: form.date,
content_format: form.content_format
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Ошибка предпросмотра");
previewUrl.value = data.url;
showPreview.value = true;
} catch (e) {
alert(t('error_preview', { error: e.message }));
} finally {
isPreviewing.value = false;
}
};
const submitBookmark = async () => {
if (!form.title || !form.content) {
resultIsError.value = true;
resultMessage.value = t('error_title_required');
return;
}
isSubmitting.value = true;
resultMessage.value = "";
try {
const payload = {
...form,
tags: tagsInput.value.split(",").map(t => t.trim()).filter(Boolean)
};
const res = await fetch("/api/submit", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Неизвестная ошибка сервера");
resultIsError.value = false;
resultMessage.value = t('success_bookmark_created', { id: data.bookmark.id || 'скрыт' });
// Черновик больше не нужен.
localStorage.removeItem(DRAFT_KEY);
} catch (e) {
resultIsError.value = true;
resultMessage.value = t('error_submit', { error: e.message });
} finally {
isSubmitting.value = false;
}
};
return {
showSettings, showPreview, isTranslating, isSubmitting, isFetching,
isExtracting, isPreviewing, isTesting, targetLang, tagsInput, urlInput,
previewUrl, dragActive, isDark, languages, formats,
settings, form, charCount, wordCount,
resultMessage, resultIsError, testMessage, testIsError,
toggleDark, openLocalLibrary, saveSettings, testConnection, uploadFile, onDrop, fetchUrl,
2026-05-31 18:45:40 +08:00
autofillMeta, translateContent, openPreview, submitBookmark,
availableLanguages, currentLang, t, changeLanguage
};
}
}).mount('#app');
</script>
</body>
</html>
"""
# ==========================================
# БЭКЕНД: ЭНДПОИНТЫ API
# ==========================================
@app.get("/", response_class=HTMLResponse)
def index():
return HTMLResponse(HTML_TEMPLATE)
@app.get("/library", response_class=HTMLResponse)
def reader_window():
"""Открывает Локальную библиотеку в отдельном окне браузера."""
return HTMLResponse(build_reader_window())
@app.get("/library/file/{relative_path:path}", response_class=HTMLResponse)
def reader_file(relative_path: str):
"""Открывает выбранный файл в режиме чтения."""
return HTMLResponse(build_reader_file_page(relative_path))
2026-05-31 18:45:40 +08:00
@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>{p}</p>" for p in paragraphs) or f"<p>{escaped}</p>"
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("<!DOCTYPE html><html><head></head><body></body></html>", "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)