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(
|
paths = await source.get_chapter_images_and_download(
|
||||||
page, first.url, dest_dir=Path(tmp), manga_url=url
|
page, first.url, dest_dir=Path(tmp), manga_url=url
|
||||||
)
|
)
|
||||||
click.echo(f" Скачано изображений: {len(paths)}")
|
click.echo(f" Скачано изображений: {len(paths)}")
|
||||||
for p in paths[:3]:
|
for p in paths[:3]:
|
||||||
click.echo(f" {p.name} ({p.stat().st_size} байт)")
|
click.echo(f" {p.name} ({p.stat().st_size} байт)")
|
||||||
|
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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",
|
"3.readmanga.ru",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Домены MangaLib по умолчанию (сидинг при первом запуске)
|
||||||
|
_DEFAULT_MANGALIB_DOMAINS = [
|
||||||
|
"mangalib.me",
|
||||||
|
"mangalib.org",
|
||||||
|
"hentailib.me",
|
||||||
|
"yaoilib.me",
|
||||||
|
"readlib.net",
|
||||||
|
]
|
||||||
|
|
||||||
_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"})
|
_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"})
|
||||||
|
|
||||||
|
|
||||||
@@ -197,6 +206,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()]
|
||||||
|
|||||||
Reference in New Issue
Block a user