Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions plugins/english/daotekno.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
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();
Binary file added public/static/src/en/daotekno/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading