diff --git a/plugins/english/CosplayTele/index.ts b/plugins/english/CosplayTele/index.ts new file mode 100644 index 00000000..c9516757 --- /dev/null +++ b/plugins/english/CosplayTele/index.ts @@ -0,0 +1,318 @@ +import { fetchText } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; +import { FilterTypes, Filters } from '@libs/filterInputs'; +import { decodeHtmlEntities, encodeHtmlEntities } from '@libs/utils'; +import { isUrlAbsolute } from '@libs/isAbsoluteUrl'; + +const SITE = 'https://cosplaytele.com'; + +const PHOTOS_SUFFIX = '#cosplaytele-photos'; +const VIDEO_SUFFIX = '#cosplaytele-video'; + +const levelCosplayOptions = [ + { label: 'All', value: '' }, + { label: 'Cosplay Nude', value: `${SITE}/category/nude/` }, + { label: 'Cosplay Ero', value: `${SITE}/category/cosplay-ero/` }, + { label: 'Cosplay', value: `${SITE}/category/cosplay/` }, +]; + +const topCosplayOptions = [ + { label: 'All', value: '' }, + { label: '24 hours', value: `${SITE}/24-hours/` }, + { label: '3 day', value: `${SITE}/3-day/` }, + { label: '7 Day', value: `${SITE}/7-day/` }, +]; + +function cleanTitle(raw: string): string { + let title = decodeHtmlEntities(raw).trim(); + title = title.replace(/\s*[“"']?\d+\s+photos?(?:\s+and\s+\d+\s+videos?)?[”"']?\s*$/i, ''); + title = title.replace(/\s*[“"']?\d+\s+videos?(?:\s+and\s+\d+\s+photos?)?[”"']?\s*$/i, ''); + title = title.replace(/[“"”]/g, '').trim(); + return title; +} + +function toPath(url: string): string { + if (!url) return '/'; + if (!isUrlAbsolute(url)) return url.startsWith('/') ? url : `/${url}`; + try { + const u = new URL(url); + if (u.origin === SITE) return `${u.pathname}${u.search}`; + return url; + } catch { + return url; + } +} + +function buildListUrl( + pageNo: number, + levelCosplay: string, + topCosplay: string, +): string { + const base = levelCosplay || topCosplay || `${SITE}/`; + const normalized = base.endsWith('/') ? base : `${base}/`; + if (pageNo <= 1) return normalized; + if (normalized === `${SITE}/`) { + return `${SITE}/page/${pageNo}/`; + } + return `${normalized}page/${pageNo}/`; +} + +function parseNovelCards($: ReturnType): Plugin.NovelItem[] { + const novels: Plugin.NovelItem[] = []; + const seen = new Set(); + + $('.large-10 .box-blog-post').each((_, box) => { + const $box = $(box); + const $link = $box.find('.post-title a, h5 a').first(); + const href = $link.attr('href'); + if (!href || href === '#') return; + const path = toPath(href); + if (seen.has(path)) return; + seen.add(path); + + const rawName = $link.text().trim() || $link.attr('title') || ''; + const name = cleanTitle(rawName); + if (!name) return; + + let cover = + $box.find('.box-image img').attr('data-src') || + $box.find('.box-image img').attr('src') || + $box.find('img').first().attr('src') || + defaultCover; + if (cover && !isUrlAbsolute(cover)) { + cover = SITE + cover; + } + + novels.push({ name, path, cover }); + }); + + return novels; +} + +function extractAuthor($: ReturnType): string { + const cosplayerLink = $('blockquote a[href*="/category/"]').first().text().trim(); + if (cosplayerLink) return cosplayerLink; + + const block = $('blockquote').first().text(); + const match = block.match(/Cosplayer:\s*([^\n\r]+)/i); + return match?.[1]?.trim() || ''; +} + +function extractDownloadSummary($: ReturnType): string { + const urls: string[] = []; + const $content = $('.entry-content').first(); + + $content.find('a.button, a[class*="button"]').each((_, el) => { + const $a = $(el); + const text = $a.find('span').first().text().trim() || $a.text().trim(); + if (!/download/i.test(text)) return; + const href = ($a.attr('href') || '').trim(); + if (href && href !== '#') { + urls.push(href); + } + }); + + if (!urls.length) return ''; + return `Download link:\n${urls.join('\n')}`; +} + +function collectPhotoUrls($: ReturnType): string[] { + const urls: string[] = []; + const seen = new Set(); + const $content = $('.entry-content').first(); + + $content.find('figure a[href]').each((_, el) => { + const href = $(el).attr('href')?.trim(); + if (!href || !/\.(webp|jpe?g|png|gif)(\?|$)/i.test(href)) return; + const full = isUrlAbsolute(href) ? href : SITE + href; + if (seen.has(full)) return; + seen.add(full); + urls.push(full); + }); + + return urls; +} + +function collectVideoEmbeds($: ReturnType): string[] { + const embeds: string[] = []; + const seen = new Set(); + const $content = $('.entry-content').first(); + + $content.find('iframe[src]').each((_, el) => { + const src = $(el).attr('src')?.trim(); + if (!src || /googletagmanager/i.test(src)) return; + if (seen.has(src)) return; + seen.add(src); + embeds.push(src); + }); + + return embeds; +} + +class CosplayTelePlugin implements Plugin.PluginBase { + id = 'cosplaytele'; + name = '🖼️ CosplayTele'; + icon = 'src/en/cosplaytele/icon.png'; + site = SITE; + version = '1.0.0'; + + customJS = 'src/en/cosplaytele/custom.js'; + + imageRequestInit: Plugin.ImageRequestInit = { + headers: { + Referer: `${SITE}/`, + }, + }; + + filters = { + levelCosplay: { + label: 'Level Cosplay', + type: FilterTypes.Picker, + value: '', + options: levelCosplayOptions, + }, + topCosplay: { + label: 'Top Cosplay', + type: FilterTypes.Picker, + value: '', + options: topCosplayOptions, + }, + } satisfies Filters; + + private async fetchPage(url: string): Promise { + return fetchText(url.startsWith('http') ? url : `${SITE}${url}`); + } + + async popularNovels( + pageNo: number, + { filters }: Plugin.PopularNovelsOptions, + ): Promise { + const level = filters.levelCosplay.value as string; + const top = filters.topCosplay.value as string; + const url = buildListUrl(pageNo, level, top); + const html = await this.fetchPage(url); + const $ = loadCheerio(html); + return parseNovelCards($); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const term = encodeURIComponent(searchTerm.trim()); + const url = + pageNo > 1 + ? `${SITE}/page/${pageNo}/?s=${term}` + : `${SITE}/?s=${term}`; + const html = await this.fetchPage(url); + const $ = loadCheerio(html); + return parseNovelCards($); + } + + async parseNovel(novelPath: string): Promise { + const path = novelPath.split('#')[0]; + const html = await this.fetchPage(path); + const $ = loadCheerio(html); + + const rawTitle = + $('h1.entry-title').first().text().trim() || + $('meta[property="og:title"]').attr('content') || + 'Untitled'; + const name = cleanTitle(rawTitle); + + const author = extractAuthor($); + const genres: string[] = []; + $('.entry-category a, h6.entry-category a').each((_, el) => { + const g = $(el).text().trim(); + if (g) genres.push(g); + }); + + let cover = + $('meta[property="og:image"]').attr('content') || + $('.entry-content figure img').first().attr('src') || + defaultCover; + if (cover && !isUrlAbsolute(cover)) cover = SITE + cover; + + const summary = extractDownloadSummary($); + const embeds = collectVideoEmbeds($); + + const chapters: Plugin.ChapterItem[] = [ + { + name: 'Photos', + path: `${path}${PHOTOS_SUFFIX}`, + chapterNumber: 1, + }, + ]; + + if (embeds.length > 0) { + chapters.push({ + name: 'Video', + path: `${path}${VIDEO_SUFFIX}`, + chapterNumber: 2, + }); + } + + return { + path, + name, + author, + artist: author, + cover, + genres: genres.join(', '), + status: NovelStatus.Completed, + summary, + chapters, + }; + } + + async parseChapter(chapterPath: string): Promise { + if (chapterPath.endsWith(PHOTOS_SUFFIX)) { + const novelPath = chapterPath.slice(0, -PHOTOS_SUFFIX.length); + const html = await this.fetchPage(novelPath); + const $ = loadCheerio(html); + const photos = collectPhotoUrls($); + if (!photos.length) { + return '

No photos found.

'; + } + const imgs = photos + .map( + url => + `
`, + ) + .join('\n'); + return `
${imgs}
`; + } + + if (chapterPath.endsWith(VIDEO_SUFFIX)) { + const novelPath = chapterPath.slice(0, -VIDEO_SUFFIX.length); + const html = await this.fetchPage(novelPath); + const $ = loadCheerio(html); + const embeds = collectVideoEmbeds($); + if (!embeds.length) { + return '

No embedded video on this post.

'; + } + + const embedJson = encodeHtmlEntities(JSON.stringify(embeds)); + return [ + '', + '', + ``, + '', + '', + ].join('\n'); + } + + return '

Unknown chapter.

'; + } + + resolveUrl(path: string, isNovel?: boolean): string { + const clean = path.split('#')[0]; + if (isUrlAbsolute(clean)) return clean; + return `${SITE}${clean.startsWith('/') ? clean : `/${clean}`}`; + } +} + +export default new CosplayTelePlugin(); \ No newline at end of file diff --git a/plugins/english/CosplayTele/webview/index.ts b/plugins/english/CosplayTele/webview/index.ts new file mode 100644 index 00000000..c1f903df --- /dev/null +++ b/plugins/english/CosplayTele/webview/index.ts @@ -0,0 +1,325 @@ +/// + +const SITE = 'https://cosplaytele.com'; + +type EmbedPlayerWindow = Window & { + LNReaderPlayer?: { + log: (msg: string) => void; + playHls: (url: string, customHlsConfig?: Record) => void; + playIframe: (url: string) => void; + playDirect: (url: string) => void; + }; + reader?: { + // eslint-disable-next-line + fetch: (url: string, init?: RequestInit) => Promise; + }; +}; + +function log(msg: string) { + const w = window as EmbedPlayerWindow; + w.LNReaderPlayer?.log(`[CosplayTele] ${msg}`); +} + +async function fetchText(url: string, referer: string): Promise { + const w = window as EmbedPlayerWindow; + // eslint-disable-next-line + const init: RequestInit = { + headers: { + Referer: referer, + 'User-Agent': navigator.userAgent, + }, + }; + if (w.reader?.fetch) { + const res = await w.reader.fetch(url, init); + return res.text(); + } + const res = await fetch(url, init); + return res.text(); +} + +function extractM3u8FromHtml(html: string): string | null { + const patterns = [ + /file:\s*['"]([^'"]+\.m3u8[^'"]*)['"]/i, + /['"](https?:\/\/[^'"]+\.m3u8[^'"]*)['"]/i, + /(https?:\/\/[^\s"'<>]+\/index\.m3u8[^\s"'<>]*)/i, + /(https?:\/\/[^\s"'<>]+\.m3u8[^\s"'<>]*)/i, + ]; + for (const re of patterns) { + const m = html.match(re); + if (m?.[1]) return m[1].replace(/\\u002F/g, '/'); + } + return null; +} + +function extractEncryptedPayload(html: string): { + ciphertext: string; + key: string; +} | null { + const payload = html.match(/const\s+videoURL\s*=\s*['"]([^'"]+)['"]/); + const key = html.match( + /decryptLink\s*\(\s*videoURL\s*,\s*['"]([0-9a-f]{32})['"]\s*\)/i, + ); + if (!payload?.[1] || !key?.[1]) return null; + return { ciphertext: payload[1], key: key[1] }; +} + +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} + +/** cossora.stream embed: AES-CBC (CryptoJS-compatible) with IV prepended to ciphertext */ +async function decryptCossoraLink( + encryptedB64: string, + keyUtf8: string, +): Promise { + try { + const raw = base64ToBytes(encryptedB64); + if (raw.length < 32) return null; + const iv = raw.slice(0, 16); + const data = raw.slice(16); + const keyBytes = new TextEncoder().encode(keyUtf8); + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'AES-CBC' }, + false, + ['decrypt'], + ); + const plain = await crypto.subtle.decrypt( + { name: 'AES-CBC', iv }, + cryptoKey, + data, + ); + const url = new TextDecoder().decode(plain).trim(); + return url || null; + } catch { + return null; + } +} + +async function resolveEmbedM3u8(embedUrl: string): Promise { + const html = await fetchText(embedUrl, `${SITE}/`); + const direct = extractM3u8FromHtml(html); + if (direct) return direct; + + const encrypted = extractEncryptedPayload(html); + if (encrypted) { + const decrypted = await decryptCossoraLink( + encrypted.ciphertext, + encrypted.key, + ); + if (decrypted && /\.m3u8/i.test(decrypted)) return decrypted; + if (decrypted && /^https?:\/\//i.test(decrypted)) return decrypted; + } + + return null; +} + +/** + * Fetch m3u8 sub-playlist via reader.fetch to bypass CORS, + * create blob URL, and use custom loader for segments. + * Skips the master m3u8 — passes the media playlist directly. + */ +async function playHlsViaReader(m3u8Url: string, embedUrl: string): Promise { + const w = window as EmbedPlayerWindow; + if (!w.LNReaderPlayer) return; + + const referer = new URL(embedUrl).origin + '/'; + + // Fetch the master m3u8 to find the sub-playlist URL + const masterText = await fetchText(m3u8Url, referer); + log(`Master m3u8 length: ${masterText.length}`); + + // Parse sub-playlist URL from master + let subUrl = ''; + const lines = masterText.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#') && /^https?:\/\//i.test(trimmed)) { + subUrl = trimmed; + break; + } + } + + if (!subUrl) { + log('No sub-playlist found in master, trying master as media playlist'); + // Master might already be a media playlist — use it directly + await playMediaPlaylist(masterText, m3u8Url, referer); + return; + } + + // Fetch the sub-playlist (media playlist) via reader.fetch + const subText = await fetchText(subUrl, referer); + log(`Sub-playlist length: ${subText.length}`); + + await playMediaPlaylist(subText, subUrl, referer); +} + +/** + * Take a media playlist text, rewrite relative URIs to absolute, + * create a blob URL, and start playback with CORS-bypassing loaders. + */ +async function playMediaPlaylist( + playlistText: string, + playlistUrl: string, + referer: string, +): Promise { + const w = window as EmbedPlayerWindow; + if (!w.LNReaderPlayer) return; + + const subUrlObj = new URL(playlistUrl); + const subBase = subUrlObj.origin + subUrlObj.pathname.replace(/\/[^/]*$/, '/'); + + // Step 1: Pre-fetch AES-128 key(s) via reader.fetch → blob URLs + // hls.js loads keys through its own internal loader (not fLoader), + // so we must replace key URIs with blob URLs to avoid CORS. + const keyBlobMap = new Map(); + for (const [, rawUri] of playlistText.matchAll(/URI="([^"]+)"/gi)) { + if (keyBlobMap.has(rawUri)) continue; + const absoluteKeyUrl = /^https?:\/\//i.test(rawUri) + ? rawUri + : subBase + rawUri; + try { + const res = await window.reader?.fetch(absoluteKeyUrl, { + headers: { Referer: referer }, + }); + if (res?.ok) { + const buf = await res.arrayBuffer(); + const blobUrl = URL.createObjectURL(new Blob([buf])); + keyBlobMap.set(rawUri, blobUrl); + log(`Key fetched → blob: ${blobUrl.substring(0, 50)}...`); + } + } catch (err) { + log(`Key fetch failed: ${(err as Error).message}`); + } + } + + // Step 2: Replace key URIs with blob URLs in playlist text + let rewritten = playlistText; + for (const [rawUri, blobUrl] of keyBlobMap) { + rewritten = rewritten.replace(rawUri, blobUrl); + } + + // Create blob URL for the media playlist + const blobUrl = URL.createObjectURL( + new Blob([rewritten], { type: 'application/vnd.apple.mpegurl' }), + ); + log(`Media playlist blob URL: ${blobUrl.substring(0, 50)}...`); + + // Custom loader that fetches via reader.fetch to bypass CORS + class ProxyLoader { + _config: any; + _controller: AbortController | null = null; + context: any = null; + stats = { + aborted: false, + loaded: 0, + retry: 0, + total: 0, + chunkCount: 0, + bwEstimate: 0, + loading: { start: 0, first: 0, end: 0 }, + parsing: { start: 0, end: 0 }, + buffering: { start: 0, first: 0, end: 0 }, + }; + + constructor(config: any) { + this._config = config; + } + + destroy() { + this.abort(); + } + + abort() { + this._controller?.abort(); + this._controller = null; + } + + load(ctx: any, _cfg: any, cbs: any) { + this.context = ctx; + this.stats.loading.start = performance.now(); + + window.reader + ?.fetch(ctx.url, { + method: 'GET', + headers: { Referer: referer }, + referrer: referer, + }) + .then((resp) => { + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + this.stats.loading.first = performance.now(); + return resp.arrayBuffer(); + }) + .then((buf) => { + this.stats.loading.end = performance.now(); + this.stats.loaded = buf.byteLength; + this.stats.total = buf.byteLength; + cbs.onSuccess({ data: buf }, this.stats, ctx, null); + }) + .catch((err) => { + if (err.name === 'AbortError') return; + this.stats.loading.end = performance.now(); + cbs.onError({ code: 0, text: err.message }, ctx, null, this.stats); + }); + } + + getResponseData(xhr: any) { + return xhr.response; + } + } + + w.LNReaderPlayer.playHls(blobUrl, { + fLoader: ProxyLoader, + }); + + log('HLS player started with CORS-bypassing loaders'); +} + +(async function main() { + const w = window as EmbedPlayerWindow; + if (!w.LNReaderPlayer) return; + + const root = document.getElementById('cosplaytele-player'); + if (!root) { + log('Missing player root.'); + return; + } + + let embeds: string[] = []; + try { + const raw = root.getAttribute('data-embeds') || '[]'; + embeds = JSON.parse(raw) as string[]; + } catch (e) { + log(`Invalid embed list: ${(e as Error).message}`); + return; + } + + if (!embeds.length) { + log('No embed URLs.'); + return; + } + + for (let i = 0; i < embeds.length; i++) { + const embed = embeds[i]; + log(`Resolving embed ${i + 1}/${embeds.length}...`); + try { + const m3u8Url = await resolveEmbedM3u8(embed); + if (m3u8Url) { + log(`Playing HLS: ${m3u8Url}`); + await playHlsViaReader(m3u8Url, embed); + return; + } + log(`Fallback iframe: ${embed}`); + w.LNReaderPlayer.playIframe(embed); + return; + } catch (err) { + log(`Embed failed: ${(err as Error).message}`); + } + } + + log('All video sources failed.'); +})(); \ No newline at end of file diff --git a/plugins/index.ts b/plugins/index.ts index 81a75909..087743f9 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -2,32 +2,33 @@ import { Plugin } from '@/types/plugin'; import p_0 from '@plugins/japanese/NocSyosetu'; import p_1 from '@plugins/japanese/Pixiv'; import p_2 from '@plugins/korean/Newtoki'; -import p_3 from '@plugins/multi/M3UPlayer'; -import p_4 from '@plugins/vietnamese/AkayTruyen'; -import p_5 from '@plugins/vietnamese/AnimeVietsub'; -import p_6 from '@plugins/vietnamese/AnimeVsub'; -import p_7 from '@plugins/vietnamese/BaoMoi'; -import p_8 from '@plugins/vietnamese/DocTruyenLN'; -import p_9 from '@plugins/vietnamese/HentaiZ'; -import p_10 from '@plugins/vietnamese/JukaNovel'; -import p_11 from '@plugins/vietnamese/KKPhim'; -import p_12 from '@plugins/vietnamese/LNHako'; -import p_13 from '@plugins/vietnamese/LNKuro'; -import p_14 from '@plugins/vietnamese/Luvevaland'; -import p_15 from '@plugins/vietnamese/MeTruyenCV'; -import p_16 from '@plugins/vietnamese/Motchill'; -import p_17 from '@plugins/vietnamese/MotTruyen'; -import p_18 from '@plugins/vietnamese/NguonC'; -import p_19 from '@plugins/vietnamese/OPhim'; -import p_20 from '@plugins/vietnamese/PhimFun'; -import p_21 from '@plugins/vietnamese/SangTacViet'; -import p_22 from '@plugins/vietnamese/TieuThuyetMang'; -import p_23 from '@plugins/vietnamese/TomatoMTL'; -import p_24 from '@plugins/vietnamese/TruyenCV'; -import p_25 from '@plugins/vietnamese/TruyenFull'; -import p_26 from '@plugins/vietnamese/Valvrareteam'; -import p_27 from '@plugins/vietnamese/YeuAnime'; -import p_28 from '@plugins/vietnamese/ZumiNovel'; +import p_3 from '@plugins/english/CosplayTele'; +import p_4 from '@plugins/multi/M3UPlayer'; +import p_5 from '@plugins/vietnamese/AkayTruyen'; +import p_6 from '@plugins/vietnamese/AnimeVietsub'; +import p_7 from '@plugins/vietnamese/AnimeVsub'; +import p_8 from '@plugins/vietnamese/BaoMoi'; +import p_9 from '@plugins/vietnamese/DocTruyenLN'; +import p_10 from '@plugins/vietnamese/HentaiZ'; +import p_11 from '@plugins/vietnamese/JukaNovel'; +import p_12 from '@plugins/vietnamese/KKPhim'; +import p_13 from '@plugins/vietnamese/LNHako'; +import p_14 from '@plugins/vietnamese/LNKuro'; +import p_15 from '@plugins/vietnamese/Luvevaland'; +import p_16 from '@plugins/vietnamese/MeTruyenCV'; +import p_17 from '@plugins/vietnamese/Motchill'; +import p_18 from '@plugins/vietnamese/MotTruyen'; +import p_19 from '@plugins/vietnamese/NguonC'; +import p_20 from '@plugins/vietnamese/OPhim'; +import p_21 from '@plugins/vietnamese/PhimFun'; +import p_22 from '@plugins/vietnamese/SangTacViet'; +import p_23 from '@plugins/vietnamese/TieuThuyetMang'; +import p_24 from '@plugins/vietnamese/TomatoMTL'; +import p_25 from '@plugins/vietnamese/TruyenCV'; +import p_26 from '@plugins/vietnamese/TruyenFull'; +import p_27 from '@plugins/vietnamese/Valvrareteam'; +import p_28 from '@plugins/vietnamese/YeuAnime'; +import p_29 from '@plugins/vietnamese/ZumiNovel'; const PLUGINS: Plugin.PluginBase[] = [ p_0, @@ -59,5 +60,6 @@ const PLUGINS: Plugin.PluginBase[] = [ p_26, p_27, p_28, + p_29, ]; export default PLUGINS; diff --git a/public/static/src/en/cosplaytele/icon.png b/public/static/src/en/cosplaytele/icon.png new file mode 100644 index 00000000..fc457dff Binary files /dev/null and b/public/static/src/en/cosplaytele/icon.png differ