mangalib
This commit is contained in:
369
CODE_REVIEW.md
369
CODE_REVIEW.md
@@ -1,369 +0,0 @@
|
||||
# Code Review: находки и предложения
|
||||
|
||||
> Статус: **только чтение** — ничего не изменено. Каждый пункт сопровождается предлагаемым исправлением.
|
||||
|
||||
---
|
||||
|
||||
## 1. Дублирование кода — `_safe_name` / `_safe_chapter_name`
|
||||
|
||||
**Файлы:** `src/api.py:251`, `src/worker.py:26–32`, `src/cli.py` (аналогичные функции)
|
||||
|
||||
Одна и та же функция определена в трёх местах. При изменении логики нужно менять сразу в трёх файлах — риск расхождения.
|
||||
|
||||
**Исправление:** вынести в `src/utils.py`, импортировать везде:
|
||||
|
||||
```python
|
||||
# src/utils.py
|
||||
import re
|
||||
from .sources.base import Chapter
|
||||
|
||||
def safe_name(s: str) -> str:
|
||||
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
|
||||
|
||||
def safe_chapter_name(ch: Chapter) -> str:
|
||||
vol = f"v{ch.volume:02d}_" if ch.volume else ""
|
||||
return f"{vol}ch{ch.number:06.1f}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Прямой доступ к `db.conn` в API-эндпоинтах
|
||||
|
||||
**Файлы:** `src/api.py`
|
||||
|
||||
| Место | Строки |
|
||||
|-------|--------|
|
||||
| `_enrich_manga` | 269–278 |
|
||||
| `retry_errors` | 680–688 |
|
||||
| `force_redownload` | 819–823 |
|
||||
| `delete_manga` | 882–885 |
|
||||
| `rename_folder` | 801–803 |
|
||||
|
||||
`api.py` напрямую исполняет SQL через `db.conn.execute(...)` вместо методов `StateDB`. Это означает, что логика разбросана между двумя слоями, и рефакторинг `StateDB` может незаметно сломать эндпоинты.
|
||||
|
||||
Для `retry_errors` и `force_redownload` в `StateDB` уже есть готовый метод `reset_failed_chapters` — он просто не используется в API.
|
||||
|
||||
**Исправление для `retry_errors`:**
|
||||
|
||||
```python
|
||||
# api.py — было:
|
||||
now = db.conn.execute("SELECT datetime('now')").fetchone()[0]
|
||||
db.conn.execute("UPDATE chapters SET status='pending' ...", (now, url))
|
||||
db.conn.commit()
|
||||
|
||||
# стало:
|
||||
db.reset_failed_chapters(url)
|
||||
```
|
||||
|
||||
Для `delete_manga` и `rename_folder` добавить соответствующие методы в `StateDB`.
|
||||
|
||||
---
|
||||
|
||||
## 3. `datetime.utcnow()` устарел
|
||||
|
||||
**Файлы:** `src/api.py:369`, `src/state.py:628`
|
||||
|
||||
`datetime.utcnow()` объявлен deprecated в Python 3.12. Используется в двух местах.
|
||||
|
||||
**Исправление:**
|
||||
|
||||
```python
|
||||
# src/state.py
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
||||
|
||||
# src/api.py — в login():
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=30)).replace(tzinfo=None).isoformat()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `check_for_updates` не использует `db_lock`
|
||||
|
||||
**Файл:** `src/worker.py:343–400`
|
||||
|
||||
`download_manga` тщательно оборачивает все обращения к БД в `db_lock` (`asyncio.Lock`). `check_for_updates` вызывает `db.update_manga_info`, `db.get_all_chapters`, `db.set_last_checked` напрямую без блокировки. При параллельном запуске нескольких авто-обновлений возможна конкуренция записей в одну и ту же строку SQLite.
|
||||
|
||||
**Исправление:** добавить `db_lock` в `check_for_updates` по аналогии с `download_manga`, либо убедиться, что авто-обновления всегда запускаются последовательно (сейчас в `_run_auto_updates` они идут через `for manga in candidates` — это последовательно, но ручной `check_now` и авто-обновление могут пересечься).
|
||||
|
||||
---
|
||||
|
||||
## 5. Хак `pages_done_count = [0]`
|
||||
|
||||
**Файл:** `src/worker.py:196`
|
||||
|
||||
```python
|
||||
pages_done_count = [0] # мутабельный список вместо nonlocal
|
||||
|
||||
async def on_page(page_idx: int, pages_total: int):
|
||||
pages_done_count[0] += 1
|
||||
```
|
||||
|
||||
Это классический обходной путь для `nonlocal` в Python 2. В Python 3 достаточно `nonlocal`.
|
||||
|
||||
**Исправление:**
|
||||
|
||||
```python
|
||||
pages_done = 0
|
||||
|
||||
async def on_page(page_idx: int, pages_total: int):
|
||||
nonlocal pages_done
|
||||
pages_done += 1
|
||||
await db_call(db.update_chapter_pages, ch.url, pages_total, pages_done)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Мёртвый код в `StateDB`
|
||||
|
||||
**Файл:** `src/state.py:405–407`
|
||||
|
||||
```python
|
||||
def increment_manga_chapters_done(self, url: str):
|
||||
# Оставлен для совместимости, но не используется в воркере
|
||||
pass
|
||||
```
|
||||
|
||||
Метод ничего не делает и нигде не вызывается.
|
||||
|
||||
**Исправление:** удалить.
|
||||
|
||||
---
|
||||
|
||||
## 7. Отложенный импорт `BrowserManager` в `_fetch_preview`
|
||||
|
||||
**Файл:** `src/api.py:548`
|
||||
|
||||
```python
|
||||
async def _fetch_preview(url: str):
|
||||
try:
|
||||
from .browser import BrowserManager # импорт внутри функции
|
||||
```
|
||||
|
||||
`BrowserManager` уже импортируется в `worker.py`. Импорт внутри функции — признак того, что его пытались скрыть из-за каких-то проблем при старте, но сейчас оснований для этого нет. Это замедляет первый вызов и затрудняет поиск зависимостей.
|
||||
|
||||
**Исправление:** добавить `from .browser import BrowserManager` в топ-уровневые импорты `api.py`.
|
||||
|
||||
Аналогично — `import shutil` внутри тел `rename_folder` (строка 789) и `delete_manga` (строка 879). Вынести в топ.
|
||||
|
||||
---
|
||||
|
||||
## 8. O(n²) назначение позиций в очереди
|
||||
|
||||
**Файл:** `src/api.py:486–491`
|
||||
|
||||
```python
|
||||
queue_list = list(download_queue._queue)
|
||||
for i, job in enumerate(queue_list):
|
||||
for r in result: # ← внутренний цикл по всем мангам
|
||||
if r["url"] == job["url"]:
|
||||
r["queue_position"] = i + 1
|
||||
```
|
||||
|
||||
При 100 мангах в очереди и 100 в БД — 10 000 итераций на каждый запрос `/api/mangas`.
|
||||
|
||||
**Исправление:**
|
||||
|
||||
```python
|
||||
queue_positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
|
||||
for r in result:
|
||||
r["queue_position"] = queue_positions.get(r["url"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Утечка памяти в `_export_pdf_pillow`
|
||||
|
||||
**Файл:** `src/exporter.py:131–135`
|
||||
|
||||
```python
|
||||
def _export_pdf_pillow(images: list[Path], out: Path):
|
||||
from PIL import Image
|
||||
pil_images = [Image.open(p).convert("RGB") for p in images]
|
||||
if pil_images:
|
||||
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
|
||||
# pil_images не закрываются — файловые дескрипторы висят до GC
|
||||
```
|
||||
|
||||
**Исправление:**
|
||||
|
||||
```python
|
||||
def _export_pdf_pillow(images: list[Path], out: Path):
|
||||
from PIL import Image
|
||||
pil_images = [Image.open(p).convert("RGB") for p in images]
|
||||
try:
|
||||
if pil_images:
|
||||
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
|
||||
finally:
|
||||
for img in pil_images:
|
||||
img.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Потенциальный SQL-инъекционный путь в `mark_done`
|
||||
|
||||
**Файл:** `src/state.py:453–459`
|
||||
|
||||
```python
|
||||
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
|
||||
col = f"output_{fmt}"
|
||||
self.conn.execute(f"""
|
||||
UPDATE chapters SET status='done', {col}=?, updated_at=?
|
||||
WHERE chapter_url=?
|
||||
""", (output_path, _now(), chapter_url))
|
||||
```
|
||||
|
||||
Сейчас `fmt` всегда приходит из `["cbz", "pdf", "epub"]` в `worker.py`, поэтому эксплуатации нет. Но паттерн хрупкий: если завтра `fmt` придёт от пользователя через API, это станет инъекцией.
|
||||
|
||||
**Исправление:**
|
||||
|
||||
```python
|
||||
_ALLOWED_FMTS = {"cbz", "pdf", "epub"}
|
||||
|
||||
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
|
||||
if fmt not in _ALLOWED_FMTS:
|
||||
raise ValueError(f"Unknown format: {fmt}")
|
||||
col = f"output_{fmt}"
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Неиспользуемый метод `BrowserManager.navigate()`
|
||||
|
||||
**Файл:** `src/browser.py`
|
||||
|
||||
`BrowserManager` содержит метод `navigate()`, который нигде не вызывается — `readmanga.py` определяет собственный `_navigate`. Это мёртвый код.
|
||||
|
||||
**Исправление:** удалить `navigate()` из `BrowserManager` или убрать `_navigate` из `readmanga.py` и использовать единый метод.
|
||||
|
||||
---
|
||||
|
||||
## 12. `cli.py` использует устаревший шим вместо реестра источников
|
||||
|
||||
**Файл:** `src/cli.py`
|
||||
|
||||
```python
|
||||
from .scraper import get_manga_info, get_chapter_images_and_download # shim
|
||||
```
|
||||
|
||||
`scraper.py` — это обёртка-пустышка, делегирующая в `ReadmangaSource`. CLI не использует `SourceRegistry`, не поддерживает `MangaMeta` полноценно. При добавлении нового источника CLI не заработает автоматически.
|
||||
|
||||
**Исправление:** переписать `cli.py` на использование `registry` и `get_source_for_url`, как это сделано в `worker.py`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Двойное чтение тела ответа в `saveRenameFolder`
|
||||
|
||||
**Файл:** `frontend/index.html`
|
||||
|
||||
```javascript
|
||||
async function saveRenameFolder() {
|
||||
const r = await fetch('/api/mangas/rename_folder', ...);
|
||||
if (!r.ok) {
|
||||
const err = await r.json(); // ← первое чтение
|
||||
...
|
||||
}
|
||||
const data = await r.json(); // ← второе чтение (тело уже прочитано!)
|
||||
```
|
||||
|
||||
`Response.body` — это `ReadableStream`, читается один раз. Второй `r.json()` вернёт ошибку или пустой объект.
|
||||
|
||||
**Исправление:**
|
||||
|
||||
```javascript
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
showError(data.detail || 'Ошибка');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. `escHtml()` не защищает от JS-инъекции в `onclick`
|
||||
|
||||
**Файл:** `frontend/index.html` — различные места типа:
|
||||
|
||||
```javascript
|
||||
`<button onclick="openEditUserModal(${u.id}, '${escHtml(u.username)}', ...)">
|
||||
```
|
||||
|
||||
`escHtml` экранирует HTML-спецсимволы (`<`, `>`, `&`), но не защищает от инъекции в контекст JavaScript-строки. Если `u.username` содержит `'); evil() //` — экранирование не поможет.
|
||||
|
||||
**Исправление:** вместо `onclick="..."` в строке шаблона использовать `data-*` атрибуты + делегирование событий:
|
||||
|
||||
```javascript
|
||||
`<button class="edit-user-btn" data-id="${u.id}" data-username="${escAttr(u.username)}">`
|
||||
|
||||
// один раз:
|
||||
document.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.edit-user-btn');
|
||||
if (btn) openEditUserModal(+btn.dataset.id, btn.dataset.username, ...);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Дублирование `forceRedownload` / `forceRedownloadModal`
|
||||
|
||||
**Файл:** `frontend/index.html`
|
||||
|
||||
Две функции с практически идентичным телом — `forceRedownload(url)` и `forceRedownloadModal(url)`. Отличие только в том, что вторая закрывает модал перед вызовом.
|
||||
|
||||
**Исправление:** одна функция с необязательным флагом или вызов `closeModal()` перед `forceRedownload()` там, где нужно.
|
||||
|
||||
---
|
||||
|
||||
## 16. `check_for_updates` в `worker.py` импортирует `scraper` шим
|
||||
|
||||
**Файл:** `src/worker.py:16`
|
||||
|
||||
```python
|
||||
from .scraper import get_manga_info, get_chapter_images_and_download # shim для обратной совместимости
|
||||
```
|
||||
|
||||
Эти символы нигде в файле не используются — `worker.py` работает через `registry`. Мёртвый импорт.
|
||||
|
||||
**Исправление:** удалить строку.
|
||||
|
||||
---
|
||||
|
||||
## 17. Одиночный SQLite-коннект с `check_same_thread=False` в async-среде
|
||||
|
||||
**Файл:** `src/state.py:27`
|
||||
|
||||
```python
|
||||
self.conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||
```
|
||||
|
||||
Каждый `StateDB()` создаёт новый коннект, но внутри одного воркера `db` живёт на всё время скачивания манги. `check_same_thread=False` отключает встроенную защиту SQLite от использования коннекта в нескольких потоках. В текущей архитектуре с `asyncio` и `db_lock` это безопасно, но хрупко — одно забытое `await` без блокировки может привести к `database is locked` или повреждению данных.
|
||||
|
||||
**Рекомендация:** ничего не менять прямо сейчас, но при следующем крупном рефакторинге рассмотреть `aiosqlite` или SQLite WAL-режим (`PRAGMA journal_mode=WAL`) для снижения вероятности блокировок при параллельных читателях.
|
||||
|
||||
---
|
||||
|
||||
## Сводная таблица приоритетов
|
||||
|
||||
| # | Файл | Проблема | Приоритет |
|
||||
|---|------|----------|-----------|
|
||||
| 2 | api.py | Прямой `db.conn` в эндпоинтах (retry, force, delete) | Высокий |
|
||||
| 8 | api.py | O(n²) очередь позиций | Высокий |
|
||||
| 13 | frontend | Двойное чтение `r.json()` — баг | Высокий |
|
||||
| 4 | worker.py | `check_for_updates` без `db_lock` | Средний |
|
||||
| 9 | exporter.py | Утечка PIL-объектов в PDF Pillow | Средний |
|
||||
| 10 | state.py | Хрупкий f-string в `mark_done` | Средний |
|
||||
| 3 | api/state | `datetime.utcnow()` deprecated | Низкий |
|
||||
| 1 | api/worker/cli | Дублирование `_safe_name` | Низкий |
|
||||
| 5 | worker.py | Хак `pages_done_count = [0]` | Низкий |
|
||||
| 6 | state.py | Мёртвый метод `increment_manga_chapters_done` | Низкий |
|
||||
| 7 | api.py | Поздний `import` внутри функций | Низкий |
|
||||
| 11 | browser.py | Мёртвый метод `navigate()` | Низкий |
|
||||
| 12 | cli.py | Устаревший шим, нет поддержки реестра | Низкий |
|
||||
| 14 | frontend | JS-инъекция через `onclick` + `escHtml` | Низкий (internal tool) |
|
||||
| 15 | frontend | Дублирование `forceRedownload*` | Низкий |
|
||||
| 16 | worker.py | Мёртвый импорт scraper shim | Низкий |
|
||||
| 17 | state.py | `check_same_thread=False` в async | На будущее |
|
||||
@@ -234,9 +234,9 @@ async def _analyze(url: str):
|
||||
paths = await source.get_chapter_images_and_download(
|
||||
page, first.url, dest_dir=Path(tmp), manga_url=url
|
||||
)
|
||||
click.echo(f" Скачано изображений: {len(paths)}")
|
||||
for p in paths[:3]:
|
||||
click.echo(f" {p.name} ({p.stat().st_size} байт)")
|
||||
click.echo(f" Скачано изображений: {len(paths)}")
|
||||
for p in paths[:3]:
|
||||
click.echo(f" {p.name} ({p.stat().st_size} байт)")
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ from typing import Optional
|
||||
|
||||
from .base import MangaSourceProtocol
|
||||
from .readmanga import ReadmangaSource
|
||||
from .mangalib import MangalibSource
|
||||
|
||||
# ── Регистрация источников ─────────────────────
|
||||
# Добавьте новые источники сюда:
|
||||
SOURCES: list = [
|
||||
ReadmangaSource(),
|
||||
MangalibSource(),
|
||||
]
|
||||
|
||||
# Быстрый поиск по slug
|
||||
|
||||
640
src/sources/mangalib.py
Normal file
640
src/sources/mangalib.py
Normal file
@@ -0,0 +1,640 @@
|
||||
"""
|
||||
Адаптер MangaLib: поддерживает mangalib.me и его зеркала.
|
||||
|
||||
Принцип работы:
|
||||
- Список глав: перехватываем ответ api.cdnlibs.org/api/manga/{slug}/chapters
|
||||
Возвращает все главы сразу (не требует пагинации).
|
||||
URL главы: {origin}/ru/{manga_slug}/read/v{vol}/c{num}
|
||||
- Страница главы: перехватываем ответ api.cdnlibs.org/api/manga/{slug}/chapter?...
|
||||
Получаем pages[] с полями: image (filename), url (relative path), slug (page index 1-based).
|
||||
Изображения: {server}{page.url}, CDN = mixlib.me / imglib.info.
|
||||
"""
|
||||
import asyncio
|
||||
import json as _json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from loguru import logger
|
||||
from playwright.async_api import Page
|
||||
|
||||
from .base import Chapter, MangaInfo
|
||||
|
||||
|
||||
class MangalibSource:
|
||||
slug = "mangalib"
|
||||
display_name = "MangaLib"
|
||||
|
||||
# CDN-домены для изображений глав (актуальные)
|
||||
cdn_patterns = ["mixlib.me", "imglib.info", "imglib", "imgslib"]
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Страница манги — список глав
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
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 = {}
|
||||
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):
|
||||
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)
|
||||
|
||||
page.on("response", on_response)
|
||||
|
||||
ok = await _navigate(page, chapters_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)
|
||||
|
||||
# Извлекаем 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)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Скачивание главы
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
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 = []
|
||||
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:
|
||||
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
|
||||
|
||||
page.on("response", on_response)
|
||||
|
||||
referer = manga_url or referer_origin
|
||||
ok = await _navigate(page, chapter_url, referer=referer)
|
||||
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)
|
||||
|
||||
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())]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Вспомогательные функции (приватные)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
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"
|
||||
27
src/state.py
27
src/state.py
@@ -20,6 +20,15 @@ _DEFAULT_READMANGA_DOMAINS = [
|
||||
"3.readmanga.ru",
|
||||
]
|
||||
|
||||
# Домены MangaLib по умолчанию (сидинг при первом запуске)
|
||||
_DEFAULT_MANGALIB_DOMAINS = [
|
||||
"mangalib.me",
|
||||
"mangalib.org",
|
||||
"hentailib.me",
|
||||
"yaoilib.me",
|
||||
"readlib.net",
|
||||
]
|
||||
|
||||
_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"})
|
||||
|
||||
|
||||
@@ -197,6 +206,24 @@ class StateDB:
|
||||
self.conn.commit()
|
||||
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())
|
||||
db_slugs = [r["slug"] for r in self.conn.execute("SELECT slug FROM sources").fetchall()]
|
||||
|
||||
Reference in New Issue
Block a user