- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration. - Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files. - Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown. - Configured analysis options and dependencies in pubspec.yaml to support Flutter development. - Established a pnpm workspace and lockfile for dependency management. - Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
329 lines
11 KiB
Dart
329 lines
11 KiB
Dart
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<CatalogMenu> get catalogMenus => const [
|
|
CatalogMenu(kind: CatalogMenuKind.latest, title: 'Latest'),
|
|
CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'),
|
|
];
|
|
|
|
@override
|
|
Map<String, String> 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<String> _getHtml(
|
|
String url, {
|
|
Map<String, String>? headers,
|
|
String label = 'request',
|
|
}) async {
|
|
try {
|
|
final res = await _dio.get<String>(
|
|
url,
|
|
options: Options(headers: headers ?? mangaReadHtmlHeaders),
|
|
);
|
|
return res.data ?? '';
|
|
} on DioException catch (e) {
|
|
_cloudflareOrRethrow(e, label);
|
|
}
|
|
}
|
|
|
|
Future<String> _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<String>(
|
|
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<List<CatalogComic>> 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<CatalogComic> _parseCatalogCards(String html, {required String baseHref}) {
|
|
final doc = html_parser.parse(html);
|
|
final cards = <dom.Element>[
|
|
...doc.querySelectorAll('.page-item-detail'),
|
|
...doc.querySelectorAll('.c-tabs-item__content'),
|
|
];
|
|
final seen = <String>{};
|
|
final out = <CatalogComic>[];
|
|
|
|
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<ChapterLink> _extractChapters(String html, String baseHref) {
|
|
final doc = html_parser.parse(html);
|
|
final items = doc.querySelectorAll('li.wp-manga-chapter');
|
|
final chapters = <ChapterLink>[];
|
|
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<SeriesDetail> 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<String> _collectPageUrls(String html, String baseHref) {
|
|
final doc = html_parser.parse(html);
|
|
final imgs = doc.querySelectorAll('img.wp-manga-chapter-img');
|
|
final urls = <String>[];
|
|
for (final img in imgs) {
|
|
final raw = _pickImageSrc(img);
|
|
if (raw == null) continue;
|
|
try {
|
|
urls.add(absoluteUrl(raw, baseHref));
|
|
} catch (_) {}
|
|
}
|
|
return urls;
|
|
}
|
|
|
|
@override
|
|
Future<List<String>> 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;
|
|
}
|
|
}
|