diff --git a/plugins/english/cherrymistcafe.ts b/plugins/english/cherrymistcafe.ts new file mode 100644 index 000000000..f107211d5 --- /dev/null +++ b/plugins/english/cherrymistcafe.ts @@ -0,0 +1,260 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +// Per-seed PUA substitution tables, solved offline by matching the site's +// cipher webfont glyph outlines against stock Open Sans artwork and verified +// by decoding full chapters to fluent English across all 16 seeds. +// tables[seed].charAt(code - 0xe000) maps U+E000..U+E0FF to plain letters. +const CIPHER_TABLES: Record = { + 0: 'lrtjttdQRofisoelnohosassbwehJihzaBhoutilafaiobetneZmwGttPoIUaoarenOLrkeertrCeilaaMhlfvetuothXtqneieesswgcKTbsnctepisuicngdaaVrnsltesnmtxwhrigErdoiprhlerfeodeciAcdaenmahgotuNnateeddaklgymottcfrpnehessryalDnrsmpohteamiyounuvnhiSaFiohWahteiaosYnweddnrdeHiyyst', + 1: 'etcasvneCaocwebotclilJheitVsrodtKFMgyfselnmeowkaohtzWeOhruestsmlaiBrtoiZulrhnqnforfoomLiayheesnusinQiipfijhdTaEtgtetntRmoPoGnwieundIiirhydpetdmatstUdroetdtaadYlleerewhrhAedNHacetehnynotsrahhhavDiernihaSubeXercfamrgaxsasoosneksygdeuaipcpbritnastgowlalentons', + 2: 'CtJccIudjfkasgpqaungdttaxgodfiltsndrtQpglseBcotdeEsnncthhenetittFVweYeloinnpDateGlnsnaoanedldhcAnfaweenahyhekiuarpdeoZthtallrKbtfssetevenaubaiehewdwXliossmriumsnywtofiaosLyynrhoNsiiOtnishrrmzHrtmiMoeebuhotleerTayahrsmdhoeaUrWhaPvocgseoitroooeiSitRhiaarreem', + 3: 'AtlKohadlflaeavaeavRyunonnhnussnJroQiinTtetLgarisdsoaoDabrOcoVwqecdiftnoepSseeNtmailaooeisPusmcWsoinFtgMryeXrtaidywemetbdnahhhgUpErhinneeophztitorYsofeoylekwoHkeaaedhomhruwbcfeuntGpreeitliwlrIcetatyeemgnfatinirsdmxdhaajtssnthZdedertsgeitsrBCuhnlthtchoarlsi', + 4: 'ntPtatngHshhatbrmtkpiaroQvylaoeissnfblhmsefraFVstdIiaterwotmetedarnooOheneysahGwplkjmwnetcJsenlsartcnhtUolaigiYsolnoaebatthpdheotnsomAitadaSfiryodidrgneuesLafdXeEnheirxMZropoednenlcdWerfwiemhorhBcretiiauaeoqcngwzRtsCieteTlNuoauuthicsDsuryKsyoeeidvehnhliagt', + 5: 'acarMsatsnraeuprHtyttohluesrsavayhrensidttewehhfoGlFBNnWmuCosLkItiitkeernoeYeoniidrystsmrpgctmbtsannuSaedopeeejVTuchcniealhesimOZeanrigllrctdgnrhbhalimdaPahqthigncrynReodlohossedoettoiyQUnfetxnaiwttJiDlnootfebrevmwlofaarpsdXiteKdiAsgewefEhhedontaoawzaihosu', + 6: 'gneriilienCacloynmnhshafhsnhiatclcabsBmhooLDstghoeneyarpafnlOrnsJtathtemoioericeeSmrthepHdshcomlbieoqhnuywoefigIhicteiavdotmUisirdenpeaaQGefdettaFootnsnoretnvAkKeYrjufilneipsdtryauTdtaubolguozEeRxtekldortaunhehestswaswrZaoadnhtaVgtresielNwerMrXsWydwdstitaP', + 7: 'ofrirridaotDsgsenpldrfdlrthitrelcnsiohugshtkneHsdonwoenSoogBuaiahesenKspyaeorgaRutdhohbeeanFotdiwsOhigicsnpsnhnVheyttlketersrdpesndZoaWtsrhcniaoeweerjtfXvdyUeccfaCzLmnnmuttMaomrittemotuixawheGabvQAattoinysymetaoroYfieIhhrmatiNTulbelqaasthielaweJaldcPinEeel', + 8: 'wletotniraytoaqrnvliolgrkaueLyVthashtthgCtlflnrtdZregtthclscthterusrtdfefibresiacenJhuSepednomotwttyatiHeespanQxuOrmhainehhheGuworkebdovshaodaecmoewfiraeDhndieseThtXUgdrainoIecjoiWpoaefiAnydeybsPessnismeapcnumaeKrEtaYstoiatrinlnooBddoRswsnmasozFigeaNihllnM', + 9: 'ekalraolooyuemnCvtljsnsrybVslnLcYrfMXlatremiaaDdJnpeEhiewdaeGOcgtwmantoaledstslaQhIbngidetrhtusethnrelidoqiaydisatoipnfaehteweohssurthxdesdkiemoeheweaauigFstmiheelcRormffursnaTieoaectoetsnStobortBhhcrfWscieoawhytodZnsttvnoonheainagzHrreinhpitnKNpPtigAryduU', + 10: 'tsEunattoopphingisfavlKsijxcdthitloaedfoZhntBonthemcsuoayilwhRhfwhtnnsdDVtarthrugneOslenymtbteanlrssefaoimeQFnirpbfeshHbrpcarrdirirPiAoaScrsmsandthiaiowGaMvalLoauehaehcetleydgoaeiagaTrogezenqrcemXdedtiYldoeiehishsweWeeokotntynewttJdraNenoutneeCtousmslkrlyU', + 11: 'intheZredADelneGreocfaeptateeeohIeftmrngrewyuekhXdctCgpgnheingsonaicnslhhdweoosztelioablataaSsotHUPsatOsiytlYnMnreiRwcrsmntstrierJeuhdsaxahhfetbLerlrmnauiyqdlyiwobasttowNEiedshhofodserlkrejruyhfKiieaiiimttWdalsovsdpnFvtBmramnaonsdQottogoccnhtpaVinuaaueoheT', + 12: 'hotedemlooWeahJiayvhyolpfVhihfhNnahrymgletnesonhqlrioanmgGdnmAieetteeasuaYalersevededutdwzrHmtpIeutacothrlbtRawnacednelosUgthriesoeiguPcanparnydhwKdjsunCifsoaBXfabaoiowlhcQeLtstnineehsttdcstterahasrrerMknattnioowislOoZbeFEysniosrfdirgtneTitcmSisioterDkaxpu', + 13: 'odMsGtmtKdwdNTagnawctdraohhlabtmnrjoJsnlaezDeidneeiuotkawnotHedhlehoyebsefisncraXpanrnoetiteerlrenlegmerdesxiepebdorwhnBotusZchnsgrfoenduidstichaaqteAmaoWPhleVtnoiaattoQoeuemtrahutsrthifvsssavkengrsnnpleiargspoftFihaOhhlEtSsheltuyCiaitcYaUolyiowRiymLrefyic', + 14: 'etpictwedrniuoJhureoiRPtIsosrTinsuoEbchorleZeexAndadoauisctsnoHnMolmKenwvrehUtetiBgelgphdahataotfnasYiayvamLoiahsWfjeeltggituOltpuenhrreobeicnaafetwktnohefnshhtyipedgnQesneitkmcsdGyqhsesaoroaaoNsacaSeyzsdmaitFhdttfnmnmdhsryrhCliarirrwteeditelDwlonoeaVbXlrt', + 15: 'ooayeicoeaeocThwcoselxmhtitshdeeFsknudiieaLOlEteopiaaYtnhidjneyiebRsftocucmnseoanantaaQwdfhGsnPbogpVeiiesWndrelihrptZduKtSuwagCegarDsemliorihyatraieeodtclIqtogytMsvsrnedshbrtlryJhaieeHfeoAuogtsNedtsiUnwhpltudinawmfoohfleXhrtlvamskaanntrrnnhtrsmnotBrthazeer', +}; + +// Seeds whose cipher font carries no 'I' artwork (I and l share the +// l-shaped glyphs there) need a sentence-start l -> I restoration pass. +function decodeCipheredText(content: string, seed: number): string { + const table = CIPHER_TABLES[seed]; + if (!table) return content; + let out = ''; + for (let i = 0; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code >= 0xe000 && code <= 0xe0ff) { + const plain = table.charAt(code - 0xe000); + out += plain === '?' ? content.charAt(i) : plain; + } else { + out += content.charAt(i); + } + } + // A standalone lowercase l is always the pronoun I. + out = out.replace(/\bl\b/g, 'I'); + if (table.indexOf('I') === -1) { + out = out.replace( + /(^|[.!?…]+["”’\s]*["“‘([]?\s*)(l)(?=[a-z])/g, + function (match, pre, _l, offset, full) { + const before = full.slice(0, offset).replace(/[\s"”’‘([]+$/, ''); + if ( + /(e\.g\.?|i\.e\.?|mr\.?|mrs\.?|ms\.?|dr\.?|st\.?|vs\.?|etc\.?|\d\.?)$/i.test( + before, + ) + ) { + return match; + } + return pre + 'I'; + }, + ); + } + return out; +} + +function toChapterHtml(decoded: string): string { + const blocks = decoded.split(/\r?\n\s*\r?\n/); + const html: string[] = []; + for (const rawBlock of blocks) { + const block = rawBlock.replace(/^\s+|\s+$/g, ''); + if (!block || block === ' ') continue; + if (block.charAt(0) === '<') { + html.push(block); + } else { + html.push('

' + block.replace(/\r?\n/g, '
') + '

'); + } + } + return html.join(''); +} + +type SeriesRow = { + id: number; + title: string; + slug: string; + cover_thumb_url?: string; + cover_image_url?: string; +}; + +type SeriesDetail = SeriesRow & { + synopsis?: string; + short_synopsis?: string; + author_name?: string; + original_author?: string; + translator?: { name?: string }; + story_status?: string; + genres?: string[]; + tags?: string[]; + total_chapters?: number; +}; + +type ChapterRow = { + id: number; + slug: string; + title: string; + chapter_number: number; + published_at?: string; +}; + +type ChapterDetail = { + id: number; + slug: string; + title: string; + content?: string; + foreword?: string; + afterword?: string; + chapter_number: number; + published_at?: string; + cipher?: { seed?: number }; +}; + +class CherryMistCafePlugin implements Plugin.PluginBase { + id = 'cherrymistcafe'; + name = 'Cherry Mist Cafe'; + icon = 'src/en/cherrymistcafe/icon.png'; + site = 'https://cherrymist.cafe'; + version = '1.0.0'; + + private async getJson(url: string): Promise { + const res = await fetchApi(url); + return (await res.json()) as T; + } + + private toNovelItem(row: SeriesRow): Plugin.NovelItem { + return { + name: row.title, + cover: row.cover_thumb_url || row.cover_image_url || defaultCover, + path: 'series/' + row.slug, + }; + } + + private seriesList(url: string): Promise { + return this.getJson<{ rows?: SeriesRow[] } | SeriesRow[]>(url).then( + data => { + const rows: SeriesRow[] = Array.isArray(data) ? data : data.rows || []; + return rows.map(row => this.toNovelItem(row)); + }, + ); + } + + async popularNovels( + pageNo: number, + options?: Plugin.PopularNovelsOptions, + ): Promise { + const sort = options?.showLatestNovels ? 'latest' : 'popular'; + return this.seriesList( + this.site + '/api/series?page=' + pageNo + '&limit=20&sort=' + sort, + ); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const term = (searchTerm || '').trim(); + if (!term) return []; + return this.seriesList( + this.site + + '/api/series?page=' + + pageNo + + '&limit=20&q=' + + encodeURIComponent(term), + ); + } + + async parseNovel(novelPath: string): Promise { + const slug = novelPath.replace(/^series\//, '').replace(/\/$/, ''); + const detail: SeriesDetail = await this.getJson( + this.site + '/api/series/' + slug, + ); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: detail.title, + cover: detail.cover_image_url || detail.cover_thumb_url || defaultCover, + }; + novel.author = + (detail.translator && detail.translator.name) || + detail.author_name || + detail.original_author || + ''; + const genres: string[] = (detail.genres || []).concat(detail.tags || []); + if (genres.length) novel.genres = genres.join(','); + novel.summary = detail.synopsis || detail.short_synopsis || ''; + + const status = (detail.story_status || '').toLowerCase(); + if (status === 'completed') novel.status = NovelStatus.Completed; + else if (status === 'hiatus') novel.status = NovelStatus.OnHiatus; + else novel.status = NovelStatus.Ongoing; + + const chapters: Plugin.ChapterItem[] = []; + const perPage = 500; + let page = 1; + for (;;) { + const rows: ChapterRow[] = await this.getJson( + this.site + + '/api/chapters?series_id=' + + detail.id + + '&limit=' + + perPage + + '&page=' + + page, + ); + if (!rows || !rows.length) break; + for (const row of rows) { + chapters.push({ + name: row.title, + path: 'chapter/' + row.id + '/' + row.slug, + chapterNumber: row.chapter_number, + releaseTime: row.published_at, + }); + } + if (rows.length < perPage) break; + page++; + if (page > 10) break; + } + chapters.sort((a, b) => (a.chapterNumber || 0) - (b.chapterNumber || 0)); + novel.chapters = chapters; + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const segment = (chapterPath.replace(/^chapter\//, '') || '').split('/')[0]; + const id = parseInt(segment, 10); + if (!id) return ''; + const detail: ChapterDetail = await this.getJson( + this.site + '/api/chapters/' + id, + ); + if (!detail) return ''; + const seed = + detail.cipher && typeof detail.cipher.seed === 'number' + ? detail.cipher.seed + : -1; + const parts: string[] = []; + if (detail.foreword) parts.push(decodeCipheredText(detail.foreword, seed)); + parts.push(decodeCipheredText(detail.content || '', seed)); + if (detail.afterword) + parts.push(decodeCipheredText(detail.afterword, seed)); + return toChapterHtml(parts.join('\r\n\r\n')); + } + + resolveUrl = (path: string, isNovel?: boolean) => { + if (isNovel || path.indexOf('series/') === 0) { + return this.site + '/story/' + path.replace(/^series\//, ''); + } + const parts = path.replace(/^chapter\//, '').split('/'); + const slug = parts.length > 1 ? parts.slice(1).join('/') : parts[0]; + return this.site + '/chapter/' + slug; + }; +} + +export default new CherryMistCafePlugin(); diff --git a/plugins/multisrc/fictioneer/custom/cherrymistcafe/chapterTransform.js b/plugins/multisrc/fictioneer/custom/cherrymistcafe/chapterTransform.js deleted file mode 100644 index 0f309828d..000000000 --- a/plugins/multisrc/fictioneer/custom/cherrymistcafe/chapterTransform.js +++ /dev/null @@ -1,46 +0,0 @@ - const ghostScript = loadedCheerio('script[id*=ghost]'); - const contentHost = loadedCheerio('#cherry-content-host'); - - if (ghostScript.length && contentHost.length) { - const poly = ghostScript.attr('data-poly'); - // data-poly attr provide id - // encoded text is stored in attr data-{id}-{number} - // create full string of all the data-poly-nums - const encoded = Array.from( - { length: +ghostScript.attr('data-total') || 0 }, - (_, i) => ghostScript.attr(`data-${poly}-${i}`) || '', - ).join(''); - - // technically copypasta from source - // var c = s.charCodeAt(i); - // if(c>=65 && c<=90) - // o+=String.fromCharCode((c-65+13)%26+65); - // else if(c>=97&&c<=122) - // o+=String.fromCharCode((c-97+13)%26+97); - // else - // o+=s.charAt(i); - if (encoded) { - const rot13 = (str) => { - return str.replace(/[a-zA-Z]/g, (char) => { - const base = char <= 'Z' ? 65 : 97; - const shift = ((char.charCodeAt(0) - base + 13) % 26) + base; - return String.fromCharCode(shift); - }); - }; - contentHost.replaceWith(decodeURIComponent(atob(rot13(encoded)))); - } - } - - loadedCheerio('script, ruby').remove(); - - loadedCheerio('section#chapter-content p [data-fcnc-rev="1"]').each((_, el) => { - const text = loadedCheerio(el).text().trim(); - if (text) loadedCheerio(el).replaceWith([...text].reverse().join('')); - }); - - return ( - loadedCheerio('section#chapter-content > div') - .html() - ?.replace(/\u00A0/g, ' ') - ?.replace(/[\u2060\u00AD\u202F\u2007\u200B]/g, '') || '' - ); diff --git a/plugins/multisrc/fictioneer/sources.json b/plugins/multisrc/fictioneer/sources.json index 06cc73009..7e0cb86b5 100644 --- a/plugins/multisrc/fictioneer/sources.json +++ b/plugins/multisrc/fictioneer/sources.json @@ -53,17 +53,5 @@ "down": true, "downSince": 1768289212907 } - }, - { - "id": "cherrymistcafe", - "sourceSite": "https://cherrymist.cafe/", - "sourceName": "Cherry Mist Cafe", - "options": { - "customJs": { - "chapterTransform": "custom/cherrymistcafe/chapterTransform.js" - }, - "versionIncrements": 1, - "browsePage": "stories" - } } ] diff --git a/public/static/multisrc/fictioneer/cherrymistcafe/icon.png b/public/static/multisrc/fictioneer/cherrymistcafe/icon.png deleted file mode 100644 index bf7eecd3d..000000000 Binary files a/public/static/multisrc/fictioneer/cherrymistcafe/icon.png and /dev/null differ diff --git a/public/static/src/en/cherrymistcafe/icon.png b/public/static/src/en/cherrymistcafe/icon.png new file mode 100644 index 000000000..62ffd60f9 Binary files /dev/null and b/public/static/src/en/cherrymistcafe/icon.png differ