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,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();
|
||||
}
|
||||
Reference in New Issue
Block a user