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,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();
}