import 'package:dio/dio.dart'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' as html_parser; import '../source.dart'; import 'constants.dart'; import 'urls.dart'; /// MangaTown mobile HTML adapter (`m.mangatown.com`). class MangaTownSource implements ComicSource { MangaTownSource({Dio? dio}) : _dio = dio ?? Dio( BaseOptions( connectTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(seconds: 60), headers: mangaTownHtmlHeaders, responseType: ResponseType.plain, validateStatus: (s) => s != null && s >= 200 && s < 300, ), ); final Dio _dio; static const _pageFetchConcurrency = 4; @override SourceId get id => SourceId.mangatown; @override String get displayName => 'MangaTown'; @override String get blurb => 'Latest, hot rankings, directory filters, and search via m.mangatown.com'; @override List get catalogMenus => const [ CatalogMenu(kind: CatalogMenuKind.latest, title: 'Latest'), CatalogMenu(kind: CatalogMenuKind.popular, title: 'Hot'), CatalogMenu(kind: CatalogMenuKind.hotToday, title: 'Hot Today'), CatalogMenu(kind: CatalogMenuKind.hotWeek, title: 'Hot Week'), CatalogMenu(kind: CatalogMenuKind.hotMonth, title: 'Hot Month'), CatalogMenu(kind: CatalogMenuKind.directory, title: 'Directory'), CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'), ]; @override Map get imageHeaders => Map.unmodifiable(mangaTownImageHeaders); Future _getHtml( String url, { Map? headers, String label = 'request', }) async { try { final res = await _dio.get( url, options: Options(headers: headers ?? mangaTownHtmlHeaders), ); return res.data ?? ''; } on DioException catch (e) { throw StateError('MangaTown $label failed: ${e.message ?? e}'); } } /// CatalogFeedPage uses caller `limit` as page stride (like MangaRead). int _sitePage(int offset, int limit) { final stride = limit > 0 ? limit : mangaTownCatalogPageSize; return (offset ~/ stride) + 1; } @override Future> loadCatalog( CatalogMenuKind kind, { String query = '', int offset = 0, int limit = 30, CatalogBrowseParams? browse, }) async { final page = _sitePage(offset, limit); late final String url; switch (kind) { case CatalogMenuKind.official: case CatalogMenuKind.uncensored: case CatalogMenuKind.recommended: throw UnsupportedError('MangaTown has no ${kind.name} catalog feed'); case CatalogMenuKind.latest: url = catalogLatestUrl(page: page); case CatalogMenuKind.popular: case CatalogMenuKind.hotToday: case CatalogMenuKind.hotWeek: case CatalogMenuKind.hotMonth: final period = hotPeriodForMenu(kind)!; url = catalogHotUrl(period: period, page: page); case CatalogMenuKind.directory: url = directoryUrl(browse ?? const CatalogBrowseParams(), page: page); case CatalogMenuKind.search: final q = query.trim(); if (q.length < 2) return const []; url = catalogSearchUrl(query: q, page: page); } final html = await _getHtml(url, label: kind.name); return _parseCatalogCards(html, baseHref: url); } List _parseCatalogCards(String html, {required String baseHref}) { final doc = html_parser.parse(html); final posts = doc.querySelectorAll('ul.post-list li div.post'); final seen = {}; final out = []; for (final post in posts) { final coverA = post.querySelector('a.manga-cover'); final href = coverA?.attributes['href']?.trim() ?? post.querySelector('a[href*="/manga/"]')?.attributes['href']?.trim(); if (href == null || href.isEmpty) continue; final listingUrl = absoluteUrl(href, baseHref); if (!listingUrl.contains('/manga/')) continue; final slug = mangaSlugFromUrl(listingUrl); if (slug == 'comic' || !seen.add(slug)) continue; var title = coverA?.attributes['rel']?.trim() ?? ''; if (title.isEmpty) { title = post .querySelector('.post-info .title') ?.text .trim() .replaceAll(RegExp(r'\s+'), ' ') ?? ''; } if (title.isEmpty) title = slug; String? coverUrl; final img = post.querySelector('img'); final rawSrc = img?.attributes['src']?.trim(); if (rawSrc != null && rawSrc.isNotEmpty && !rawSrc.startsWith('data:')) { coverUrl = absoluteUrl(rawSrc, baseHref); } out.add( CatalogComic( source: SourceId.mangatown, slug: slug, title: title, listingUrl: listingUrlForSlug(slug), coverUrl: coverUrl, ), ); } return out; } String? _extractTitle(String html) { final doc = html_parser.parse(html); final t = doc .querySelector('.manga-detail-top .title') ?.text .trim() .replaceAll(RegExp(r'\s+'), ' '); if (t != null && t.isNotEmpty) return t; final titleEl = doc.querySelector('title')?.text.trim() ?? ''; final m = RegExp( r'Read\s+(.+?)\s+Manga', caseSensitive: false, ).firstMatch(titleEl); return m?.group(1)?.trim(); } String? _extractCover(String html, String baseHref) { final doc = html_parser.parse(html); final img = doc.querySelector('img.detail-cover'); final raw = img?.attributes['src']?.trim(); if (raw == null || raw.isEmpty) return null; return absoluteUrl(raw, baseHref); } String? _extractDescription(String html) { final doc = html_parser.parse(html); final show = doc.querySelector('#show'); if (show != null) { final clone = show.clone(true); clone.querySelectorAll('a').forEach((a) => a.remove()); final text = clone.text.trim().replaceAll(RegExp(r'\s+'), ' '); if (text.isNotEmpty) return text; } final hide = doc.querySelector('#hide'); if (hide != null) { final clone = hide.clone(true); clone.querySelectorAll('a').forEach((a) => a.remove()); final text = clone.text.trim().replaceAll(RegExp(r'\s+'), ' '); if (text.isNotEmpty) return text; } return null; } List _extractChapters(String html, String baseHref) { final doc = html_parser.parse(html); final items = doc.querySelectorAll('ul.detail-ch-list li a'); final chapters = []; for (final a in items) { final href = a.attributes['href']?.trim(); if (href == null || href.isEmpty) continue; final clone = a.clone(true); clone.querySelectorAll('span').forEach((s) => s.remove()); final title = clone.text.trim().replaceAll(RegExp(r'\s+'), ' '); final url = absoluteUrl(href, baseHref); final key = chapterKeyFromUrl(url); chapters.add( ChapterLink( url: chapterBaseUrl(url), title: title.isNotEmpty ? title : key, chapterKey: key, chapterNumber: chapterNumberFromTitle(title), ), ); } return chapters; } @override Future fetchSeries(String listingUrl) async { final base = mangaBaseUrlFromListingUrl(listingUrl); final html = await _getHtml(base, label: 'series'); var chapters = _extractChapters(html, base); if (chapters.length > 1) { chapters = chapters.reversed.toList(growable: false); } final slug = mangaSlugFromUrl(base); return SeriesDetail( source: SourceId.mangatown, listingUrl: listingUrlForSlug(slug), slug: slug, title: _extractTitle(html) ?? slug, coverUrl: _extractCover(html, base), description: _extractDescription(html), chapters: chapters, ); } int _pageCountFromHtml(String html) { final doc = html_parser.parse(html); final options = doc.querySelectorAll('select.index-page option'); if (options.isNotEmpty) return options.length; final selects = doc.querySelectorAll('.ch-select select option'); if (selects.isNotEmpty) return selects.length; final pager = doc.querySelector('#pager')?.text ?? ''; final m = RegExp(r'/\s*(\d+)').firstMatch(pager); if (m != null) { return int.tryParse(m.group(1)!) ?? 1; } return 1; } String? _imageFromReaderHtml(String html, String baseHref) { final doc = html_parser.parse(html); dom.Element? img = doc.querySelector('#viewer img#image') ?? doc.querySelector('img#image'); final raw = img?.attributes['src']?.trim(); if (raw == null || raw.isEmpty || raw.startsWith('data:')) return null; return absoluteUrl(raw, baseHref); } Future> _fetchPagesParallel( String chapterBase, int fromPage, int total, ) async { if (fromPage > total) return const []; final count = total - fromPage + 1; final urls = List.filled(count, null); var next = fromPage; Future worker() async { while (true) { final i = next; next++; if (i > total) return; final pageUrl = chapterPageUrl(chapterBase, i); final html = await _getHtml(pageUrl, label: 'chapter page $i'); urls[i - fromPage] = _imageFromReaderHtml(html, pageUrl); } } final workers = List.generate( _pageFetchConcurrency.clamp(1, count), (_) => worker(), ); await Future.wait(workers); return urls.whereType().toList(growable: false); } @override Future> fetchPageUrls(String chapterUrl) async { final base = chapterBaseUrl(chapterUrl); final firstHtml = await _getHtml(base, label: 'chapter'); final total = _pageCountFromHtml(firstHtml); final first = _imageFromReaderHtml(firstHtml, base); if (total <= 1) { return first == null ? const [] : [first]; } final rest = await _fetchPagesParallel(base, 2, total); if (first == null) return rest; return [first, ...rest]; } }