upd
This commit is contained in:
668
src/scraper.py
668
src/scraper.py
@@ -1,665 +1,19 @@
|
||||
"""
|
||||
Парсер readmanga.ru: список глав и URL/байты изображений внутри главы.
|
||||
Обратно-совместимый shim: делегирует вызовы ReadmangaSource.
|
||||
Не используйте напрямую в новом коде — используйте src.sources.registry.
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .sources.base import Chapter, MangaInfo # noqa: F401 — реэкспорт для импортёров
|
||||
from .sources.readmanga import ReadmangaSource
|
||||
|
||||
from loguru import logger
|
||||
from playwright.async_api import Page
|
||||
|
||||
from .browser import BrowserManager
|
||||
_instance = ReadmangaSource()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Модели данных
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Chapter:
|
||||
title: str
|
||||
url: str
|
||||
number: float = 0.0
|
||||
volume: int = 0
|
||||
async def get_manga_info(page, url):
|
||||
return await _instance.get_manga_info(page, url)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MangaInfo:
|
||||
title: str
|
||||
url: str
|
||||
chapters: list[Chapter] = field(default_factory=list)
|
||||
pub_status: str = "unknown" # completed / ongoing / unknown
|
||||
title_ru: str = "" # Только русский тайтл (для папки)
|
||||
title_full: str = "" # Полный тайтл как на странице
|
||||
description: str = "" # Описание/синопсис
|
||||
genres: list[str] = field(default_factory=list) # Жанры
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Страница манги — список глав
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def get_manga_info(page: Page, url: str) -> Optional[MangaInfo]:
|
||||
"""Открывает страницу манги и возвращает список всех глав."""
|
||||
logger.info("Загружаем страницу манги: {}", url)
|
||||
ok = await _navigate(page, url)
|
||||
if not ok:
|
||||
return None
|
||||
|
||||
title_full = await page.title()
|
||||
title_full = re.sub(r"\s*[-–|].*$", "", title_full).strip()
|
||||
|
||||
# Пробуем взять русский тайтл напрямую из DOM
|
||||
title_ru = await _extract_ru_title_from_dom(page)
|
||||
if not title_ru:
|
||||
title_ru = _parse_ru_title(title_full)
|
||||
|
||||
logger.info("Манга: {} | ru: {}", title_full, title_ru)
|
||||
|
||||
pub_status = await _extract_pub_status(page)
|
||||
logger.info("Статус выпуска: {}", pub_status)
|
||||
|
||||
description = await _extract_description(page)
|
||||
genres = await _extract_genres(page)
|
||||
|
||||
await _expand_chapters(page)
|
||||
chapters = await _extract_chapters(page)
|
||||
if not chapters:
|
||||
chapters = await _extract_chapters_alt(page)
|
||||
|
||||
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(page, chapter_url, dest_dir,
|
||||
manga_url=None, on_page=None):
|
||||
return await _instance.get_chapter_images_and_download(
|
||||
page, chapter_url, dest_dir, manga_url=manga_url, on_page=on_page
|
||||
)
|
||||
|
||||
|
||||
async def _extract_ru_title_from_dom(page: Page) -> str:
|
||||
"""Ищет русский тайтл в структуре страницы readmanga."""
|
||||
try:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
// readmanga: основной тайтл в span.name внутри .names
|
||||
const selectors = [
|
||||
'.names .name',
|
||||
'h1.manga-title',
|
||||
'h1 .name',
|
||||
'.name-block .name',
|
||||
];
|
||||
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 ""
|
||||
|
||||
|
||||
def _parse_ru_title(full_title: str) -> str:
|
||||
"""Извлекает русский тайтл из полной строки тайтла.
|
||||
|
||||
Примеры:
|
||||
'Манга Режим — АД. Хардкорный геймер ... (Hellmode)' → 'Режим — АД. Хардкорный геймер ...'
|
||||
'Манга Магическая битва (Sorcery Fight) Гэгэ онлайн' → 'Магическая битва'
|
||||
'Авантюрист Monster Eater Adventurer' → 'Авантюрист'
|
||||
"""
|
||||
t = full_title.strip()
|
||||
# Убираем префикс "Манга "
|
||||
t = re.sub(r'^Манга\s+', '', t).strip()
|
||||
# Берём только до первой скобки (начало английского тайтла)
|
||||
t = re.split(r'\s*[\(\[]', t)[0].strip()
|
||||
# Убираем суффикс " онлайн"
|
||||
t = re.sub(r'\s+онлайн\s*$', '', t, flags=re.IGNORECASE).strip()
|
||||
|
||||
# Обрезаем хвост из латинских слов.
|
||||
# Правило: стоп только на токене содержащем латиницу (a-zA-Z).
|
||||
# Пунктуация между кириллическими словами (—, –, ., :, !) — сохраняем.
|
||||
words = t.split()
|
||||
result = []
|
||||
for w in words:
|
||||
if re.search(r'[а-яёА-ЯЁ]', w):
|
||||
result.append(w)
|
||||
elif re.search(r'[a-zA-Z]', w):
|
||||
# Первое латинское слово после кириллических — обрезаем здесь
|
||||
if result:
|
||||
break
|
||||
else:
|
||||
# Чисто пунктуационный токен (—, –, ., :, …)
|
||||
# Добавляем только если уже есть кириллические слова (связка внутри)
|
||||
if result:
|
||||
result.append(w)
|
||||
|
||||
# Убираем висячую пунктуацию в конце (если последнее слово — не кириллица)
|
||||
while result and not re.search(r'[а-яёА-ЯЁ]', result[-1]):
|
||||
result.pop()
|
||||
|
||||
if result:
|
||||
t = ' '.join(result)
|
||||
return t
|
||||
|
||||
|
||||
async def _extract_pub_status(page: Page) -> str:
|
||||
"""Извлекает статус выпуска: completed / ongoing / unknown."""
|
||||
try:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
// readmanga хранит статус в .elem_status .value или похожих блоках
|
||||
const statusSelectors = [
|
||||
'.elem_status .value',
|
||||
'.manga-info .status',
|
||||
'[class*="status"] .value',
|
||||
'.property .status',
|
||||
];
|
||||
for (const sel of statusSelectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
const t = el.textContent.toLowerCase();
|
||||
if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed';
|
||||
if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing';
|
||||
}
|
||||
}
|
||||
// Fallback: сканируем весь текст страницы
|
||||
const bodyText = document.body ? document.body.innerText.toLowerCase() : '';
|
||||
if (bodyText.includes('выпуск завершён') || bodyText.includes('выпуск завершен')) return 'completed';
|
||||
if (bodyText.includes('продолжается')) return 'ongoing';
|
||||
return 'unknown';
|
||||
}
|
||||
""")
|
||||
return result or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
async def _extract_description(page: Page) -> str:
|
||||
"""Извлекает описание/синопсис манги."""
|
||||
try:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
const selectors = [
|
||||
'.manga-description',
|
||||
'.elem_descr .value',
|
||||
'#tab-description .description-text',
|
||||
'.description',
|
||||
'[itemprop="description"]',
|
||||
];
|
||||
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] # обрезаем до 2000 символов
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _extract_genres(page: Page) -> list[str]:
|
||||
"""Извлекает список жанров манги."""
|
||||
try:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
const selectors = [
|
||||
'.elem_genre .value a',
|
||||
'.genres a',
|
||||
'[itemprop="genre"]',
|
||||
'.genre-list 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 _navigate(page: Page, url: str, retries: int = 3,
|
||||
referer: str | None = None) -> bool:
|
||||
from urllib.parse import urlparse
|
||||
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=10_000)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Попытка {}/{}: {}", attempt, retries, e)
|
||||
await asyncio.sleep(3 * attempt)
|
||||
return False
|
||||
|
||||
|
||||
async def _expand_chapters(page: Page):
|
||||
for sel in ["a.chapter-link.all", "button:has-text('Все главы')",
|
||||
"a:has-text('Все главы')"]:
|
||||
try:
|
||||
el = page.locator(sel).first
|
||||
if await el.is_visible(timeout=2000):
|
||||
await el.click()
|
||||
await page.wait_for_load_state("networkidle", timeout=10_000)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _extract_chapters(page: Page) -> list[Chapter]:
|
||||
"""Основной парсер: #chapters-list → tr.item-row → td[data-num] a.chapter-link"""
|
||||
rows = await page.query_selector_all("#chapters-list tr.item-row")
|
||||
chapters = []
|
||||
for row in rows:
|
||||
link = await row.query_selector("td[class*='item-title'] a")
|
||||
if not link:
|
||||
continue
|
||||
href = await link.get_attribute("href") or ""
|
||||
text = (await link.inner_text()).strip()
|
||||
if not href:
|
||||
continue
|
||||
td = await row.query_selector("td[data-num]")
|
||||
vol = int((await td.get_attribute("data-vol") or "0")) if td else 0
|
||||
num_raw = int((await td.get_attribute("data-num") or "0")) if td else 0
|
||||
number = num_raw / 10.0
|
||||
full_url = href if href.startswith("http") else _base_url(page.url) + href
|
||||
chapters.append(Chapter(title=text, url=full_url, number=number, volume=vol))
|
||||
return chapters
|
||||
|
||||
|
||||
async def _extract_chapters_alt(page: Page) -> list[Chapter]:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
const links = Array.from(document.querySelectorAll('a[href*="/vol"]'));
|
||||
return links.map(a => ({ href: a.href, text: a.textContent.trim() }))
|
||||
.filter(x => x.href && x.text);
|
||||
}
|
||||
""")
|
||||
return [Chapter(title=x["text"], url=x["href"],
|
||||
number=_parse_num(x["text"]), volume=_parse_vol(x["text"]))
|
||||
for x in result]
|
||||
|
||||
|
||||
def _base_url(url: str) -> str:
|
||||
m = re.match(r"(https?://[^/]+)", url)
|
||||
return m.group(1) if m else "https://readmanga.ru"
|
||||
|
||||
|
||||
def _parse_num(text: str) -> float:
|
||||
m = re.search(r"[\d]+(?:[.,]\d+)?", text.replace(",", "."))
|
||||
return float(m.group()) if m else 0.0
|
||||
|
||||
|
||||
def _parse_vol(text: str) -> int:
|
||||
m = re.search(r"Том\s+(\d+)", text, re.IGNORECASE)
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Страница главы — получение URL изображений
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def _extract_images_from_js(page: Page) -> list[str]:
|
||||
"""
|
||||
Извлекает URL из rm_h.readerInit(chapterInfo, [[base, '', path, w, h], ...]).
|
||||
Считает скобки для точного захвата массива.
|
||||
"""
|
||||
try:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
for (const s of document.querySelectorAll('script')) {
|
||||
const text = s.textContent || '';
|
||||
const mi = text.indexOf('readerInit');
|
||||
if (mi === -1) continue;
|
||||
const ai = text.indexOf('[', mi);
|
||||
if (ai === -1) continue;
|
||||
let depth = 0, end = -1;
|
||||
for (let i = ai; i < text.length; i++) {
|
||||
if (text[i] === '[') depth++;
|
||||
else if (text[i] === ']') { depth--; if (!depth) { end = i+1; break; } }
|
||||
}
|
||||
if (end === -1) continue;
|
||||
try {
|
||||
const arr = eval(text.slice(ai, end));
|
||||
if (Array.isArray(arr) && arr.length)
|
||||
return arr.map(item => Array.isArray(item) && item.length >= 3
|
||||
? item[0] + item[2] : null).filter(Boolean);
|
||||
} catch(e) {}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
""")
|
||||
if result:
|
||||
logger.debug("JS readerInit нашёл {} изображений", len(result))
|
||||
return result or []
|
||||
except Exception as e:
|
||||
logger.debug("JS-метод не сработал: {}", e)
|
||||
return []
|
||||
|
||||
|
||||
async def _extract_images_from_dom(page: Page) -> list[str]:
|
||||
try:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
for (const sel of ['img.manga-page', '.page-image img', '#mangaReader img', 'img[data-src]']) {
|
||||
const found = Array.from(document.querySelectorAll(sel));
|
||||
if (found.length) return found.map(i => i.src || i.dataset.src).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
""")
|
||||
return result or []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Скачивание главы
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def get_chapter_images_and_download(
|
||||
page: Page,
|
||||
chapter_url: str,
|
||||
dest_dir: Path,
|
||||
manga_url: str | None = None,
|
||||
on_page: object = None,
|
||||
) -> list[Path]:
|
||||
"""
|
||||
1. Открывает страницу главы (устанавливает DDoS-Guard cookies для CDN).
|
||||
2. Извлекает список URL из readerInit.
|
||||
3. Перехватывает img-запросы через page.route() + route.fetch()
|
||||
(браузерный стек — правильные Sec-Fetch-* заголовки, cookies).
|
||||
4. Пролистывает читалку клавишей ArrowRight чтобы загрузить все страницы.
|
||||
5. Retry для страниц с timeout через JS fetch.
|
||||
"""
|
||||
t_start = time.monotonic()
|
||||
ch_id = chapter_url.split("/")[-1] # короткий идентификатор для логов
|
||||
logger.info("[{}] Загружаем главу: {}", ch_id, chapter_url)
|
||||
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(chapter_url)
|
||||
parts = parsed.path.strip("/").split("/")
|
||||
manga_slug = parts[0] if parts else ""
|
||||
referer = manga_url or f"{parsed.scheme}://{parsed.netloc}/{manga_slug}"
|
||||
|
||||
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _base(u: str) -> str:
|
||||
return u.split("?")[0]
|
||||
|
||||
# Баннеры/рекламные изображения — игнорируем без логирования
|
||||
BANNER_RE = re.compile(r"466_p\.|570_p\.|banner|advert", re.I)
|
||||
|
||||
def _is_manga_image(url: str) -> bool:
|
||||
base = _base(url)
|
||||
if not re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", base, re.I):
|
||||
return False
|
||||
if "resrmr." in url or "/static/" in url:
|
||||
return False
|
||||
return bool(re.search(r"one-way\.work|staticfa\.|rm\.one-way|cdnmanga|reimg", url, re.I))
|
||||
|
||||
captured: dict[str, bytes] = {} # base_url → bytes
|
||||
route_errors: dict[str, str] = {} # base_url → текст ошибки
|
||||
route_statuses: dict[str, int] = {} # base_url → HTTP status (не 200/206)
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async def route_handler(route, request):
|
||||
url = request.url
|
||||
base = _base(url)
|
||||
if not _is_manga_image(url):
|
||||
await route.continue_()
|
||||
return
|
||||
if BANNER_RE.search(base):
|
||||
await route.continue_()
|
||||
return
|
||||
async with lock:
|
||||
already = base in captured
|
||||
if already:
|
||||
await route.continue_()
|
||||
return
|
||||
fname = base.split("/")[-1]
|
||||
try:
|
||||
response = await route.fetch()
|
||||
status = response.status
|
||||
body = await response.body()
|
||||
if body and len(body) > 500 and status in (200, 206):
|
||||
async with lock:
|
||||
if base not in captured:
|
||||
captured[base] = body
|
||||
logger.debug("[{}] ✓ {}: {} байт", ch_id, fname, len(body))
|
||||
if on_page:
|
||||
try:
|
||||
asyncio.ensure_future(on_page(0, 0))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
async with lock:
|
||||
route_statuses[base] = status
|
||||
if status not in (200, 206):
|
||||
logger.warning("[{}] CDN HTTP {} для '{}' | {}",
|
||||
ch_id, status, fname, base[-70:])
|
||||
else:
|
||||
logger.warning("[{}] Слишком мал ответ ({} байт) для '{}'",
|
||||
ch_id, len(body), fname)
|
||||
await route.fulfill(response=response)
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
async with lock:
|
||||
route_errors[base] = err
|
||||
is_timeout = "timeout" in err.lower()
|
||||
level = logger.warning if is_timeout else logger.warning
|
||||
level("[{}] route.fetch {} '{}': {}",
|
||||
ch_id, "timeout" if is_timeout else "ошибка", fname, err[:150])
|
||||
try:
|
||||
await route.continue_()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await page.route("**/*", route_handler)
|
||||
|
||||
# 1. Открываем главу
|
||||
ok = await _navigate(page, load_url, referer=referer)
|
||||
if not ok:
|
||||
await page.unroute("**/*", route_handler)
|
||||
logger.error("[{}] Не удалось открыть главу после всех retry: {}", ch_id, chapter_url)
|
||||
return []
|
||||
|
||||
# 2. Ждём readerInit
|
||||
try:
|
||||
await page.wait_for_function(
|
||||
"() => Array.from(document.querySelectorAll('script'))"
|
||||
".some(s => s.textContent.includes('readerInit'))",
|
||||
timeout=15_000,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[{}] readerInit не появился за 15с ({}). "
|
||||
"Продолжаем через DOM-fallback.", ch_id, str(e)[:80])
|
||||
|
||||
# 3. Извлекаем список URL
|
||||
image_urls = await _extract_images_from_js(page)
|
||||
if not image_urls:
|
||||
logger.debug("[{}] JS readerInit не дал URL, пробуем DOM-парсинг", ch_id)
|
||||
image_urls = await _extract_images_from_dom(page)
|
||||
if not image_urls:
|
||||
await page.unroute("**/*", route_handler)
|
||||
try:
|
||||
page_info = await page.evaluate("() => document.title + ' | ' + location.href")
|
||||
except Exception:
|
||||
page_info = "?"
|
||||
logger.error("[{}] Список изображений пуст. Текущая страница: {}", ch_id, page_info)
|
||||
return []
|
||||
|
||||
logger.info("[{}] Найдено изображений: {}", ch_id, len(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)}
|
||||
total = len(image_urls)
|
||||
|
||||
def _count_matched() -> int:
|
||||
count = 0
|
||||
for base_url in captured:
|
||||
if base_url in url_to_idx or base_url.split("/")[-1] in filename_to_idx:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
# 4. Пролистываем читалку
|
||||
await asyncio.sleep(1)
|
||||
stall_count = 0
|
||||
prev_done = -1
|
||||
for i in range(total + 20):
|
||||
done = _count_matched()
|
||||
if done >= total:
|
||||
break
|
||||
try:
|
||||
await page.keyboard.press("ArrowRight")
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception as e:
|
||||
logger.warning("[{}] Ошибка листания на шаге {}: {}", ch_id, i + 1, e)
|
||||
break
|
||||
if i % 20 == 19:
|
||||
done = _count_matched()
|
||||
logger.debug("[{}] Пролистано {}, загружено: {}/{}", ch_id, i + 1, done, total)
|
||||
if done == prev_done:
|
||||
stall_count += 1
|
||||
if stall_count >= 3:
|
||||
logger.warning("[{}] Прогресс завис ({}/{}) после {} листаний — прерываем",
|
||||
ch_id, done, total, i + 1)
|
||||
break
|
||||
else:
|
||||
stall_count = 0
|
||||
prev_done = done
|
||||
|
||||
# Финальное ожидание
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 5. Retry для страниц с 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 через JS fetch...",
|
||||
ch_id, len(timeout_bases))
|
||||
for retry_base in timeout_bases:
|
||||
if retry_base in captured:
|
||||
continue
|
||||
fname = retry_base.split("/")[-1]
|
||||
try:
|
||||
data_b64 = await page.evaluate("""async (url) => {
|
||||
try {
|
||||
const r = await fetch(url, {credentials: 'include'});
|
||||
if (!r.ok) return null;
|
||||
const buf = await r.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
for (let b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin);
|
||||
} catch(e) { return null; }
|
||||
}""", retry_base)
|
||||
if data_b64:
|
||||
import base64
|
||||
body = base64.b64decode(data_b64)
|
||||
if len(body) > 500:
|
||||
async with lock:
|
||||
captured[retry_base] = body
|
||||
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
|
||||
else:
|
||||
logger.warning("[{}] Retry вернул {} байт для '{}' — игнорируем",
|
||||
ch_id, len(body), fname)
|
||||
else:
|
||||
logger.warning("[{}] Retry вернул null для '{}' | {}",
|
||||
ch_id, fname, retry_base[-70:])
|
||||
except Exception as e2:
|
||||
logger.warning("[{}] Retry JS ошибка для '{}': {}", ch_id, fname, e2)
|
||||
|
||||
await page.unroute("**/*", route_handler)
|
||||
|
||||
done = _count_matched()
|
||||
elapsed = time.monotonic() - t_start
|
||||
logger.info("[{}] Перехвачено: {}/{} за {:.1f}с", ch_id, done, total, elapsed)
|
||||
|
||||
# 6. Сохраняем в правильном порядке
|
||||
filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)}
|
||||
|
||||
paths: dict[int, Path] = {}
|
||||
unmatched_other: list[str] = []
|
||||
for base_url, body in captured.items():
|
||||
idx = url_to_idx.get(base_url)
|
||||
if idx is None:
|
||||
fname = base_url.split("/")[-1]
|
||||
idx = filename_to_idx.get(fname)
|
||||
if idx is None:
|
||||
if not BANNER_RE.search(base_url):
|
||||
unmatched_other.append(base_url.split("/")[-1])
|
||||
continue
|
||||
ext = _get_ext(base_url)
|
||||
p = dest_dir / f"{idx:04d}{ext}"
|
||||
p.write_bytes(body)
|
||||
paths[idx] = p
|
||||
|
||||
if unmatched_other:
|
||||
logger.debug("[{}] Перехвачено, но не совпало с readerInit ({}): {}",
|
||||
ch_id, len(unmatched_other), unmatched_other)
|
||||
|
||||
# 7. Итоговый отчёт по пропущенным страницам
|
||||
missing_idxs = [i for i in range(total) if i not in paths]
|
||||
if missing_idxs:
|
||||
missing_files = [_base(image_urls[i]).split("/")[-1] for i in missing_idxs]
|
||||
missing_full = [_base(image_urls[i]) for i in missing_idxs]
|
||||
|
||||
timeout_miss = [missing_files[j] for j, i in enumerate(missing_idxs)
|
||||
if missing_full[j] in route_errors
|
||||
and "timeout" in route_errors[missing_full[j]].lower()]
|
||||
http_miss = [f"{missing_files[j]}(HTTP {route_statuses.get(missing_full[j], '?')})"
|
||||
for j, i in enumerate(missing_idxs)
|
||||
if missing_full[j] in route_statuses]
|
||||
unrcv = [missing_files[j] for j, i in enumerate(missing_idxs)
|
||||
if missing_full[j] not in route_errors
|
||||
and missing_full[j] not in route_statuses]
|
||||
|
||||
reasons = []
|
||||
if timeout_miss:
|
||||
reasons.append(f"timeout×{len(timeout_miss)}: {timeout_miss}")
|
||||
if http_miss:
|
||||
reasons.append(f"HTTP-err×{len(http_miss)}: {http_miss}")
|
||||
if unrcv:
|
||||
reasons.append(f"не_перехвачено×{len(unrcv)}: {unrcv}")
|
||||
|
||||
logger.warning(
|
||||
"[{}] Пропущено {}/{} стр. | №: {} | причины: {}",
|
||||
ch_id, len(missing_idxs), total,
|
||||
[i + 1 for i in missing_idxs],
|
||||
" | ".join(reasons) if reasons else "неизвестно",
|
||||
)
|
||||
logger.debug("[{}] Полные URL пропущенных: {}", ch_id, missing_full)
|
||||
|
||||
return [paths[i] for i in sorted(paths.keys())]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user