Files
dinlo e1b2485156 Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:45:40 +08:00

1376 lines
64 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = """
<!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: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-attachment: fixed;
}
.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 {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.btn-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: all 0.3s ease;
}
.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); } }
</style>
</head>
<body class="min-h-screen">
<div id="app" class="max-w-7xl mx-auto p-6 md:p-8">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4 fade-in">
<div>
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-2 drop-shadow-lg">
📚 {{ t('app_title') }}
</h1>
<p class="text-white/80 text-sm md:text-base">{{ t('app_subtitle') }}</p>
</div>
<div class="flex gap-3">
<button @click="toggleDark" class="glass px-5 py-3 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-300 font-semibold text-gray-700 hover:scale-105" :title="isDark ? t('theme_light') : t('theme_dark')">
{{ isDark ? '☀️' : '🌙' }}
</button>
<button @click="showSettings = true" class="glass px-6 py-3 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-300 font-semibold text-gray-700 hover:scale-105">
⚙️ {{ t('settings') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 md:gap-8">
<!-- Левая панель: Загрузка и контент -->
<div class="glass-dark p-6 md:p-8 rounded-3xl shadow-2xl card-hover fade-in">
<div class="flex items-center gap-3 mb-6">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
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="w-full input-modern rounded-2xl p-4 font-mono text-sm resize-none transition-all"
: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="glass-dark p-6 md:p-8 rounded-3xl shadow-2xl card-hover fade-in">
<div class="flex items-center justify-between gap-3 mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-pink-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
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="w-10 h-10 rounded-xl bg-gradient-to-br from-green-500 to-teal-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
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 backdrop-blur-sm flex items-center justify-center z-50 p-4 fade-in">
<div class="glass-dark p-8 rounded-3xl w-full max-w-md shadow-2xl">
<div class="flex items-center gap-3 mb-6">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-2xl shadow-lg">
⚙️
</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-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" });
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");
};
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, saveSettings, testConnection, uploadFile, onDrop, fetchUrl,
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("/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)