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'; /// MangaRead / Madara HTML adapter (port of cxyz `mangareadFetchChapters.js`). class MangaReadSource implements ComicSource { MangaReadSource({Dio? dio}) : _dio = dio ?? Dio( BaseOptions( connectTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(seconds: 60), headers: mangaReadHtmlHeaders, responseType: ResponseType.plain, validateStatus: (s) => s != null && s >= 200 && s < 300, ), ); final Dio _dio; @override SourceId get id => SourceId.mangaread; @override String get displayName => 'MangaRead'; @override String get blurb => 'Latest and search via mangaread.org (Madara HTML)'; @override List get catalogMenus => const [ CatalogMenu(kind: CatalogMenuKind.latest, title: 'Latest'), CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'), ]; @override Map get imageHeaders => Map.unmodifiable(mangaReadCdnHeaders); Never _cloudflareOrRethrow(DioException e, String label) { final code = e.response?.statusCode; if (code == 403) { throw StateError( 'MangaRead $label blocked (HTTP 403 / Cloudflare). ' 'Direct fetch is not available right now.', ); } throw StateError('MangaRead $label failed: ${e.message ?? e}'); } Future _getHtml( String url, { Map? headers, String label = 'request', }) async { try { final res = await _dio.get( url, options: Options(headers: headers ?? mangaReadHtmlHeaders), ); return res.data ?? ''; } on DioException catch (e) { _cloudflareOrRethrow(e, label); } } Future _postAjaxChapters(String mangaBaseUrl, String refererUrl) async { final base = mangaBaseUrl.endsWith('/') ? mangaBaseUrl : '$mangaBaseUrl/'; final ajaxUrl = Uri.parse(base).resolve('ajax/chapters/').toString(); final origin = Uri.parse(refererUrl).origin; try { final res = await _dio.post( ajaxUrl, data: '', options: Options( headers: { ...mangaReadHtmlHeaders, 'Accept': '*/*', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Referer': refererUrl, 'Origin': origin, 'X-Requested-With': 'XMLHttpRequest', }, responseType: ResponseType.plain, ), ); return res.data ?? ''; } on DioException catch (e) { _cloudflareOrRethrow(e, 'ajax/chapters'); } } @override Future> loadCatalog( CatalogMenuKind kind, { String query = '', int offset = 0, int limit = 30, CatalogBrowseParams? browse, }) async { switch (kind) { case CatalogMenuKind.popular: throw UnsupportedError('MangaRead has no popular catalog feed'); case CatalogMenuKind.official: throw UnsupportedError('MangaRead has no official catalog feed'); case CatalogMenuKind.uncensored: throw UnsupportedError('MangaRead has no uncensored catalog feed'); case CatalogMenuKind.recommended: throw UnsupportedError('MangaRead has no recommended catalog feed'); case CatalogMenuKind.hotToday: case CatalogMenuKind.hotWeek: case CatalogMenuKind.hotMonth: case CatalogMenuKind.directory: throw UnsupportedError('MangaRead has no ${kind.name} catalog feed'); case CatalogMenuKind.latest: // Madara HTML pages are ~12 titles each. Callers pass offset as // (pageIndex * theirLimit); map that to a 1-based site page. final page = (offset ~/ (limit > 0 ? limit : 12)) + 1; final url = page <= 1 ? '${mangaReadOrigin}manga/?m_orderby=latest' : '${mangaReadOrigin}manga/page/$page/?m_orderby=latest'; final html = await _getHtml(url, label: 'latest'); return _parseCatalogCards(html, baseHref: url); case CatalogMenuKind.search: final q = query.trim(); if (q.length < 2) return const []; // Same page mapping as latest; Madara search paginates via /page/N/. final page = (offset ~/ (limit > 0 ? limit : 12)) + 1; final uri = page <= 1 ? Uri.parse(mangaReadOrigin).replace( queryParameters: { 's': q, 'post_type': 'wp-manga', }, ) : Uri.parse( '${mangaReadOrigin}page/$page/', ).replace( queryParameters: { 's': q, 'post_type': 'wp-manga', }, ); final html = await _getHtml(uri.toString(), label: 'search'); return _parseCatalogCards(html, baseHref: uri.toString()); } } List _parseCatalogCards(String html, {required String baseHref}) { final doc = html_parser.parse(html); final cards = [ ...doc.querySelectorAll('.page-item-detail'), ...doc.querySelectorAll('.c-tabs-item__content'), ]; final seen = {}; final out = []; for (final card in cards) { final titleA = card.querySelector('.post-title a') ?? card.querySelector('h3 a') ?? card.querySelector('h5 a') ?? card.querySelector('a'); if (titleA == null) continue; final href = titleA.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; final title = titleA.text.trim().replaceAll(RegExp(r'\s+'), ' '); if (title.isEmpty) continue; String? coverUrl; final img = card.querySelector('img'); if (img != null) { coverUrl = _pickImageSrc(img); if (coverUrl != null) { coverUrl = absoluteUrl(coverUrl, baseHref); } } out.add( CatalogComic( source: SourceId.mangaread, slug: slug, title: title, listingUrl: listingUrlForSlug(slug), coverUrl: coverUrl, ), ); } return out; } String? _pickImageSrc(dom.Element img) { for (final attr in [ 'data-src', 'data-lazy-src', 'data-original', 'data-full-url', 'src', ]) { final raw = img.attributes[attr]?.trim(); if (raw != null && raw.isNotEmpty && !raw.startsWith('data:')) { return raw; } } return null; } List _extractChapters(String html, String baseHref) { final doc = html_parser.parse(html); final items = doc.querySelectorAll('li.wp-manga-chapter'); final chapters = []; for (final li in items) { final a = li.querySelector('a'); if (a == null) continue; final href = a.attributes['href']?.trim(); if (href == null || href.isEmpty) continue; final title = a.text.trim().replaceAll(RegExp(r'\s+'), ' '); final url = absoluteUrl(href, baseHref); chapters.add( ChapterLink( url: url, title: title.isNotEmpty ? title : 'Chapter', chapterKey: chapterKeyFromUrl(url), chapterNumber: chapterNumberFromTitle(title), ), ); } return chapters; } String? _extractTitle(String html) { final doc = html_parser.parse(html); final og = doc.querySelector('meta[property="og:title"]'); final ogContent = og?.attributes['content']?.trim(); if (ogContent != null && ogContent.isNotEmpty) { return ogContent.replaceAll(RegExp(r'\s+'), ' '); } final h1 = doc.querySelector('.profile-manga .post-title h1'); final h1Text = h1?.text.trim().replaceAll(RegExp(r'\s+'), ' '); if (h1Text != null && h1Text.isNotEmpty) return h1Text; final titleEl = doc.querySelector('title'); final titleText = titleEl?.text.trim() ?? ''; final m = RegExp(r'^Read\s+(.+?)\s+-\s+manga', caseSensitive: false) .firstMatch(titleText); if (m != null) { return m.group(1)!.trim().replaceAll(RegExp(r'\s+'), ' '); } return null; } String? _extractCover(String html, String baseHref) { final doc = html_parser.parse(html); final og = doc.querySelector('meta[property="og:image"]'); final ogUrl = og?.attributes['content']?.trim(); if (ogUrl != null && ogUrl.isNotEmpty) { return absoluteUrl(ogUrl, baseHref); } final img = doc.querySelector('.summary_image img') ?? doc.querySelector('.tab-summary img'); if (img == null) return null; final raw = _pickImageSrc(img); if (raw == null) return null; return absoluteUrl(raw, baseHref); } @override Future fetchSeries(String listingUrl) async { final base = mangaBaseUrlFromListingUrl(listingUrl); final html = await _getHtml(base, label: 'series'); var chapters = _extractChapters(html, base); if (chapters.isEmpty) { final ajaxHtml = await _postAjaxChapters(base, listingUrl); chapters = _extractChapters(ajaxHtml, base); } // Madara lists newest first; store oldest → newest for consistent numbering. if (chapters.length > 1) { chapters = chapters.reversed.toList(growable: false); } final slug = mangaSlugFromUrl(base); final title = _extractTitle(html) ?? slug; return SeriesDetail( source: SourceId.mangaread, listingUrl: base, slug: slug, title: title, coverUrl: _extractCover(html, base), chapters: chapters, ); } List _collectPageUrls(String html, String baseHref) { final doc = html_parser.parse(html); final imgs = doc.querySelectorAll('img.wp-manga-chapter-img'); final urls = []; for (final img in imgs) { final raw = _pickImageSrc(img); if (raw == null) continue; try { urls.add(absoluteUrl(raw, baseHref)); } catch (_) {} } return urls; } @override Future> fetchPageUrls(String chapterUrl) async { var html = await _getHtml( chapterUrl, headers: mangaReadReaderHeaders, label: 'chapter (mobile)', ); var urls = _collectPageUrls(html, chapterUrl); if (urls.isEmpty) { html = await _getHtml(chapterUrl, label: 'chapter (desktop)'); urls = _collectPageUrls(html, chapterUrl); } return urls; } }