Files
toreadeck/__pycache__/main.cpython-312.pyc
T

833 lines
67 KiB
Plaintext
Raw Normal View History

2026-05-31 18:45:40 +08:00
Ë
jîãóœddlZddlZddlZddlZddlZddlZddlZddlZddlZddl Z ddl
Z
ddl m Z ddl
mZmZmZmZddlmZddlmZddlmZddlmZddlZddlZddlZ ddlZdZ ejBjEejBjGe$««Z%ejBjMe%d «Z'd
Z(d Z)iZ*ejV«Z,e-ej\j^«gd ¢zZ/gd
¢gd¢gd¢dœZ0de1fdZ2de1de1fdZ3de1de1fdZ4de1de1fdZ5e)fde1de6de e1fdZ7de1de1de1fdZ8de1de9fdZ:de9fdZ;de9fdZ<ejzd efd!„«Z>ed"e>¬#«Z?Gd$„d%e«Z@Gd&„d'e«ZAGd(„d)e«ZBGd*„d+e«ZCGd,„d-e«ZDGd.„d/e«ZEd0ZFe?jd1e¬2«d3„«ZHe?jd4«d5„«ZIe?j•d4«d6e@fd7„«ZKe?j•d8«ed9«fd:efd;„«ZLe?j•d<«d=eAfd>„«ZMe?j•d?«d=eBfd@„«ZNe?j•dA«d=eCfdB„«ZOe?j•dC«d=eDfdD„«ZPe?j•dE«d6e@fdF„«ZQdGe1dHe1de1fdI„ZRdJe1dKe9de1fdL„ZSe?j•dM«d=eEfdN„«ZTGdO„dPe«ZUe?j•dQ«d=eUfdR„«ZVe?jdS«dTe1fdU„«ZWeXdVk(r3eYdWe «eYdXe2««e
j´e?dYe dZd[¬\«yy#e$rdZYŒâwxYw)]éN)ÚList)ÚFastAPIÚ
UploadFileÚFileÚ
HTTPException)Ú HTMLResponse)Ú BaseModel)Ú
BeautifulSoup)ÚGoogleTranslatoriÎz config.jsonii”)Ú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Ús)ÚclassÚidÚtitleÚlang)Úhrefr+ÚrelÚtarget)ÚsrcÚaltr+ÚwidthÚheight)ÚarÚreturncóú tjtjtj«5}|jd«|j «dcddd«S#1swYyxYw#t
$rYywxYw)N)z8.8.8.8éPrz 127.0.0.1)ÚsocketÚAF_INETÚ
SOCK_DGRAMÚconnectÚ getsocknameÚ Exception)r(s ú C:\Users\dimir\toreadeck\main.pyÚ
get_lan_ipr@:sZðÜ
]‰]œ6Ÿ>™>¬6×+<Ñ+<Ô
Ø
I‰I —==“? >×
=ûô òÙðús.2A.´$A"Á A.Á"A+Á'A.Á+A.Á. A:Á9A:Úhtmlcójttj««}tj«}t5t
j
«Dcgc]\}}||dz
tkDsŒ|Œ}}}|D]}t
j|d«Œ||dœt
|<ddd«|Scc}}w#1swY|SxYw)upСохранÑет HTML под новым UUID, попутно Ð¿Ð¾Ð´Ñ‡Ð¸Ñ‰Ð°Ñ Ð¿Ñ€Ð¾Ñ‚ÑƒÑ…ÑˆÐ¸Ðµ запиÑи.ÚcreatedN)rArC) ÚstrÚuuidÚuuid4ÚtimeÚ _store_lockÚ
CONTENT_STOREÚitemsÚ CONTENT_TTLÚpop)rAÚ
content_idÚnowÚexpireds r?Ú
store_contentrRBsä”T—ZZ“\Ó"€JÜ
)‰)+€CÞ Ü!.×!4Ñ!4Ô!6Ô[Ñ!6™˜˜A¸À)Á Ñ:LÌ{Ó:Z’1Ð!6ˆÑˆ × Ñ ˜a Õ à-1¸cÑ$BŒ

ð
Ðùó \÷
ð
Ðús#¸B(ÁB"Á(B"Á,,B(Â"B(Â(B2cóDtj|ttd¬«S)uЧиÑтит HTML от потенциально опаÑных тегов/атрибутов перед публикацией.T)ÚtagsÚ
attributesÚstrip)ÚbleachÚcleanÚ ALLOWED_TAGSÚ
ALLOWED_ATTRS)rAs r?Ú
sanitize_htmlr[Msä <‰<˜¤<¼MÐQUÔ Útextcó4tj|gd¢¬«S)u]Конвертирует Markdown в HTML (Ñ Ñ‚Ð°Ð±Ð»Ð¸Ñ†Ð°Ð¼Ð¸ и блоками кода).)ÚextraÚ
sane_listsÚnl2br)Ú
extensions)Úmd_libÚmarkdown)r]s r?Úmarkdown_to_htmlreQsä ?‰?˜4Ò,LÔ Mr\Úlimitcó¶t|«|kr|gStjd|«}gd}}|D]•}t|«|kDr=|r|j|«d}|j|d|«||d}t|«|kDrŒ=t|«t|«zdz|kr|d|j «}Œ|r|j|«|}Œ—|r|j|«|S)Режет длинный текÑÑ‚ на куÑки <= limit Ñимволов, по возможноÑти по границам
предложений/Ñлов, чтобы не уперетьÑÑ Ð² лимит Google на один запроÑ.u(?<=[.!?。…\n])\s+ÚÚ )ÚlenÚreÚsplitÚappendrV)r]rfÚpartsÚchunksÚbufÚparts r?Ú
chunk_textrsUô ˆ4ƒy؈vˆ
Ü H‰HÐ-¨tÓ 4€EØbˆC€FÛˆä$‹i˜%ÒÙØ
˜Ø M‰M˜$˜v ˜ ˜˜<ˆ $‹i˜%Óô ˆs‹8”c˜$“iÑ  !Ñ #  E˜˜4˜&/×)‰CáØ
˜‰Cðñ Ø
Ø €Mr\Ú target_langcó|r|j«s|Std|¬«}g}t|«D])} |j|«}|j |r|n|«Œ+dj
|«S#t
$r|j |«YŒYwxYw)u†ÐŸÐµÑ€ÐµÐ²Ð¾Ð´Ð¸Ñ‚ произвольно длинный текÑÑ‚, Ñ€Ð°Ð·Ð±Ð¸Ð²Ð°Ñ ÐµÐ³Ð¾ на куÑки под лимит Google.Úauto©Úsourcer/rj)rVr rsÚ translaternr>Újoin)r]rtÚ
translatorÚoutÚchunkÚress r?Útranslate_longrns„á t—z‘z”|؈ ܸ ÔD€JØ
€CܘDÖ!ˆð Ø×& -ˆ J‰J™cs   8‰8Cøôò Ø J‰J ð ús³&A,Á,B ÂB cóbt|d«Šdddœˆfd
}d}jr:‰jjr$‰jjj«}|d¬«xs|}||d¬ «xs |d
¬«|d ¬ «xs |d ¬«|d
¬«|d¬«xs |d¬ «dœS)uTДоÑтаёт title/author/description/site_name/date из HTML-метатегов.ú html.parserN)ÚnameÚpropcóÈ|rjdd|i¬«}njdd|i¬«}|r1|jd«r |jdd«j«SdS)metar©ÚattrsÚpropertyÚcontentrh)ÚfindÚgetrV)rÚtagÚsoups €r?r…z(extract_metadata_from_html.<locals>.meta€s`ø€Ù Ø—)‘)˜F¨6°4¨.9‰Cà—))˜F¨:°tÐ*<=ˆCÙ14¸¿¹ÀÔ9Kˆsw‰wy %×SÐQSÐSr\rhzog:title)Úauthor)rzarticle:authorÚ descriptionzog:descriptionú og:site_nameúarticle:published_timeÚdate)r+ÚauthorsrÚ site_namer)r
r+ÚstringrV)rAr…r+rs @r?Úextract_metadata_from_htmlr|ø€ä ˜˜}Ó -€Dà öTð
€EØ ‡zzd—jj×
×!×)ˆÙ  * U€EðÙ˜E©$Ð4DÔ*EÙ ÔN±4Ð=MÔ3NÙ˜~ÔÐH±tÀÔ7Hñ  ðr\códdt«dœ}tjjt«rT t tdd¬«5}t
j|«}t|t«r|j|«ddd«|Std
td «|S#1swYŒ xYw#t j$r$}td|«td«Yd}~|Sd}~wt$r}td|d «Yd}~|Sd}~wwxYw) Nrh)Ú readeck_urlÚ
readeck_tokenÚ public_hostÚutf-8©Úencodinguf
[WARNING] ОШИБКРВ config.json! Файл Ñодержит неверный формат JSON: u{[WARNING] Проверьте, нет ли там лишних запÑтых или пропущенных кавычек.
u?
[WARNING] Ðе удалоÑÑŒ прочитать config.json: Ú
u
[INFO] Файл uN не найден. ИÑпользуютÑÑ Ð¿ÑƒÑтые наÑтройки.
)r@ÚosÚpathÚexistsÚ CONFIG_FILEÚopenÚjsonÚloadÚ
isinstanceÚdictÚupdateÚJSONDecodeErrorÚprintr>)Údefault_configÚdataÚes r?Ú load_configr°s
àØÜ!“|ñ€Nô 
‡ww‡~~”kÔ \Ü”k Õ9¸—y‘y “|ܘd¤DÔ"×)¨$Ô Ðô Ð"¤; -Ð/~Ðô Aà Ð÷9ûô× RÜ Ð{Ð|}Ð{~Ðô
AÜ ðQ÷
Rð
Rð Ðûô ò \Ü ÐTÐUVÐTWÐWYÐZ×  Ðûð  \ús;³B'Á7BÁ<B'ÂB$ B'Â'C?Â:Cà C?Ã%C:Ã:C?Úconfigcóä ttdd¬«5}tj||d¬«ddd«y#1swYyxYw#t$r$}t d|d«t
dd |¬
«d}~wwxYw) wrœré)Úindentu=
[ERROR] Ðе удалоÑÑŒ Ñохранить config.json: rŸéôu8Ошибка запиÑи в файл наÑтроек: )Ú status_codeÚdetail)Údumpr>r)r­s r?Ú save_configrº«smðtÜ
”+˜s¨WÕ
Ü I‰If˜a¨Õ 6×
5ûä òtÜ
ÐNÈqÈcÐQSШÐ6nÐopÐnqÐ4rÔsûðtús.A6­A?»A¿AÁ A/Á A*Á*A/ÚappcódKd}tjd|«j«d­y­w)Ncó<tjdt«y)Nzhttp://127.0.0.1:)Ú
webbrowserr¤ÚPORT©r\r?Ú open_browserzlifespan.<locals>.open_browser¹sÜÐ+¬D¨6Ð3r\gø?)Ú threadingÚTimerÚstart)s r?ÚlifespanrÅ·s%èø€ò
‡OOC˜Ó&× ùs.0zReadeck Local Importer)r+có8eZdZUdZeed<dZeed<dZeed<y)Ú
SettingsModelrhr˜r™N)Ú__name__Ú
__module__Ú __qualname__r˜rDÚ__annotations__r™r\r?Äs Ø€KÓØ€MØ€KÔr\có&eZdZUeed<dZeed<y)ÚTranslateRequestr‰ÚrurtN)rDrtr\r?ÉsØ
ƒLØ€KÔr\cóeZdZUeed<y)ÚFetchUrlRequestÚurlN©rDr\r?ÍsØ „Hr\cóeZdZUeed<y)ÚExtractMetaRequestr‰NrÒr\r?ÐóØ
„Lr\cóeZdZUeed<y)ÚMarkdownRequestr‰NrÒr\r?r×r×Ór\r×cóªeZdZUeed<dZeed<dZeed<dZeed<dZeed<dZ eed<dZ
eed <gZ e eed
<d Z
eed <d Zeed
<dZeed<y)Ú
SubmitRequestr‰rhr+rr“r”rÚlanguagerTfavoriteÚarchiverAÚcontent_formatN)rDr+rr“r”rrTrÚboolrÜr\r?ÖsmØ
ƒLØ€Eˆ3ƒOØ€KÓØ€GˆSÓØ€IˆsÓØ€Dˆ#ƒNØ€HˆcÓØ€Dˆ$ˆs‰)ÓØ€HˆdÓØ€GˆTÓØ €N r\u
<!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">
📚 Readeck Importer
</h1>
<p class="text-white/80 text-sm md:text-base">Импорт локальных Ñтатей в Readeck Ñ Ð¿ÐµÑ€ÐµÐ²Ð¾Ð´Ð¾Ð¼</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 ? 'Ð¡Ð²ÐµÑ‚Ð»Ð°Ñ Ñ‚ÐµÐ¼Ð°' : 'Ð¢Ñ‘Ð¼Ð½Ð°Ñ Ñ‚ÐµÐ¼Ð°'">
{{ 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">
âš™ï¸ ÐаÑтройки
</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">Загрузка и Контент</h2>
</div>
<!-- Импорт по URL -->
<div class="mb-5">
<label class="block text-sm font-semibold mb-2 text-gray-700">🔗 Импорт по ÑÑылке</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="https://example.com/article">
<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 ? 'Загрузка...' : 'â¬‡ï¸ Ð—Ð°Ð³Ñ€ÑƒÐ·Ð¸Ñ‚ÑŒ' }}
</button>
</div>
</div>
<div class="mb-5">
<label class="block text-sm font-semibold mb-2 text-gray-700">📄 Файл (.txt, .html, .md)</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">📠Формат контента</label>
<div class="flex gap-2 flex-wrap">
<button v-for="fmt in formats" :key="fmt.value" @click="form.content_format = fmt.value"
:class="form.content_format === fmt.value ? 'btn-gradient text-white shadow-lg' : 'glass text-gray-700'"
class="px-4 py-2 rounded-xl text-sm font-semibold transition-all">
{{ fmt.label }}
</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">âœï¸ Контент</label>
<span class="text-xs text-gray-500 font-medium">{{ charCount }} Ñимв. · {{ 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="Загрузите файл, перетащите его Ñюда, вÑтавьте текÑÑ‚ или импортируйте по ÑÑылке..."></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">🌠Перевод:</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 ? 'Перевод...' : '🔄 ПеревеÑти' }}
</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>
ðŸ‘ï¸ ÐŸÑ€ÐµÐ´Ð¿Ñ€Ð¾Ñмотр
</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">Метаданные</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="Заполнить из HTML-метатегов контента">
<span v-if="isExtracting" class="spinner" style="border-top-color:#667eea;"></span>
✨ Ðвтозаполнить
</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">Заголовок *</label>
<input v-model="form.title" type="text" class="w-full input-modern rounded-xl p-3 text-sm font-medium" placeholder="ÐœÐ¾Ñ ÑтатьÑ">
</div>
<div>
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">Ðвторы</label>
<input v-model="form.authors" type="text" class="w-full input-modern rounded-xl p-3 text-sm" placeholder="Иван Иванов">
</div>
<div>
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">Дата (ISO)</label>
<input v-model="form.date" type="text" class="w-full input-modern rounded-xl p-3 text-sm" placeholder="2023-10-01">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">ОпиÑание</label>
<input v-model="form.description" type="text" class="w-full input-modern rounded-xl p-3 text-sm" placeholder="Краткое опиÑание...">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">Ðазвание Ñайта</label>
<input v-model="form.site_name" type="text" class="w-full input-modern rounded-xl p-3 text-sm" placeholder="Local Source">
</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">Опции Readeck</h2>
</div>
<div class="mb-5">
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">ðŸ·ï¸ Теги</label>
<input v-model="tagsInput" type="text" class="w-full input-modern rounded-xl p-3 text-sm" placeholder="tech, news, translated">
</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">
⭠В Избранное
</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">
📦 Ð’ Ðрхив
</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 ? 'ⳠОтправка в Readeck...' : '🚀 Создать закладку' }}
</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">ÐаÑтройки</h2>
</div>
<div class="mb-4">
<label class="block text-sm font-bold mb-2 text-gray-700">🌠Readeck URL</label>
<input v-model="settings.readeck_url" type="text" class="w-full input-modern rounded-xl p-3 text-sm" placeholder="http://192.168.1.10:8000">
</div>
<div class="mb-4">
<label class="block text-sm font-bold mb-2 text-gray-700">🔑 API Токен</label>
<input v-model="settings.readeck_token" type="password" class="w-full input-modern rounded-xl p-3 text-sm font-mono" placeholder="rdk_...">
</div>
<div class="mb-6">
<label class="block text-sm font-bold mb-2 text-gray-700">📡 Ваш LAN IP</label>
<input v-model="settings.public_host" type="text" class="w-full input-modern rounded-xl p-3 text-sm" placeholder="192.168.x.x">
</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>
🔌 Проверить подключение
</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">
Отмена
</button>
<button @click="saveSettings" class="flex-1 btn-gradient text-white px-6 py-3 rounded-xl font-semibold shadow-lg">
💾 Сохранить
</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">ПредпроÑмотр</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">â†—ï¸ Ð’ новой вкладке</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">✕ Закрыть</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: "" });
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 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 () => {
// Тема: ÑÐ¾Ñ…Ñ€Ð°Ð½Ñ‘Ð½Ð½Ð°Ñ Ð¸Ð»Ð¸ ÑиÑтемнаÑ.
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 */ }
try {
const res = await fetch("/api/settings");
if (res.ok) Object.assign(settings, await res.json());
} catch (e) {
console.error("Ошибка загрузки наÑтроек при Ñтарте:", e);
}
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("Ðе удалоÑÑŒ Ñохранить наÑтройки:\n" + await parseError(res));
return;
}
showSettings.value = false;
testMessage.value = "";
} catch (e) {
alert("Ð¡ÐµÑ‚ÐµÐ²Ð°Ñ Ð¾ÑˆÐ¸Ð±ÐºÐ° при Ñохранении:\n" + 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 = "✅ " + (data.message || "Подключение уÑпешно");
} catch (e) {
testIsError.value = true;
testMessage.value = "⌠" + 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("Ðе удалоÑÑŒ прочитать файл:\n" + 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 = "✅ Ð¡Ñ‚Ð°Ñ‚ÑŒÑ Ð·Ð°Ð³Ñ€ÑƒÐ¶ÐµÐ½Ð° и метаданные заполнены";
} catch (e) {
resultIsError.value = true;
resultMessage.value = "Ошибка загрузки:\n" + 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("Ðе удалоÑÑŒ извлечь метаданные:\n" + 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 = "Ошибка перевода:\n" + 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("Ðе удалоÑÑŒ поÑтроить предпроÑмотр:\n" + e.message);
} finally {
isPreviewing.value = false;
}
};
const submitBookmark = async () => {
if (!form.title || !form.content) {
resultIsError.value = true;
resultMessage.value = "Ошибка: Заголовок и Контент обÑзательны!";
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 = `УÑпешно! Закладка Ñоздана (ID: ${data.bookmark.id || 'Ñкрыт'})`;
// Черновик больше не нужен.
localStorage.removeItem(DRAFT_KEY);
} catch (e) {
resultIsError.value = true;
resultMessage.value = "Ошибка:\n" + 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
};
}
}).mount('#app');
</script>
</body>
</html>
Ú/)Úresponse_classcó tt«S©N)rÚ
HTML_TEMPLATErÀr\r?Úindexrä¥s
ä œ
Ó &r\z
/api/settingscót«S)r\r?Ú get_settingsræ©s
ä r\Úsettingscóxt|d«r|j«n|j«}t|«ddiS)
model_dumpÚstatusÚok)Úhasattrré)Ú config_dicts r?Úupdate_settingsrî­s8ä+2°8¸\Ô+J(×'ÐPX×P]ÑP]ÓP_€KÜ ÔØ  Ðr\z /api/upload.Úfilecƒó°K|j«ƒd{}dD]} |j|«}d|icStdd«7Œ.#t$rYŒ5wxYw­w)N)z windows-1251zlatin-1r‰éuFÐевозможно прочитать кодировку файла.)ÚreadÚdecodeÚUnicodeDecodeErrorr)r‰Úencr]s r?Ú upload_filerö³sdèø€à—I‘I“K×€GÛð Ø—>> &ˆ˜  ˜Ð
 ùô
 Ù ð üs1AA— A¡AµAÁ AÁAÁAÁAz/api/translateÚreqcóº‡ ‡
t|jd«}|j«s"dt|j|j«iSt d|j¬«Š g}g}|j
d¬«D]\}|jjdvsŒ|j«sŒ-|j|«|j|j««Œ^|sd|jiSgŠ
gdcŠŠ ˆˆ ˆ
ˆ fd „}|D]}t|«tkDr-|«
jt||j««ŒB‰ t|«zd
ztkDst«d k\r|«j|«‰ t|«d
zz
Š Œ’|«t|«D].\}}|t
«ksŒ
|sŒ|j
|«Œ0dt|«iS) NrÚ
translatedrvrwT)r•)ÚstyleÚscriptÚheadrcó
sy j«}j|«gdcŠŠy#t$rKD]C} jj |««Œ%#t$rj|«YŒAwxYwYŒYwxYw)Nr)Útranslate_batchÚextendr>rnry)r~r]ÚbatchÚ batch_lenÚtranslated_textsr{s €€€€r?Ú flush_batchz"translate_api.<locals>.flush_batchØsø€áØ ð×,¨UÓ3ˆCØ × # CÔ ˜qЈ‰yøô
òð$×+¨J×,@Ñ,@ÀÓ,FÕGøÜ ò+¨DÖ2úòð 2ús2†".®B½ AÁBÁA;Á8BÁ:A;Á;BÂBrié)r
r‰rrtr Úfind_allÚparentrrVrnrkÚTRANSLATE_CHAR_LIMITÚ enumerateÚ replace_withrD) rÚnodes_to_translateÚtextsÚnoderr]Úirrrr{s @@@@r?Ú
translate_apir¾û€ä ˜Ÿ mÓ 4€Dð 9‰9Œ;Øœn¨S¯[©[¸#¿/¹/Ó¸¿¹ÔH€JØÐØ €Eà
 T
ÖØ ;‰;× Ñ Ð#>Ò >À4Ç:Á:Å<Ø × % dÔ L‰L˜ŸÕ 
ؘcŸk™kÐ
ÐØ˜1ЀEˆ9÷
ˆÜ ˆt‹9Ô ŒMØ × #¤N°4¸¿¹Ó$IÔ à ”s˜4“yÑ   $Ô';Ò ;¼sÀ5»zÈRÒ?OÙ ŒMØ
Ø”S˜“Y ‘]Ñ"‰ ðñ„MäÐ0‰ˆˆ4Ø ŒsÐ $Ð)9¸!Ó)<Ø × Ñ Ð.¨qÑ 
œ#˜d $r\z/api/fetch-urlcƒóúK|jj«}|s tdd«|jd«sd|z}t tdd«dd i} t j d
d |¬ «4ƒd{}|j|«ƒd{}|j«|j}ddd«ƒd{t jdd
d
d
|¬«}|r t|«nd}t|«} |s0t!|d«}
|
j"} | rtt%| ««nd}|| dœS7Œµ7Œž7Œt#1ƒd{7swYŒ„xYw#t$r*}tdd
t|«jd|«d}~wwxYw­w)u‹Ð¡ÐºÐ°Ñ‡Ð¸Ð²Ð°ÐµÑ‚ Ñтраницу по URL и извлекает из неё чиÑтый текÑÑ‚ Ñтатьи + метаданные.rñuURL не указан.)úhttp://úhttps://rNr¶uWБиблиотека trafilatura не уÑтановлена (pip install trafilatura).z
User-Agentz-Mozilla/5.0 (compatible; ReadeckImporter/1.0)Tg>@)Úfollow_redirectsÚtimeoutÚheadersu9Ðе удалоÑÑŒ загрузить Ñтраницу: ú: rA)Ú
output_formatÚ
include_linksÚinclude_imagesÚinclude_formattingrÑrhr)r‰r…)rVrÚ