Compare commits
3 Commits
validation
...
6c0958b92e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c0958b92e | |||
| 93eff68b8d | |||
| ebc1825794 |
369
CODE_REVIEW.md
369
CODE_REVIEW.md
@@ -1,369 +0,0 @@
|
|||||||
# Code Review: находки и предложения
|
|
||||||
|
|
||||||
> Статус: **только чтение** — ничего не изменено. Каждый пункт сопровождается предлагаемым исправлением.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Дублирование кода — `_safe_name` / `_safe_chapter_name`
|
|
||||||
|
|
||||||
**Файлы:** `src/api.py:251`, `src/worker.py:26–32`, `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` | 269–278 |
|
|
||||||
| `retry_errors` | 680–688 |
|
|
||||||
| `force_redownload` | 819–823 |
|
|
||||||
| `delete_manga` | 882–885 |
|
|
||||||
| `rename_folder` | 801–803 |
|
|
||||||
|
|
||||||
`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:343–400`
|
|
||||||
|
|
||||||
`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:405–407`
|
|
||||||
|
|
||||||
```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:486–491`
|
|
||||||
|
|
||||||
```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:131–135`
|
|
||||||
|
|
||||||
```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:453–459`
|
|
||||||
|
|
||||||
```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 | На будущее |
|
|
||||||
@@ -19,4 +19,4 @@ VOLUME ["/app/output", "/app/state"]
|
|||||||
|
|
||||||
# По умолчанию запускаем веб-сервер
|
# По умолчанию запускаем веб-сервер
|
||||||
ENTRYPOINT []
|
ENTRYPOINT []
|
||||||
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
|
||||||
|
|||||||
@@ -1,352 +0,0 @@
|
|||||||
# План реализации: Multi-Source архитектура
|
|
||||||
|
|
||||||
Рефакторинг под систему плагинов-адаптеров: каждый источник — отдельный класс с унифицированным `Protocol`-интерфейсом. Новые таблицы `sources` / `source_domains` в БД, автоопределение источника по домену URL, CRUD-API для доменов и UI-компоненты во фронтенде. Существующий `scraper.py` становится адаптером `ReadmangaSource`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Архитектура системы источников
|
|
||||||
|
|
||||||
**Организация**: `Protocol`-интерфейс + реестр (`SourceRegistry`) + slug-имена в коде.
|
|
||||||
|
|
||||||
Создать `src/sources/` — пакет с адаптерами:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/sources/
|
|
||||||
__init__.py ← реестр + фабрика
|
|
||||||
base.py ← MangaSourceProtocol (Protocol-класс)
|
|
||||||
readmanga.py ← ReadmangaSource (перенесённый scraper.py)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `base.py` — Protocol-интерфейс
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MangaSourceProtocol(Protocol):
|
|
||||||
slug: str # "readmanga" — уникальный код в коде
|
|
||||||
display_name: str # "ReadManga" — для UI
|
|
||||||
|
|
||||||
async def get_manga_info(self, page, url) -> Optional[MangaInfo]: ...
|
|
||||||
async def get_chapter_images_and_download(
|
|
||||||
self, page, chapter_url, dest_dir, ...
|
|
||||||
) -> list[Path]: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### `__init__.py` — реестр и резолвинг
|
|
||||||
|
|
||||||
`SourceRegistry` — dict `slug → instance`. Список источников **определяется только в коде** — новый источник добавляется созданием нового класса и регистрацией в реестре. Через API управлять можно **только доменами**.
|
|
||||||
|
|
||||||
Экспортирует:
|
|
||||||
|
|
||||||
- `registry.get_by_slug(slug)` — по коду источника
|
|
||||||
- `registry.get_by_id(source_id, db)` — через БД: `sources.id → slug → экземпляр`
|
|
||||||
- `registry.all()` — полный список зарегистрированных источников (для синхронизации с БД и отображения в UI)
|
|
||||||
- `get_source_for_url(url, db)` — извлекает домен из URL, ищет в `source_domains`, возвращает адаптер или `None` (домен неизвестен)
|
|
||||||
|
|
||||||
### `readmanga.py` — `ReadmangaSource`
|
|
||||||
|
|
||||||
Класс с `slug = "readmanga"`. Весь текущий код `scraper.py` переезжает сюда без изменений. CDN-фильтр вынесен в атрибут `cdn_patterns: list[str]`, который можно переопределить настройками из `sources.settings` (JSON). Адаптер самодостаточен.
|
|
||||||
|
|
||||||
### Добавление нового источника
|
|
||||||
|
|
||||||
Создать файл `src/sources/mysource.py`, реализовать Protocol, зарегистрировать:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# src/sources/__init__.py
|
|
||||||
from .readmanga import ReadmangaSource
|
|
||||||
from .mysource import MySource
|
|
||||||
|
|
||||||
registry = SourceRegistry([
|
|
||||||
ReadmangaSource(),
|
|
||||||
MySource(),
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
При следующем старте приложения `StateDB._sync_sources()` автоматически добавит запись нового источника в таблицу `sources` (если её ещё нет). Удалять источники из кода не рекомендуется без предварительной миграции манг.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Изменения БД
|
|
||||||
|
|
||||||
### Новые таблицы
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS sources (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
slug TEXT UNIQUE NOT NULL, -- "readmanga" — совпадает с кодом
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
settings TEXT DEFAULT '{}', -- JSON: cdn_patterns и др.
|
|
||||||
created_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS source_domains (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
source_id INTEGER NOT NULL REFERENCES sources(id),
|
|
||||||
domain TEXT UNIQUE NOT NULL -- "readmanga.ru", "readmanga.live"
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Изменение таблицы `mangas`
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE mangas ADD COLUMN source_id INTEGER REFERENCES sources(id);
|
|
||||||
```
|
|
||||||
|
|
||||||
Добавляется через существующий паттерн миграций в `StateDB._init()`.
|
|
||||||
|
|
||||||
### Синхронизация источников с кодом (`_sync_sources`)
|
|
||||||
|
|
||||||
При старте (в `_init()`) вызывается `_sync_sources(registry)`:
|
|
||||||
1. Для каждого источника из реестра — вставить запись в `sources` если ещё нет (по `slug`).
|
|
||||||
2. Обновить `display_name` если изменился.
|
|
||||||
3. **Не удалять** источники из БД даже если они убраны из реестра — только логировать предупреждение.
|
|
||||||
|
|
||||||
### Авто-миграция существующих манг
|
|
||||||
|
|
||||||
При старте пройтись по всем мангам с `source_id IS NULL`, определить домен из `url`, проставить `source_id` по совпадению в `source_domains`. Если домен не найден — оставить `NULL` (отобразится в UI как «источник не определён»).
|
|
||||||
|
|
||||||
### Сидинг доменов ReadManga
|
|
||||||
|
|
||||||
```python
|
|
||||||
DEFAULT_READMANGA_DOMAINS = [
|
|
||||||
"readmanga.ru", "readmanga.live", "readmanga.me", "readmanga.io",
|
|
||||||
"3.readmanga.ru",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Вставляется однократно при первом старте (если нет ни одного домена для `readmanga`).
|
|
||||||
|
|
||||||
### Новые методы `StateDB`
|
|
||||||
|
|
||||||
- `get_source_by_domain(domain)` → `dict | None`
|
|
||||||
- `get_all_sources()` → `list[dict]` (с вложенными доменами)
|
|
||||||
- `add_domain(source_id, domain)` → `bool`
|
|
||||||
- `remove_domain(source_id, domain)`
|
|
||||||
- `set_manga_source(manga_url, source_id)` — меняет источник + привязывает домен URL к новому источнику (см. §3)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Рефакторинг `scraper.py` и `worker.py`
|
|
||||||
|
|
||||||
### `src/scraper.py` — shim для обратной совместимости
|
|
||||||
|
|
||||||
После переноса кода в `ReadmangaSource`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# src/scraper.py
|
|
||||||
from .sources.readmanga import ReadmangaSource as _src
|
|
||||||
from .sources.base import MangaInfo, Chapter
|
|
||||||
|
|
||||||
_instance = _src()
|
|
||||||
|
|
||||||
async def get_manga_info(page, url):
|
|
||||||
return await _instance.get_manga_info(page, url)
|
|
||||||
|
|
||||||
async def get_chapter_images_and_download(page, chapter_url, dest_dir, **kw):
|
|
||||||
return await _instance.get_chapter_images_and_download(page, chapter_url, dest_dir, **kw)
|
|
||||||
```
|
|
||||||
|
|
||||||
Это позволяет не ломать `worker.py` и `cli.py` на переходном этапе.
|
|
||||||
|
|
||||||
### `src/worker.py` — подключение реестра
|
|
||||||
|
|
||||||
В `download_manga(url, fmt, ...)`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from .sources import get_source_for_url
|
|
||||||
|
|
||||||
source = get_source_for_url(url, db)
|
|
||||||
if source is None:
|
|
||||||
# Источник не определён — ошибка, уведомить через WS
|
|
||||||
await ws_broadcast({"type": "source_unknown", "url": url})
|
|
||||||
return
|
|
||||||
```
|
|
||||||
|
|
||||||
Передавать `source` в `process_chapter()` и далее в функции скачивания.
|
|
||||||
|
|
||||||
`check_for_updates()` — аналогично резолвит источник.
|
|
||||||
|
|
||||||
### Смена источника + перепривязка домена
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def switch_source(manga_url: str, new_source_id: int, db: StateDB):
|
|
||||||
"""Меняет источник манги и привязывает домен URL к новому источнику."""
|
|
||||||
domain = extract_domain(manga_url) # извлечь домен из URL манги
|
|
||||||
old_domain_source = db.get_source_by_domain(domain)
|
|
||||||
|
|
||||||
# Перепривязать домен к новому источнику
|
|
||||||
if old_domain_source:
|
|
||||||
db.remove_domain(old_domain_source["id"], domain)
|
|
||||||
db.add_domain(new_source_id, domain)
|
|
||||||
|
|
||||||
# Сменить источник у манги
|
|
||||||
db.set_manga_source(manga_url, new_source_id)
|
|
||||||
|
|
||||||
# Сбросить failed/partial главы → pending
|
|
||||||
db.reset_failed_chapters(manga_url)
|
|
||||||
```
|
|
||||||
|
|
||||||
Таким образом, при следующем добавлении манги с того же домена источник будет определён автоматически правильно.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. API эндпоинты
|
|
||||||
|
|
||||||
**Создание и удаление источников через API недоступны** — источники определяются только в коде.
|
|
||||||
|
|
||||||
### Источники (только чтение + управление доменами)
|
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
|
||||||
|-------|------|----------|
|
|
||||||
| `GET` | `/api/sources` | Список всех источников с доменами |
|
|
||||||
| `POST` | `/api/sources/{id}/domains` | Добавить домен к источнику `{domain}` |
|
|
||||||
| `DELETE` | `/api/sources/{id}/domains/{domain}` | Удалить домен |
|
|
||||||
| `GET` | `/api/resolve-source?url=` | Определить источник по URL → `{source_id, slug, display_name} \| null` |
|
|
||||||
|
|
||||||
### Управление мангой
|
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
|
||||||
|-------|------|----------|
|
|
||||||
| `POST` | `/api/mangas/switch-source` | Сменить источник `{url, source_id}` (не во время загрузки) |
|
|
||||||
|
|
||||||
### Pydantic-модели
|
|
||||||
|
|
||||||
```python
|
|
||||||
class DomainAdd(BaseModel):
|
|
||||||
domain: str
|
|
||||||
|
|
||||||
class SourceOut(BaseModel):
|
|
||||||
id: int
|
|
||||||
slug: str
|
|
||||||
display_name: str
|
|
||||||
domains: list[str]
|
|
||||||
settings: dict
|
|
||||||
|
|
||||||
class SwitchSourceRequest(BaseModel):
|
|
||||||
url: str
|
|
||||||
source_id: int
|
|
||||||
# домен всегда перепривязывается автоматически
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Изменения фронтенда
|
|
||||||
|
|
||||||
### Диалог добавления манги
|
|
||||||
|
|
||||||
1. После ввода URL (debounce 400 мс) → GET `/api/resolve-source?url=...`
|
|
||||||
2. **Источник найден** → показать badge «Источник: ReadManga» под полем ввода
|
|
||||||
3. **Источник неизвестен** → показать предупреждение:
|
|
||||||
> ⚠ Домен не распознан. Выберите источник вручную:
|
|
||||||
|
|
||||||
Под предупреждением — `<select>` со списком всех доступных источников. Без выбора источника кнопка «Добавить» неактивна.
|
|
||||||
|
|
||||||
После добавления домен URL автоматически привязывается к выбранному источнику (бэкенд делает это в момент добавления манги).
|
|
||||||
|
|
||||||
### Карточка манги
|
|
||||||
|
|
||||||
- Badge с `source.display_name` рядом с названием (серый, если источник не определён → «Источник неизвестен»)
|
|
||||||
- Кнопка **«↔ Источник»** — видима всегда, кроме статуса `downloading`; открывает модал:
|
|
||||||
- Текущий источник (или «не определён»)
|
|
||||||
- `<select>` со всеми источниками
|
|
||||||
- Статичное предупреждение под select (всегда видимо): «⚠ Домен `xyz.com` будет перепривязан к выбранному источнику. Это затронет все манги с этого домена.»
|
|
||||||
- Кнопка «Применить» → POST `/api/mangas/switch-source`
|
|
||||||
|
|
||||||
### Новая вкладка «Настройки»
|
|
||||||
|
|
||||||
Добавить четвёртую вкладку в навигацию.
|
|
||||||
|
|
||||||
**Подраздел «Источники»** (единственный на данном этапе):
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─ Источники ──────────────────────────────────────────┐
|
|
||||||
│ Источники определяются в коде приложения. │
|
|
||||||
│ Здесь можно управлять доменами для каждого источника│
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────────────────────────────────────────┐│
|
|
||||||
│ │ ReadManga slug: readmanga ││
|
|
||||||
│ │ Домены: ││
|
|
||||||
│ │ • readmanga.ru [✕] • readmanga.live [✕] ││
|
|
||||||
│ │ • 3.readmanga.ru [✕] [+ добавить домен] ││
|
|
||||||
│ └────────────────────────────────────────────────────┘│
|
|
||||||
│ ┌────────────────────────────────────────────────────┐│
|
|
||||||
│ │ Другой источник slug: other ││
|
|
||||||
│ │ ... ││
|
|
||||||
│ └────────────────────────────────────────────────────┘│
|
|
||||||
└──────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Inline-редактирование:
|
|
||||||
- `[+ добавить домен]` → inline `<input>` + кнопка «✓» → POST `/api/sources/{id}/domains`
|
|
||||||
- `[✕]` рядом с доменом → DELETE `/api/sources/{id}/domains/{domain}`
|
|
||||||
|
|
||||||
Кнопок «Создать источник» или «Удалить источник» **нет**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. WebSocket события
|
|
||||||
|
|
||||||
| `type` | Когда | Данные |
|
|
||||||
|--------|-------|--------|
|
|
||||||
| `source_domain_added` | POST /api/sources/{id}/domains | `{source_id, domain}` |
|
|
||||||
| `source_domain_removed` | DELETE /api/sources/{id}/domains/... | `{source_id, domain}` |
|
|
||||||
| `source_switched` | POST /api/mangas/switch-source | `{url, old_source_id, new_source_id, domain_rebound: true}` |
|
|
||||||
| `source_unknown` | Попытка загрузки манги без источника | `{url}` — фронт показывает уведомление |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Решённые вопросы
|
|
||||||
|
|
||||||
### 7.1 CDN-паттерны и настройки источника
|
|
||||||
|
|
||||||
Каждый источник хранит свои технические настройки (CDN-паттерны и т.п.) **только в коде** внутри класса-адаптера. Поле `settings` в таблице `sources` не используется для пользовательского редактирования — оно остаётся зарезервированным для внутренних нужд адаптера. Никакого UI для редактирования настроек нет.
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ReadmangaSource:
|
|
||||||
slug = "readmanga"
|
|
||||||
display_name = "ReadManga"
|
|
||||||
cdn_patterns = ["one-way.work", "staticfa.", "cdnmanga", "reimg"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 Домен, уже привязанный к другому источнику
|
|
||||||
|
|
||||||
При смене источника у манги перепривязка домена к новому источнику происходит **автоматически** без дополнительного подтверждения. Флаг `rebind_domain` не нужен.
|
|
||||||
|
|
||||||
В UI рядом с `<select>` источника отображается статичное предупреждение:
|
|
||||||
|
|
||||||
> ⚠ Домен `xyz.com` будет перепривязан к выбранному источнику. Это затронет все манги с этого домена.
|
|
||||||
|
|
||||||
Флаг `rebind_domain` в `SwitchSourceRequest` не нужен — бэкенд всегда перепривязывает домен.
|
|
||||||
|
|
||||||
### 7.3 Удалённые из кода источники
|
|
||||||
|
|
||||||
При старте логировать предупреждение для каждого источника в БД, которого нет в реестре. В UI такие манги показывают badge **«Источник недоступен»** красным цветом. Загрузка таких манг невозможна до смены источника.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Порядок реализации (этапы)
|
|
||||||
|
|
||||||
### Этап 1 — БД (без ломки текущей логики)
|
|
||||||
- Добавить таблицы `sources`, `source_domains` в `state.py`
|
|
||||||
- Добавить колонку `source_id` в `mangas`
|
|
||||||
- Реализовать `_sync_sources(registry)` + сидинг readmanga-доменов
|
|
||||||
- Авто-миграция существующих манг (проставить `source_id` по домену)
|
|
||||||
- Новые методы `StateDB`
|
|
||||||
|
|
||||||
### Этап 2 — Адаптер + Реестр
|
|
||||||
- Создать `src/sources/` пакет
|
|
||||||
- Перенести `scraper.py` → `src/sources/readmanga.py` (класс `ReadmangaSource`)
|
|
||||||
- Реализовать `SourceRegistry`, `get_source_for_url()`
|
|
||||||
- Написать shim `src/scraper.py` (обратная совместимость)
|
|
||||||
|
|
||||||
### Этап 3 — Worker + API
|
|
||||||
- Подключить реестр в `worker.py`
|
|
||||||
- Добавить `switch_source()` с перепривязкой домена
|
|
||||||
- Реализовать API эндпоинты (только домены + switch)
|
|
||||||
- WS-события
|
|
||||||
|
|
||||||
### Этап 4 — Фронтенд
|
|
||||||
- Badge источника на карточках манги
|
|
||||||
- Автоопределение при вводе URL + предупреждение + ручной выбор для неизвестных доменов
|
|
||||||
- Диалог смены источника с предупреждением о перепривязке домена
|
|
||||||
- Вкладка «Настройки → Источники»
|
|
||||||
|
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@ services:
|
|||||||
- ./state:/app/state
|
- ./state:/app/state
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
|
# Заставляем glibc возвращать освобождённую память ОС (уменьшает RSS в простое)
|
||||||
|
- MALLOC_MMAP_THRESHOLD_=65536
|
||||||
|
- MALLOC_TRIM_THRESHOLD_=65536
|
||||||
# Расписание авто-проверки новых глав (cron-синтаксис).
|
# Расписание авто-проверки новых глав (cron-синтаксис).
|
||||||
# Примеры: "0 */6 * * *" — каждые 6 ч | "0 3 * * *" — каждый день в 03:00
|
# Примеры: "0 */6 * * *" — каждые 6 ч | "0 3 * * *" — каждый день в 03:00
|
||||||
# Оставьте пустым чтобы отключить планировщик.
|
# Оставьте пустым чтобы отключить планировщик.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -210,6 +222,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="users-list" class="flex flex-col gap-2"></div>
|
<div id="users-list" class="flex flex-col gap-2"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Обновить все метаданные (только admin) -->
|
||||||
|
<div id="refresh-all-section" class="hidden px-5 py-4 border-t border-gray-800">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-1">Обновить все метаданные</h3>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">Запускает браузер для каждой скачанной манги: обновляет обложку, синопсис, жанры и метаданные в файлах CBZ/PDF/EPUB.</p>
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<button id="refresh-all-btn" onclick="refreshAllMeta()"
|
||||||
|
class="text-xs px-4 py-2 rounded-lg font-semibold text-white"
|
||||||
|
style="background:#312e81;border:1px solid #4338ca;color:#a78bfa">
|
||||||
|
🔄 Обновить все метаданные
|
||||||
|
</button>
|
||||||
|
<div id="refresh-all-status" class="text-xs text-gray-400 hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Смена своего пароля -->
|
<!-- Смена своего пароля -->
|
||||||
<div id="chpwd-section" class="px-5 py-4 border-t border-gray-800">
|
<div id="chpwd-section" class="px-5 py-4 border-t border-gray-800">
|
||||||
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Сменить пароль</h3>
|
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Сменить пароль</h3>
|
||||||
@@ -376,8 +401,11 @@ 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
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────
|
// ── Auth ─────────────────────────────────────
|
||||||
@@ -523,6 +551,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 +562,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 +706,26 @@ 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 'refresh_all_started':
|
||||||
|
_handleRefreshAllStarted(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'refresh_all_progress':
|
||||||
|
_handleRefreshAllProgress(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'refresh_all_done':
|
||||||
|
_handleRefreshAllDone(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'manga_meta_updated':
|
case 'manga_meta_updated':
|
||||||
@@ -750,6 +797,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,7 +837,7 @@ function switchTab(tab) {
|
|||||||
document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas');
|
document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas');
|
||||||
if(tab === 'history') loadHistory();
|
if(tab === 'history') loadHistory();
|
||||||
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
|
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
|
||||||
if(tab === 'settings') { loadSources(); showUsersSection(); }
|
if(tab === 'settings') { loadSources(); showUsersSection(); showRefreshAllSection(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNewsBadge() {
|
function updateNewsBadge() {
|
||||||
@@ -1087,6 +1157,11 @@ function showUsersSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showRefreshAllSection() {
|
||||||
|
const el = document.getElementById('refresh-all-section');
|
||||||
|
if(el) el.classList.toggle('hidden', !isAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
if(!isAdmin()) return;
|
if(!isAdmin()) return;
|
||||||
try {
|
try {
|
||||||
@@ -1300,6 +1375,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 +1448,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,33 +1669,95 @@ 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'});
|
const btn = document.getElementById('modal-refresh-meta-btn');
|
||||||
if(r.ok) {
|
if(!btn) return;
|
||||||
const btn = document.querySelector(`[data-refresh-url="${CSS.escape(url)}"]`);
|
const inProgress = state.metaUpdating.has(url);
|
||||||
if(btn) { btn.textContent = '✓'; btn.disabled = true; setTimeout(() => { btn.textContent = '🏷'; btn.disabled = false; }, 2000); }
|
if(inProgress) {
|
||||||
|
btn.innerHTML = '<span class="meta-spinner"></span> Обновляем...';
|
||||||
|
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.borderColor = '#166534';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = '🏷 Обновить метаданные';
|
||||||
|
btn.style.color = '#a78bfa';
|
||||||
|
btn.style.borderColor = '#312e81';
|
||||||
|
}, 2500);
|
||||||
|
} else if(result === 'error') {
|
||||||
|
btn.innerHTML = '❌ Ошибка';
|
||||||
|
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 refreshMetaModal(url) {
|
async function refreshMeta(url) {
|
||||||
const btn = document.getElementById('modal-refresh-meta-btn');
|
|
||||||
if(btn) { btn.textContent = '⏳ Обновляем...'; btn.disabled = true; }
|
|
||||||
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
|
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
|
||||||
if(btn) {
|
if(!r.ok) return;
|
||||||
if(r.ok) {
|
// state будет обновлён через WS meta_refresh_started
|
||||||
btn.textContent = '✅ Метатеги обновлены';
|
}
|
||||||
btn.style.color = '#4ade80';
|
|
||||||
btn.style.borderColor = '#166534';
|
async function refreshMetaModal(url) {
|
||||||
setTimeout(() => {
|
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
|
||||||
btn.textContent = '🏷 Обновить метатеги';
|
if(!r.ok) {
|
||||||
btn.disabled = false;
|
const btn = document.getElementById('modal-refresh-meta-btn');
|
||||||
btn.style.color = '#a78bfa';
|
if(btn) { btn.innerHTML = '❌ Ошибка'; }
|
||||||
btn.style.borderColor = '#312e81';
|
}
|
||||||
}, 2500);
|
// Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed
|
||||||
} else {
|
}
|
||||||
btn.textContent = '❌ Ошибка';
|
|
||||||
btn.disabled = false;
|
async function refreshAllMeta() {
|
||||||
}
|
const btn = document.getElementById('refresh-all-btn');
|
||||||
|
const status = document.getElementById('refresh-all-status');
|
||||||
|
const r = await fetch('/api/mangas/refresh_all_meta', {method:'POST'});
|
||||||
|
if(!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
if(status) { status.textContent = err.detail || 'Ошибка запуска'; status.classList.remove('hidden'); status.style.color = '#f87171'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(btn) { btn.disabled = true; btn.textContent = '⏳ Запускаем...'; }
|
||||||
|
if(status) { status.textContent = 'Инициализация...'; status.classList.remove('hidden'); status.style.color = '#94a3b8'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleRefreshAllStarted(data) {
|
||||||
|
const btn = document.getElementById('refresh-all-btn');
|
||||||
|
const status = document.getElementById('refresh-all-status');
|
||||||
|
if(btn) { btn.disabled = true; btn.textContent = '⏳ Обновляем...'; }
|
||||||
|
if(status) { status.textContent = `0 / ${data.total}`; status.classList.remove('hidden'); status.style.color = '#94a3b8'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleRefreshAllProgress(data) {
|
||||||
|
const status = document.getElementById('refresh-all-status');
|
||||||
|
if(status) {
|
||||||
|
const title = data.title ? ` — ${data.title}` : '';
|
||||||
|
status.textContent = `${data.done + 1} / ${data.total}${title}`;
|
||||||
|
status.style.color = '#94a3b8';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleRefreshAllDone(data) {
|
||||||
|
const btn = document.getElementById('refresh-all-btn');
|
||||||
|
const status = document.getElementById('refresh-all-status');
|
||||||
|
if(btn) { btn.disabled = false; btn.textContent = '🔄 Обновить все метаданные'; }
|
||||||
|
if(status) {
|
||||||
|
status.textContent = `Готово: ${data.total} манг, обновлено файлов: ${data.total_updated}`;
|
||||||
|
status.style.color = '#4ade80';
|
||||||
|
status.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1827,9 +2051,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;
|
||||||
@@ -2064,7 +2306,7 @@ function renderModalBody(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">
|
||||||
🏷 Обновить метатеги
|
🏷 Обновить метаданные
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? `
|
${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? `
|
||||||
<button onclick="forceRedownloadModal('${escHtml(data.url)}')"
|
<button onclick="forceRedownloadModal('${escHtml(data.url)}')"
|
||||||
@@ -2310,6 +2552,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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ ebooklib==0.18
|
|||||||
tqdm==4.66.4
|
tqdm==4.66.4
|
||||||
loguru==0.7.2
|
loguru==0.7.2
|
||||||
fastapi==0.111.0
|
fastapi==0.111.0
|
||||||
uvicorn[standard]==0.29.0
|
uvicorn==0.29.0
|
||||||
websockets==12.0
|
websockets==12.0
|
||||||
pypdf==4.2.0
|
pypdf==4.2.0
|
||||||
croniter==3.0.3
|
croniter==3.0.3
|
||||||
|
|||||||
151
src/api.py
151
src/api.py
@@ -3,6 +3,9 @@ FastAPI веб-сервер: REST API + WebSocket для мониторинга
|
|||||||
Многопользовательская система с ролями admin / user.
|
Многопользовательская система с ролями admin / user.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ctypes
|
||||||
|
import gc
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -15,7 +18,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, refresh_manga_metadata
|
||||||
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
|
||||||
@@ -70,6 +73,7 @@ ws_manager = ConnectionManager()
|
|||||||
# ── Очередь загрузки ─────────────────────────
|
# ── Очередь загрузки ─────────────────────────
|
||||||
download_queue: asyncio.Queue = asyncio.Queue()
|
download_queue: asyncio.Queue = asyncio.Queue()
|
||||||
active_tasks: dict = {}
|
active_tasks: dict = {}
|
||||||
|
_refresh_all_running: bool = False
|
||||||
async def _broadcast_queue_positions():
|
async def _broadcast_queue_positions():
|
||||||
queue_list = list(download_queue._queue)
|
queue_list = list(download_queue._queue)
|
||||||
positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
|
positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
|
||||||
@@ -153,6 +157,7 @@ async def startup_event():
|
|||||||
_db.close()
|
_db.close()
|
||||||
asyncio.create_task(queue_worker())
|
asyncio.create_task(queue_worker())
|
||||||
asyncio.create_task(update_scheduler())
|
asyncio.create_task(update_scheduler())
|
||||||
|
asyncio.create_task(memory_trimmer())
|
||||||
db = StateDB()
|
db = StateDB()
|
||||||
try:
|
try:
|
||||||
for manga in db.get_all_mangas():
|
for manga in db.get_all_mangas():
|
||||||
@@ -187,6 +192,17 @@ def _parse_schedule() -> Optional[str]:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error("UPDATE_INTERVAL_HOURS='{}' — не число", hours_raw)
|
logger.error("UPDATE_INTERVAL_HOURS='{}' — не число", hours_raw)
|
||||||
return None
|
return None
|
||||||
|
async def memory_trimmer():
|
||||||
|
"""Периодически принудительно возвращает неиспользуемую память ОС."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(600) # каждые 10 минут
|
||||||
|
gc.collect()
|
||||||
|
try:
|
||||||
|
ctypes.CDLL("libc.so.6").malloc_trim(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def update_scheduler():
|
async def update_scheduler():
|
||||||
cron_expr = _parse_schedule()
|
cron_expr = _parse_schedule()
|
||||||
if not cron_expr:
|
if not cron_expr:
|
||||||
@@ -265,7 +281,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"):
|
||||||
@@ -678,44 +695,74 @@ async def refresh_meta(url: str, current_user: dict = Depends(get_current_user))
|
|||||||
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):
|
async def _do_refresh_meta(url: str):
|
||||||
db = StateDB()
|
|
||||||
try:
|
try:
|
||||||
manga = db.get_manga(url)
|
await ws_manager.broadcast({"type": "meta_refresh_started", "url": url})
|
||||||
if not manga:
|
updated, failed = await refresh_manga_metadata(url)
|
||||||
return
|
|
||||||
chapters = db.get_all_chapters(url)
|
|
||||||
chapters_total = len(chapters)
|
|
||||||
pub_status = manga.get("pub_status", "unknown") or "unknown"
|
|
||||||
updated = failed = 0
|
|
||||||
for ch in chapters:
|
|
||||||
for fmt_col, ext in (("output_cbz", ".cbz"), ("output_pdf", ".pdf"), ("output_epub", ".epub")):
|
|
||||||
fpath = ch.get(fmt_col)
|
|
||||||
if not fpath:
|
|
||||||
continue
|
|
||||||
p = Path(fpath)
|
|
||||||
if not p.exists():
|
|
||||||
continue
|
|
||||||
meta = MangaMeta(
|
|
||||||
series=manga.get("title_ru") or manga.get("title") or "",
|
|
||||||
series_full=manga.get("title_full") or "",
|
|
||||||
chapter_title=ch.get("title") or "",
|
|
||||||
number=float(ch.get("number") or 0),
|
|
||||||
volume=int(ch.get("volume") or 0),
|
|
||||||
chapters_total=chapters_total,
|
|
||||||
pub_status=pub_status,
|
|
||||||
source_url=url,
|
|
||||||
)
|
|
||||||
if patch_meta(p, meta):
|
|
||||||
updated += 1
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
logger.info("refresh_meta {}: обновлено {}, ошибок {}", url, updated, failed)
|
|
||||||
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)
|
||||||
|
await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": 0, "failed": -1})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/mangas/refresh_all_meta")
|
||||||
|
async def refresh_all_meta_endpoint(current_user: dict = Depends(require_admin)):
|
||||||
|
global _refresh_all_running
|
||||||
|
if _refresh_all_running:
|
||||||
|
raise HTTPException(status_code=409, detail="Обновление метаданных уже выполняется")
|
||||||
|
asyncio.create_task(_do_refresh_all_meta())
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/mangas/refresh_all_meta/status")
|
||||||
|
async def refresh_all_meta_status(current_user: dict = Depends(require_admin)):
|
||||||
|
return {"running": _refresh_all_running}
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_refresh_all_meta():
|
||||||
|
global _refresh_all_running
|
||||||
|
_refresh_all_running = True
|
||||||
|
db = StateDB()
|
||||||
|
try:
|
||||||
|
mangas = db.get_all_mangas()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
done_mangas = [m for m in mangas if m["status"] == "done"]
|
||||||
|
total = len(done_mangas)
|
||||||
|
await ws_manager.broadcast({"type": "refresh_all_started", "total": total})
|
||||||
|
logger.info("refresh_all_meta: начало, всего манг: {}", total)
|
||||||
|
|
||||||
|
total_updated = total_failed = 0
|
||||||
|
try:
|
||||||
|
for i, manga in enumerate(done_mangas):
|
||||||
|
url = manga["url"]
|
||||||
|
await ws_manager.broadcast({
|
||||||
|
"type": "refresh_all_progress",
|
||||||
|
"done": i,
|
||||||
|
"total": total,
|
||||||
|
"url": url,
|
||||||
|
"title": manga.get("title_ru") or manga.get("title") or url,
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
updated, failed = await refresh_manga_metadata(url)
|
||||||
|
total_updated += updated
|
||||||
|
total_failed += failed
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("refresh_all_meta {}: {}", url, e)
|
||||||
|
|
||||||
|
await ws_manager.broadcast({
|
||||||
|
"type": "refresh_all_done",
|
||||||
|
"total": total,
|
||||||
|
"total_updated": total_updated,
|
||||||
|
"total_failed": total_failed,
|
||||||
|
})
|
||||||
|
logger.info("refresh_all_meta: завершено, обновлено файлов: {}, ошибок: {}",
|
||||||
|
total_updated, total_failed)
|
||||||
|
finally:
|
||||||
|
_refresh_all_running = False
|
||||||
|
|
||||||
|
|
||||||
class UpdateMetaRequest(BaseModel):
|
class UpdateMetaRequest(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
title_ru: str
|
title_ru: str
|
||||||
@@ -846,11 +893,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 +958,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()
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Браузерный слой: запуск Playwright Chromium с антидетект-настройками.
|
Браузерный слой: запуск Playwright Chromium с антидетект-настройками.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from playwright.async_api import Browser, BrowserContext, Page
|
||||||
|
|
||||||
|
|
||||||
# Реалистичный User-Agent Chrome 124 Linux
|
# Реалистичный User-Agent Chrome 124 Linux
|
||||||
@@ -53,6 +57,7 @@ class BrowserManager:
|
|||||||
self._browser: Optional[Browser] = None
|
self._browser: Optional[Browser] = None
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
self._playwright = await async_playwright().start()
|
self._playwright = await async_playwright().start()
|
||||||
self._browser = await self._playwright.chromium.launch(
|
self._browser = await self._playwright.chromium.launch(
|
||||||
headless=self.headless,
|
headless=self.headless,
|
||||||
|
|||||||
@@ -234,9 +234,9 @@ async def _analyze(url: str):
|
|||||||
paths = await source.get_chapter_images_and_download(
|
paths = await source.get_chapter_images_and_download(
|
||||||
page, first.url, dest_dir=Path(tmp), manga_url=url
|
page, first.url, dest_dir=Path(tmp), manga_url=url
|
||||||
)
|
)
|
||||||
click.echo(f" Скачано изображений: {len(paths)}")
|
click.echo(f" Скачано изображений: {len(paths)}")
|
||||||
for p in paths[:3]:
|
for p in paths[:3]:
|
||||||
click.echo(f" {p.name} ({p.stat().st_size} байт)")
|
click.echo(f" {p.name} ({p.stat().st_size} байт)")
|
||||||
|
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -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 — стандартная японская манга
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"""
|
"""
|
||||||
Базовые модели данных и Protocol-интерфейс для источников манги.
|
Базовые модели данных и Protocol-интерфейс для источников манги.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Protocol, runtime_checkable
|
from typing import TYPE_CHECKING, Optional, Protocol, runtime_checkable
|
||||||
|
|
||||||
from playwright.async_api import Page
|
if TYPE_CHECKING:
|
||||||
|
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 +40,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 = ""
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
796
src/sources/mangalib.py
Normal file
796
src/sources/mangalib.py
Normal file
@@ -0,0 +1,796 @@
|
|||||||
|
"""
|
||||||
|
Адаптер 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.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json as _json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
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())]
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Вспомогательные функции (приватные)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Зеркальные домены: при недоступности основного переключаемся на зеркало
|
||||||
|
_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"
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Адаптер ReadManga: поддерживает readmanga.ru и все его клоны.
|
Адаптер ReadManga: поддерживает readmanga.ru и все его клоны.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from playwright.async_api import Page
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from playwright.async_api import Page
|
||||||
|
|
||||||
from .base import Chapter, MangaInfo
|
from .base import Chapter, MangaInfo
|
||||||
|
|
||||||
@@ -47,6 +51,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 +69,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 +123,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):
|
||||||
await route.continue_()
|
# Fallback: домен не в cdn_patterns, но имя файла совпадает с readerInit —
|
||||||
return
|
# значит CDN сменился (VPN, балансировка). Перехватываем.
|
||||||
|
if not expected_filenames or fname not in expected_filenames:
|
||||||
|
await route.continue_()
|
||||||
|
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 +218,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,40 +255,73 @@ class ReadmangaSource:
|
|||||||
|
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
# Retry timeout через JS fetch
|
async def _js_fetch(url: str) -> bytes | None:
|
||||||
|
"""Скачивает изображение через JS fetch в контексте браузера."""
|
||||||
|
try:
|
||||||
|
data_b64 = await page.evaluate("""async (url) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {credentials: 'include'});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const buf = await r.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let bin = '';
|
||||||
|
for (let b of bytes) bin += String.fromCharCode(b);
|
||||||
|
return btoa(bin);
|
||||||
|
} catch(e) { return null; }
|
||||||
|
}""", url)
|
||||||
|
if data_b64:
|
||||||
|
body = base64.b64decode(data_b64)
|
||||||
|
return body if len(body) > 500 else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Retry 1: timeout-ошибки через JS fetch
|
||||||
async with lock:
|
async with lock:
|
||||||
timeout_bases = [u for u, e in route_errors.items()
|
timeout_bases = [u for u, e in route_errors.items()
|
||||||
if "timeout" in e.lower() and u not in captured]
|
if "timeout" in e.lower() and u not in captured]
|
||||||
if timeout_bases:
|
if timeout_bases:
|
||||||
logger.info("[{}] Retry {} страниц с timeout...", ch_id, len(timeout_bases))
|
logger.info("[{}] Retry {} страниц с timeout...", ch_id, len(timeout_bases))
|
||||||
for retry_base in timeout_bases:
|
for retry_base in timeout_bases:
|
||||||
if retry_base in captured:
|
async with lock:
|
||||||
continue
|
if retry_base in captured:
|
||||||
|
continue
|
||||||
fname = retry_base.split("/")[-1]
|
fname = retry_base.split("/")[-1]
|
||||||
try:
|
body = await _js_fetch(retry_base)
|
||||||
data_b64 = await page.evaluate("""async (url) => {
|
if body:
|
||||||
try {
|
async with lock:
|
||||||
const r = await fetch(url, {credentials: 'include'});
|
captured[retry_base] = body
|
||||||
if (!r.ok) return null;
|
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
|
||||||
const buf = await r.arrayBuffer();
|
else:
|
||||||
const bytes = new Uint8Array(buf);
|
logger.warning("[{}] Retry null для '{}'", ch_id, fname)
|
||||||
let bin = '';
|
|
||||||
for (let b of bytes) bin += String.fromCharCode(b);
|
# Retry 2: не_перехваченные — CDN-домен сменился (VPN, балансировка).
|
||||||
return btoa(bin);
|
# Браузер их загрузил, но route_handler не захватил байты.
|
||||||
} catch(e) { return null; }
|
# Берём URL напрямую из readerInit и достаём через JS fetch.
|
||||||
}""", retry_base)
|
async with lock:
|
||||||
if data_b64:
|
captured_fnames = {b.split("/")[-1] for b in captured}
|
||||||
body = base64.b64decode(data_b64)
|
unperceived = [
|
||||||
if len(body) > 500:
|
_base(u) for u in image_urls
|
||||||
async with lock:
|
if _base(u).split("/")[-1] not in captured_fnames
|
||||||
captured[retry_base] = body
|
and _base(u) not in route_errors
|
||||||
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
|
and _base(u) not in route_statuses
|
||||||
else:
|
]
|
||||||
logger.warning("[{}] Retry вернул {} байт — игнорируем", ch_id, len(body))
|
if unperceived:
|
||||||
else:
|
logger.info("[{}] JS retry для {} не_перехваченных (CDN-домен?)..",
|
||||||
logger.warning("[{}] Retry null для '{}'", ch_id, fname)
|
ch_id, len(unperceived))
|
||||||
except Exception as e2:
|
for retry_base in unperceived:
|
||||||
logger.warning("[{}] Retry JS ошибка '{}': {}", ch_id, fname, e2)
|
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)
|
||||||
|
|
||||||
@@ -430,6 +482,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 +511,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("""
|
||||||
|
|||||||
103
src/state.py
103
src/state.py
@@ -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 ""
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
312
src/worker.py
312
src/worker.py
@@ -11,8 +11,9 @@ 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 .exporter import export, MangaMeta
|
from .sources.base import Chapter, MangaInfo, AuthRequiredError
|
||||||
|
from .exporter import export, patch_meta, 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()
|
||||||
|
|
||||||
manga = await source.get_manga_info(info_page, url)
|
try:
|
||||||
await info_page.close()
|
manga = await source.get_manga_info(info_page, url)
|
||||||
|
except AuthRequiredError as e:
|
||||||
|
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,43 @@ async def download_manga(
|
|||||||
db.close()
|
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 +459,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 +483,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 +539,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)}",
|
||||||
@@ -401,3 +555,129 @@ async def check_for_updates(
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_manga_metadata(
|
||||||
|
url: str,
|
||||||
|
output_dir: Path = OUTPUT_DIR,
|
||||||
|
on_event: Optional[Callable] = None,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Обновляет метаданные манги через браузер: скачивает обложку через Playwright,
|
||||||
|
обновляет ComicInfo.xml/PDF/EPUB с актуальными данными (включая жанры и синопсис).
|
||||||
|
Возвращает (updated, failed).
|
||||||
|
"""
|
||||||
|
async def emit(event: dict):
|
||||||
|
if on_event:
|
||||||
|
try:
|
||||||
|
await on_event(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db = StateDB()
|
||||||
|
db_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def db_call(fn, *args, **kwargs):
|
||||||
|
async with db_lock:
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
logger.warning("refresh_manga_metadata: источник не найден для {}", url)
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
# Inject auth token for sources that need it
|
||||||
|
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:
|
||||||
|
_, page = await bm.new_page()
|
||||||
|
try:
|
||||||
|
manga = await source.get_manga_info(page, url)
|
||||||
|
if not manga:
|
||||||
|
logger.warning("refresh_manga_metadata: get_manga_info вернул None для {}", url)
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
# Сохраняем свежие данные в БД
|
||||||
|
await db_call(
|
||||||
|
db.update_manga_info, url,
|
||||||
|
title=manga.title_ru or manga.title,
|
||||||
|
chapters_total=len(manga.chapters),
|
||||||
|
title_ru=manga.title_ru,
|
||||||
|
title_full=manga.title_full,
|
||||||
|
pub_status=manga.pub_status,
|
||||||
|
description=manga.description,
|
||||||
|
tags=_json.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
|
||||||
|
cover_url=manga.cover_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Скачиваем обложку через Playwright (правильные куки/заголовки)
|
||||||
|
manga_row = await db_call(db.get_manga, url)
|
||||||
|
manga_fmt = (manga_row or {}).get("format", "cbz") or "cbz"
|
||||||
|
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.cover_url and manga_fmt in ("cbz", "all") and manga_dir.exists():
|
||||||
|
await _download_cover(manga.cover_url, manga_dir, url, page)
|
||||||
|
|
||||||
|
# Обновляем метаданные в файлах с актуальными данными из источника
|
||||||
|
chapters = await db_call(db.get_all_chapters, url)
|
||||||
|
chapters_total = len(chapters)
|
||||||
|
series = manga.title_ru or manga.title
|
||||||
|
series_full = manga.title_full or ""
|
||||||
|
pub_status = manga.pub_status or "unknown"
|
||||||
|
summary = manga.description or ""
|
||||||
|
tags_str = ", ".join(manga.tags) if manga.tags else ""
|
||||||
|
genre_str = ", ".join(manga.genres) if manga.genres else ""
|
||||||
|
|
||||||
|
def do_patch():
|
||||||
|
updated = failed = 0
|
||||||
|
for ch in chapters:
|
||||||
|
for fmt_col in ("output_cbz", "output_pdf", "output_epub"):
|
||||||
|
fpath = ch.get(fmt_col)
|
||||||
|
if not fpath:
|
||||||
|
continue
|
||||||
|
p = Path(fpath)
|
||||||
|
if not p.exists():
|
||||||
|
continue
|
||||||
|
meta = MangaMeta(
|
||||||
|
series=series,
|
||||||
|
series_full=series_full,
|
||||||
|
chapter_title=ch.get("title") or "",
|
||||||
|
number=float(ch.get("number") or 0),
|
||||||
|
volume=int(ch.get("volume") or 0),
|
||||||
|
chapters_total=chapters_total,
|
||||||
|
pub_status=pub_status,
|
||||||
|
source_url=url,
|
||||||
|
summary=summary,
|
||||||
|
tags=tags_str,
|
||||||
|
genre=genre_str,
|
||||||
|
)
|
||||||
|
if patch_meta(p, meta):
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
return updated, failed
|
||||||
|
|
||||||
|
updated, failed = await asyncio.to_thread(do_patch)
|
||||||
|
logger.info("refresh_manga_metadata {}: обновлено {}, ошибок {}", url, updated, failed)
|
||||||
|
return updated, failed
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user