diff --git a/plugins/bangla/golpercity.ts b/plugins/bangla/golpercity.ts new file mode 100644 index 000000000..f8c08ee1a --- /dev/null +++ b/plugins/bangla/golpercity.ts @@ -0,0 +1,214 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters } from '@libs/filterInputs'; +import { load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; + +type FeedLink = { rel: string; href: string }; +type FeedEntry = { + title: { $t: string }; + link: FeedLink[]; + published?: { $t: string }; + media$thumbnail?: { url: string }; +}; +type FeedResponse = { + feed?: { + openSearch$totalResults?: { $t: string }; + category?: { term: string }[]; + entry?: FeedEntry[]; + }; +}; + +const PAGE_SIZE = 20; +const FEED_PAGE_SIZE = 150; +const COVER_SIZE = 's320'; +const CHAPTER_PATH = /^\/\d{4}\/\d{2}\/[^/]+\.html$/; +const NOVEL_PATH = /^\/search\/label\/(.+)$/; +// Labels that are series names double as novels. The ones below group posts by +// genre or are not stories at all. +const EXCLUDED_LABELS = new Set([ + 'ফ্যান্টাসি গল্প', + 'রোমান্টিক গল্প', + 'সামাজিক গল্প', + 'এসাইনমেন্ট', + 'Web Novel Link', +]); + +const postHref = (entry: FeedEntry): string | undefined => + entry.link?.find(link => link.rel === 'alternate')?.href; + +class GolperCity implements Plugin.PluginBase { + id = 'golpercity'; + name = 'Golper City'; + icon = 'src/bn/golpercity/icon.png'; + site = 'https://golpercity.blogspot.com'; + version = '1.0.0'; + filters: Filters | undefined = undefined; + + private async request(url: string) { + const response = await fetchApi(url); + if (!response.ok) { + throw Object.assign(new Error(`Golper City: HTTP ${response.status}.`), { + status: response.status, + }); + } + return response; + } + + private async getFeed( + params: Record, + label?: string, + ): Promise { + const feed = label ? `summary/-/${encodeURIComponent(label)}` : 'summary'; + const query = new URLSearchParams({ alt: 'json', ...params }).toString(); + const data: FeedResponse = await ( + await this.request(`${this.site}/feeds/posts/${feed}?${query}`) + ).json(); + return data.feed; + } + + /** Blogger's label list is the blog's own grouping of novels. */ + private async novelLabels(): Promise { + const feed = await this.getFeed({ 'max-results': '1' }); + return (feed?.category || []) + .map(category => category.term) + .filter(label => !EXCLUDED_LABELS.has(label)); + } + + private novelPath(label: string): string { + return `/search/label/${encodeURIComponent(label)}`; + } + + private labelFromPath(novelPath: string): string { + const match = NOVEL_PATH.exec(new URL(novelPath, this.site).pathname); + if (!match) throw new Error('Golper City: invalid novel address.'); + return decodeURIComponent(match[1]); + } + + private coverFrom(entry?: FeedEntry): string { + const thumbnail = entry?.media$thumbnail?.url; + if (!thumbnail) return defaultCover; + return thumbnail.replace(/\/s\d+(-c)?\//, `/${COVER_SIZE}/`); + } + + /** Each novel's cover is the thumbnail of its newest post, if any. */ + private async toNovelItems(labels: string[]): Promise { + return Promise.all( + labels.map(async label => { + const feed = await this.getFeed({ 'max-results': '1' }, label).catch( + () => undefined, + ); + return { + name: label, + path: this.novelPath(label), + cover: this.coverFrom(feed?.entry?.[0]), + }; + }), + ); + } + + async popularNovels(pageNo: number): Promise { + const labels = (await this.novelLabels()).slice( + (pageNo - 1) * PAGE_SIZE, + pageNo * PAGE_SIZE, + ); + return this.toNovelItems(labels); + } + + /** Every post of a label, newest first, with duplicates across pages removed. */ + private async labelPosts(label: string): Promise { + const posts: FeedEntry[] = []; + const seen = new Set(); + let start = 1; + let total = 1; + while (start <= total) { + const feed = await this.getFeed( + { + 'max-results': String(FEED_PAGE_SIZE), + 'start-index': String(start), + }, + label, + ); + const entries = feed?.entry || []; + if (!entries.length) break; + for (const entry of entries) { + const href = postHref(entry); + if (!href || seen.has(href)) continue; + seen.add(href); + posts.push(entry); + } + // Blogger returns fewer posts than asked per page, so step by what came back. + start += entries.length; + total = Number(feed?.openSearch$totalResults?.$t || 0); + } + return posts; + } + + async parseNovel(novelPath: string): Promise { + const label = this.labelFromPath(novelPath); + const posts = await this.labelPosts(label); + + const chapters: Plugin.ChapterItem[] = []; + for (const entry of posts) { + const href = postHref(entry); + const name = entry.title?.$t?.trim(); + if (!href || !name) continue; + const path = new URL(href).pathname; + if (!CHAPTER_PATH.test(path)) continue; + chapters.push({ + name, + path, + releaseTime: entry.published?.$t, + }); + } + if (!chapters.length) + throw new Error('Golper City: no chapters were found.'); + + // Blogger lists newest first; readers expect the series in publish order. + chapters.reverse(); + + return { + name: label, + path: this.novelPath(label), + cover: this.coverFrom(posts[0]), + chapters, + }; + } + + async parseChapter(chapterPath: string): Promise { + const url = this.resolveUrl(chapterPath); + const $ = loadCheerio(await (await this.request(url)).text()); + const content = $('div.post-body').first(); + content + .find( + 'script,style,iframe,noscript,form,.sharethis-inline-reaction-buttons', + ) + .remove(); + return content.html() || ''; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const terms = searchTerm.toLowerCase().split(/\s+/).filter(Boolean); + if (!terms.length) return []; + // Searching the label catalogue finds novels by name. Blogger's post + // search would return chapter posts instead, which crowd novels out. + const labels = (await this.novelLabels()).filter(label => + terms.every(term => label.toLowerCase().includes(term)), + ); + return this.toNovelItems( + labels.slice((pageNo - 1) * PAGE_SIZE, pageNo * PAGE_SIZE), + ); + } + + resolveUrl(path: string): string { + const url = new URL(path, this.site); + if (url.origin !== this.site) + throw new Error('Golper City: invalid page address.'); + return url.href; + } +} + +export default new GolperCity(); diff --git a/public/static/src/bn/golpercity/icon.png b/public/static/src/bn/golpercity/icon.png new file mode 100644 index 000000000..6fb098fb6 Binary files /dev/null and b/public/static/src/bn/golpercity/icon.png differ diff --git a/scripts/languages.js b/scripts/languages.js index 2892b5935..168a7b512 100644 --- a/scripts/languages.js +++ b/scripts/languages.js @@ -1,5 +1,6 @@ export default { Arabic: '‎العربية', + Bangla: 'বাংলা', Chinese: '中文, 汉语, 漢語', English: 'English', French: 'Français',