- 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.
308 lines
10 KiB
Dart
308 lines
10 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';
|
|
|
|
/// 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<CatalogMenu> 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<String, String> get imageHeaders =>
|
|
Map.unmodifiable(mangaTownImageHeaders);
|
|
|
|
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 ?? 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<List<CatalogComic>> 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<CatalogComic> _parseCatalogCards(String html, {required String baseHref}) {
|
|
final doc = html_parser.parse(html);
|
|
final posts = doc.querySelectorAll('ul.post-list li div.post');
|
|
final seen = <String>{};
|
|
final out = <CatalogComic>[];
|
|
|
|
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<ChapterLink> _extractChapters(String html, String baseHref) {
|
|
final doc = html_parser.parse(html);
|
|
final items = doc.querySelectorAll('ul.detail-ch-list li a');
|
|
final chapters = <ChapterLink>[];
|
|
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<SeriesDetail> 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<List<String>> _fetchPagesParallel(
|
|
String chapterBase,
|
|
int fromPage,
|
|
int total,
|
|
) async {
|
|
if (fromPage > total) return const [];
|
|
final count = total - fromPage + 1;
|
|
final urls = List<String?>.filled(count, null);
|
|
var next = fromPage;
|
|
Future<void> 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<String>().toList(growable: false);
|
|
}
|
|
|
|
@override
|
|
Future<List<String>> 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];
|
|
}
|
|
}
|