diff --git a/plugins/english/konkon.ts b/plugins/english/konkon.ts new file mode 100644 index 000000000..889ea6cae --- /dev/null +++ b/plugins/english/konkon.ts @@ -0,0 +1,315 @@ +import { fetchApi } from '@libs/fetch'; +import { Filters } from '@libs/filterInputs'; +import { NovelStatus } from '@libs/novelStatus'; +import { defaultCover } from '@libs/defaultCover'; +import { storage } from '@libs/storage'; +import { Plugin } from '@/types/plugin'; +import { load as parseHTML } from 'cheerio'; + +type ImageKeys = { + featured_image?: string | null; + featured_image_key?: string | null; + featured_image_thumb_small_key?: string | null; + featured_image_thumb_medium_key?: string | null; +}; + +type NovelSummary = ImageKeys & { + id: number; + title: string; + slug: string; + novel_status?: string | null; +}; + +type Chapter = { + id: number; + title: string; + slug: string; + status: string; + sort_order?: number; + is_locked?: boolean; + user_has_access?: boolean; + scheduled_for?: string | null; + created_at?: string | null; +}; + +type Volume = { + order?: number; + chapters?: Chapter[]; +}; + +type NovelDetails = NovelSummary & { + author_name?: string | null; + description?: string | null; + genres?: { name: string }[]; + tags?: { name: string }[]; + volumes?: Volume[]; + chapters_pagination?: { + last_page?: number; + }; +}; + +type ChapterDetails = { + id: number; + title: string; + content?: string | null; + locked?: boolean; + is_locked?: boolean; + user_has_access?: boolean; +}; + +type DataResponse = { + data: T; +}; + +type SearchResponse = { + results: T[]; +}; + +class KonkonPlugin implements Plugin.PluginBase { + id = 'konkon'; + name = 'Konkon'; + icon = 'src/en/konkon/icon.png'; + site = 'https://konkon.ink'; + version = '1.0.0'; + filters: Filters | undefined = undefined; + pluginSettings = { + hideLocked: { + value: '', + label: 'Hide locked chapters', + type: 'Switch', + }, + }; + hideLocked = storage.get('hideLocked'); + + private api = 'https://api-k.konkon.ink'; + private pageSize = 20; + + private async getJson(path: string): Promise { + const response = await fetchApi(this.api + path, { + headers: { + Accept: 'application/json', + Referer: this.site + '/', + }, + }); + + if (!response.ok) { + throw new Error( + `Could not reach Konkon (${response.status}). Try opening the site in webview.`, + ); + } + + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + throw new Error( + 'Konkon returned an unexpected response. Try opening the site in webview.', + ); + } + + return (await response.json()) as T; + } + + private encodeBase64(input: string): string { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + let output = ''; + + for ( + let block = 0, charCode, index = 0, map = chars; + input.charAt(index | 0) || ((map = '='), index % 1); + output += map.charAt(63 & (block >> (8 - (index % 1) * 8))) + ) { + charCode = input.charCodeAt((index += 3 / 4)); + if (charCode > 0xff) { + throw new Error('Could not encode Konkon media path.'); + } + block = (block << 8) | charCode; + } + + return output; + } + + private coverUrl(novel: ImageKeys): string { + const key = + novel.featured_image_thumb_medium_key || + novel.featured_image_key || + novel.featured_image_thumb_small_key || + novel.featured_image; + + if (!key) return defaultCover; + return `${this.api}/api/media/k/${this.encodeBase64(key)}`; + } + + private toNovelItem(novel: NovelSummary): Plugin.NovelItem { + return { + name: novel.title || 'Untitled', + path: `/read/${novel.slug}`, + cover: this.coverUrl(novel), + }; + } + + async popularNovels( + pageNo: number, + { showLatestNovels }: Plugin.PopularNovelsOptions, + ): Promise { + if (showLatestNovels) { + const response = await this.getJson>( + `/api/public/latest-updates?per_page=${this.pageSize}&page=${pageNo}`, + ); + return response.data.map(novel => this.toNovelItem(novel)); + } + + const limit = pageNo * this.pageSize; + const response = await this.getJson>( + `/api/public/novels_trending?limit=${limit}`, + ); + return response.data + .slice((pageNo - 1) * this.pageSize, limit) + .map(novel => this.toNovelItem(novel)); + } + + private status(status?: string | null): string { + switch (status?.toLowerCase()) { + case 'ongoing': + return NovelStatus.Ongoing; + case 'completed': + case 'complete': + return NovelStatus.Completed; + case 'cancelled': + case 'canceled': + return NovelStatus.Cancelled; + case 'hiatus': + case 'on hiatus': + return NovelStatus.OnHiatus; + default: + return NovelStatus.Unknown; + } + } + + private readerHtml(html: string): string { + const content = parseHTML(html, undefined, false); + content('script, style').remove(); + content('[style]').each((_, element) => { + const style = content(element).attr('style') || ''; + const readerSafeStyle = style + .replace(/(^|;)\s*color\s*:[^;]*/gi, '$1') + .replace(/;{2,}/g, ';') + .replace(/^\s*;|;\s*$/g, '') + .trim(); + + if (readerSafeStyle) content(element).attr('style', readerSafeStyle); + else content(element).removeAttr('style'); + }); + return content.root().html() || html; + } + + private summaryText(html: string): string { + const content = parseHTML(html, undefined, false); + const paragraphs = content('p') + .map((_, element) => content(element).text().trim()) + .get() + .filter(Boolean); + return ( + paragraphs.length ? paragraphs.join('\n\n') : content.text() + ).trim(); + } + + private async getNovelPage( + slug: string, + pageNo: number, + ): Promise { + const response = await this.getJson>( + `/api/public/novels/${encodeURIComponent(slug)}?page=${pageNo}&per_page=100`, + ); + return response.data; + } + + async parseNovel(novelPath: string): Promise { + const slug = novelPath.replace(/^\/?read\//, '').split(/[?#]/)[0]; + const firstPage = await this.getNovelPage(slug, 1); + const lastPage = Math.max( + 1, + Number(firstPage.chapters_pagination?.last_page) || 1, + ); + const pages = [firstPage]; + + for (let pageNo = 2; pageNo <= lastPage; pageNo += 1) { + pages.push(await this.getNovelPage(slug, pageNo)); + } + + const chapters = pages + .flatMap(page => page.volumes || []) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + .flatMap(volume => + [...(volume.chapters || [])].sort( + (a, b) => (a.sort_order || 0) - (b.sort_order || 0), + ), + ) + .filter(chapter => chapter.status === 'published') + .filter( + chapter => + !this.hideLocked || !chapter.is_locked || chapter.user_has_access, + ) + .map((chapter, index): Plugin.ChapterItem => { + const locked = Boolean(chapter.is_locked && !chapter.user_has_access); + return { + name: `${locked ? '🔒 ' : ''}${chapter.title}`, + path: `/read/chapter/${chapter.id}/${chapter.slug}`, + chapterNumber: index + 1, + releaseTime: chapter.scheduled_for || chapter.created_at || null, + }; + }); + + const genres = [...(firstPage.genres || []), ...(firstPage.tags || [])] + .map(item => item.name) + .filter((name, index, all) => name && all.indexOf(name) === index) + .join(', '); + + return { + name: firstPage.title || 'Untitled', + path: `/read/${firstPage.slug}`, + cover: this.coverUrl(firstPage), + author: firstPage.author_name || undefined, + genres, + summary: firstPage.description + ? this.summaryText(firstPage.description) + : undefined, + status: this.status(firstPage.novel_status), + chapters, + }; + } + + async parseChapter(chapterPath: string): Promise { + const chapterId = chapterPath.match(/\/read\/chapter\/(\d+)/)?.[1]; + if (!chapterId) throw new Error('Invalid chapter path.'); + + const response = await this.getJson>( + `/api/public/chapters/${chapterId}`, + ); + const chapter = response.data; + + if ((chapter.locked || chapter.is_locked) && !chapter.user_has_access) { + throw new Error('This chapter is locked.'); + } + if (!chapter.content) { + throw new Error('Konkon returned no chapter content.'); + } + + return this.readerHtml(chapter.content); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (pageNo > 1) return []; + const response = await this.getJson>( + `/api/public/search?q=${encodeURIComponent(searchTerm.trim())}`, + ); + + return response.results.map(novel => this.toNovelItem(novel)); + } + + resolveUrl = (path: string) => this.site + path; +} + +export default new KonkonPlugin(); diff --git a/public/static/src/en/konkon/icon.png b/public/static/src/en/konkon/icon.png new file mode 100644 index 000000000..67bf8422f Binary files /dev/null and b/public/static/src/en/konkon/icon.png differ