Compare commits

...

3 Commits

Author SHA1 Message Date
6c0958b92e Update metadata 2026-05-03 15:18:09 +03:00
93eff68b8d optimization 2026-05-03 14:50:37 +03:00
ebc1825794 Add MangaLib source 2026-05-03 14:38:38 +03:00
16 changed files with 1725 additions and 861 deletions

View File

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

View File

@@ -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"]

View File

@@ -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 + предупреждение + ручной выбор для неизвестных доменов
- Диалог смены источника с предупреждением о перепривязке домена
- Вкладка «Настройки → Источники»

View File

@@ -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
# Оставьте пустым чтобы отключить планировщик. # Оставьте пустым чтобы отключить планировщик.

View File

@@ -50,6 +50,8 @@
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} } @keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
.pulse-dot { width:8px; height:8px; border-radius:50%; background:#3b82f6; animation:pulse 1.5s infinite; } .pulse-dot { width:8px; height:8px; border-radius:50%; background:#3b82f6; animation:pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} } @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
.meta-spinner { display:inline-block; width:12px; height:12px; border:2px solid #4f46e5; border-top-color:transparent; border-radius:50%; animation:spin 0.7s linear infinite; vertical-align:middle; }
@keyframes spin { to { transform:rotate(360deg); } }
::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:#0f1117; } ::-webkit-scrollbar-thumb { background:#2d3148; border-radius:3px; } ::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:#0f1117; } ::-webkit-scrollbar-thumb { background:#2d3148; border-radius:3px; }
/* Login screen */ /* Login screen */
#login-screen { position:fixed; inset:0; z-index:9999; background:#0f1117; display:flex; align-items:center; justify-content:center; } #login-screen { position:fixed; inset:0; z-index:9999; background:#0f1117; display:flex; align-items:center; justify-content:center; }
@@ -106,6 +108,9 @@
<!-- Stats Row --> <!-- Stats Row -->
<div id="stats-row" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6"></div> <div id="stats-row" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6"></div>
<!-- Auth Warnings -->
<div id="auth-warnings" class="hidden mb-4 flex flex-col gap-2"></div>
<!-- Add Manga Panel --> <!-- Add Manga Panel -->
<div class="card rounded-xl p-5 mb-6"> <div class="card rounded-xl p-5 mb-6">
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Добавить мангу</h2> <h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Добавить мангу</h2>
@@ -157,11 +162,18 @@
<button onclick="filterMangas('done')" id="filter-done" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Готово</button> <button onclick="filterMangas('done')" id="filter-done" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Готово</button>
<button onclick="filterMangas('failed')" id="filter-failed" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Ошибки</button> <button onclick="filterMangas('failed')" id="filter-failed" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Ошибки</button>
<button onclick="filterMangas('stopped')" id="filter-stopped" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Остановлены</button> <button onclick="filterMangas('stopped')" id="filter-stopped" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Остановлены</button>
<button onclick="filterMangas('ongoing')" id="filter-ongoing" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">🔄 Продолжаются</button>
</div> </div>
</div> </div>
<!-- Manga List --> <!-- Manga List -->
<div id="tab-content-mangas"> <div id="tab-content-mangas">
<div class="px-4 py-2 border-b border-gray-800">
<input id="manga-search" type="search" placeholder="🔍 Поиск по названию..."
oninput="onMangaSearch(this.value)"
class="w-full px-3 py-1.5 text-sm rounded-lg"
style="background:#0f1117;border:1px solid #2d3148;color:#e2e8f0;outline:none">
</div>
<div id="manga-list" class="divide-y divide-gray-800"> <div id="manga-list" class="divide-y divide-gray-800">
<div class="px-5 py-8 text-center text-gray-500 text-sm">Загрузка...</div> <div class="px-5 py-8 text-center text-gray-500 text-sm">Загрузка...</div>
</div> </div>
@@ -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,34 +1669,96 @@ async function confirmDelete() {
loadStats(); loadStats();
} }
async function refreshMeta(url) { function _updateMetaBtn(url, result) {
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
if(r.ok) {
const btn = document.querySelector(`[data-refresh-url="${CSS.escape(url)}"]`);
if(btn) { btn.textContent = '✓'; btn.disabled = true; setTimeout(() => { btn.textContent = '🏷'; btn.disabled = false; }, 2000); }
}
}
async function refreshMetaModal(url) {
const btn = document.getElementById('modal-refresh-meta-btn'); const btn = document.getElementById('modal-refresh-meta-btn');
if(btn) { btn.textContent = '⏳ Обновляем...'; btn.disabled = true; } if(!btn) return;
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'}); const inProgress = state.metaUpdating.has(url);
if(btn) { if(inProgress) {
if(r.ok) { btn.innerHTML = '<span class="meta-spinner"></span> Обновляем...';
btn.textContent = '✅ Метатеги обновлены'; btn.disabled = true;
btn.style.color = '#94a3b8';
btn.style.borderColor = '#334155';
} else if(result === 'done') {
btn.innerHTML = '✅ Готово';
btn.disabled = false;
btn.style.color = '#4ade80'; btn.style.color = '#4ade80';
btn.style.borderColor = '#166534'; btn.style.borderColor = '#166534';
setTimeout(() => { setTimeout(() => {
btn.textContent = '🏷 Обновить метатеги'; btn.innerHTML = '🏷 Обновить метаданные';
btn.disabled = false;
btn.style.color = '#a78bfa'; btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81'; btn.style.borderColor = '#312e81';
}, 2500); }, 2500);
} else { } else if(result === 'error') {
btn.textContent = '❌ Ошибка'; btn.innerHTML = '❌ Ошибка';
btn.disabled = false; btn.disabled = false;
btn.style.color = '#f87171';
btn.style.borderColor = '#7f1d1d';
setTimeout(() => {
btn.innerHTML = '🏷 Обновить метаданные';
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
}, 3000);
} else {
btn.innerHTML = '🏷 Обновить метаданные';
btn.disabled = false;
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
} }
} }
async function refreshMeta(url) {
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
if(!r.ok) return;
// state будет обновлён через WS meta_refresh_started
}
async function refreshMetaModal(url) {
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
if(!r.ok) {
const btn = document.getElementById('modal-refresh-meta-btn');
if(btn) { btn.innerHTML = '❌ Ошибка'; }
}
// Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed
}
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');
}
} }
async function forceRedownload(url, closeModalAfter = false) { async function forceRedownload(url, closeModalAfter = false) {
@@ -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) {}
} }

View File

@@ -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

View File

@@ -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()

View File

@@ -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,

View File

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

View File

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

View File

@@ -1,13 +1,23 @@
""" """
Базовые модели данных и 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
if TYPE_CHECKING:
from playwright.async_api import Page from playwright.async_api import Page
class AuthRequiredError(Exception):
"""Источник требует авторизации — токен не задан или просрочен."""
def __init__(self, source_slug: str):
self.source_slug = source_slug
super().__init__(f"Auth required for source: {source_slug}")
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Модели данных (общие для всех источников) # Модели данных (общие для всех источников)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@@ -30,6 +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
View 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"

View File

@@ -1,15 +1,19 @@
""" """
Адаптер 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
if TYPE_CHECKING:
from playwright.async_api import Page 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):
# Fallback: домен не в cdn_patterns, но имя файла совпадает с readerInit —
# значит CDN сменился (VPN, балансировка). Перехватываем.
if not expected_filenames or fname not in expected_filenames:
await route.continue_() await route.continue_()
return return
logger.debug("[{}] CDN fallback: {} (unknown domain: {})",
ch_id, fname, url.split("/")[2])
if BANNER_RE.search(base): if BANNER_RE.search(base):
await route.continue_() await route.continue_()
return return
@@ -201,6 +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,16 +255,8 @@ class ReadmangaSource:
await asyncio.sleep(3) await asyncio.sleep(3)
# Retry timeout через JS fetch async def _js_fetch(url: str) -> bytes | None:
async with lock: """Скачивает изображение через JS fetch в контексте браузера."""
timeout_bases = [u for u, e in route_errors.items()
if "timeout" in e.lower() and u not in captured]
if timeout_bases:
logger.info("[{}] Retry {} страниц с timeout...", ch_id, len(timeout_bases))
for retry_base in timeout_bases:
if retry_base in captured:
continue
fname = retry_base.split("/")[-1]
try: try:
data_b64 = await page.evaluate("""async (url) => { data_b64 = await page.evaluate("""async (url) => {
try { try {
@@ -257,19 +268,60 @@ class ReadmangaSource:
for (let b of bytes) bin += String.fromCharCode(b); for (let b of bytes) bin += String.fromCharCode(b);
return btoa(bin); return btoa(bin);
} catch(e) { return null; } } catch(e) { return null; }
}""", retry_base) }""", url)
if data_b64: if data_b64:
body = base64.b64decode(data_b64) body = base64.b64decode(data_b64)
if len(body) > 500: return body if len(body) > 500 else None
except Exception:
pass
return None
# Retry 1: timeout-ошибки через JS fetch
async with lock:
timeout_bases = [u for u, e in route_errors.items()
if "timeout" in e.lower() and u not in captured]
if timeout_bases:
logger.info("[{}] Retry {} страниц с timeout...", ch_id, len(timeout_bases))
for retry_base in timeout_bases:
async with lock:
if retry_base in captured:
continue
fname = retry_base.split("/")[-1]
body = await _js_fetch(retry_base)
if body:
async with lock: async with lock:
captured[retry_base] = body captured[retry_base] = body
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body)) logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
else:
logger.warning("[{}] Retry вернул {} байт — игнорируем", ch_id, len(body))
else: else:
logger.warning("[{}] Retry null для '{}'", ch_id, fname) logger.warning("[{}] Retry null для '{}'", ch_id, fname)
except Exception as e2:
logger.warning("[{}] Retry JS ошибка '{}': {}", ch_id, fname, e2) # Retry 2: не_перехваченные — CDN-домен сменился (VPN, балансировка).
# Браузер их загрузил, но route_handler не захватил байты.
# Берём URL напрямую из readerInit и достаём через JS fetch.
async with lock:
captured_fnames = {b.split("/")[-1] for b in captured}
unperceived = [
_base(u) for u in image_urls
if _base(u).split("/")[-1] not in captured_fnames
and _base(u) not in route_errors
and _base(u) not in route_statuses
]
if unperceived:
logger.info("[{}] JS retry для {} не_перехваченных (CDN-домен?)..",
ch_id, len(unperceived))
for retry_base in unperceived:
async with lock:
if retry_base.split("/")[-1] in captured_fnames:
continue
fname = retry_base.split("/")[-1]
body = await _js_fetch(retry_base)
if body:
async with lock:
captured[retry_base] = body
captured_fnames.add(fname)
logger.info("[{}] CDN retry OK: {} ({} байт)", ch_id, fname, len(body))
else:
logger.warning("[{}] CDN retry null для '{}'", ch_id, fname)
await page.unroute("**/*", route_handler) await page.unroute("**/*", route_handler)
@@ -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("""

View File

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

View File

@@ -11,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()
try:
manga = await source.get_manga_info(info_page, url) manga = await source.get_manga_info(info_page, url)
except AuthRequiredError as e:
await info_page.close() await info_page.close()
await db_call(db.update_manga_status, url, "stopped")
await db_call(db.set_manga_last_error, url, f"auth_required:{e.source_slug}")
finished_ts = await db_call(db.mark_finished, url)
await emit({"type": "auth_required", "url": url,
"source_slug": e.source_slug, "finished_at": finished_ts})
return
if not manga: if not manga:
await info_page.close()
await db_call(db.update_manga_status, url, "failed") await db_call(db.update_manga_status, url, "failed")
await emit({"type": "manga_failed", "url": url, await emit({"type": "manga_failed", "url": url,
"error": "Не удалось получить информацию о манге"}) "error": "Не удалось получить информацию о манге"})
return return
import json as _json_mod
await db_call( await db_call(
db.update_manga_info, db.update_manga_info,
url, url,
@@ -86,6 +108,9 @@ async def download_manga(
title_ru=manga.title_ru, title_ru=manga.title_ru,
title_full=manga.title_full, title_full=manga.title_full,
pub_status=manga.pub_status, pub_status=manga.pub_status,
description=manga.description,
tags=_json_mod.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
cover_url=manga.cover_url,
) )
await emit({ await emit({
"type": "manga_info", "type": "manga_info",
@@ -106,6 +131,12 @@ async def download_manga(
manga_dir = output_dir / folder_name manga_dir = output_dir / folder_name
manga_dir.mkdir(parents=True, exist_ok=True) manga_dir.mkdir(parents=True, exist_ok=True)
# Скачиваем обложку для CBZ-формата (info_page ещё открыта — контекст браузера жив)
if manga.cover_url and fmt in ("cbz", "all"):
await _download_cover(manga.cover_url, manga_dir, url, info_page)
await info_page.close()
for ch in manga.chapters: for ch in manga.chapters:
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume) await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
@@ -229,6 +260,7 @@ async def download_manga(
source_url=url, source_url=url,
summary=manga.description, summary=manga.description,
genre=", ".join(manga.genres) if manga.genres else "", genre=", ".join(manga.genres) if manga.genres else "",
tags=", ".join(manga.tags) if manga.tags else "",
) )
for f in formats: for f in formats:
out_file = manga_dir / f"{ch_name}.{f}" out_file = manga_dir / f"{ch_name}.{f}"
@@ -267,6 +299,8 @@ async def download_manga(
"chapters_total": len(manga.chapters), "chapters_total": len(manga.chapters),
}) })
except AuthRequiredError:
raise
except Exception as e: except Exception as e:
logger.exception( logger.exception(
"Необработанное исключение в Т{} Гл.{} '{}' | {}: {}", "Необработанное исключение в Т{} Гл.{} '{}' | {}: {}",
@@ -282,14 +316,70 @@ async def download_manga(
tasks = [process_chapter(ch) for ch in to_download] tasks = [process_chapter(ch) for ch in to_download]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# Логируем неожиданные исключения из gather # Логируем неожиданные исключения из gather; обнаруживаем auth ошибки
auth_slug = None
for ch, res in zip(to_download, results): for ch, res in zip(to_download, results):
if isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError): if isinstance(res, AuthRequiredError):
auth_slug = res.source_slug
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
logger.exception( logger.exception(
"gather: необработанное исключение Т{} Гл.{} '{}': {}", "gather: необработанное исключение Т{} Гл.{} '{}': {}",
ch.volume, ch.number, ch.title, res, ch.volume, ch.number, ch.title, res,
) )
if auth_slug:
await db_call(db.update_manga_status, url, "stopped")
await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}")
finished_ts = await db_call(db.mark_finished, url)
await emit({"type": "auth_required", "url": url,
"source_slug": auth_slug, "finished_at": finished_ts})
return
# ── Автоповтор неудачных глав (до 3 раз) ─────────────────────
MAX_AUTO_RETRIES = 3
for retry_attempt in range(1, MAX_AUTO_RETRIES + 1):
stats = await db_call(db.get_chapter_stats, url)
if stats["failed"] + stats["partial"] == 0:
break
failed_count = stats["failed"] + stats["partial"]
logger.info(
"Автоповтор {}/{}: {} неудачных/частичных глав для {}",
retry_attempt, MAX_AUTO_RETRIES, failed_count, url,
)
await emit({
"type": "retry_errors_auto",
"url": url,
"attempt": retry_attempt,
"max_attempts": MAX_AUTO_RETRIES,
"failed_count": failed_count,
})
await db_call(db.reset_failed_chapters, url)
all_ch_rows = await db_call(db.get_all_chapters, url)
pending_urls = {c["chapter_url"] for c in all_ch_rows if c["status"] == "pending"}
retry_chapters = [ch for ch in manga.chapters if ch.url in pending_urls]
if not retry_chapters:
break
retry_results = await asyncio.gather(
*[process_chapter(ch) for ch in retry_chapters],
return_exceptions=True,
)
auth_slug = None
for ch, res in zip(retry_chapters, retry_results):
if isinstance(res, AuthRequiredError):
auth_slug = res.source_slug
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
logger.exception(
"retry {}: необработанное исключение Т{} Гл.{} '{}': {}",
retry_attempt, ch.volume, ch.number, ch.title, res,
)
if auth_slug:
await db_call(db.update_manga_status, url, "stopped")
await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}")
finished_ts = await db_call(db.mark_finished, url)
await emit({"type": "auth_required", "url": url,
"source_slug": auth_slug, "finished_at": finished_ts})
return
real_done = await db_call(db.sync_chapters_done, url) real_done = await db_call(db.sync_chapters_done, url)
await db_call(db.update_manga_status, url, "done") await db_call(db.update_manga_status, url, "done")
finished_ts = await db_call(db.mark_finished, url) finished_ts = await db_call(db.mark_finished, url)
@@ -316,6 +406,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()