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:
2026-08-29 17:50:31 +07:00
parent 6e7f1ced35
commit a7c6058540
105 changed files with 16721 additions and 275 deletions
@@ -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;
}
+66
View File
@@ -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;
}
+176
View File
@@ -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();