Spaces:
Sleeping
Sleeping
File size: 12,062 Bytes
18b6a95 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 | """
Chapter link discovery — inspired by v1's direct approach:
fetch the index page, find the container holding the most same-domain
links, treat those as the chapter list. Pagination handled if needed.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Callable, Optional
from urllib.parse import urljoin, urlparse, parse_qs, urlunparse
from bs4 import BeautifulSoup
from .engine import Chapter, smart_fetch, FetchMode, _is_cloudflare
import aiohttp
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# URLs / text that are never chapter links
# ---------------------------------------------------------------------------
_BAD_URL_FRAGMENTS = [
"/tag/", "/category/", "/author/", "/search", "/login", "/register",
"/contact", "/about", "/privacy", "/sitemap", "/feed", "/cdn-cgi/",
"javascript:", "mailto:", "#", "?s=", "/wp-", "/page/1",
]
_BAD_TEXT_PATTERNS = re.compile(
r"^(home|about|contact|privacy|terms|login|register|sign.?in|sign.?up|"
r"search|tags?|categor|author|admin|next|prev|previous|back|forward|"
r"more|see more|load more|show more|menu|nav)$",
re.IGNORECASE,
)
# ---------------------------------------------------------------------------
# Chapter number detector — used for sorting and validation
# ---------------------------------------------------------------------------
_CH_NUM_RE = re.compile(
r"(?:chapter|ch\.?|ep\.?|episode|part|vol\.?|volume)?\s*(\d+(?:\.\d+)?)",
re.IGNORECASE,
)
_SIDE_STORY_RE = re.compile(
r"side\s*story|extra|bonus|omake|special|ss\s*\d", re.IGNORECASE
)
def _chapter_sort_key(ch: Chapter) -> tuple[float, float, str]:
is_side = 1.0 if _SIDE_STORY_RE.search(ch.title) else 0.0
m = _CH_NUM_RE.search(ch.title)
num = float(m.group(1)) if m else 0.0
return (is_side, num, ch.title.lower())
def sort_chapters(chapters: list[Chapter]) -> list[Chapter]:
sorted_chs = sorted(chapters, key=_chapter_sort_key)
for i, ch in enumerate(sorted_chs, start=1):
ch.index = i
return sorted_chs
# ---------------------------------------------------------------------------
# Core helper: extract all plausible chapter links from a page
# ---------------------------------------------------------------------------
def _is_bad_url(href: str) -> bool:
low = href.lower()
return any(frag in low for frag in _BAD_URL_FRAGMENTS)
def _is_bad_text(text: str) -> bool:
text = text.strip()
if not text or len(text) > 200:
return True
return bool(_BAD_TEXT_PATTERNS.match(text))
def _extract_links_from_html(html: str, base_url: str) -> list[tuple[str, str]]:
"""
V1-inspired approach: parse the page, collect every same-domain <a> link
that isn't obviously navigation/noise. Return [(url, title), ...] in
document order, deduplicated.
"""
base_domain = urlparse(base_url).netloc
soup = BeautifulSoup(html, "lxml")
# Strip script/style/nav noise up front
for tag in soup.find_all(["script", "style", "noscript", "iframe",
"header", "footer", "nav"]):
tag.decompose()
seen: dict[str, str] = {}
for a in soup.find_all("a", href=True):
href = a.get("href", "").strip()
if not href:
continue
full_url = urljoin(base_url, href)
parsed = urlparse(full_url)
# Same domain only
if parsed.netloc and parsed.netloc != base_domain:
continue
if _is_bad_url(full_url):
continue
title = a.get_text(strip=True)
if _is_bad_text(title):
continue
if full_url not in seen:
seen[full_url] = title
return list(seen.items())
# ---------------------------------------------------------------------------
# Container-based strategy (v1's key insight)
#
# Rather than scoring individual links, find the HTML container that holds
# the MOST links to the same domain. That container is almost always the
# chapter list. This matches exactly how v1 worked for novelhall — it just
# happened to know the specific container ID. Here we discover it
# automatically by counting links per container.
# ---------------------------------------------------------------------------
def _find_chapter_list_container(html: str, base_url: str) -> list[tuple[str, str]]:
"""
Find the single container element with the most same-domain links.
Returns [(url, title), ...] from that container.
"""
base_domain = urlparse(base_url).netloc
soup = BeautifulSoup(html, "lxml")
for tag in soup.find_all(["script", "style", "noscript", "iframe",
"header", "footer", "nav"]):
tag.decompose()
best_container = None
best_count = 0
# Walk all block-level containers, find the one with most valid links
for el in soup.find_all(["div", "ul", "ol", "section", "article", "main", "table"]):
links = []
for a in el.find_all("a", href=True, recursive=True):
href = a.get("href", "").strip()
if not href:
continue
full_url = urljoin(base_url, href)
parsed = urlparse(full_url)
if parsed.netloc and parsed.netloc != base_domain:
continue
if _is_bad_url(full_url):
continue
title = a.get_text(strip=True)
if _is_bad_text(title):
continue
links.append((full_url, title))
# Prefer the container with the most unique links
unique = len(dict(links))
if unique > best_count:
best_count = unique
best_container = links
if best_container and best_count >= 3:
# Deduplicate preserving order
seen: dict[str, str] = {}
for url, title in best_container:
if url not in seen:
seen[url] = title
return list(seen.items())
return []
# ---------------------------------------------------------------------------
# Pagination
# ---------------------------------------------------------------------------
def _next_page_url(html: str, current_url: str) -> str | None:
"""Find a 'next page' link for paginated chapter lists."""
soup = BeautifulSoup(html, "lxml")
base_domain = urlparse(current_url).netloc
# Look for explicit next-page links
for a in soup.find_all("a", href=True):
text = a.get_text(strip=True).lower()
rel = a.get("rel", [])
if text in ("next", "next page", "›", "»", ">") or "next" in rel:
href = urljoin(current_url, a["href"])
if urlparse(href).netloc == base_domain and href != current_url:
return href
return None
def _build_page_urls(base_url: str, max_pages: int = 50) -> list[str]:
"""Generate ?page=N and /page/N variants."""
parsed = urlparse(base_url)
candidates = []
for i in range(2, max_pages + 1):
qs = parse_qs(parsed.query)
qs["page"] = [str(i)]
params = "&".join(f"{k}={v[0]}" for k, v in qs.items())
candidates.append(urlunparse(parsed._replace(query=params)))
path_paged = parsed.path.rstrip("/") + f"/page/{i}"
candidates.append(urlunparse(parsed._replace(path=path_paged, query="")))
return candidates
# ---------------------------------------------------------------------------
# TOC URL variants to try
# ---------------------------------------------------------------------------
_TOC_SUFFIXES = [
"",
"/chapter-list",
"/chapters",
"/chapter",
"/contents",
"/toc",
"/episodes",
"/all-chapters",
"/table-of-contents",
]
def _toc_candidates(url: str) -> list[str]:
parsed = urlparse(url)
base = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"
return [base + s for s in _TOC_SUFFIXES]
# ---------------------------------------------------------------------------
# Main discover function
# ---------------------------------------------------------------------------
async def discover_chapters(
session: aiohttp.ClientSession,
novel_url: str,
mode: FetchMode = FetchMode.BALANCED,
cookies: dict | None = None,
log_cb: Callable[[str], None] | None = None,
) -> list[Chapter]:
"""
Discover all chapter links. Uses v1's core insight: find the page
container with the most links — that's the chapter list.
"""
def log(msg: str):
if log_cb:
log_cb(msg)
logger.info(msg)
async def fetch(url: str) -> str | None:
try:
html, status = await smart_fetch(
session, url, mode=mode, cookies=cookies, log_cb=log_cb
)
if _is_cloudflare(html, status):
log(f"🛑 Cloudflare wall at {url}")
return None
return html
except Exception as exc:
log(f"⚠️ Fetch failed for {url}: {exc}")
return None
log(f"🔍 Discovering chapters for: {novel_url}")
all_links: dict[str, str] = {} # url -> title
# ── Step 1: Try TOC URL variants, use the one with most chapter links ──
best_html = None
best_url = None
best_count = 0
for toc_url in _toc_candidates(novel_url):
html = await fetch(toc_url)
if not html:
continue
links = _find_chapter_list_container(html, toc_url)
log(f" {toc_url} → {len(links)} links")
if len(links) > best_count:
best_count = len(links)
best_html = html
best_url = toc_url
# If the base URL already has plenty, stop trying variants
if toc_url == _toc_candidates(novel_url)[0] and best_count >= 10:
break
if best_html and best_count >= 3:
log(f"✅ Best TOC page: {best_url} ({best_count} links)")
for url, title in _find_chapter_list_container(best_html, best_url):
if url not in all_links:
all_links[url] = title
# ── Step 2: Follow pagination if the list seems incomplete ────────────
if best_html and best_url:
current_html = best_html
current_url = best_url
page = 2
while page <= 100:
# Try explicit next-page link first
next_url = _next_page_url(current_html, current_url)
if not next_url:
# Try ?page=N pattern
parsed = urlparse(current_url)
qs = parse_qs(parsed.query)
qs["page"] = [str(page)]
params = "&".join(f"{k}={v[0]}" for k, v in qs.items())
next_url = urlunparse(parsed._replace(query=params))
html = await fetch(next_url)
if not html:
break
new_links = _find_chapter_list_container(html, next_url)
before = len(all_links)
for url, title in new_links:
if url not in all_links:
all_links[url] = title
added = len(all_links) - before
if added == 0:
break # No new links on this page — stop paginating
log(f" Page {page}: +{added} new links ({len(all_links)} total)")
current_html = html
current_url = next_url
page += 1
if not all_links:
log("⚠️ Container method found nothing — falling back to flat link scan")
html = await fetch(novel_url)
if html:
for url, title in _extract_links_from_html(html, novel_url):
all_links[url] = title
log(f"✅ Discovered {len(all_links)} chapter links total")
chapters: list[Chapter] = []
for i, (href, title) in enumerate(all_links.items(), start=1):
if not title:
title = f"Chapter {i}"
chapters.append(Chapter(index=i, title=title, url=href))
return sort_chapters(chapters)
|