Compare commits

...

9 Commits

Author SHA1 Message Date
672e199d3a validation 2026-05-03 14:37:57 +03:00
0f8707fe93 retry 2026-05-03 14:12:25 +03:00
84b24b2b5b upd 2026-05-03 14:07:18 +03:00
bb6f2d67d8 upd 2026-05-03 13:37:21 +03:00
2cb244d973 upd 2026-05-03 13:12:55 +03:00
07bc7ef1e0 mangalib 2026-05-02 22:31:33 +03:00
a7eaa22646 mangalib 2026-05-02 21:59:59 +03:00
419614d295 upd 2026-05-02 20:15:36 +03:00
fcd1dfb74c upd 2026-05-02 20:03:21 +03:00
11 changed files with 1904 additions and 490 deletions

View File

@@ -1,369 +0,0 @@
# Code Review: находки и предложения
> Статус: **только чтение** — ничего не изменено. Каждый пункт сопровождается предлагаемым исправлением.
---
## 1. Дублирование кода — `_safe_name` / `_safe_chapter_name`
**Файлы:** `src/api.py:251`, `src/worker.py:2632`, `src/cli.py` (аналогичные функции)
Одна и та же функция определена в трёх местах. При изменении логики нужно менять сразу в трёх файлах — риск расхождения.
**Исправление:** вынести в `src/utils.py`, импортировать везде:
```python
# src/utils.py
import re
from .sources.base import Chapter
def safe_name(s: str) -> str:
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
def safe_chapter_name(ch: Chapter) -> str:
vol = f"v{ch.volume:02d}_" if ch.volume else ""
return f"{vol}ch{ch.number:06.1f}"
```
---
## 2. Прямой доступ к `db.conn` в API-эндпоинтах
**Файлы:** `src/api.py`
| Место | Строки |
|-------|--------|
| `_enrich_manga` | 269278 |
| `retry_errors` | 680688 |
| `force_redownload` | 819823 |
| `delete_manga` | 882885 |
| `rename_folder` | 801803 |
`api.py` напрямую исполняет SQL через `db.conn.execute(...)` вместо методов `StateDB`. Это означает, что логика разбросана между двумя слоями, и рефакторинг `StateDB` может незаметно сломать эндпоинты.
Для `retry_errors` и `force_redownload` в `StateDB` уже есть готовый метод `reset_failed_chapters` — он просто не используется в API.
**Исправление для `retry_errors`:**
```python
# api.py — было:
now = db.conn.execute("SELECT datetime('now')").fetchone()[0]
db.conn.execute("UPDATE chapters SET status='pending' ...", (now, url))
db.conn.commit()
# стало:
db.reset_failed_chapters(url)
```
Для `delete_manga` и `rename_folder` добавить соответствующие методы в `StateDB`.
---
## 3. `datetime.utcnow()` устарел
**Файлы:** `src/api.py:369`, `src/state.py:628`
`datetime.utcnow()` объявлен deprecated в Python 3.12. Используется в двух местах.
**Исправление:**
```python
# src/state.py
from datetime import datetime, timezone
def _now() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
# src/api.py — в login():
expires_at = (datetime.now(timezone.utc) + timedelta(days=30)).replace(tzinfo=None).isoformat()
```
---
## 4. `check_for_updates` не использует `db_lock`
**Файл:** `src/worker.py:343400`
`download_manga` тщательно оборачивает все обращения к БД в `db_lock` (`asyncio.Lock`). `check_for_updates` вызывает `db.update_manga_info`, `db.get_all_chapters`, `db.set_last_checked` напрямую без блокировки. При параллельном запуске нескольких авто-обновлений возможна конкуренция записей в одну и ту же строку SQLite.
**Исправление:** добавить `db_lock` в `check_for_updates` по аналогии с `download_manga`, либо убедиться, что авто-обновления всегда запускаются последовательно (сейчас в `_run_auto_updates` они идут через `for manga in candidates` — это последовательно, но ручной `check_now` и авто-обновление могут пересечься).
---
## 5. Хак `pages_done_count = [0]`
**Файл:** `src/worker.py:196`
```python
pages_done_count = [0] # мутабельный список вместо nonlocal
async def on_page(page_idx: int, pages_total: int):
pages_done_count[0] += 1
```
Это классический обходной путь для `nonlocal` в Python 2. В Python 3 достаточно `nonlocal`.
**Исправление:**
```python
pages_done = 0
async def on_page(page_idx: int, pages_total: int):
nonlocal pages_done
pages_done += 1
await db_call(db.update_chapter_pages, ch.url, pages_total, pages_done)
```
---
## 6. Мёртвый код в `StateDB`
**Файл:** `src/state.py:405407`
```python
def increment_manga_chapters_done(self, url: str):
# Оставлен для совместимости, но не используется в воркере
pass
```
Метод ничего не делает и нигде не вызывается.
**Исправление:** удалить.
---
## 7. Отложенный импорт `BrowserManager` в `_fetch_preview`
**Файл:** `src/api.py:548`
```python
async def _fetch_preview(url: str):
try:
from .browser import BrowserManager # импорт внутри функции
```
`BrowserManager` уже импортируется в `worker.py`. Импорт внутри функции — признак того, что его пытались скрыть из-за каких-то проблем при старте, но сейчас оснований для этого нет. Это замедляет первый вызов и затрудняет поиск зависимостей.
**Исправление:** добавить `from .browser import BrowserManager` в топ-уровневые импорты `api.py`.
Аналогично — `import shutil` внутри тел `rename_folder` (строка 789) и `delete_manga` (строка 879). Вынести в топ.
---
## 8. O(n²) назначение позиций в очереди
**Файл:** `src/api.py:486491`
```python
queue_list = list(download_queue._queue)
for i, job in enumerate(queue_list):
for r in result: # ← внутренний цикл по всем мангам
if r["url"] == job["url"]:
r["queue_position"] = i + 1
```
При 100 мангах в очереди и 100 в БД — 10 000 итераций на каждый запрос `/api/mangas`.
**Исправление:**
```python
queue_positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
for r in result:
r["queue_position"] = queue_positions.get(r["url"])
```
---
## 9. Утечка памяти в `_export_pdf_pillow`
**Файл:** `src/exporter.py:131135`
```python
def _export_pdf_pillow(images: list[Path], out: Path):
from PIL import Image
pil_images = [Image.open(p).convert("RGB") for p in images]
if pil_images:
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
# pil_images не закрываются — файловые дескрипторы висят до GC
```
**Исправление:**
```python
def _export_pdf_pillow(images: list[Path], out: Path):
from PIL import Image
pil_images = [Image.open(p).convert("RGB") for p in images]
try:
if pil_images:
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
finally:
for img in pil_images:
img.close()
```
---
## 10. Потенциальный SQL-инъекционный путь в `mark_done`
**Файл:** `src/state.py:453459`
```python
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
col = f"output_{fmt}"
self.conn.execute(f"""
UPDATE chapters SET status='done', {col}=?, updated_at=?
WHERE chapter_url=?
""", (output_path, _now(), chapter_url))
```
Сейчас `fmt` всегда приходит из `["cbz", "pdf", "epub"]` в `worker.py`, поэтому эксплуатации нет. Но паттерн хрупкий: если завтра `fmt` придёт от пользователя через API, это станет инъекцией.
**Исправление:**
```python
_ALLOWED_FMTS = {"cbz", "pdf", "epub"}
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
if fmt not in _ALLOWED_FMTS:
raise ValueError(f"Unknown format: {fmt}")
col = f"output_{fmt}"
...
```
---
## 11. Неиспользуемый метод `BrowserManager.navigate()`
**Файл:** `src/browser.py`
`BrowserManager` содержит метод `navigate()`, который нигде не вызывается — `readmanga.py` определяет собственный `_navigate`. Это мёртвый код.
**Исправление:** удалить `navigate()` из `BrowserManager` или убрать `_navigate` из `readmanga.py` и использовать единый метод.
---
## 12. `cli.py` использует устаревший шим вместо реестра источников
**Файл:** `src/cli.py`
```python
from .scraper import get_manga_info, get_chapter_images_and_download # shim
```
`scraper.py` — это обёртка-пустышка, делегирующая в `ReadmangaSource`. CLI не использует `SourceRegistry`, не поддерживает `MangaMeta` полноценно. При добавлении нового источника CLI не заработает автоматически.
**Исправление:** переписать `cli.py` на использование `registry` и `get_source_for_url`, как это сделано в `worker.py`.
---
## 13. Двойное чтение тела ответа в `saveRenameFolder`
**Файл:** `frontend/index.html`
```javascript
async function saveRenameFolder() {
const r = await fetch('/api/mangas/rename_folder', ...);
if (!r.ok) {
const err = await r.json(); // ← первое чтение
...
}
const data = await r.json(); // ← второе чтение (тело уже прочитано!)
```
`Response.body` — это `ReadableStream`, читается один раз. Второй `r.json()` вернёт ошибку или пустой объект.
**Исправление:**
```javascript
const data = await r.json();
if (!r.ok) {
showError(data.detail || 'Ошибка');
return;
}
```
---
## 14. `escHtml()` не защищает от JS-инъекции в `onclick`
**Файл:** `frontend/index.html` — различные места типа:
```javascript
`<button onclick="openEditUserModal(${u.id}, '${escHtml(u.username)}', ...)">
```
`escHtml` экранирует HTML-спецсимволы (`<`, `>`, `&`), но не защищает от инъекции в контекст JavaScript-строки. Если `u.username` содержит `'); evil() //` — экранирование не поможет.
**Исправление:** вместо `onclick="..."` в строке шаблона использовать `data-*` атрибуты + делегирование событий:
```javascript
`<button class="edit-user-btn" data-id="${u.id}" data-username="${escAttr(u.username)}">`
// один раз:
document.addEventListener('click', e => {
const btn = e.target.closest('.edit-user-btn');
if (btn) openEditUserModal(+btn.dataset.id, btn.dataset.username, ...);
});
```
---
## 15. Дублирование `forceRedownload` / `forceRedownloadModal`
**Файл:** `frontend/index.html`
Две функции с практически идентичным телом — `forceRedownload(url)` и `forceRedownloadModal(url)`. Отличие только в том, что вторая закрывает модал перед вызовом.
**Исправление:** одна функция с необязательным флагом или вызов `closeModal()` перед `forceRedownload()` там, где нужно.
---
## 16. `check_for_updates` в `worker.py` импортирует `scraper` шим
**Файл:** `src/worker.py:16`
```python
from .scraper import get_manga_info, get_chapter_images_and_download # shim для обратной совместимости
```
Эти символы нигде в файле не используются — `worker.py` работает через `registry`. Мёртвый импорт.
**Исправление:** удалить строку.
---
## 17. Одиночный SQLite-коннект с `check_same_thread=False` в async-среде
**Файл:** `src/state.py:27`
```python
self.conn = sqlite3.connect(str(db_path), check_same_thread=False)
```
Каждый `StateDB()` создаёт новый коннект, но внутри одного воркера `db` живёт на всё время скачивания манги. `check_same_thread=False` отключает встроенную защиту SQLite от использования коннекта в нескольких потоках. В текущей архитектуре с `asyncio` и `db_lock` это безопасно, но хрупко — одно забытое `await` без блокировки может привести к `database is locked` или повреждению данных.
**Рекомендация:** ничего не менять прямо сейчас, но при следующем крупном рефакторинге рассмотреть `aiosqlite` или SQLite WAL-режим (`PRAGMA journal_mode=WAL`) для снижения вероятности блокировок при параллельных читателях.
---
## Сводная таблица приоритетов
| # | Файл | Проблема | Приоритет |
|---|------|----------|-----------|
| 2 | api.py | Прямой `db.conn` в эндпоинтах (retry, force, delete) | Высокий |
| 8 | api.py | O(n²) очередь позиций | Высокий |
| 13 | frontend | Двойное чтение `r.json()`баг | Высокий |
| 4 | worker.py | `check_for_updates` без `db_lock` | Средний |
| 9 | exporter.py | Утечка PIL-объектов в PDF Pillow | Средний |
| 10 | state.py | Хрупкий f-string в `mark_done` | Средний |
| 3 | api/state | `datetime.utcnow()` deprecated | Низкий |
| 1 | api/worker/cli | Дублирование `_safe_name` | Низкий |
| 5 | worker.py | Хак `pages_done_count = [0]` | Низкий |
| 6 | state.py | Мёртвый метод `increment_manga_chapters_done` | Низкий |
| 7 | api.py | Поздний `import` внутри функций | Низкий |
| 11 | browser.py | Мёртвый метод `navigate()` | Низкий |
| 12 | cli.py | Устаревший шим, нет поддержки реестра | Низкий |
| 14 | frontend | JS-инъекция через `onclick` + `escHtml` | Низкий (internal tool) |
| 15 | frontend | Дублирование `forceRedownload*` | Низкий |
| 16 | worker.py | Мёртвый импорт scraper shim | Низкий |
| 17 | state.py | `check_same_thread=False` в async | На будущее |

View File

@@ -50,6 +50,8 @@
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} } @keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
.pulse-dot { width:8px; height:8px; border-radius:50%; background:#3b82f6; animation:pulse 1.5s infinite; } .pulse-dot { width:8px; height:8px; border-radius:50%; background:#3b82f6; animation:pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} } @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
.meta-spinner { display:inline-block; width:12px; height:12px; border:2px solid #4f46e5; border-top-color:transparent; border-radius:50%; animation:spin 0.7s linear infinite; vertical-align:middle; }
@keyframes spin { to { transform:rotate(360deg); } }
::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:#0f1117; } ::-webkit-scrollbar-thumb { background:#2d3148; border-radius:3px; } ::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:#0f1117; } ::-webkit-scrollbar-thumb { background:#2d3148; border-radius:3px; }
/* Login screen */ /* Login screen */
#login-screen { position:fixed; inset:0; z-index:9999; background:#0f1117; display:flex; align-items:center; justify-content:center; } #login-screen { position:fixed; inset:0; z-index:9999; background:#0f1117; display:flex; align-items:center; justify-content:center; }
@@ -106,6 +108,9 @@
<!-- Stats Row --> <!-- Stats Row -->
<div id="stats-row" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6"></div> <div id="stats-row" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6"></div>
<!-- Auth Warnings -->
<div id="auth-warnings" class="hidden mb-4 flex flex-col gap-2"></div>
<!-- Add Manga Panel --> <!-- Add Manga Panel -->
<div class="card rounded-xl p-5 mb-6"> <div class="card rounded-xl p-5 mb-6">
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Добавить мангу</h2> <h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Добавить мангу</h2>
@@ -157,11 +162,18 @@
<button onclick="filterMangas('done')" id="filter-done" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Готово</button> <button onclick="filterMangas('done')" id="filter-done" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Готово</button>
<button onclick="filterMangas('failed')" id="filter-failed" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Ошибки</button> <button onclick="filterMangas('failed')" id="filter-failed" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Ошибки</button>
<button onclick="filterMangas('stopped')" id="filter-stopped" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Остановлены</button> <button onclick="filterMangas('stopped')" id="filter-stopped" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Остановлены</button>
<button onclick="filterMangas('ongoing')" id="filter-ongoing" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">🔄 Продолжаются</button>
</div> </div>
</div> </div>
<!-- Manga List --> <!-- Manga List -->
<div id="tab-content-mangas"> <div id="tab-content-mangas">
<div class="px-4 py-2 border-b border-gray-800">
<input id="manga-search" type="search" placeholder="🔍 Поиск по названию..."
oninput="onMangaSearch(this.value)"
class="w-full px-3 py-1.5 text-sm rounded-lg"
style="background:#0f1117;border:1px solid #2d3148;color:#e2e8f0;outline:none">
</div>
<div id="manga-list" class="divide-y divide-gray-800"> <div id="manga-list" class="divide-y divide-gray-800">
<div class="px-5 py-8 text-center text-gray-500 text-sm">Загрузка...</div> <div class="px-5 py-8 text-center text-gray-500 text-sm">Загрузка...</div>
</div> </div>
@@ -376,8 +388,12 @@ const state = {
mangas: {}, // url → manga object mangas: {}, // url → manga object
chapters: {}, // manga_url → [chapter, ...] chapters: {}, // manga_url → [chapter, ...]
filter: 'all', filter: 'all',
search: '',
sources: [], // [{id, slug, display_name, domains}] sources: [], // [{id, slug, display_name, domains}]
currentUser: null, // {id, username, role} currentUser: null, // {id, username, role}
authWarnings: {}, // source_slug → {source_slug, source_name}
metaUpdating: new Set(), // urls where meta refresh is in progress
validating: {}, // url → {checked, total} for in-progress validations
}; };
// ── Auth ───────────────────────────────────── // ── Auth ─────────────────────────────────────
@@ -523,6 +539,7 @@ function handleEvent(msg) {
case 'snapshot': case 'snapshot':
msg.mangas.forEach(m => { state.mangas[m.url] = m; }); msg.mangas.forEach(m => { state.mangas[m.url] = m; });
renderList(); renderList();
renderAuthWarnings();
loadStats(); loadStats();
// Дополнительно запрашиваем свежие данные с сервера — на случай если // Дополнительно запрашиваем свежие данные с сервера — на случай если
// пока WS был отключён, статусы изменились и события были потеряны // пока WS был отключён, статусы изменились и события были потеряны
@@ -533,7 +550,7 @@ function handleEvent(msg) {
if(!state.mangas[msg.url]) { if(!state.mangas[msg.url]) {
const srcInfo = msg.source_id ? (state.sources.find(s => s.id === msg.source_id) || null) : null; const srcInfo = msg.source_id ? (state.sources.find(s => s.id === msg.source_id) || null) : null;
state.mangas[msg.url] = { url: msg.url, title: msg.url, status: 'queued', format: msg.format, state.mangas[msg.url] = { url: msg.url, title: msg.url, status: 'queued', format: msg.format,
chapters_total: 0, chapters_done: 0, size_human: '', chapters_total: 0, chapters_done: 0, size_human: '0.0 Б',
added_by: msg.added_by || null, added_by: msg.added_by || null,
added_by_username: msg.added_by_username || null, added_by_username: msg.added_by_username || null,
source: srcInfo ? {id: srcInfo.id, slug: srcInfo.slug, display_name: srcInfo.display_name} : null }; source: srcInfo ? {id: srcInfo.id, slug: srcInfo.slug, display_name: srcInfo.display_name} : null };
@@ -677,8 +694,39 @@ function handleEvent(msg) {
loadStats(); loadStats();
break; break;
case 'meta_refresh_started':
state.metaUpdating.add(msg.url);
_updateMetaBtn(msg.url);
break;
case 'meta_refreshed': case 'meta_refreshed':
// Ничего не делаем визуально — файлы обновлены на диске state.metaUpdating.delete(msg.url);
_updateMetaBtn(msg.url, msg.failed === -1 ? 'error' : 'done');
break;
case 'validate_started':
state.validating[msg.url] = {checked: 0, total: 0};
_updateValidateBtn(msg.url);
break;
case 'validate_progress':
if(state.validating[msg.url]) {
state.validating[msg.url].checked = msg.checked;
state.validating[msg.url].total = msg.total;
}
_updateValidateBtn(msg.url);
break;
case 'validate_done': {
delete state.validating[msg.url];
const result = msg.total_to_redownload > 0 || msg.new_chapters > 0 ? 'issues' : 'ok';
_updateValidateBtn(msg.url, result, msg);
break;
}
case 'validate_error':
delete state.validating[msg.url];
_updateValidateBtn(msg.url, 'error');
break; break;
case 'manga_meta_updated': case 'manga_meta_updated':
@@ -750,6 +798,29 @@ function handleEvent(msg) {
updateMangaRow(msg.url); updateMangaRow(msg.url);
} }
break; break;
case 'auth_required':
if(state.mangas[msg.url]) {
state.mangas[msg.url].status = 'stopped';
state.mangas[msg.url].last_error = `auth_required:${msg.source_slug}`;
if(msg.finished_at) state.mangas[msg.url].finished_at = msg.finished_at;
}
state.authWarnings[msg.source_slug] = {source_slug: msg.source_slug, source_name: msg.source_slug};
renderList();
renderAuthWarnings();
loadStats();
break;
case 'source_settings_updated':
loadSources().then(() => {
// Clear warnings for sources that now have a token
state.sources.forEach(s => {
if(s.has_token) delete state.authWarnings[s.slug];
});
// Refresh mangas to get cleared last_error values
_refreshMangaList().then(() => renderAuthWarnings());
});
break;
} }
} }
@@ -1300,6 +1371,21 @@ function renderSources() {
</button> </button>
</span>` : ''} </span>` : ''}
</div> </div>
${s.supports_auth_token && isAdmin() ? `
<div class="mt-3 pt-3" style="border-top:1px solid #1e293b">
<div class="text-xs text-gray-400 mb-2">Токен авторизации (Bearer JWT)</div>
${s.has_token ? `<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-green-400">✓ Токен сохранён</span>
<button onclick="clearSourceToken(${s.id})" class="text-xs px-2 py-1 rounded" style="background:#1e293b;color:#ef4444;border:1px solid #374151">Удалить</button>
</div>` : ''}
<div class="flex items-center gap-2">
<input id="token-input-${s.id}" type="password" placeholder="${s.has_token ? 'Введите новый токен для замены' : 'eyJ0eXAiOiJKV1Qi...'}"
class="text-xs px-2 py-1 rounded flex-1" style="background:#0f1117;border:1px solid #334155;color:#e2e8f0;min-width:0"
onkeydown="if(event.key==='Enter') saveSourceToken(${s.id})">
<button onclick="saveSourceToken(${s.id})" class="text-xs px-3 py-1 rounded font-semibold flex-shrink-0" style="background:#4f46e5;color:white">Сохранить</button>
</div>
</div>
` : ''}
</div> </div>
`).join(''); `).join('');
} }
@@ -1358,6 +1444,78 @@ async function removeDomain(sourceId, domain) {
} }
} }
async function saveSourceToken(sourceId) {
const input = document.getElementById('token-input-' + sourceId);
if(!input) return;
const token = input.value.trim();
try {
const r = await fetch(`/api/sources/${sourceId}/settings`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({settings: {auth_token: token}}),
});
if(!r.ok) {
const err = await r.json();
_showNotification('Ошибка: ' + (err.detail || 'неизвестная ошибка'), 'error');
return;
}
input.value = '';
_showNotification('Токен сохранён', 'success');
await loadSources();
} catch(e) {
_showNotification('Ошибка: ' + e.message, 'error');
}
}
async function clearSourceToken(sourceId) {
if(!confirm('Удалить токен авторизации?')) return;
try {
const r = await fetch(`/api/sources/${sourceId}/settings`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({settings: {auth_token: ''}}),
});
if(r.ok) {
_showNotification('Токен удалён', 'success');
await loadSources();
}
} catch(e) {}
}
function renderAuthWarnings() {
const container = document.getElementById('auth-warnings');
if(!container) return;
// Collect unique source slugs with unresolved auth errors from current manga state
const slugs = {};
Object.values(state.mangas).forEach(m => {
const err = m.last_error || '';
if(err.startsWith('auth_required:')) {
const slug = err.slice('auth_required:'.length);
if(!slugs[slug]) {
const src = state.sources.find(s => s.slug === slug);
slugs[slug] = src ? src.display_name : slug;
}
}
});
// Also include warnings from state.authWarnings (received via WS before manga list refresh)
Object.entries(state.authWarnings).forEach(([slug, info]) => {
if(!slugs[slug]) slugs[slug] = info.source_name || slug;
});
const entries = Object.entries(slugs);
if(!entries.length) {
container.classList.add('hidden');
container.innerHTML = '';
return;
}
container.classList.remove('hidden');
container.innerHTML = entries.map(([slug, name]) => `
<div class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm" style="background:#431407;border:1px solid #7c2d12;color:#fed7aa">
<span style="font-size:1.1rem">⚠</span>
<span>Токен авторизации для <strong>${escHtml(name)}</strong> устарел или отсутствует. Обновите токен в <button onclick="switchTab('settings')" class="underline hover:text-orange-200">Настройках</button>.</span>
</div>
`).join('');
}
// ── Switch Source Modal ─────────────────────── // ── Switch Source Modal ───────────────────────
let _switchSourceUrl = null; let _switchSourceUrl = null;
@@ -1507,32 +1665,111 @@ async function confirmDelete() {
loadStats(); loadStats();
} }
async function refreshMeta(url) { function _updateMetaBtn(url, result) {
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
if(r.ok) {
const btn = document.querySelector(`[data-refresh-url="${CSS.escape(url)}"]`);
if(btn) { btn.textContent = '✓'; btn.disabled = true; setTimeout(() => { btn.textContent = '🏷'; btn.disabled = false; }, 2000); }
}
}
async function refreshMetaModal(url) {
const btn = document.getElementById('modal-refresh-meta-btn'); const btn = document.getElementById('modal-refresh-meta-btn');
if(btn) { btn.textContent = '⏳ Обновляем...'; btn.disabled = true; } if(!btn) return;
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'}); const inProgress = state.metaUpdating.has(url);
if(btn) { if(inProgress) {
if(r.ok) { btn.innerHTML = '<span class="meta-spinner"></span> Обновляем...';
btn.textContent = '✅ Метатеги обновлены'; btn.disabled = true;
btn.style.color = '#94a3b8';
btn.style.borderColor = '#334155';
} else if(result === 'done') {
btn.innerHTML = '✅ Готово';
btn.disabled = false;
btn.style.color = '#4ade80'; btn.style.color = '#4ade80';
btn.style.borderColor = '#166534'; btn.style.borderColor = '#166534';
setTimeout(() => { setTimeout(() => {
btn.textContent = '🏷 Обновить метатеги'; btn.innerHTML = '🏷 Обновить метатеги';
btn.disabled = false;
btn.style.color = '#a78bfa'; btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81'; btn.style.borderColor = '#312e81';
}, 2500); }, 2500);
} else { } else if(result === 'error') {
btn.textContent = '❌ Ошибка'; btn.innerHTML = '❌ Ошибка';
btn.disabled = false; btn.disabled = false;
btn.style.color = '#f87171';
btn.style.borderColor = '#7f1d1d';
setTimeout(() => {
btn.innerHTML = '🏷 Обновить метатеги';
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
}, 3000);
} else {
btn.innerHTML = '🏷 Обновить метатеги';
btn.disabled = false;
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
}
}
async function refreshMeta(url) {
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
if(!r.ok) return;
// state будет обновлён через WS meta_refresh_started
}
async function refreshMetaModal(url) {
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
if(!r.ok) {
const btn = document.getElementById('modal-refresh-meta-btn');
if(btn) { btn.innerHTML = '❌ Ошибка'; }
}
// Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed
}
function _updateValidateBtn(url, result, data) {
const modal = document.getElementById('modal');
if(!modal || modal.classList.contains('hidden') || modal.dataset.currentUrl !== url) return;
const btn = document.getElementById('modal-validate-btn');
if(!btn) return;
const v = state.validating[url];
if(v !== undefined) {
const prog = v.total > 0 ? ` ${v.checked}/${v.total}` : '...';
btn.innerHTML = `<span class="meta-spinner"></span> Проверка${prog}`;
btn.disabled = true;
btn.style.color = '#94a3b8';
btn.style.borderColor = '#334155';
} else if(result === 'ok') {
btn.innerHTML = '✅ Всё в порядке';
btn.disabled = false;
btn.style.color = '#4ade80';
btn.style.borderColor = '#166534';
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 3000);
} else if(result === 'issues') {
const n = data ? (data.total_to_redownload + data.new_chapters) : '?';
btn.innerHTML = `⚡ Найдено проблем: ${n} — поставлено в очередь`;
btn.disabled = true;
btn.style.color = '#fbbf24';
btn.style.borderColor = '#78350f';
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 5000);
} else if(result === 'error') {
btn.innerHTML = '❌ Ошибка валидации';
btn.disabled = false;
btn.style.color = '#f87171';
btn.style.borderColor = '#7f1d1d';
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 3000);
} else {
btn.innerHTML = '🔍 Проверить целостность';
btn.disabled = false;
btn.style.color = '#67e8f9';
btn.style.borderColor = '#164e63';
}
}
async function validateManga(url) {
const btn = document.getElementById('modal-validate-btn');
if(btn) {
btn.innerHTML = '<span class="meta-spinner"></span> Запуск...';
btn.disabled = true;
}
const r = await fetch('/api/mangas/validate?url='+encodeURIComponent(url), {method:'POST'});
if(!r.ok) {
const err = await r.json().catch(() => ({}));
if(btn) {
btn.innerHTML = '❌ ' + (err.detail || 'Ошибка');
btn.style.color = '#f87171';
btn.style.borderColor = '#7f1d1d';
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 3000);
} }
} }
} }
@@ -1827,9 +2064,27 @@ function _rowAuto(m) {
</div>`; </div>`;
} }
let _searchTimer = null;
function onMangaSearch(val) {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => { state.search = val.trim().toLowerCase(); renderList(); }, 120);
}
function _sortedMangas() { function _sortedMangas() {
let mangas = Object.values(state.mangas); let mangas = Object.values(state.mangas);
if(state.filter !== 'all') mangas = mangas.filter(m => m.status === state.filter); if(state.filter === 'ongoing') {
mangas = mangas.filter(m => m.pub_status === 'ongoing');
} else if(state.filter !== 'all') {
mangas = mangas.filter(m => m.status === state.filter);
}
if(state.search) {
const q = state.search;
mangas = mangas.filter(m =>
(m.title || '').toLowerCase().includes(q) ||
(m.title_ru || '').toLowerCase().includes(q) ||
(m.title_full || '').toLowerCase().includes(q)
);
}
const order = {downloading: 0, queued: 1, stopped: 2, failed: 3, done: 4}; const order = {downloading: 0, queued: 1, stopped: 2, failed: 3, done: 4};
mangas.sort((a, b) => { mangas.sort((a, b) => {
const oa = order[a.status] ?? 2, ob = order[b.status] ?? 2; const oa = order[a.status] ?? 2, ob = order[b.status] ?? 2;
@@ -2061,6 +2316,15 @@ function renderModalBody(data) {
📁 Переименовать папку 📁 Переименовать папку
</button>` : ''} </button>` : ''}
${data.status === 'done' && canManage(data) ? ` ${data.status === 'done' && canManage(data) ? `
<button id="modal-validate-btn" onclick="validateManga('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#0c1a2e;color:#67e8f9;border:1px solid #164e63"
${state.validating[data.url] !== undefined ? 'disabled' : ''}>
${state.validating[data.url] !== undefined
? `<span class="meta-spinner"></span> Проверка${state.validating[data.url].total > 0 ? ' '+state.validating[data.url].checked+'/'+state.validating[data.url].total : '...'}`
: '🔍 Проверить целостность'}
</button>` : ''}
${data.status === 'done' && canManage(data) ? `
<button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')" <button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors" class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#1e1b4b;color:#a78bfa;border:1px solid #312e81"> style="background:#1e1b4b;color:#a78bfa;border:1px solid #312e81">
@@ -2310,6 +2574,7 @@ async function _refreshMangaList() {
const mangas = await r.json(); const mangas = await r.json();
mangas.forEach(m => { state.mangas[m.url] = m; }); mangas.forEach(m => { state.mangas[m.url] = m; });
renderList(); renderList();
renderAuthWarnings();
} catch(e) {} } catch(e) {}
} }

View File

@@ -3,6 +3,7 @@ FastAPI веб-сервер: REST API + WebSocket для мониторинга
Многопользовательская система с ролями admin / user. Многопользовательская система с ролями admin / user.
""" """
import asyncio import asyncio
import json
import os import os
import shutil import shutil
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -15,7 +16,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from loguru import logger from loguru import logger
from .state import StateDB from .state import StateDB
from .worker import download_manga, check_for_updates from .worker import download_manga, check_for_updates, validate_manga
from .browser import BrowserManager from .browser import BrowserManager
from .exporter import patch_meta, MangaMeta from .exporter import patch_meta, MangaMeta
from .sources import registry, get_source_for_url, extract_domain from .sources import registry, get_source_for_url, extract_domain
@@ -265,7 +266,8 @@ def _format_size(bytes_val: int) -> str:
bytes_val /= 1024 bytes_val /= 1024
return f"{bytes_val:.1f} ТБ" return f"{bytes_val:.1f} ТБ"
def _enrich_manga(m: dict, db: StateDB) -> dict: def _enrich_manga(m: dict, db: StateDB) -> dict:
size_bytes = _dir_size(_manga_folder(m)) folder = _manga_folder(m)
size_bytes = _dir_size(folder) if (m.get("folder_name") or m.get("title")) else 0
stats = db.get_chapter_stats(m["url"]) stats = db.get_chapter_stats(m["url"])
source_info = None source_info = None
if m.get("source_id"): if m.get("source_id"):
@@ -677,18 +679,17 @@ async def refresh_meta(url: str, current_user: dict = Depends(get_current_user))
db.close() db.close()
asyncio.create_task(_do_refresh_meta(url)) asyncio.create_task(_do_refresh_meta(url))
return {"ok": True} return {"ok": True}
async def _do_refresh_meta(url: str): def _patch_meta_sync(manga: dict, chapters: list, chapters_total: int, pub_status: str) -> tuple[int, int]:
db = StateDB()
try:
manga = db.get_manga(url)
if not manga:
return
chapters = db.get_all_chapters(url)
chapters_total = len(chapters)
pub_status = manga.get("pub_status", "unknown") or "unknown"
updated = failed = 0 updated = failed = 0
url = manga["url"]
summary = manga.get("description") or ""
tags_raw = manga.get("tags") or ""
try:
tags_str = ", ".join(json.loads(tags_raw)) if tags_raw else ""
except Exception:
tags_str = ""
for ch in chapters: for ch in chapters:
for fmt_col, ext in (("output_cbz", ".cbz"), ("output_pdf", ".pdf"), ("output_epub", ".epub")): for fmt_col in ("output_cbz", "output_pdf", "output_epub"):
fpath = ch.get(fmt_col) fpath = ch.get(fmt_col)
if not fpath: if not fpath:
continue continue
@@ -704,18 +705,80 @@ async def _do_refresh_meta(url: str):
chapters_total=chapters_total, chapters_total=chapters_total,
pub_status=pub_status, pub_status=pub_status,
source_url=url, source_url=url,
summary=summary,
tags=tags_str,
) )
if patch_meta(p, meta): if patch_meta(p, meta):
updated += 1 updated += 1
else: else:
failed += 1 failed += 1
return updated, failed
def _refresh_cover_sync(manga: dict, manga_dir: Path) -> None:
"""Скачивает или обновляет обложку через urllib (синхронно, для asyncio.to_thread)."""
import urllib.request as _urllib_req
import re as _re
cover_url = manga.get("cover_url") or ""
if not cover_url:
return
# Определяем Referer по URL обложки (MangaLib CDN — cdnlibs / mangalib)
if any(pat in cover_url for pat in ("mangalib", "cdnlibs", "imglib")):
referer = "https://mangalib.me/"
else:
from urllib.parse import urlparse as _up
parsed = _up(manga.get("url") or "")
referer = f"{parsed.scheme}://{parsed.netloc}/" if parsed.netloc else "https://readmanga.ru/"
try:
req = _urllib_req.Request(cover_url, headers={
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124.0.0.0",
"Referer": referer,
"Accept": "image/png,image/jpeg,image/webp,image/*,*/*",
})
with _urllib_req.urlopen(req, timeout=30) as resp:
body = resp.read()
if len(body) < 500:
logger.warning("refresh_cover: слишком малый ответ ({} байт)", len(body))
return
m = _re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", cover_url, _re.IGNORECASE)
ext = ("." + (m.group(1).lower() if m else "jpg")).replace(".jpeg", ".jpg")
cover_path = manga_dir / f"cover{ext}"
cover_path.write_bytes(body)
logger.info("Обложка обновлена: {} ({} байт)", cover_path.name, len(body))
except Exception as e:
logger.warning("refresh_cover error {}: {}", cover_url, e)
async def _do_refresh_meta(url: str):
db = StateDB()
try:
manga = db.get_manga(url)
if not manga:
return
chapters = db.get_all_chapters(url)
chapters_total = len(chapters)
pub_status = manga.get("pub_status", "unknown") or "unknown"
finally:
db.close()
try:
await ws_manager.broadcast({"type": "meta_refresh_started", "url": url})
updated, failed = await asyncio.to_thread(_patch_meta_sync, manga, chapters, chapters_total, pub_status)
logger.info("refresh_meta {}: обновлено {}, ошибок {}", url, updated, failed) logger.info("refresh_meta {}: обновлено {}, ошибок {}", url, updated, failed)
# Обновляем обложку если у манги формат cbz
manga_fmt = manga.get("format", "cbz") or "cbz"
if manga_fmt in ("cbz", "all") and manga.get("cover_url"):
manga_dir = _manga_folder(manga)
if manga_dir.exists():
await asyncio.to_thread(_refresh_cover_sync, manga, manga_dir)
await ws_manager.broadcast({"type": "meta_refreshed", "url": url, await ws_manager.broadcast({"type": "meta_refreshed", "url": url,
"updated": updated, "failed": failed}) "updated": updated, "failed": failed})
except Exception as e: except Exception as e:
logger.error("_do_refresh_meta {}: {}", url, e) logger.error("_do_refresh_meta {}: {}", url, e)
finally: await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": 0, "failed": -1})
db.close()
class UpdateMetaRequest(BaseModel): class UpdateMetaRequest(BaseModel):
url: str url: str
title_ru: str title_ru: str
@@ -786,6 +849,44 @@ async def force_redownload(url: str, _: dict = Depends(require_admin)):
return {"ok": True} return {"ok": True}
finally: finally:
db.close() db.close()
@app.post("/api/mangas/validate")
async def validate_manga_endpoint(url: str, current_user: dict = Depends(get_current_user)):
db = StateDB()
try:
manga = db.get_manga(url)
if not manga:
raise HTTPException(status_code=404, detail="Манга не найдена")
if manga["status"] != "done":
raise HTTPException(status_code=400, detail="Валидация доступна только для манг в статусе 'Готово'")
_check_manga_access(manga, current_user)
finally:
db.close()
asyncio.create_task(_do_validate(url))
return {"ok": True}
async def _do_validate(url: str):
db = StateDB()
try:
manga = db.get_manga(url)
fmt = manga["format"] if manga else "cbz"
finally:
db.close()
result = await validate_manga(url, on_event=ws_manager.broadcast)
if not result.get("ok"):
return
chapters_to_retry = result.get("chapters_to_redownload", [])
new_chapters = result.get("new_chapters", 0)
if not chapters_to_retry and not new_chapters:
return
db2 = StateDB()
try:
for chapter_url in chapters_to_retry:
db2.reset_chapter(chapter_url)
db2.update_manga_status(url, "queued")
finally:
db2.close()
await download_queue.put({"url": url, "fmt": fmt})
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": fmt})
await _broadcast_queue_positions()
@app.post("/api/mangas/stop") @app.post("/api/mangas/stop")
async def stop_manga(url: str, current_user: dict = Depends(get_current_user)): async def stop_manga(url: str, current_user: dict = Depends(get_current_user)):
db = StateDB() db = StateDB()
@@ -846,11 +947,20 @@ class DomainAdd(BaseModel):
class SwitchSourceRequest(BaseModel): class SwitchSourceRequest(BaseModel):
url: str url: str
source_id: int source_id: int
class UpdateSourceSettingsRequest(BaseModel):
settings: dict
@app.get("/api/sources") @app.get("/api/sources")
async def list_sources(_: dict = Depends(get_current_user)): async def list_sources(_: dict = Depends(get_current_user)):
db = StateDB() db = StateDB()
try: try:
return db.get_all_sources() sources = db.get_all_sources()
for s in sources:
src_obj = registry.get_by_slug(s["slug"])
s["supports_auth_token"] = bool(src_obj and getattr(src_obj, "supports_auth_token", False))
settings = s.get("settings") or {}
s["has_token"] = bool(settings.get("auth_token"))
settings.pop("auth_token", None) # never send raw token to frontend
return sources
finally: finally:
db.close() db.close()
@app.get("/api/resolve-source") @app.get("/api/resolve-source")
@@ -902,6 +1012,33 @@ async def remove_domain(source_id: int, domain: str, _: dict = Depends(require_a
return {"ok": True} return {"ok": True}
finally: finally:
db.close() db.close()
@app.patch("/api/sources/{source_id}/settings")
async def update_source_settings(source_id: int, body: UpdateSourceSettingsRequest,
_: dict = Depends(require_admin)):
db = StateDB()
try:
source = db.get_source_by_id(source_id)
if not source:
raise HTTPException(status_code=404, detail="Источник не найден")
existing_raw = source.get("settings") or "{}"
try:
existing = json.loads(existing_raw) if isinstance(existing_raw, str) else (existing_raw or {})
except Exception:
existing = {}
existing.update(body.settings)
# Remove empty/null auth_token to keep settings clean
if "auth_token" in existing and not existing["auth_token"]:
del existing["auth_token"]
db.update_source_settings(source_id, existing)
# If auth_token was saved, clear auth errors on mangas from this source
if body.settings.get("auth_token"):
for m in db.get_mangas_by_source(source_id):
if (m.get("last_error") or "").startswith("auth_required:"):
db.set_manga_last_error(m["url"], None)
await ws_manager.broadcast({"type": "source_settings_updated", "source_id": source_id})
return {"ok": True}
finally:
db.close()
@app.post("/api/mangas/switch-source") @app.post("/api/mangas/switch-source")
async def switch_manga_source(body: SwitchSourceRequest, _: dict = Depends(require_admin)): async def switch_manga_source(body: SwitchSourceRequest, _: dict = Depends(require_admin)):
db = StateDB() db = StateDB()

View File

@@ -26,6 +26,7 @@ class MangaMeta:
language: str = "ru" language: str = "ru"
summary: str = "" # Описание/синопсис серии summary: str = "" # Описание/синопсис серии
genre: str = "" # Жанры через запятую (для ComicInfo Genre) genre: str = "" # Жанры через запятую (для ComicInfo Genre)
tags: str = "" # Теги через запятую (для ComicInfo Tags)
series_group: str = "" # Группа/коллекция (для ComicInfo SeriesGroup) series_group: str = "" # Группа/коллекция (для ComicInfo SeriesGroup)
@@ -89,6 +90,7 @@ def _make_comic_info(meta: MangaMeta) -> str:
add("Count", meta.chapters_total) add("Count", meta.chapters_total)
add("Genre", meta.genre) add("Genre", meta.genre)
add("Tags", meta.tags)
add("LanguageISO", meta.language) add("LanguageISO", meta.language)
# Manga = YesAndRightToLeft — стандартная японская манга # Manga = YesAndRightToLeft — стандартная японская манга

View File

@@ -10,11 +10,13 @@ from typing import Optional
from .base import MangaSourceProtocol from .base import MangaSourceProtocol
from .readmanga import ReadmangaSource from .readmanga import ReadmangaSource
from .mangalib import MangalibSource
# ── Регистрация источников ───────────────────── # ── Регистрация источников ─────────────────────
# Добавьте новые источники сюда: # Добавьте новые источники сюда:
SOURCES: list = [ SOURCES: list = [
ReadmangaSource(), ReadmangaSource(),
MangalibSource(),
] ]
# Быстрый поиск по slug # Быстрый поиск по slug

View File

@@ -8,6 +8,13 @@ from typing import Optional, Protocol, runtime_checkable
from playwright.async_api import Page from playwright.async_api import Page
class AuthRequiredError(Exception):
"""Источник требует авторизации — токен не задан или просрочен."""
def __init__(self, source_slug: str):
self.source_slug = source_slug
super().__init__(f"Auth required for source: {source_slug}")
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Модели данных (общие для всех источников) # Модели данных (общие для всех источников)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@@ -30,6 +37,8 @@ class MangaInfo:
title_full: str = "" title_full: str = ""
description: str = "" description: str = ""
genres: list[str] = field(default_factory=list) genres: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
cover_url: str = ""
# ────────────────────────────────────────────── # ──────────────────────────────────────────────

845
src/sources/mangalib.py Normal file
View File

@@ -0,0 +1,845 @@
"""
Адаптер MangaLib: поддерживает mangalib.me и его зеркала.
Принцип работы:
- Список глав: перехватываем ответ api.cdnlibs.org/api/manga/{slug}/chapters
Возвращает все главы сразу (не требует пагинации).
URL главы: {origin}/ru/{manga_slug}/read/v{vol}/c{num}
- Страница главы: перехватываем ответ api.cdnlibs.org/api/manga/{slug}/chapter?...
Получаем pages[] с полями: image (filename), url (relative path), slug (page index 1-based).
Изображения: {server}{page.url}, CDN = mixlib.me / imglib.info.
"""
import asyncio
import json as _json
import re
import time
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
from loguru import logger
from playwright.async_api import Page
from .base import Chapter, MangaInfo, AuthRequiredError
class MangalibSource:
slug = "mangalib"
display_name = "MangaLib"
supports_auth_token = True
# CDN-домены для изображений глав (актуальные)
cdn_patterns = ["mixlib.me", "imglib.info", "imglib", "imgslib"]
# Токен авторизации — устанавливается воркером из настроек источника в БД
auth_token: Optional[str] = None
# ──────────────────────────────────────────────
# Страница манги — список глав
# ──────────────────────────────────────────────
async def get_manga_info(self, page: Page, url: str) -> Optional[MangaInfo]:
"""Открывает страницу манги и возвращает список всех глав."""
logger.info("Загружаем страницу манги MangaLib: {}", url)
chapters_url = _ensure_chapters_section(url)
base_manga_url = url.split("?")[0].rstrip("/")
# Слушаем API-ответы до навигации
chapters_api_data: list = []
manga_api_data: dict = {}
chapters_auth_error: list = []
lock = asyncio.Lock()
async def on_response(resp):
resp_url = resp.url
if "api.cdnlibs.org" not in resp_url:
return
try:
# api.cdnlibs.org/api/manga/{slug}/chapters (без query-параметров)
if re.search(r"/chapters$", resp_url):
if resp.status in (401, 403):
chapters_auth_error.append(True)
return
body = await resp.body()
data = _json.loads(body)
raw = data.get("data", [])
if isinstance(raw, list) and raw:
async with lock:
if not chapters_api_data:
chapters_api_data.extend(raw)
logger.debug("Chapters API: {} глав получено", len(raw))
# api.cdnlibs.org/api/manga/{slug}?fields[]=...
elif re.search(r"/manga/[^/]+$", resp_url.split("?")[0]) and "fields" in resp_url:
body = await resp.body()
data = _json.loads(body)
raw = data.get("data", {})
if isinstance(raw, dict) and raw:
async with lock:
if not manga_api_data:
manga_api_data.update(raw)
except Exception as e:
logger.debug("API parse error: {}", e)
if self.auth_token:
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
page.on("response", on_response)
ok = await _navigate(page, chapters_url)
if not ok:
mirror_chapters_url = _switch_to_mirror(chapters_url)
if mirror_chapters_url != chapters_url:
logger.info("Основной домен недоступен, пробуем зеркало: {}", mirror_chapters_url)
ok = await _navigate(page, mirror_chapters_url)
if ok:
chapters_url = mirror_chapters_url
base_manga_url = _switch_to_mirror(base_manga_url)
if not ok:
page.remove_listener("response", on_response)
return None
# Ждём API-ответов (обычно приходят за 1-3 секунды)
for _ in range(30):
async with lock:
if chapters_api_data:
break
await asyncio.sleep(0.3)
page.remove_listener("response", on_response)
if chapters_auth_error and not chapters_api_data:
raise AuthRequiredError(self.slug)
# Извлекаем pub_status из API манги (надёжнее DOM)
async with lock:
manga_meta = dict(manga_api_data)
pub_status = _pub_status_from_api(manga_meta)
if pub_status == "unknown":
pub_status = await _extract_pub_status(page)
# Предпочитаем имена из API (надёжнее DOM и page.title)
async with lock:
manga_meta_snap = dict(manga_api_data)
title_ru = (manga_meta_snap.get("rus_name") or "").strip()
title_name = (manga_meta_snap.get("name") or "").strip()
if not title_ru:
title_ru = await _extract_title(page)
title_full = (f"{title_ru} / {title_name}" if title_name and title_ru and title_name != title_ru
else title_ru or title_name)
if not title_full:
try:
page_title = await page.title()
page_title = re.sub(r"\s*([-|•]|читать|онлайн).*$", "", page_title, flags=re.IGNORECASE).strip()
title_full = page_title
except Exception:
pass
if not title_ru:
title_ru = title_full
logger.info("Манга: {} | ru: {}", title_full, title_ru)
logger.info("Статус выпуска: {}", pub_status)
description = await _extract_description(page)
genres = await _extract_genres(page)
# Получаем обложку, описание и теги из API
async with lock:
manga_meta_for_extras = dict(manga_api_data)
cover_url, extra_description, tags = await _fetch_extra_meta(
page, manga_meta_for_extras, url, self.auth_token
)
if extra_description:
description = extra_description
if not description:
description = await _extract_description(page)
async with lock:
raw_chapters = list(chapters_api_data)
if raw_chapters:
chapters = _chapters_from_api(raw_chapters, base_manga_url)
else:
logger.warning("Chapters API не ответил, используем DOM-fallback")
chapters = await _chapters_from_dom(page, base_manga_url)
logger.info("Найдено глав: {}", len(chapters))
return MangaInfo(
title=title_ru or title_full,
url=url,
chapters=chapters,
pub_status=pub_status,
title_ru=title_ru,
title_full=title_full,
description=description,
genres=genres,
tags=tags,
cover_url=cover_url,
)
# ──────────────────────────────────────────────
# Скачивание главы
# ──────────────────────────────────────────────
async def get_chapter_images_and_download(
self,
page: Page,
chapter_url: str,
dest_dir: Path,
manga_url: Optional[str] = None,
on_page: object = None,
) -> list[Path]:
"""
1. Открывает страницу читалки.
2. Пассивно наблюдает ответы через page.on("response"):
- api.cdnlibs.org/chapter? → список страниц
- api.cdnlibs.org/imageServers → серверы CDN
3. Скачивает все страницы через page.context.request.get()
(разделяет cookies с браузером, без CORS-ограничений).
"""
t_start = time.monotonic()
ch_id = chapter_url.rstrip("/").split("/")[-1]
logger.info("[{}] Загружаем главу MangaLib: {}", ch_id, chapter_url)
dest_dir.mkdir(parents=True, exist_ok=True)
referer_origin = _base_url(manga_url or chapter_url)
chapter_api: dict = {}
image_servers: list = []
chapter_auth_error: list = []
lock = asyncio.Lock()
async def on_response(resp):
resp_url = resp.url
try:
if "api.cdnlibs.org" in resp_url and "/chapter?" in resp_url:
if resp.status in (401, 403):
chapter_auth_error.append(True)
return
body = await resp.body()
data = _json.loads(body)
async with lock:
if not chapter_api.get("pages"):
chapter_api.update(data.get("data", {}))
elif "api.cdnlibs.org" in resp_url and "imageServers" in resp_url:
body = await resp.body()
data = _json.loads(body)
servers = data.get("data", {}).get("imageServers", [])
async with lock:
if not image_servers:
image_servers.extend(s["url"] for s in servers if "url" in s)
except Exception:
pass
if self.auth_token:
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
page.on("response", on_response)
referer = manga_url or referer_origin
ok = await _navigate(page, chapter_url, referer=referer)
if not ok:
mirror_chapter_url = _switch_to_mirror(chapter_url)
if mirror_chapter_url != chapter_url:
logger.info("[{}] Основной домен недоступен, пробуем зеркало: {}", ch_id, mirror_chapter_url)
mirror_referer = _switch_to_mirror(referer) if referer else referer
ok = await _navigate(page, mirror_chapter_url, referer=mirror_referer)
if ok:
chapter_url = mirror_chapter_url
referer_origin = _base_url(mirror_chapter_url)
if not ok:
page.remove_listener("response", on_response)
logger.error("[{}] Не удалось открыть главу: {}", ch_id, chapter_url)
return []
# Ждём ответ chapter API (обычно приходит за 1-3 секунды)
for _ in range(40):
async with lock:
if chapter_api.get("pages"):
break
await asyncio.sleep(0.5)
page.remove_listener("response", on_response)
if chapter_auth_error and not chapter_api.get("pages"):
raise AuthRequiredError(self.slug)
async with lock:
pages_info = list(chapter_api.get("pages", []))
servers_list = list(image_servers)
if not pages_info:
try:
page_info = await page.evaluate("() => document.title + ' | ' + location.href")
except Exception:
page_info = "?"
logger.error("[{}] API не вернул данные страниц. Страница: {}", ch_id, page_info)
return []
total = len(pages_info)
logger.info("[{}] Страниц по API: {}", ch_id, total)
# Строим маппинг: filename → 0-based index (slug 1-based)
fname_to_idx: dict[str, int] = {}
page_url_by_idx: dict[int, str] = {}
for p in pages_info:
try:
idx = int(p.get("slug", 0)) - 1
if idx < 0:
continue
fname = p.get("image", "")
url_part = p.get("url", "")
if fname:
fname_to_idx[fname] = idx
if url_part:
page_url_by_idx[idx] = url_part
url_fname = url_part.rstrip("/").split("/")[-1]
if url_fname and url_fname not in fname_to_idx:
fname_to_idx[url_fname] = idx
except Exception:
pass
# Определяем CDN сервер из img src или constants API
server = await _detect_server(page, servers_list)
logger.info("[{}] CDN сервер: {}", ch_id, server)
alt_servers = [s for s in servers_list if s != server]
# Скачиваем все страницы через Playwright APIRequestContext
captured: dict[str, bytes] = {}
failed_idxs: list[int] = []
all_servers = [server] + alt_servers
logger.info("[{}] Скачиваем {} страниц...", ch_id, total)
for idx in range(total):
url_part = page_url_by_idx.get(idx, "")
if not url_part:
continue
fname = url_part.rstrip("/").split("/")[-1]
body = None
for srv in all_servers:
body = await _api_fetch(page, srv + url_part, referer_origin)
if body:
if srv != server:
logger.debug("[{}] alt сервер OK: {} ({})", ch_id, fname, srv)
break
if body:
captured[fname] = body
logger.debug("[{}] ✓ {}: {} байт", ch_id, fname, len(body))
if on_page:
try:
asyncio.ensure_future(on_page(0, 0))
except Exception:
pass
else:
failed_idxs.append(idx)
# Retry провалившихся страниц с задержкой
if failed_idxs:
logger.info("[{}] Retry {} страниц с задержкой...", ch_id, len(failed_idxs))
await asyncio.sleep(2)
for idx in failed_idxs:
url_part = page_url_by_idx.get(idx, "")
if not url_part:
continue
fname = url_part.rstrip("/").split("/")[-1]
body = None
for srv in all_servers:
body = await _api_fetch(page, srv + url_part, referer_origin)
if body:
break
if body:
captured[fname] = body
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
if on_page:
try:
asyncio.ensure_future(on_page(0, 0))
except Exception:
pass
else:
logger.warning("[{}] Не удалось скачать: {}", ch_id, fname)
elapsed = time.monotonic() - t_start
matched = sum(1 for f in captured if f in fname_to_idx)
logger.info("[{}] Скачано: {}/{} за {:.1f}с", ch_id, matched, total, elapsed)
# Сохраняем файлы
paths: dict[int, Path] = {}
for fname, body in captured.items():
idx = fname_to_idx.get(fname)
if idx is None:
continue
ext = _get_ext(fname)
p = dest_dir / f"{idx:04d}{ext}"
p.write_bytes(body)
paths[idx] = p
missing_idxs = [i for i in range(total) if i not in paths]
if missing_idxs:
logger.warning("[{}] Пропущено {}/{} стр. №: {}",
ch_id, len(missing_idxs), total, [i + 1 for i in missing_idxs])
return [paths[i] for i in sorted(paths.keys())]
async def get_chapter_page_count(
self, page: Page, chapter_url: str, manga_url: Optional[str] = None
) -> int:
"""Открывает главу и возвращает количество страниц через API без скачивания изображений."""
pages_info: list = []
auth_err: list = []
lock = asyncio.Lock()
async def on_response(resp):
try:
if "api.cdnlibs.org" in resp.url and "/chapter?" in resp.url:
if resp.status in (401, 403):
auth_err.append(True)
return
body = await resp.body()
data = _json.loads(body)
async with lock:
if not pages_info:
pages_info.extend(data.get("data", {}).get("pages", []))
except Exception:
pass
if self.auth_token:
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
page.on("response", on_response)
referer = manga_url or _base_url(chapter_url)
ok = await _navigate(page, chapter_url, referer=referer)
if not ok:
mirror_url = _switch_to_mirror(chapter_url)
if mirror_url != chapter_url:
ok = await _navigate(
page, mirror_url,
referer=_switch_to_mirror(referer) if referer else referer,
)
if not ok:
page.remove_listener("response", on_response)
return 0
for _ in range(40):
async with lock:
if pages_info or auth_err:
break
await asyncio.sleep(0.5)
page.remove_listener("response", on_response)
if auth_err and not pages_info:
raise AuthRequiredError(self.slug)
return len(pages_info)
# ──────────────────────────────────────────────
# Вспомогательные функции (приватные)
# ──────────────────────────────────────────────
# Зеркальные домены: при недоступности основного переключаемся на зеркало
_MIRROR_MAP = {
"mangalib.me": "mangalib.org",
"mangalib.org": "mangalib.me",
"hentailib.me": "mangalib.org",
"yaoilib.me": "mangalib.org",
"readlib.net": "mangalib.org",
}
def _switch_to_mirror(url: str) -> str:
"""Заменяет домен в URL на зеркало из _MIRROR_MAP. Возвращает исходный URL если зеркала нет."""
parsed = urlparse(url)
host = parsed.netloc.lower().removeprefix("www.")
mirror = _MIRROR_MAP.get(host)
if not mirror:
return url
return parsed._replace(netloc=mirror).geturl()
def _ensure_chapters_section(url: str) -> str:
if "section=chapters" in url:
return url
sep = "&" if "?" in url else "?"
return url + sep + "section=chapters"
def _manga_slug_from_url(url: str) -> str:
"""Извлекает slug манги из URL страницы или главы.
Примеры входных URL:
https://mangalib.me/ru/manga/11312--subete... → 11312--subete...
https://mangalib.me/ru/11312--subete.../read/v1/c1 → 11312--subete...
"""
parsed = urlparse(url)
parts = [p for p in parsed.path.split("/") if p]
# Убираем языковой префикс ('ru', 'en', ...)
if parts and len(parts[0]) <= 3 and parts[0].isalpha():
parts = parts[1:]
# Убираем 'manga' если есть
if parts and parts[0] == "manga":
parts = parts[1:]
return parts[0] if parts else ""
def _chapters_from_api(raw: list, manga_url: str) -> list[Chapter]:
"""Строит список глав из ответа api.cdnlibs.org/chapters."""
parsed = urlparse(manga_url)
origin = f"{parsed.scheme}://{parsed.netloc}"
slug = _manga_slug_from_url(manga_url)
# Определяем языковой префикс из оригинального URL (/ru/, /en/, ...)
path_parts = [p for p in parsed.path.split("/") if p]
lang_prefix = path_parts[0] if path_parts and len(path_parts[0]) <= 3 else "ru"
chapters = []
for ch in raw:
try:
vol = str(ch.get("volume") or "1")
num = str(ch.get("number") or "0")
name = ch.get("name") or ""
try:
number_f = float(num)
except Exception:
number_f = 0.0
try:
vol_i = int(float(vol))
except Exception:
vol_i = 0
ch_url = f"{origin}/{lang_prefix}/{slug}/read/v{vol}/c{num}"
title = f"Том {vol}, Глава {num}"
if name:
title += f" - {name}"
chapters.append(Chapter(title=title, url=ch_url, number=number_f, volume=vol_i))
except Exception as e:
logger.debug("Пропуск главы из API: {}", e)
chapters.sort(key=lambda c: (c.volume, c.number))
return chapters
async def _chapters_from_dom(page: Page, manga_url: str) -> list[Chapter]:
"""Fallback: извлекает главы из DOM-ссылок вида /read/v{vol}/c{num}."""
try:
raw = await page.evaluate("""
() => {
const links = Array.from(document.querySelectorAll('a[href*="/read/v"]'));
const result = [];
const seen = new Set();
for (const a of links) {
const href = a.href;
if (!href || seen.has(href)) continue;
if (!/\\/read\\/v\\d/.test(href)) continue;
const text = a.textContent.trim();
// Пропускаем ссылки без нормального текста (кнопки навигации и т.п.)
if (!text || /^\\d+\\s*\\/\\s*\\d+$/.test(text)) continue;
seen.add(href);
result.push({ href, text });
}
return result;
}
""")
if not raw:
return []
chapters = []
for item in raw:
href = item["href"]
m = re.search(r"/read/v(\d+(?:\.\d+)?)/c(\d+(?:\.\d+)?)", href)
if not m:
continue
vol_s, num_s = m.group(1), m.group(2)
try:
number_f = float(num_s)
vol_i = int(float(vol_s))
except Exception:
continue
text = item["text"] or f"Том {vol_s}, Глава {num_s}"
chapters.append(Chapter(title=text, url=href, number=number_f, volume=vol_i))
chapters.sort(key=lambda c: (c.volume, c.number))
return chapters
except Exception as e:
logger.debug("_chapters_from_dom: {}", e)
return []
def _pub_status_from_api(manga_meta: dict) -> str:
"""Извлекает статус публикации из ответа API манги."""
status = manga_meta.get("status", {})
if isinstance(status, dict):
label = (status.get("label") or "").lower()
if "завершён" in label or "завершен" in label or "complete" in label:
return "completed"
if "продолжает" in label or "ongoing" in label or "выпускает" in label:
return "ongoing"
return "unknown"
async def _navigate(page: Page, url: str, retries: int = 3,
referer: str | None = None) -> bool:
if referer is None:
p = urlparse(url)
referer = f"{p.scheme}://{p.netloc}/"
for attempt in range(1, retries + 1):
try:
resp = await page.goto(url, wait_until="domcontentloaded",
timeout=60_000, referer=referer)
if resp and resp.status >= 400:
logger.warning("Попытка {}/{}: HTTP {}", attempt, retries, resp.status)
await asyncio.sleep(3 * attempt)
continue
try:
await page.wait_for_load_state("networkidle", timeout=15_000)
except Exception:
pass
return True
except Exception as e:
logger.warning("Попытка {}/{}: {}", attempt, retries, e)
await asyncio.sleep(3 * attempt)
return False
async def _extract_title(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
if (window.__DATA__ && window.__DATA__.manga) {
const m = window.__DATA__.manga;
return m.rus_name || m.name || '';
}
const selectors = [
'.media-name__main',
'.manga-name h1',
'h1.media-title',
'h1.page-title',
'h1',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.textContent.trim()) return el.textContent.trim();
}
return '';
}
""")
return (result or "").strip()
except Exception:
return ""
async def _extract_pub_status(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.status) {
const s = window.__DATA__.manga.status;
const label = (s.label || s.name || '').toLowerCase();
if (label.includes('завершён') || label.includes('завершен') || label.includes('complete')) return 'completed';
if (label.includes('продолжает') || label.includes('ongoing') || label.includes('выпускает')) return 'ongoing';
}
const selectors = [
'.media-info-item__status',
'.status-value',
'[class*="status"] .value',
'[class*="status"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (!el) continue;
const t = el.textContent.toLowerCase();
if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed';
if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing';
}
return 'unknown';
}
""")
return result or "unknown"
except Exception:
return "unknown"
async def _extract_description(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.summary) {
return window.__DATA__.manga.summary;
}
const selectors = [
'.media-description__text',
'.description-text',
'.manga-description',
'[class*="description"] p',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.textContent.trim()) return el.textContent.trim();
}
return '';
}
""")
return (result or "").strip()[:2000]
except Exception:
return ""
async def _extract_genres(page: Page) -> list[str]:
try:
result = await page.evaluate("""
() => {
if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.genres) {
return window.__DATA__.manga.genres.map(g => g.name || g.label || '').filter(Boolean);
}
const selectors = [
'.genre-list a',
'.media-tags a',
'.tags a',
'[class*="genre"] a',
];
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
}
return [];
}
""")
return result or []
except Exception:
return []
def _parse_summary_doc(doc) -> str:
"""Конвертирует ProseMirror JSON-документ в plain text."""
if not doc or not isinstance(doc, dict):
return ""
if doc.get("type") == "text":
return doc.get("text", "")
parts = []
for node in doc.get("content", []):
text = _parse_summary_doc(node)
if text:
parts.append(text)
return " ".join(parts)
async def _fetch_extra_meta(
page: Page,
manga_api_data: dict,
manga_url: str,
auth_token: str | None,
) -> tuple[str, str, list[str]]:
"""
Возвращает (cover_url, description, tags) из уже полученных данных API или,
если нужных полей нет, делает явный supplementary-запрос к API.
"""
def _extract_from_data(data: dict) -> tuple[str, str, list[str]]:
cover_url = ""
cover_obj = data.get("cover")
if isinstance(cover_obj, dict):
cover_url = cover_obj.get("default") or cover_obj.get("thumbnail") or ""
description = ""
summary = data.get("summary")
if summary:
if isinstance(summary, dict):
description = _parse_summary_doc(summary).strip()
elif isinstance(summary, str):
description = summary.strip()
tags: list[str] = []
for t in data.get("tags") or []:
name = (t.get("name") or t.get("label") or "").strip()
if name:
tags.append(name)
return cover_url, description, tags
cover_url, description, tags = _extract_from_data(manga_api_data)
# Если хотя бы одного поля нет — делаем явный supplementary-запрос
if not cover_url or not description or not tags:
slug = _manga_slug_from_url(manga_url)
referer = _base_url(manga_url) + "/"
api_url = (
f"https://api.cdnlibs.org/api/manga/{slug}"
"?fields[]=summary&fields[]=tags&fields[]=cover"
)
try:
headers: dict = {"Referer": referer, "Accept": "application/json"}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
resp = await page.context.request.get(api_url, headers=headers)
if resp.ok:
body = await resp.body()
data = _json.loads(body).get("data", {})
extra_cover, extra_desc, extra_tags = _extract_from_data(data)
if not cover_url:
cover_url = extra_cover
if not description:
description = extra_desc
if not tags:
tags = extra_tags
logger.debug("Supplementary API: cover={}, desc_len={}, tags={}",
bool(cover_url), len(description), len(tags))
except Exception as e:
logger.debug("Supplementary API error: {}", e)
return cover_url, description, tags
async def _detect_server(page: Page, servers_list: list[str]) -> str:
"""Определяет CDN-сервер из img src на странице или из constants API."""
try:
imgs = await page.evaluate("""() =>
Array.from(document.querySelectorAll('img')).map(i => i.src)
.filter(s => s && /https?:\\/\\//.test(s) && /\\.(png|jpg|webp)/i.test(s))
""")
for img_src in imgs:
m = re.match(r"(https?://[^/]+)", img_src)
if m:
srv = m.group(1)
if any(pat in srv for pat in ["mixlib", "imglib", "imgslib"]):
return srv
except Exception:
pass
if servers_list:
return servers_list[0]
return "https://img3.mixlib.me"
async def _api_fetch(page: Page, url: str, referer: str = "") -> bytes | None:
"""
Скачивает изображение через Playwright APIRequestContext.
Разделяет cookies с браузерным контекстом, не ограничен CORS.
"""
try:
headers: dict = {"Accept": "image/png,image/jpeg,image/webp,image/*,*/*"}
if referer:
headers["Referer"] = referer
response = await page.context.request.get(url, headers=headers)
if response.ok:
body = await response.body()
return body if len(body) > 500 else None
except Exception:
pass
return None
def _get_ext(url: str) -> str:
m = re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, re.IGNORECASE)
if m:
ext = m.group(1).lower()
return ".jpg" if ext == "jpeg" else f".{ext}"
return ".jpg"
def _base_url(url: str) -> str:
m = re.match(r"(https?://[^/]+)", url)
return m.group(1) if m else "https://mangalib.me"

View File

@@ -47,6 +47,8 @@ class ReadmangaSource:
description = await _extract_description(page) description = await _extract_description(page)
genres = await _extract_genres(page) genres = await _extract_genres(page)
tags = await _extract_tags(page)
cover_url = await _get_cover_url(page)
await _expand_chapters(page) await _expand_chapters(page)
chapters = await _extract_chapters(page) chapters = await _extract_chapters(page)
@@ -63,6 +65,8 @@ class ReadmangaSource:
title_full=title_full, title_full=title_full,
description=description, description=description,
genres=genres, genres=genres,
tags=tags,
cover_url=cover_url,
) )
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@@ -115,13 +119,22 @@ class ReadmangaSource:
route_errors: dict[str, str] = {} route_errors: dict[str, str] = {}
route_statuses: dict[str, int] = {} route_statuses: dict[str, int] = {}
lock = asyncio.Lock() lock = asyncio.Lock()
# Имена файлов из readerInit — заполняются после парсинга страницы.
# Позволяет перехватывать картинки с незнакомых CDN-доменов (например, при VPN).
expected_filenames: set[str] = set()
async def route_handler(route, request): async def route_handler(route, request):
url = request.url url = request.url
base = _base(url) base = _base(url)
fname = base.split("/")[-1]
if not _is_manga_image(url): if not _is_manga_image(url):
# Fallback: домен не в cdn_patterns, но имя файла совпадает с readerInit —
# значит CDN сменился (VPN, балансировка). Перехватываем.
if not expected_filenames or fname not in expected_filenames:
await route.continue_() await route.continue_()
return return
logger.debug("[{}] CDN fallback: {} (unknown domain: {})",
ch_id, fname, url.split("/")[2])
if BANNER_RE.search(base): if BANNER_RE.search(base):
await route.continue_() await route.continue_()
return return
@@ -201,6 +214,8 @@ class ReadmangaSource:
url_to_idx = {_base(u): i for i, u in enumerate(image_urls)} url_to_idx = {_base(u): i for i, u in enumerate(image_urls)}
filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)} filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)}
total = len(image_urls) total = len(image_urls)
# Активируем CDN-fallback в route_handler: теперь он знает ожидаемые имена файлов
expected_filenames.update(filename_to_idx.keys())
def _count_matched() -> int: def _count_matched() -> int:
count = 0 count = 0
@@ -236,16 +251,8 @@ class ReadmangaSource:
await asyncio.sleep(3) await asyncio.sleep(3)
# Retry timeout через JS fetch async def _js_fetch(url: str) -> bytes | None:
async with lock: """Скачивает изображение через JS fetch в контексте браузера."""
timeout_bases = [u for u, e in route_errors.items()
if "timeout" in e.lower() and u not in captured]
if timeout_bases:
logger.info("[{}] Retry {} страниц с timeout...", ch_id, len(timeout_bases))
for retry_base in timeout_bases:
if retry_base in captured:
continue
fname = retry_base.split("/")[-1]
try: try:
data_b64 = await page.evaluate("""async (url) => { data_b64 = await page.evaluate("""async (url) => {
try { try {
@@ -257,19 +264,60 @@ class ReadmangaSource:
for (let b of bytes) bin += String.fromCharCode(b); for (let b of bytes) bin += String.fromCharCode(b);
return btoa(bin); return btoa(bin);
} catch(e) { return null; } } catch(e) { return null; }
}""", retry_base) }""", url)
if data_b64: if data_b64:
body = base64.b64decode(data_b64) body = base64.b64decode(data_b64)
if len(body) > 500: return body if len(body) > 500 else None
except Exception:
pass
return None
# Retry 1: timeout-ошибки через JS fetch
async with lock:
timeout_bases = [u for u, e in route_errors.items()
if "timeout" in e.lower() and u not in captured]
if timeout_bases:
logger.info("[{}] Retry {} страниц с timeout...", ch_id, len(timeout_bases))
for retry_base in timeout_bases:
async with lock:
if retry_base in captured:
continue
fname = retry_base.split("/")[-1]
body = await _js_fetch(retry_base)
if body:
async with lock: async with lock:
captured[retry_base] = body captured[retry_base] = body
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body)) logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
else:
logger.warning("[{}] Retry вернул {} байт — игнорируем", ch_id, len(body))
else: else:
logger.warning("[{}] Retry null для '{}'", ch_id, fname) logger.warning("[{}] Retry null для '{}'", ch_id, fname)
except Exception as e2:
logger.warning("[{}] Retry JS ошибка '{}': {}", ch_id, fname, e2) # Retry 2: не_перехваченные — CDN-домен сменился (VPN, балансировка).
# Браузер их загрузил, но route_handler не захватил байты.
# Берём URL напрямую из readerInit и достаём через JS fetch.
async with lock:
captured_fnames = {b.split("/")[-1] for b in captured}
unperceived = [
_base(u) for u in image_urls
if _base(u).split("/")[-1] not in captured_fnames
and _base(u) not in route_errors
and _base(u) not in route_statuses
]
if unperceived:
logger.info("[{}] JS retry для {} не_перехваченных (CDN-домен?)..",
ch_id, len(unperceived))
for retry_base in unperceived:
async with lock:
if retry_base.split("/")[-1] in captured_fnames:
continue
fname = retry_base.split("/")[-1]
body = await _js_fetch(retry_base)
if body:
async with lock:
captured[retry_base] = body
captured_fnames.add(fname)
logger.info("[{}] CDN retry OK: {} ({} байт)", ch_id, fname, len(body))
else:
logger.warning("[{}] CDN retry null для '{}'", ch_id, fname)
await page.unroute("**/*", route_handler) await page.unroute("**/*", route_handler)
@@ -328,6 +376,19 @@ class ReadmangaSource:
return [paths[i] for i in sorted(paths.keys())] return [paths[i] for i in sorted(paths.keys())]
async def get_chapter_page_count(
self, page: Page, chapter_url: str, manga_url: Optional[str] = None
) -> int:
"""Открывает главу и возвращает количество страниц без скачивания изображений."""
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
ok = await _navigate(page, load_url)
if not ok:
return 0
urls = await _extract_images_from_js(page)
if not urls:
urls = await _extract_images_from_dom(page)
return len(urls)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Вспомогательные функции (приватные) # Вспомогательные функции (приватные)
@@ -430,6 +491,18 @@ async def _extract_description(page: Page) -> str:
try: try:
result = await page.evaluate(""" result = await page.evaluate("""
() => { () => {
// Приоритетный селектор — новый сайт ReadManga
const crDesc = document.querySelector('.cr-description__content');
if (crDesc) {
const parts = [];
crDesc.querySelectorAll('p, span, div').forEach(el => {
const t = el.textContent.trim();
if (t) parts.push(t);
});
if (parts.length) return parts.join(' ');
const t = crDesc.textContent.trim();
if (t) return t;
}
const selectors = [ const selectors = [
'.manga-description', '.elem_descr .value', '.manga-description', '.elem_descr .value',
'#tab-description .description-text', '.description', '#tab-description .description-text', '.description',
@@ -447,6 +520,42 @@ async def _extract_description(page: Page) -> str:
return "" return ""
async def _extract_tags(page: Page) -> list[str]:
try:
result = await page.evaluate("""
() => {
const crTags = document.querySelector('.cr-tags');
if (crTags) {
const els = crTags.querySelectorAll('a, span, li');
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
const t = crTags.textContent.trim();
if (t) return t.split(/[,;]/).map(s => s.trim()).filter(Boolean);
}
return [];
}
""")
return result or []
except Exception:
return []
async def _get_cover_url(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
const wrapper = document.querySelector('.cr-hero-poster-wrapper');
if (wrapper) {
const img = wrapper.querySelector('img');
if (img) return img.src || img.dataset.src || '';
}
return '';
}
""")
return (result or "").strip()
except Exception:
return ""
async def _extract_genres(page: Page) -> list[str]: async def _extract_genres(page: Page) -> list[str]:
try: try:
result = await page.evaluate(""" result = await page.evaluate("""

View File

@@ -20,6 +20,32 @@ _DEFAULT_READMANGA_DOMAINS = [
"3.readmanga.ru", "3.readmanga.ru",
] ]
# Домены MangaLib по умолчанию (сидинг при первом запуске)
_DEFAULT_MANGALIB_DOMAINS = [
"mangalib.me",
"mangalib.org",
"hentailib.me",
"yaoilib.me",
"readlib.net",
]
_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"})
def _now() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
def _extract_domain(url: str) -> str:
"""Извлекает домен без www."""
try:
domain = urlparse(url).netloc.lower()
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
return ""
class StateDB: class StateDB:
def __init__(self, db_path: Path = DB_PATH): def __init__(self, db_path: Path = DB_PATH):
@@ -46,7 +72,11 @@ class StateDB:
added_at TEXT, added_at TEXT,
updated_at TEXT, updated_at TEXT,
started_at TEXT, started_at TEXT,
finished_at TEXT finished_at TEXT,
folder_name TEXT,
source_id INTEGER REFERENCES sources(id),
added_by INTEGER REFERENCES users(id),
last_error TEXT
) )
""") """)
self.conn.execute(""" self.conn.execute("""
@@ -128,7 +158,11 @@ class StateDB:
("mangas", "folder_name", "TEXT"), ("mangas", "folder_name", "TEXT"),
("mangas", "source_id", "INTEGER REFERENCES sources(id)"), ("mangas", "source_id", "INTEGER REFERENCES sources(id)"),
("mangas", "added_by", "INTEGER REFERENCES users(id)"), ("mangas", "added_by", "INTEGER REFERENCES users(id)"),
("mangas", "last_error", "TEXT"),
("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"), ("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"),
("mangas", "description", "TEXT"),
("mangas", "tags", "TEXT"),
("mangas", "cover_url", "TEXT"),
] ]
for table, col, typedef in migrations: for table, col, typedef in migrations:
try: try:
@@ -180,6 +214,24 @@ class StateDB:
self.conn.commit() self.conn.commit()
logger.info("Сидинг доменов ReadManga: {} доменов", len(_DEFAULT_READMANGA_DOMAINS)) logger.info("Сидинг доменов ReadManga: {} доменов", len(_DEFAULT_READMANGA_DOMAINS))
# Сидинг доменов MangaLib при первом запуске
ml = self.conn.execute("SELECT id FROM sources WHERE slug='mangalib'").fetchone()
if ml:
count = self.conn.execute(
"SELECT COUNT(*) FROM source_domains WHERE source_id=?", (ml["id"],)
).fetchone()[0]
if count == 0:
for domain in _DEFAULT_MANGALIB_DOMAINS:
try:
self.conn.execute(
"INSERT INTO source_domains (source_id, domain) VALUES (?,?)",
(ml["id"], domain)
)
except Exception:
pass
self.conn.commit()
logger.info("Сидинг доменов MangaLib: {} доменов", len(_DEFAULT_MANGALIB_DOMAINS))
# Логируем источники в БД без кода (не в реестре) # Логируем источники в БД без кода (не в реестре)
known_slugs = set(registry.all_slugs()) known_slugs = set(registry.all_slugs())
db_slugs = [r["slug"] for r in self.conn.execute("SELECT slug FROM sources").fetchall()] db_slugs = [r["slug"] for r in self.conn.execute("SELECT slug FROM sources").fetchall()]
@@ -321,11 +373,16 @@ class StateDB:
def update_manga_info(self, url: str, title: str, chapters_total: int, def update_manga_info(self, url: str, title: str, chapters_total: int,
title_ru: str = "", title_full: str = "", title_ru: str = "", title_full: str = "",
pub_status: str = "unknown"): pub_status: str = "unknown",
description: str = "", tags: str = "",
cover_url: str = ""):
self.conn.execute(""" self.conn.execute("""
UPDATE mangas SET title=?, title_ru=?, title_full=?, pub_status=?, UPDATE mangas SET title=?, title_ru=?, title_full=?, pub_status=?,
chapters_total=?, updated_at=? WHERE url=? chapters_total=?, updated_at=?,
""", (title, title_ru, title_full, pub_status, chapters_total, _now(), url)) description=?, tags=?, cover_url=?
WHERE url=?
""", (title, title_ru, title_full, pub_status, chapters_total, _now(),
description or None, tags or None, cover_url or None, url))
self.conn.commit() self.conn.commit()
def set_folder_name(self, url: str, folder_name: str): def set_folder_name(self, url: str, folder_name: str):
@@ -372,6 +429,26 @@ class StateDB:
""", (status, _now(), url)) """, (status, _now(), url))
self.conn.commit() self.conn.commit()
def set_manga_last_error(self, manga_url: str, error: Optional[str]) -> None:
self.conn.execute(
"UPDATE mangas SET last_error=?, updated_at=? WHERE url=?",
(error, _now(), manga_url)
)
self.conn.commit()
def get_mangas_by_source(self, source_id: int) -> list[dict]:
cur = self.conn.execute(
"SELECT url, last_error FROM mangas WHERE source_id=?", (source_id,)
)
return [dict(r) for r in cur.fetchall()]
def update_source_settings(self, source_id: int, settings: dict) -> None:
self.conn.execute(
"UPDATE sources SET settings=? WHERE id=?",
(json.dumps(settings), source_id)
)
self.conn.commit()
def mark_started(self, url: str) -> str: def mark_started(self, url: str) -> str:
"""Записывает время начала загрузки. Возвращает timestamp.""" """Записывает время начала загрузки. Возвращает timestamp."""
ts = _now() ts = _now()
@@ -673,21 +750,3 @@ class StateDB:
self.conn.close() self.conn.close()
_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"})
def _now() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
def _extract_domain(url: str) -> str:
"""Извлекает домен без www."""
try:
domain = urlparse(url).netloc.lower()
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
return ""

View File

@@ -11,7 +11,8 @@ from loguru import logger
from .browser import BrowserManager from .browser import BrowserManager
from .sources import registry, get_source_for_url, extract_domain from .sources import registry, get_source_for_url, extract_domain
from .sources.base import Chapter, MangaInfo import json as _json
from .sources.base import Chapter, MangaInfo, AuthRequiredError
from .exporter import export, MangaMeta from .exporter import export, MangaMeta
from .state import StateDB from .state import StateDB
from .utils import safe_name, safe_chapter_name from .utils import safe_name, safe_chapter_name
@@ -66,18 +67,39 @@ async def download_manga(
"error": "Источник не определён. Выберите источник в настройках манги."}) "error": "Источник не определён. Выберите источник в настройках манги."})
return return
# Inject auth token from source DB settings
if hasattr(source, "auth_token"):
_src_row = await db_call(db.get_source_by_slug, source.slug)
if _src_row:
_settings_raw = _src_row.get("settings") or "{}"
try:
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
except Exception:
_settings = {}
source.auth_token = _settings.get("auth_token") or None
async with BrowserManager(headless=True) as bm: async with BrowserManager(headless=True) as bm:
ctx, info_page = await bm.new_page() ctx, info_page = await bm.new_page()
try:
manga = await source.get_manga_info(info_page, url) manga = await source.get_manga_info(info_page, url)
except AuthRequiredError as e:
await info_page.close() await info_page.close()
await db_call(db.update_manga_status, url, "stopped")
await db_call(db.set_manga_last_error, url, f"auth_required:{e.source_slug}")
finished_ts = await db_call(db.mark_finished, url)
await emit({"type": "auth_required", "url": url,
"source_slug": e.source_slug, "finished_at": finished_ts})
return
if not manga: if not manga:
await info_page.close()
await db_call(db.update_manga_status, url, "failed") await db_call(db.update_manga_status, url, "failed")
await emit({"type": "manga_failed", "url": url, await emit({"type": "manga_failed", "url": url,
"error": "Не удалось получить информацию о манге"}) "error": "Не удалось получить информацию о манге"})
return return
import json as _json_mod
await db_call( await db_call(
db.update_manga_info, db.update_manga_info,
url, url,
@@ -86,6 +108,9 @@ async def download_manga(
title_ru=manga.title_ru, title_ru=manga.title_ru,
title_full=manga.title_full, title_full=manga.title_full,
pub_status=manga.pub_status, pub_status=manga.pub_status,
description=manga.description,
tags=_json_mod.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
cover_url=manga.cover_url,
) )
await emit({ await emit({
"type": "manga_info", "type": "manga_info",
@@ -106,6 +131,12 @@ async def download_manga(
manga_dir = output_dir / folder_name manga_dir = output_dir / folder_name
manga_dir.mkdir(parents=True, exist_ok=True) manga_dir.mkdir(parents=True, exist_ok=True)
# Скачиваем обложку для CBZ-формата (info_page ещё открыта — контекст браузера жив)
if manga.cover_url and fmt in ("cbz", "all"):
await _download_cover(manga.cover_url, manga_dir, url, info_page)
await info_page.close()
for ch in manga.chapters: for ch in manga.chapters:
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume) await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
@@ -229,6 +260,7 @@ async def download_manga(
source_url=url, source_url=url,
summary=manga.description, summary=manga.description,
genre=", ".join(manga.genres) if manga.genres else "", genre=", ".join(manga.genres) if manga.genres else "",
tags=", ".join(manga.tags) if manga.tags else "",
) )
for f in formats: for f in formats:
out_file = manga_dir / f"{ch_name}.{f}" out_file = manga_dir / f"{ch_name}.{f}"
@@ -267,6 +299,8 @@ async def download_manga(
"chapters_total": len(manga.chapters), "chapters_total": len(manga.chapters),
}) })
except AuthRequiredError:
raise
except Exception as e: except Exception as e:
logger.exception( logger.exception(
"Необработанное исключение в Т{} Гл.{} '{}' | {}: {}", "Необработанное исключение в Т{} Гл.{} '{}' | {}: {}",
@@ -282,14 +316,70 @@ async def download_manga(
tasks = [process_chapter(ch) for ch in to_download] tasks = [process_chapter(ch) for ch in to_download]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# Логируем неожиданные исключения из gather # Логируем неожиданные исключения из gather; обнаруживаем auth ошибки
auth_slug = None
for ch, res in zip(to_download, results): for ch, res in zip(to_download, results):
if isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError): if isinstance(res, AuthRequiredError):
auth_slug = res.source_slug
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
logger.exception( logger.exception(
"gather: необработанное исключение Т{} Гл.{} '{}': {}", "gather: необработанное исключение Т{} Гл.{} '{}': {}",
ch.volume, ch.number, ch.title, res, ch.volume, ch.number, ch.title, res,
) )
if auth_slug:
await db_call(db.update_manga_status, url, "stopped")
await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}")
finished_ts = await db_call(db.mark_finished, url)
await emit({"type": "auth_required", "url": url,
"source_slug": auth_slug, "finished_at": finished_ts})
return
# ── Автоповтор неудачных глав (до 3 раз) ─────────────────────
MAX_AUTO_RETRIES = 3
for retry_attempt in range(1, MAX_AUTO_RETRIES + 1):
stats = await db_call(db.get_chapter_stats, url)
if stats["failed"] + stats["partial"] == 0:
break
failed_count = stats["failed"] + stats["partial"]
logger.info(
"Автоповтор {}/{}: {} неудачных/частичных глав для {}",
retry_attempt, MAX_AUTO_RETRIES, failed_count, url,
)
await emit({
"type": "retry_errors_auto",
"url": url,
"attempt": retry_attempt,
"max_attempts": MAX_AUTO_RETRIES,
"failed_count": failed_count,
})
await db_call(db.reset_failed_chapters, url)
all_ch_rows = await db_call(db.get_all_chapters, url)
pending_urls = {c["chapter_url"] for c in all_ch_rows if c["status"] == "pending"}
retry_chapters = [ch for ch in manga.chapters if ch.url in pending_urls]
if not retry_chapters:
break
retry_results = await asyncio.gather(
*[process_chapter(ch) for ch in retry_chapters],
return_exceptions=True,
)
auth_slug = None
for ch, res in zip(retry_chapters, retry_results):
if isinstance(res, AuthRequiredError):
auth_slug = res.source_slug
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
logger.exception(
"retry {}: необработанное исключение Т{} Гл.{} '{}': {}",
retry_attempt, ch.volume, ch.number, ch.title, res,
)
if auth_slug:
await db_call(db.update_manga_status, url, "stopped")
await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}")
finished_ts = await db_call(db.mark_finished, url)
await emit({"type": "auth_required", "url": url,
"source_slug": auth_slug, "finished_at": finished_ts})
return
real_done = await db_call(db.sync_chapters_done, url) real_done = await db_call(db.sync_chapters_done, url)
await db_call(db.update_manga_status, url, "done") await db_call(db.update_manga_status, url, "done")
finished_ts = await db_call(db.mark_finished, url) finished_ts = await db_call(db.mark_finished, url)
@@ -316,6 +406,244 @@ async def download_manga(
db.close() db.close()
async def validate_manga(
url: str,
output_dir: Path = OUTPUT_DIR,
on_event=None,
) -> dict:
"""
Проверяет целостность скачанной манги, сравнивая с сайтом.
- Получает актуальный список глав с сайта
- Добавляет новые главы в БД
- Для скачанных глав: проверяет наличие файлов и количество страниц
- Возвращает dict с chapters_to_redownload и статистикой
"""
async def emit(event: dict):
if on_event:
try:
await on_event(event)
except Exception as e:
logger.debug("on_event error: {}", e)
db = StateDB()
db_lock = asyncio.Lock()
async def db_call(fn, *args, **kwargs):
async with db_lock:
return fn(*args, **kwargs)
try:
await emit({"type": "validate_started", "url": url})
source = get_source_for_url(url, db)
if source is None:
manga_row = await db_call(db.get_manga, url)
if manga_row and manga_row.get("source_id"):
source = registry.get_by_db_id(manga_row["source_id"], db)
if source is None:
await emit({"type": "validate_error", "url": url,
"error": "Источник не определён. Выберите источник в настройках манги."})
return {"ok": False, "chapters_to_redownload": []}
if hasattr(source, "auth_token"):
_src_row = await db_call(db.get_source_by_slug, source.slug)
if _src_row:
_settings_raw = _src_row.get("settings") or "{}"
try:
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
except Exception:
_settings = {}
source.auth_token = _settings.get("auth_token") or None
manga_row = await db_call(db.get_manga, url)
fmt = (manga_row or {}).get("format", "cbz")
fmt_list = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt]
async with BrowserManager(headless=True) as bm:
ctx, info_page = await bm.new_page()
try:
manga = await source.get_manga_info(info_page, url)
except Exception as e:
logger.error("validate: get_manga_info ошибка для {}: {}", url, e)
await emit({"type": "validate_error", "url": url, "error": str(e)})
return {"ok": False, "chapters_to_redownload": []}
finally:
await info_page.close()
if not manga:
await emit({"type": "validate_error", "url": url,
"error": "Не удалось получить информацию о манге с сайта"})
return {"ok": False, "chapters_to_redownload": []}
for ch in manga.chapters:
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
all_ch_rows = await db_call(db.get_all_chapters, url)
db_chapters = {c["chapter_url"]: c for c in all_ch_rows}
new_chapters = [
ch for ch in manga.chapters
if db_chapters.get(ch.url, {}).get("status") == "pending"
]
done_chapters = [
ch for ch in manga.chapters
if db_chapters.get(ch.url, {}).get("status") == "done"
]
to_redownload: set = set()
fast_issues = 0
for ch in done_chapters:
db_ch = db_chapters[ch.url]
if db_ch.get("pages_total", 0) > 0 and db_ch.get("pages_done", 0) < db_ch["pages_total"]:
to_redownload.add(ch.url)
fast_issues += 1
continue
for f in fmt_list:
fpath = db_ch.get(f"output_{f}")
if fpath and not Path(fpath).exists():
to_redownload.add(ch.url)
fast_issues += 1
break
chapters_for_deep = [
ch for ch in done_chapters if ch.url not in to_redownload
]
site_mismatched = 0
checked = 0
has_page_count = hasattr(source, "get_chapter_page_count")
if has_page_count and chapters_for_deep:
sem = asyncio.Semaphore(2)
count_lock = asyncio.Lock()
async def check_one(ch: Chapter) -> None:
nonlocal checked, site_mismatched
async with sem:
db_ch = db_chapters[ch.url]
ch_page = await ctx.new_page()
mismatch = False
site_count = 0
try:
site_count = await source.get_chapter_page_count(
ch_page, ch.url, url
)
except AuthRequiredError:
raise
except Exception as e:
logger.warning(
"validate page count Т{} Гл.{}: {}", ch.volume, ch.number, e
)
finally:
await ch_page.close()
pages_have = db_ch.get("pages_done", 0)
if site_count > 0 and site_count != pages_have:
mismatch = True
logger.info(
"validate: Т{} Гл.{} — сайт {} стр., у нас {} → повтор",
ch.volume, ch.number, site_count, pages_have,
)
async with count_lock:
checked += 1
if mismatch:
to_redownload.add(ch.url)
site_mismatched += 1
await emit({
"type": "validate_progress",
"url": url,
"checked": checked,
"total": len(chapters_for_deep),
"chapter_number": ch.number,
"volume": ch.volume,
"mismatch": mismatch,
"site_count": site_count,
})
results = await asyncio.gather(
*[check_one(ch) for ch in chapters_for_deep],
return_exceptions=True,
)
auth_slug = None
for res in results:
if isinstance(res, AuthRequiredError):
auth_slug = res.source_slug
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
logger.exception("validate gather exception: {}", res)
if auth_slug:
await emit({"type": "validate_error", "url": url,
"error": f"auth_required:{auth_slug}"})
return {"ok": False, "chapters_to_redownload": []}
to_redownload_list = list(to_redownload)
result = {
"ok": True,
"url": url,
"site_chapters": len(manga.chapters),
"new_chapters": len(new_chapters),
"fast_issues": fast_issues,
"site_mismatched": site_mismatched,
"total_to_redownload": len(to_redownload_list),
"chapters_to_redownload": to_redownload_list,
}
await emit({
"type": "validate_done",
**{k: v for k, v in result.items() if k != "chapters_to_redownload"},
})
return result
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("validate_manga {}: {}", url, e)
await emit({"type": "validate_error", "url": url, "error": str(e)})
return {"ok": False, "chapters_to_redownload": []}
finally:
db.close()
def _cover_ext_from_url(url: str) -> str:
import re as _re
m = _re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, _re.IGNORECASE)
if m:
ext = m.group(1).lower()
return ".jpg" if ext == "jpeg" else f".{ext}"
return ".jpg"
async def _download_cover(cover_url: str, manga_dir: Path, manga_url: str, page) -> Optional[Path]:
"""Скачивает обложку в manga_dir/cover.{ext}. Использует существующий Playwright page."""
from urllib.parse import urlparse as _urlparse
try:
parsed = _urlparse(manga_url)
referer = f"{parsed.scheme}://{parsed.netloc}/"
headers = {
"Accept": "image/png,image/jpeg,image/webp,image/*,*/*",
"Referer": referer,
}
response = await page.context.request.get(cover_url, headers=headers)
if not response.ok:
logger.warning("Обложка: HTTP {} для {}", response.status, cover_url)
return None
body = await response.body()
if len(body) < 500:
logger.warning("Обложка: слишком малый ответ ({} байт)", len(body))
return None
ext = _cover_ext_from_url(cover_url)
cover_path = manga_dir / f"cover{ext}"
cover_path.write_bytes(body)
logger.info("Обложка сохранена: {} ({} байт)", cover_path.name, len(body))
return cover_path
except Exception as e:
logger.warning("Ошибка скачивания обложки {}: {}", cover_url, e)
return None
async def check_for_updates( async def check_for_updates(
url: str, url: str,
on_event: Optional[Callable] = None, on_event: Optional[Callable] = None,
@@ -332,15 +660,21 @@ async def check_for_updates(
pass pass
db = StateDB() db = StateDB()
db_lock = asyncio.Lock()
async def db_call(fn, *args, **kwargs):
async with db_lock:
return fn(*args, **kwargs)
try: try:
db.set_last_checked(url) await db_call(db.set_last_checked, url)
db.add_history(manga_url=url, event_type="check_started") await db_call(db.add_history, manga_url=url, event_type="check_started")
await emit({"type": "check_started", "url": url}) await emit({"type": "check_started", "url": url})
# Резолвим источник # Резолвим источник
source = get_source_for_url(url, db) source = get_source_for_url(url, db)
if source is None: if source is None:
manga_row = db.get_manga(url) manga_row = await db_call(db.get_manga, url)
if manga_row and manga_row.get("source_id"): if manga_row and manga_row.get("source_id"):
source = registry.get_by_db_id(manga_row["source_id"], db) source = registry.get_by_db_id(manga_row["source_id"], db)
if source is None: if source is None:
@@ -350,27 +684,47 @@ async def check_for_updates(
async with BrowserManager(headless=True) as bm: async with BrowserManager(headless=True) as bm:
_, page = await bm.new_page() _, page = await bm.new_page()
manga = await source.get_manga_info(page, url) manga = await source.get_manga_info(page, url)
await page.close()
if not manga: if not manga:
await page.close()
return [] return []
# Обновляем pub_status и количество глав import json as _json_mod
db.update_manga_info( # Обновляем pub_status, количество глав и мета-поля
await db_call(
db.update_manga_info,
url, url,
title=manga.title_ru or manga.title, title=manga.title_ru or manga.title,
chapters_total=len(manga.chapters), chapters_total=len(manga.chapters),
title_ru=manga.title_ru, title_ru=manga.title_ru,
title_full=manga.title_full, title_full=manga.title_full,
pub_status=manga.pub_status, pub_status=manga.pub_status,
description=manga.description,
tags=_json_mod.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
cover_url=manga.cover_url,
) )
# Обновляем обложку если манга сохраняется как cbz
manga_row = await db_call(db.get_manga, url)
manga_fmt = (manga_row or {}).get("format", "cbz")
if manga.cover_url and manga_fmt in ("cbz", "all"):
folder_name = (
(manga_row.get("folder_name") if manga_row else None)
or safe_name(manga.title_ru or manga.title)
)
manga_dir = OUTPUT_DIR / folder_name
if manga_dir.exists():
await _download_cover(manga.cover_url, manga_dir, url, page)
await page.close()
# Находим главы которых ещё нет в БД # Находим главы которых ещё нет в БД
known = {ch["chapter_url"] for ch in db.get_all_chapters(url)} known = {ch["chapter_url"] for ch in await db_call(db.get_all_chapters, url)}
new_chapters = [ch for ch in manga.chapters if ch.url not in known] new_chapters = [ch for ch in manga.chapters if ch.url not in known]
for ch in new_chapters: for ch in new_chapters:
db.upsert_chapter(url, ch.url, ch.title, ch.number, ch.volume) await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
db.add_history( await db_call(
db.add_history,
manga_url=url, manga_url=url,
event_type="new_chapter_found", event_type="new_chapter_found",
chapter_url=ch.url, chapter_url=ch.url,
@@ -386,7 +740,8 @@ async def check_for_updates(
"chapter_number": ch.number, "chapter_number": ch.number,
}) })
db.add_history( await db_call(
db.add_history,
manga_url=url, manga_url=url,
event_type="check_done", event_type="check_done",
details=f"Найдено новых: {len(new_chapters)}", details=f"Найдено новых: {len(new_chapters)}",