b5f89dc259
Co-authored-by: AI agent pi <noreply@openai.com>
200 lines
7.0 KiB
TypeScript
200 lines
7.0 KiB
TypeScript
import { load as parseHTML } from 'cheerio';
|
|
import { fetchApi, FetchInit } from '@libs/fetch';
|
|
import { Plugin } from '@/types/plugin';
|
|
import { defaultCover } from '@libs/defaultCover';
|
|
|
|
/**
|
|
* DaoTekno (daotekno.com) LNReader plugin
|
|
*
|
|
* daotekno.com serves the DaoTranslate.com catalog — pages are titled
|
|
* "DaoTranslate.com - Web Novel Reader" and chapter footers link to
|
|
* DaoTranslate (the connection mentioned in the plugin request). It is a
|
|
* custom PHP reader, not WordPress/Madara:
|
|
*
|
|
* - Catalog: `/?page=N` — 50 `a.novel-card.novel-item` rows per page,
|
|
* title in `.novel-title`, page marker `N / M`.
|
|
* - Search: `/?q=<term>` — filtered server-side, same card markup,
|
|
* single page (page parameters are ignored).
|
|
* - Novel page: `/series/<slug>/` — `<h1>` title, chapter tiles in
|
|
* `#allChaptersGrid` (`.chapter-box`, newest first) split
|
|
* across `?page=N`, 200 per page.
|
|
* - Chapter page: `/series/<slug>/<chapter>/` — text inside `#novel-content`.
|
|
*
|
|
* The site gates chapter pages on User-Agent: desktop UAs receive a
|
|
* "Mobile Only Content" interstitial instead of the chapter, so every
|
|
* request sends a mobile browser UA.
|
|
*/
|
|
class DaoTekno implements Plugin.PluginBase {
|
|
id = 'daotekno';
|
|
name = 'DaoTekno';
|
|
version = '1.0.1';
|
|
icon = 'src/en/daotekno/icon.png';
|
|
site = 'https://daotekno.com/';
|
|
|
|
// Browser-like headers (important for Cloudflare-fronted sites, which
|
|
// serve a bot-check page to requests without them). The UA is a mobile
|
|
// Chrome: the site serves chapter pages only to mobile UAs. The
|
|
// sec-ch-ua/sec-fetch-* fields mirror what a Chrome fetch() sends here
|
|
// (Sec-Fetch-Mode: cors is added by fetchApi's default headers).
|
|
private headers = {
|
|
'User-Agent':
|
|
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36',
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
'Referer': this.site,
|
|
'sec-ch-ua':
|
|
'"Chromium";v="126", "Not(A:Brand";v="24", "Google Chrome";v="126"',
|
|
'sec-ch-ua-mobile': '?1',
|
|
'sec-ch-ua-platform': '"Android"',
|
|
'Sec-Fetch-Site': 'cross-site',
|
|
'Sec-Fetch-Dest': 'empty',
|
|
};
|
|
|
|
// Throw (carrying the HTTP status) on a refused response so a
|
|
// runner-side block is reported INCONCLUSIVE per docs/testing.md
|
|
// instead of being parsed into a false empty-result FAIL.
|
|
private async fetchSite(url: string, init?: FetchInit) {
|
|
const res = await fetchApi(url, init);
|
|
if (!res.ok) {
|
|
throw Object.assign(new Error('Request failed: ' + res.status), {
|
|
status: res.status,
|
|
});
|
|
}
|
|
return res;
|
|
}
|
|
|
|
private parseNovelCards(html: string): Plugin.NovelItem[] {
|
|
const $ = parseHTML(html);
|
|
const novels: Plugin.NovelItem[] = [];
|
|
$('a.novel-card.novel-item').each((_, el) => {
|
|
const href = $(el).attr('href');
|
|
const name =
|
|
$(el).find('.novel-title').text().trim() ||
|
|
$(el)
|
|
.text()
|
|
.replace(/Index\s*»/g, '')
|
|
.trim();
|
|
if (!href || !name) return;
|
|
novels.push({
|
|
name,
|
|
path: href.replace(/^\//, ''),
|
|
// The listing exposes no covers at all.
|
|
cover: defaultCover,
|
|
});
|
|
});
|
|
return novels;
|
|
}
|
|
|
|
private collectChapters(
|
|
$: ReturnType<typeof parseHTML>,
|
|
): Plugin.ChapterItem[] {
|
|
const chapters: Plugin.ChapterItem[] = [];
|
|
$('#allChaptersGrid > a.chapter-box').each((_, el) => {
|
|
const href = $(el).attr('href');
|
|
const name = $(el).text().replace(/\s+/g, ' ').trim();
|
|
if (!href || !name) return;
|
|
chapters.push({ name, path: href.replace(/^\//, '') });
|
|
});
|
|
return chapters;
|
|
}
|
|
|
|
private nextChapterPage($: ReturnType<typeof parseHTML>): string | null {
|
|
const next = $('a.btn-page')
|
|
.filter((_, el) => $(el).text().includes('Next'))
|
|
.attr('href');
|
|
return next ?? null;
|
|
}
|
|
|
|
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
|
const html = await this.fetchSite(`${this.site}?page=${pageNo}`, {
|
|
headers: this.headers,
|
|
}).then(r => r.text());
|
|
|
|
// Out-of-range pages are clamped to the last page (?page=11 serves
|
|
// page 10), so compare the requested page against the `N / M` marker
|
|
// to stop pagination instead of returning the same cards forever.
|
|
const marker = parseHTML(html)('span.btn-pagi.active').text();
|
|
const totalPages = parseInt(marker.split('/')[1] ?? '', 10);
|
|
if (Number.isFinite(totalPages) && pageNo > totalPages) {
|
|
return [];
|
|
}
|
|
|
|
return this.parseNovelCards(html);
|
|
}
|
|
|
|
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
|
const firstHtml = await this.fetchSite(this.site + novelPath, {
|
|
headers: this.headers,
|
|
}).then(r => r.text());
|
|
const $first = parseHTML(firstHtml);
|
|
|
|
const novel: Plugin.SourceNovel = {
|
|
path: novelPath,
|
|
name: $first('h1').first().text().trim() || 'Untitled',
|
|
// The novel page carries no cover, summary, author or status.
|
|
cover: defaultCover,
|
|
chapters: [],
|
|
};
|
|
|
|
const chapters = this.collectChapters($first);
|
|
let nextHref = this.nextChapterPage($first);
|
|
let pagesFetched = 1;
|
|
const maxChapterPages = 100;
|
|
|
|
while (nextHref && pagesFetched < maxChapterPages) {
|
|
const html = await this.fetchSite(this.site + novelPath + nextHref, {
|
|
headers: this.headers,
|
|
}).then(r => r.text());
|
|
const $ = parseHTML(html);
|
|
chapters.push(...this.collectChapters($));
|
|
nextHref = this.nextChapterPage($);
|
|
pagesFetched++;
|
|
}
|
|
|
|
// Every page lists the newest chapter first, and the pages themselves
|
|
// are served newest first, so the concatenation is fully descending.
|
|
novel.chapters = chapters.reverse();
|
|
return novel;
|
|
}
|
|
|
|
async parseChapter(chapterPath: string): Promise<string> {
|
|
const html = await this.fetchSite(this.site + chapterPath, {
|
|
headers: this.headers,
|
|
}).then(r => r.text());
|
|
const $ = parseHTML(html);
|
|
|
|
const content = $('#novel-content');
|
|
if (content.length === 0) {
|
|
// The site serves a "Mobile Only Content" interstitial (HTTP 200)
|
|
// to desktop UAs instead of the chapter body; treat a page without
|
|
// the content container as a real failure, not empty content.
|
|
throw new Error('Chapter content not found (site served a placeholder)');
|
|
}
|
|
|
|
content.find('script, style, .promo').remove();
|
|
return content.html() ?? '';
|
|
}
|
|
|
|
async searchNovels(
|
|
searchTerm: string,
|
|
pageNo: number,
|
|
): Promise<Plugin.NovelItem[]> {
|
|
// Search is filtered server-side and served as a single page; the
|
|
// site ignores page parameters on `?q=` requests.
|
|
if (pageNo > 1) {
|
|
return [];
|
|
}
|
|
|
|
const html = await this.fetchSite(
|
|
`${this.site}?q=${encodeURIComponent(searchTerm)}`,
|
|
{ headers: this.headers },
|
|
).then(r => r.text());
|
|
|
|
return this.parseNovelCards(html);
|
|
}
|
|
|
|
resolveUrl = (path: string) => this.site + path;
|
|
}
|
|
|
|
export default new DaoTekno();
|