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