Add cxyz Android project structure and initial configuration
- 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.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../source.dart';
|
||||
import 'constants.dart';
|
||||
import 'urls.dart';
|
||||
|
||||
/// ComicLand JSON adapter (port of cxyz `src/comicland/*`).
|
||||
class ComicLandSource implements ComicSource {
|
||||
ComicLandSource({Dio? dio})
|
||||
: _dio = dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: comicLandApiBase,
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
headers: comicLandApiHeaders,
|
||||
responseType: ResponseType.json,
|
||||
validateStatus: (s) => s != null && s >= 200 && s < 300,
|
||||
),
|
||||
);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
SourceId get id => SourceId.comicland;
|
||||
|
||||
@override
|
||||
String get displayName => 'ComicLand';
|
||||
|
||||
@override
|
||||
String get blurb =>
|
||||
'Newest, popular, official, uncensored, recommended, and search via ComicLand API';
|
||||
|
||||
@override
|
||||
List<CatalogMenu> get catalogMenus => const [
|
||||
CatalogMenu(kind: CatalogMenuKind.latest, title: 'Newest'),
|
||||
CatalogMenu(kind: CatalogMenuKind.popular, title: 'Popular'),
|
||||
CatalogMenu(kind: CatalogMenuKind.official, title: 'Official'),
|
||||
CatalogMenu(kind: CatalogMenuKind.uncensored, title: 'Uncensored'),
|
||||
CatalogMenu(kind: CatalogMenuKind.recommended, title: 'Recommended'),
|
||||
CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'),
|
||||
];
|
||||
|
||||
@override
|
||||
Map<String, String> get imageHeaders => Map.unmodifiable(comicLandCdnHeaders);
|
||||
|
||||
Map<String, dynamic> _unwrap(dynamic payload, String label) {
|
||||
if (payload is! Map) {
|
||||
throw StateError('ComicLand $label: empty response');
|
||||
}
|
||||
final map = Map<String, dynamic>.from(payload);
|
||||
final code = map['code'];
|
||||
if (code != 0) {
|
||||
final msg = (map['message'] as String?)?.trim();
|
||||
throw StateError(
|
||||
'ComicLand $label failed: ${msg?.isNotEmpty == true ? msg : 'code $code'}',
|
||||
);
|
||||
}
|
||||
final data = map['data'];
|
||||
if (data is! Map) {
|
||||
throw StateError('ComicLand $label: missing data');
|
||||
}
|
||||
return Map<String, dynamic>.from(data);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _get(
|
||||
String path, {
|
||||
Map<String, dynamic>? query,
|
||||
String label = 'request',
|
||||
}) async {
|
||||
final res = await _dio.get<dynamic>(path, queryParameters: query);
|
||||
return _unwrap(res.data, label);
|
||||
}
|
||||
|
||||
List<CatalogComic> _listFrom(Map<String, dynamic> data, {String listKey = 'list'}) {
|
||||
final raw = data[listKey];
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((e) => catalogFromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CatalogComic>> loadCatalog(
|
||||
CatalogMenuKind kind, {
|
||||
String query = '',
|
||||
int offset = 0,
|
||||
int limit = 30,
|
||||
CatalogBrowseParams? browse,
|
||||
}) async {
|
||||
switch (kind) {
|
||||
case CatalogMenuKind.hotToday:
|
||||
case CatalogMenuKind.hotWeek:
|
||||
case CatalogMenuKind.hotMonth:
|
||||
case CatalogMenuKind.directory:
|
||||
throw UnsupportedError('ComicLand has no ${kind.name} catalog feed');
|
||||
case CatalogMenuKind.popular:
|
||||
final data = await _get('/comics/popular', label: 'comics/popular');
|
||||
final all = _listFrom(data);
|
||||
if (offset == 0 && all.length <= limit) return all;
|
||||
final end = (offset + limit).clamp(0, all.length);
|
||||
if (offset >= all.length) return const [];
|
||||
return all.sublist(offset, end);
|
||||
case CatalogMenuKind.latest:
|
||||
final data = await _get(
|
||||
'/comics',
|
||||
query: {'offset': offset, 'limit': limit, 'status': 'ongoing'},
|
||||
label: 'comics/newest',
|
||||
);
|
||||
return _listFrom(data);
|
||||
case CatalogMenuKind.official:
|
||||
final data = await _get(
|
||||
'/comics/official',
|
||||
query: {'offset': offset, 'limit': limit},
|
||||
label: 'comics/official',
|
||||
);
|
||||
return _listFrom(data);
|
||||
case CatalogMenuKind.uncensored:
|
||||
final data = await _get(
|
||||
'/comic/search',
|
||||
query: {'q': 'uncensored', 'offset': offset, 'limit': limit},
|
||||
label: 'comic/search/uncensored',
|
||||
);
|
||||
return _listFrom(data, listKey: 'items');
|
||||
case CatalogMenuKind.recommended:
|
||||
final data = await _get(
|
||||
'/comics',
|
||||
query: {'offset': offset, 'limit': limit},
|
||||
label: 'comics/recommended',
|
||||
);
|
||||
return _listFrom(data);
|
||||
case CatalogMenuKind.search:
|
||||
final q = query.trim();
|
||||
if (q.length < 2) return const [];
|
||||
final data = await _get(
|
||||
'/comic/search',
|
||||
query: {'q': q, 'offset': offset, 'limit': limit},
|
||||
label: 'comic/search',
|
||||
);
|
||||
return _listFrom(data, listKey: 'items');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SeriesDetail> fetchSeries(String listingUrl) async {
|
||||
final parts = parseComicLandUrl(listingUrl);
|
||||
final data = await _get(
|
||||
'/comic/detail',
|
||||
query: {'slug': parts.slug},
|
||||
label: 'comic/detail',
|
||||
);
|
||||
|
||||
final title = (data['title'] as String?)?.trim().isNotEmpty == true
|
||||
? (data['title'] as String).trim()
|
||||
: parts.slug;
|
||||
|
||||
final rawChapters = data['chapters'];
|
||||
final chapters = <ChapterLink>[];
|
||||
if (rawChapters is List) {
|
||||
for (final row in rawChapters) {
|
||||
if (row is! Map) continue;
|
||||
final map = Map<String, dynamic>.from(row);
|
||||
final index = _chapterIndexString(map['chapter_index']);
|
||||
if (index == null) continue;
|
||||
final label = (map['title'] as String?)?.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
final chapterTitle =
|
||||
(label != null && label.isNotEmpty) ? label : 'Chapter $index';
|
||||
final url = chapterUrlFromParts(parts.origin, parts.slug, index);
|
||||
chapters.add(
|
||||
ChapterLink(
|
||||
url: url,
|
||||
title: chapterTitle,
|
||||
chapterKey: chapterKeyFromUrl(url),
|
||||
chapterNumber: chapterNumberFromTitle(chapterTitle) ?? index,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
chapters.sort((a, b) {
|
||||
final na = double.tryParse(Uri.parse(a.url).pathSegments.last);
|
||||
final nb = double.tryParse(Uri.parse(b.url).pathSegments.last);
|
||||
if (na != null && nb != null && na != nb) return na.compareTo(nb);
|
||||
return a.url.compareTo(b.url);
|
||||
});
|
||||
|
||||
final listing = listingUrlForSlug(parts.slug);
|
||||
return SeriesDetail(
|
||||
source: SourceId.comicland,
|
||||
listingUrl: listing,
|
||||
slug: parts.slug,
|
||||
title: title,
|
||||
coverUrl: data['cover_url'] as String?,
|
||||
description: data['description'] as String?,
|
||||
chapters: chapters,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> fetchPageUrls(String chapterUrl) async {
|
||||
final parts = parseComicLandUrl(chapterUrl);
|
||||
if (parts.chapterIndex == null) {
|
||||
throw StateError('ComicLand chapter URL is missing an index: $chapterUrl');
|
||||
}
|
||||
final data = await _get(
|
||||
'/chapter/pages_by_index',
|
||||
query: {'slug': parts.slug, 'index': parts.chapterIndex},
|
||||
label: 'chapter/pages_by_index',
|
||||
);
|
||||
final pages = data['pages'];
|
||||
if (pages is! List) return const [];
|
||||
final urls = <String>[];
|
||||
for (final raw in pages) {
|
||||
if (raw is! String || raw.trim().isEmpty) continue;
|
||||
try {
|
||||
urls.add(Uri.parse(raw.trim()).toString());
|
||||
} catch (_) {}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
String? _chapterIndexString(dynamic rawIndex) {
|
||||
if (rawIndex is num && rawIndex.isFinite) {
|
||||
if (rawIndex == rawIndex.roundToDouble()) {
|
||||
return rawIndex.toInt().toString();
|
||||
}
|
||||
return rawIndex.toString();
|
||||
}
|
||||
if (rawIndex is String && rawIndex.trim().isNotEmpty) {
|
||||
return rawIndex.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// ComicLand API / CDN constants (port of cxyz `src/comicland/constants.js`).
|
||||
|
||||
const comicLandHosts = <String>['comicland.org', 'www.comicland.org'];
|
||||
|
||||
const comicLandOrigin = 'https://comicland.org/';
|
||||
|
||||
const comicLandApiOrigin = 'https://api.comicland.org';
|
||||
|
||||
/// Axios-style base used by the SPA: `https://api.comicland.org/api`.
|
||||
const comicLandApiBase = '$comicLandApiOrigin/api';
|
||||
|
||||
const comicLandDefaultListing =
|
||||
'https://comicland.org/comic/the-delinquent-girl';
|
||||
|
||||
const comicLandUserAgent =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
const comicLandApiHeaders = <String, String>{
|
||||
'Accept': 'application/json,text/plain,*/*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Origin': 'https://comicland.org',
|
||||
'Referer': comicLandOrigin,
|
||||
'User-Agent': comicLandUserAgent,
|
||||
};
|
||||
|
||||
const comicLandCdnHeaders = <String, String>{
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': comicLandOrigin,
|
||||
'Origin': 'https://comicland.org',
|
||||
'User-Agent': comicLandUserAgent,
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import '../source.dart';
|
||||
import 'constants.dart';
|
||||
|
||||
class ComicLandUrlParts {
|
||||
const ComicLandUrlParts({
|
||||
required this.origin,
|
||||
required this.slug,
|
||||
this.chapterIndex,
|
||||
});
|
||||
|
||||
final String origin;
|
||||
final String slug;
|
||||
final String? chapterIndex;
|
||||
}
|
||||
|
||||
bool isLikelyComicLandSite(String url) {
|
||||
try {
|
||||
final host = Uri.parse(url).host.toLowerCase();
|
||||
return comicLandHosts.contains(host) || host.contains('comicland');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String seriesSlugFromListingUrl(String pageUrl) {
|
||||
try {
|
||||
final parts = Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final comicIdx = parts.indexOf('comic');
|
||||
if (comicIdx != -1 && comicIdx + 1 < parts.length) {
|
||||
return parts[comicIdx + 1];
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'comic';
|
||||
}
|
||||
|
||||
ComicLandUrlParts parseComicLandUrl(String pageUrl) {
|
||||
final u = Uri.parse(pageUrl);
|
||||
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final comicIdx = parts.indexOf('comic');
|
||||
final slug =
|
||||
comicIdx != -1 && comicIdx + 1 < parts.length ? parts[comicIdx + 1] : 'comic';
|
||||
String? chapterIndex;
|
||||
final chapterIdx = parts.indexOf('chapter');
|
||||
if (chapterIdx != -1 && chapterIdx + 1 < parts.length) {
|
||||
chapterIndex = parts[chapterIdx + 1];
|
||||
}
|
||||
return ComicLandUrlParts(
|
||||
origin: '${u.scheme}://${u.host}',
|
||||
slug: slug,
|
||||
chapterIndex: chapterIndex,
|
||||
);
|
||||
}
|
||||
|
||||
String chapterUrlFromParts(String siteOrigin, String slug, Object chapterIndex) {
|
||||
final origin = siteOrigin.replaceAll(RegExp(r'/+$'), '');
|
||||
return '$origin/comic/$slug/chapter/$chapterIndex';
|
||||
}
|
||||
|
||||
String listingUrlForSlug(String slug) =>
|
||||
'${comicLandOrigin.replaceAll(RegExp(r'/+$'), '')}/comic/$slug';
|
||||
|
||||
String chapterKeyFromUrl(String chapterUrl) {
|
||||
try {
|
||||
final segs = Uri.parse(chapterUrl).pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
return segs.isEmpty ? 'chapter' : segs.last;
|
||||
} catch (_) {
|
||||
return 'chapter';
|
||||
}
|
||||
}
|
||||
|
||||
String? chapterNumberFromTitle(String title) {
|
||||
final m = RegExp(r'^Chapter\s+([\d.]+)', caseSensitive: false)
|
||||
.firstMatch(title.trim().replaceAll(RegExp(r'\s+'), ' '));
|
||||
return m?.group(1);
|
||||
}
|
||||
|
||||
String sanitizeFsStem(String stem) {
|
||||
final cleaned = stem
|
||||
.replaceAll(RegExp(r'[^a-z0-9._-]+', caseSensitive: false), '_')
|
||||
.replaceAll(RegExp(r'^_+|_+$'), '');
|
||||
if (cleaned.isEmpty) return 'chapter';
|
||||
return cleaned.length > 180 ? cleaned.substring(0, 180) : cleaned;
|
||||
}
|
||||
|
||||
String sanitizeComicDirName(String name) {
|
||||
final collapsed = name.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
final cleaned = String.fromCharCodes(
|
||||
collapsed.runes.where((code) {
|
||||
if (code < 32 || code == 127) return false;
|
||||
final ch = String.fromCharCode(code);
|
||||
return !RegExp(r'[<>:"/\\|?*]').hasMatch(ch);
|
||||
}),
|
||||
).replaceAll(RegExp(r'\.+$'), '').trim();
|
||||
if (cleaned.isEmpty) return 'comic';
|
||||
return cleaned.length > 180 ? cleaned.substring(0, 180) : cleaned;
|
||||
}
|
||||
|
||||
String stemForComicLandChapterCbz(String chapterPageUrl) {
|
||||
try {
|
||||
final segs =
|
||||
Uri.parse(chapterPageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final comicIdx = segs.indexOf('comic');
|
||||
if (comicIdx != -1 &&
|
||||
segs.length >= comicIdx + 4 &&
|
||||
segs[comicIdx + 2] == 'chapter') {
|
||||
return '${segs[comicIdx + 1]}-chapter-${segs[comicIdx + 3]}';
|
||||
}
|
||||
if (segs.length >= 2) {
|
||||
return '${segs[segs.length - 2]}-${segs.last}';
|
||||
}
|
||||
return segs.isEmpty ? 'chapter' : segs.last;
|
||||
} catch (_) {
|
||||
return 'chapter';
|
||||
}
|
||||
}
|
||||
|
||||
String transferLinkBasename(String? chapterNumber, String? label, String? cbzPath) {
|
||||
final candidates = [chapterNumber, label]
|
||||
.whereType<String>()
|
||||
.where((s) => s.trim().isNotEmpty);
|
||||
for (final raw in candidates) {
|
||||
final text = raw.trim();
|
||||
final m = RegExp(r'(\d+)((?:\.\d+)*)([a-zA-Z]*)').firstMatch(text);
|
||||
if (m != null) {
|
||||
final padded = m.group(1)!.padLeft(3, '0');
|
||||
return 'chapter $padded${m.group(2) ?? ''}${m.group(3) ?? ''}.cbz';
|
||||
}
|
||||
}
|
||||
if (cbzPath != null && cbzPath.isNotEmpty) {
|
||||
final base = cbzPath.split('/').last;
|
||||
if (base.isNotEmpty) return base;
|
||||
}
|
||||
return 'chapter unknown.cbz';
|
||||
}
|
||||
|
||||
CatalogComic catalogFromJson(Map<String, dynamic> row) {
|
||||
final slug = (row['slug'] as String?)?.trim() ?? 'comic';
|
||||
return CatalogComic(
|
||||
source: SourceId.comicland,
|
||||
slug: slug,
|
||||
title: (row['title'] as String?)?.trim().isNotEmpty == true
|
||||
? (row['title'] as String).trim()
|
||||
: slug,
|
||||
listingUrl: listingUrlForSlug(slug),
|
||||
coverUrl: row['cover_url'] as String?,
|
||||
description: row['description'] as String?,
|
||||
rating: (row['rating'] is num) ? (row['rating'] as num).toDouble() : null,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// MangaRead / Madara constants (port of cxyz mangaread fetch helpers).
|
||||
library;
|
||||
const mangaReadHosts = <String>['www.mangaread.org', 'mangaread.org'];
|
||||
|
||||
const mangaReadOrigin = 'https://www.mangaread.org/';
|
||||
|
||||
const mangaReadDesktopUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
const mangaReadMobileUa =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 '
|
||||
'(KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
const mangaReadHtmlHeaders = <String, String>{
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'User-Agent': mangaReadDesktopUa,
|
||||
};
|
||||
|
||||
const mangaReadReaderHeaders = <String, String>{
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'User-Agent': mangaReadMobileUa,
|
||||
};
|
||||
|
||||
const mangaReadCdnHeaders = <String, String>{
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': mangaReadOrigin,
|
||||
'User-Agent': mangaReadDesktopUa,
|
||||
};
|
||||
@@ -0,0 +1,328 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
String mangaSlugFromUrl(String pageUrl) {
|
||||
try {
|
||||
final parts = Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final mi = parts.indexOf('manga');
|
||||
if (mi != -1 && mi + 1 < parts.length) {
|
||||
return parts[mi + 1];
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'comic';
|
||||
}
|
||||
|
||||
String mangaBaseUrlFromListingUrl(String pageUrl) {
|
||||
final u = Uri.parse(pageUrl);
|
||||
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final mi = parts.indexOf('manga');
|
||||
if (mi == -1 || mi + 1 >= parts.length) {
|
||||
throw StateError(
|
||||
'Could not find /manga/{slug}/ in URL; use a manga or chapter link from MangaRead.',
|
||||
);
|
||||
}
|
||||
final slug = parts[mi + 1];
|
||||
return Uri(scheme: u.scheme, host: u.host, path: '/manga/$slug/').toString();
|
||||
}
|
||||
|
||||
String listingUrlForSlug(String slug, {String origin = 'https://www.mangaread.org'}) {
|
||||
final base = origin.replaceAll(RegExp(r'/+$'), '');
|
||||
return '$base/manga/$slug/';
|
||||
}
|
||||
|
||||
String chapterKeyFromUrl(String chapterUrl) {
|
||||
try {
|
||||
final segs = Uri.parse(chapterUrl).pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
return segs.isEmpty ? 'chapter' : segs.last;
|
||||
} catch (_) {
|
||||
return 'chapter';
|
||||
}
|
||||
}
|
||||
|
||||
String? chapterNumberFromTitle(String title) {
|
||||
final m = RegExp(r'(?:Chapter|Ch\.?)\s*([\d.]+)', caseSensitive: false)
|
||||
.firstMatch(title.trim().replaceAll(RegExp(r'\s+'), ' '));
|
||||
return m?.group(1);
|
||||
}
|
||||
|
||||
String absoluteUrl(String href, String baseHref) {
|
||||
return Uri.parse(baseHref).resolve(href.trim()).toString();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// MangaTown mobile (`m.mangatown.com`) constants.
|
||||
library;
|
||||
|
||||
const mangaTownOrigin = 'https://m.mangatown.com';
|
||||
|
||||
const mangaTownMobileUa =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 '
|
||||
'(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
const mangaTownHtmlHeaders = <String, String>{
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'User-Agent': mangaTownMobileUa,
|
||||
};
|
||||
|
||||
const mangaTownImageHeaders = <String, String>{
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': '$mangaTownOrigin/',
|
||||
'User-Agent': mangaTownMobileUa,
|
||||
};
|
||||
|
||||
/// Site catalog page size (Latest / Hot / Directory).
|
||||
const mangaTownCatalogPageSize = 20;
|
||||
@@ -0,0 +1,307 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import '../source.dart';
|
||||
import 'constants.dart';
|
||||
|
||||
/// Hot ranking period for `/hot` feeds.
|
||||
enum MangaTownHotPeriod {
|
||||
total,
|
||||
today,
|
||||
week,
|
||||
month,
|
||||
}
|
||||
|
||||
String? hotPeriodQuery(MangaTownHotPeriod period) {
|
||||
switch (period) {
|
||||
case MangaTownHotPeriod.total:
|
||||
return null;
|
||||
case MangaTownHotPeriod.today:
|
||||
return 'tviews.za';
|
||||
case MangaTownHotPeriod.week:
|
||||
return 'wviews.za';
|
||||
case MangaTownHotPeriod.month:
|
||||
return 'mviews.za';
|
||||
}
|
||||
}
|
||||
|
||||
MangaTownHotPeriod? hotPeriodForMenu(CatalogMenuKind kind) {
|
||||
switch (kind) {
|
||||
case CatalogMenuKind.popular:
|
||||
return MangaTownHotPeriod.total;
|
||||
case CatalogMenuKind.hotToday:
|
||||
return MangaTownHotPeriod.today;
|
||||
case CatalogMenuKind.hotWeek:
|
||||
return MangaTownHotPeriod.week;
|
||||
case CatalogMenuKind.hotMonth:
|
||||
return MangaTownHotPeriod.month;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Page 1 uses the bare path; page 2+ appends `/{n}.html`.
|
||||
String _pagedPath(String path, int page) {
|
||||
final base = path.replaceAll(RegExp(r'/+$'), '');
|
||||
if (page <= 1) return base;
|
||||
return '$base/$page.html';
|
||||
}
|
||||
|
||||
Uri _withOptionalQuery(String path, String? queryToken) {
|
||||
final uri = Uri.parse('$mangaTownOrigin$path');
|
||||
if (queryToken == null || queryToken.isEmpty) return uri;
|
||||
// Site uses bare tokens like `?wviews.za` (no `=` value).
|
||||
return Uri.parse('${uri.toString()}?$queryToken');
|
||||
}
|
||||
|
||||
String catalogLatestUrl({int page = 1}) {
|
||||
return _withOptionalQuery(_pagedPath('/latest', page), null).toString();
|
||||
}
|
||||
|
||||
String catalogHotUrl({
|
||||
MangaTownHotPeriod period = MangaTownHotPeriod.total,
|
||||
int page = 1,
|
||||
}) {
|
||||
return _withOptionalQuery(
|
||||
_pagedPath('/hot', page),
|
||||
hotPeriodQuery(period),
|
||||
).toString();
|
||||
}
|
||||
|
||||
/// `/directory/{demographic}-{genre}-0-{status}-{initial}-0`
|
||||
String directoryUrl(
|
||||
CatalogBrowseParams browse, {
|
||||
int page = 1,
|
||||
}) {
|
||||
final demo = _seg(browse.demographic);
|
||||
final genre = _seg(browse.genre);
|
||||
final status = _seg(browse.status);
|
||||
final initial = _seg(browse.initial);
|
||||
final path = _pagedPath(
|
||||
'/directory/$demo-$genre-0-$status-$initial-0',
|
||||
page,
|
||||
);
|
||||
return '$mangaTownOrigin$path';
|
||||
}
|
||||
|
||||
String catalogSearchUrl({required String query, int page = 1}) {
|
||||
final path = _pagedPath('/search', page);
|
||||
return Uri.parse('$mangaTownOrigin$path').replace(
|
||||
queryParameters: {'name': query},
|
||||
).toString();
|
||||
}
|
||||
|
||||
String _seg(String raw) {
|
||||
final t = raw.trim();
|
||||
if (t.isEmpty) return '0';
|
||||
return t;
|
||||
}
|
||||
|
||||
String mangaSlugFromUrl(String pageUrl) {
|
||||
try {
|
||||
final parts =
|
||||
Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final mi = parts.indexOf('manga');
|
||||
if (mi != -1 && mi + 1 < parts.length) {
|
||||
return parts[mi + 1];
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'comic';
|
||||
}
|
||||
|
||||
String listingUrlForSlug(String slug, {String origin = mangaTownOrigin}) {
|
||||
final base = origin.replaceAll(RegExp(r'/+$'), '');
|
||||
return '$base/manga/$slug';
|
||||
}
|
||||
|
||||
String mangaBaseUrlFromListingUrl(String pageUrl) {
|
||||
final u = Uri.parse(pageUrl);
|
||||
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final mi = parts.indexOf('manga');
|
||||
if (mi == -1 || mi + 1 >= parts.length) {
|
||||
throw StateError(
|
||||
'Could not find /manga/{slug}/ in URL; use a manga or chapter link from MangaTown.',
|
||||
);
|
||||
}
|
||||
final slug = parts[mi + 1];
|
||||
return Uri(scheme: u.scheme, host: u.host, path: '/manga/$slug').toString();
|
||||
}
|
||||
|
||||
/// Strip reader page suffixes like `/2.html` so we keep `/manga/{slug}/c005/`.
|
||||
String chapterBaseUrl(String chapterUrl) {
|
||||
final u = Uri.parse(chapterUrl);
|
||||
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
if (parts.isEmpty) return chapterUrl;
|
||||
final last = parts.last;
|
||||
if (RegExp(r'^\d+\.html$', caseSensitive: false).hasMatch(last)) {
|
||||
parts.removeLast();
|
||||
}
|
||||
final path = '/${parts.join('/')}/';
|
||||
return Uri(scheme: u.scheme, host: u.host, path: path).toString();
|
||||
}
|
||||
|
||||
/// 1-based reader page URL under a chapter base.
|
||||
String chapterPageUrl(String chapterBase, int pageIndex) {
|
||||
final base = chapterBaseUrl(chapterBase);
|
||||
if (pageIndex <= 1) return base;
|
||||
final trimmed = base.replaceAll(RegExp(r'/+$'), '');
|
||||
return '$trimmed/$pageIndex.html';
|
||||
}
|
||||
|
||||
String chapterKeyFromUrl(String chapterUrl) {
|
||||
try {
|
||||
final segs = Uri.parse(chapterBaseUrl(chapterUrl))
|
||||
.pathSegments
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
return segs.isEmpty ? 'chapter' : segs.last;
|
||||
} catch (_) {
|
||||
return 'chapter';
|
||||
}
|
||||
}
|
||||
|
||||
String? chapterNumberFromTitle(String title) {
|
||||
final cleaned = title.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
final m = RegExp(
|
||||
r'(?:Chapter|Ch\.?|C\.?)\s*([\d.]+)',
|
||||
caseSensitive: false,
|
||||
).firstMatch(cleaned);
|
||||
return m?.group(1);
|
||||
}
|
||||
|
||||
String absoluteUrl(String href, String baseHref) {
|
||||
final resolved = Uri.parse(baseHref).resolve(href.trim()).toString();
|
||||
if (resolved.startsWith('//')) {
|
||||
return 'https:$resolved';
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// Demographics offered by MangaTown directory filters.
|
||||
const mangaTownDemographics = <MapEntry<String, String>>[
|
||||
MapEntry('0', 'All'),
|
||||
MapEntry('josei', 'Josei'),
|
||||
MapEntry('seinen', 'Seinen'),
|
||||
MapEntry('shoujo', 'Shoujo'),
|
||||
MapEntry('shoujo_ai', 'Shoujo Ai'),
|
||||
MapEntry('shounen', 'Shounen'),
|
||||
MapEntry('shounen_ai', 'Shounen Ai'),
|
||||
MapEntry('yaoi', 'Yaoi'),
|
||||
MapEntry('yuri', 'Yuri'),
|
||||
];
|
||||
|
||||
/// Genre slug → label for directory filters.
|
||||
const mangaTownGenres = <MapEntry<String, String>>[
|
||||
MapEntry('0', 'All'),
|
||||
MapEntry('4_koma', '4 Koma'),
|
||||
MapEntry('action', 'Action'),
|
||||
MapEntry('adventure', 'Adventure'),
|
||||
MapEntry('comedy', 'Comedy'),
|
||||
MapEntry('cooking', 'Cooking'),
|
||||
MapEntry('doujinshi', 'Doujinshi'),
|
||||
MapEntry('drama', 'Drama'),
|
||||
MapEntry('ecchi', 'Ecchi'),
|
||||
MapEntry('fantasy', 'Fantasy'),
|
||||
MapEntry('gender_bender', 'Gender Bender'),
|
||||
MapEntry('harem', 'Harem'),
|
||||
MapEntry('historical', 'Historical'),
|
||||
MapEntry('horror', 'Horror'),
|
||||
MapEntry('martial_arts', 'Martial Arts'),
|
||||
MapEntry('mature', 'Mature'),
|
||||
MapEntry('mecha', 'Mecha'),
|
||||
MapEntry('music', 'Music'),
|
||||
MapEntry('mystery', 'Mystery'),
|
||||
MapEntry('one_shot', 'One Shot'),
|
||||
MapEntry('psychological', 'Psychological'),
|
||||
MapEntry('reverse_harem', 'Reverse Harem'),
|
||||
MapEntry('romance', 'Romance'),
|
||||
MapEntry('school_life', 'School Life'),
|
||||
MapEntry('sci_fi', 'Sci Fi'),
|
||||
MapEntry('shotacon', 'Shotacon'),
|
||||
MapEntry('slice_of_life', 'Slice Of Life'),
|
||||
MapEntry('smut', 'Smut'),
|
||||
MapEntry('sports', 'Sports'),
|
||||
MapEntry('supernatural', 'Supernatural'),
|
||||
MapEntry('suspense', 'Suspense'),
|
||||
MapEntry('tragedy', 'Tragedy'),
|
||||
MapEntry('vampire', 'Vampire'),
|
||||
MapEntry('webtoons', 'Webtoons'),
|
||||
MapEntry('youkai', 'Youkai'),
|
||||
];
|
||||
|
||||
const mangaTownStatuses = <MapEntry<String, String>>[
|
||||
MapEntry('0', 'All'),
|
||||
MapEntry('ongoing', 'Ongoing'),
|
||||
MapEntry('new', 'New'),
|
||||
MapEntry('completed', 'Completed'),
|
||||
];
|
||||
|
||||
/// A–Z plus `0-9`.
|
||||
List<MapEntry<String, String>> mangaTownInitials() {
|
||||
final out = <MapEntry<String, String>>[const MapEntry('0', 'All')];
|
||||
for (var c = 0; c < 26; c++) {
|
||||
final letter = String.fromCharCode('a'.codeUnitAt(0) + c);
|
||||
out.add(MapEntry(letter, letter.toUpperCase()));
|
||||
}
|
||||
out.add(const MapEntry('0-9', '0-9'));
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'comicland/comicland_source.dart';
|
||||
import 'mangaread/mangaread_source.dart';
|
||||
import 'mangatown/mangatown_source.dart';
|
||||
import 'source.dart';
|
||||
import 'source_flags.dart';
|
||||
|
||||
class SourceRegistry {
|
||||
SourceRegistry({
|
||||
Set<SourceId>? enabled,
|
||||
ComicSource? comicLand,
|
||||
ComicSource? mangaRead,
|
||||
ComicSource? mangaTown,
|
||||
}) : _sources = _build(
|
||||
enabled ?? enabledSourceIds(),
|
||||
comicLand: comicLand,
|
||||
mangaRead: mangaRead,
|
||||
mangaTown: mangaTown,
|
||||
);
|
||||
|
||||
final Map<SourceId, ComicSource> _sources;
|
||||
|
||||
static Map<SourceId, ComicSource> _build(
|
||||
Set<SourceId> enabled, {
|
||||
ComicSource? comicLand,
|
||||
ComicSource? mangaRead,
|
||||
ComicSource? mangaTown,
|
||||
}) {
|
||||
final map = <SourceId, ComicSource>{};
|
||||
for (final id in enabled) {
|
||||
switch (id) {
|
||||
case SourceId.comicland:
|
||||
map[id] = comicLand ?? ComicLandSource();
|
||||
case SourceId.mangaread:
|
||||
map[id] = mangaRead ?? MangaReadSource();
|
||||
case SourceId.mangatown:
|
||||
map[id] = mangaTown ?? MangaTownSource();
|
||||
}
|
||||
}
|
||||
if (map.isEmpty) {
|
||||
map[SourceId.comicland] = comicLand ?? ComicLandSource();
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
List<ComicSource> get all {
|
||||
// Stable order matching SourceId declaration.
|
||||
return SourceId.values
|
||||
.where(_sources.containsKey)
|
||||
.map((id) => _sources[id]!)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
bool get hasMultiple => _sources.length > 1;
|
||||
|
||||
ComicSource get(SourceId id) {
|
||||
final s = _sources[id];
|
||||
if (s == null) {
|
||||
throw StateError('Source not enabled in this build: $id');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
bool isEnabled(SourceId id) => _sources.containsKey(id);
|
||||
|
||||
ComicSource get defaultSource => all.first;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Shared models and source-adapter contract (port of cxyz `sources.js`).
|
||||
|
||||
enum SourceId {
|
||||
comicland,
|
||||
mangaread,
|
||||
mangatown;
|
||||
|
||||
String get id => name;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case SourceId.comicland:
|
||||
return 'ComicLand';
|
||||
case SourceId.mangaread:
|
||||
return 'MangaRead';
|
||||
case SourceId.mangatown:
|
||||
return 'MangaTown';
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundled favicon asset (not fetched at runtime).
|
||||
String get logoAsset => 'assets/sources/$name.png';
|
||||
|
||||
static SourceId? tryParse(String raw) {
|
||||
final key = raw.trim().toLowerCase();
|
||||
for (final id in SourceId.values) {
|
||||
if (id.name == key) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse menus a source may expose on its Catalog home.
|
||||
enum CatalogMenuKind {
|
||||
popular,
|
||||
latest,
|
||||
official,
|
||||
uncensored,
|
||||
recommended,
|
||||
search,
|
||||
hotToday,
|
||||
hotWeek,
|
||||
hotMonth,
|
||||
directory;
|
||||
|
||||
String get pathSegment => name;
|
||||
}
|
||||
|
||||
/// Optional directory filters (MangaTown). Segment `0` means All.
|
||||
class CatalogBrowseParams {
|
||||
const CatalogBrowseParams({
|
||||
this.demographic = '0',
|
||||
this.genre = '0',
|
||||
this.status = '0',
|
||||
this.initial = '0',
|
||||
});
|
||||
|
||||
final String demographic;
|
||||
final String genre;
|
||||
final String status;
|
||||
final String initial;
|
||||
|
||||
CatalogBrowseParams copyWith({
|
||||
String? demographic,
|
||||
String? genre,
|
||||
String? status,
|
||||
String? initial,
|
||||
}) {
|
||||
return CatalogBrowseParams(
|
||||
demographic: demographic ?? this.demographic,
|
||||
genre: genre ?? this.genre,
|
||||
status: status ?? this.status,
|
||||
initial: initial ?? this.initial,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is CatalogBrowseParams &&
|
||||
other.demographic == demographic &&
|
||||
other.genre == genre &&
|
||||
other.status == status &&
|
||||
other.initial == initial;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(demographic, genre, status, initial);
|
||||
}
|
||||
|
||||
class CatalogMenu {
|
||||
const CatalogMenu({required this.kind, required this.title});
|
||||
|
||||
final CatalogMenuKind kind;
|
||||
final String title;
|
||||
}
|
||||
|
||||
class CatalogComic {
|
||||
const CatalogComic({
|
||||
required this.source,
|
||||
required this.slug,
|
||||
required this.title,
|
||||
required this.listingUrl,
|
||||
this.coverUrl,
|
||||
this.description,
|
||||
this.rating,
|
||||
});
|
||||
|
||||
final SourceId source;
|
||||
final String slug;
|
||||
final String title;
|
||||
final String listingUrl;
|
||||
final String? coverUrl;
|
||||
final String? description;
|
||||
final double? rating;
|
||||
}
|
||||
|
||||
class ChapterLink {
|
||||
const ChapterLink({
|
||||
required this.url,
|
||||
required this.title,
|
||||
required this.chapterKey,
|
||||
this.chapterNumber,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final String title;
|
||||
final String chapterKey;
|
||||
final String? chapterNumber;
|
||||
}
|
||||
|
||||
class SeriesDetail {
|
||||
const SeriesDetail({
|
||||
required this.source,
|
||||
required this.listingUrl,
|
||||
required this.slug,
|
||||
required this.title,
|
||||
required this.chapters,
|
||||
this.coverUrl,
|
||||
this.description,
|
||||
});
|
||||
|
||||
final SourceId source;
|
||||
final String listingUrl;
|
||||
final String slug;
|
||||
final String title;
|
||||
final String? coverUrl;
|
||||
final String? description;
|
||||
final List<ChapterLink> chapters;
|
||||
}
|
||||
|
||||
/// Site adapter: per-source catalog menus + series + page URLs.
|
||||
abstract class ComicSource {
|
||||
SourceId get id;
|
||||
|
||||
String get displayName;
|
||||
|
||||
String get blurb;
|
||||
|
||||
/// Menus on this source’s Catalog home. Omit unsupported feeds (e.g. no search).
|
||||
List<CatalogMenu> get catalogMenus;
|
||||
|
||||
/// Headers for page/CDN image requests.
|
||||
Map<String, String> get imageHeaders;
|
||||
|
||||
Future<List<CatalogComic>> loadCatalog(
|
||||
CatalogMenuKind kind, {
|
||||
String query = '',
|
||||
int offset = 0,
|
||||
int limit = 30,
|
||||
CatalogBrowseParams? browse,
|
||||
});
|
||||
|
||||
Future<SeriesDetail> fetchSeries(String listingUrl);
|
||||
|
||||
Future<List<String>> fetchPageUrls(String chapterUrl);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'source.dart';
|
||||
|
||||
/// Compile-time source gate: `--dart-define=SOURCES=comicland,mangaread,mangatown`
|
||||
/// or `--dart-define=SOURCES=all`.
|
||||
///
|
||||
/// Unset / invalid → ComicLand only. Default is **not** all sources.
|
||||
const String kSourcesDefine = String.fromEnvironment(
|
||||
'SOURCES',
|
||||
defaultValue: 'comicland',
|
||||
);
|
||||
|
||||
/// Parses [raw] into enabled [SourceId]s.
|
||||
///
|
||||
/// - Token `all` (case-insensitive) enables every [SourceId].
|
||||
/// - Unknown tokens ignored.
|
||||
/// - Empty result falls back to `{SourceId.comicland}`.
|
||||
Set<SourceId> parseEnabledSourceIds([String raw = kSourcesDefine]) {
|
||||
final parts = raw.split(',').map((p) => p.trim().toLowerCase()).where(
|
||||
(p) => p.isNotEmpty,
|
||||
);
|
||||
for (final part in parts) {
|
||||
if (part == 'all') {
|
||||
return SourceId.values.toSet();
|
||||
}
|
||||
}
|
||||
final out = <SourceId>{};
|
||||
for (final part in parts) {
|
||||
final id = SourceId.tryParse(part);
|
||||
if (id != null) out.add(id);
|
||||
}
|
||||
if (out.isEmpty) {
|
||||
return {SourceId.comicland};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Set<SourceId> enabledSourceIds() => parseEnabledSourceIds();
|
||||
Reference in New Issue
Block a user