diff --git a/public/static/src/en/ao3/icon.png b/public/static/src/en/ao3/icon.png new file mode 100644 index 0000000..35a2b24 Binary files /dev/null and b/public/static/src/en/ao3/icon.png differ diff --git a/src/plugins/english/ao3.ts b/src/plugins/english/ao3.ts new file mode 100644 index 0000000..04cf909 --- /dev/null +++ b/src/plugins/english/ao3.ts @@ -0,0 +1,491 @@ +import { CheerioAPI, load as parseHTML } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@typings/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { defaultCover } from '@libs/defaultCover'; + +class ArchiveOfOurOwn implements Plugin.PluginBase { + id = 'archiveofourown'; + name = 'Archive Of Our Own'; + version = '1.0.0'; + icon = 'src/en/ao3/icon.png'; + site = 'https://archiveofourown.org/'; + + parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] { + const novels: Plugin.NovelItem[] = []; + + loadedCheerio('li.work').each((idx, ele) => { + const novelName = loadedCheerio(ele) + .find('h4.heading > a') + .first() + .text() + .trim(); + const novelUrl = loadedCheerio(ele) + .find('h4.heading > a') + .first() + .attr('href') + ?.trim(); + + if (!novelUrl) return; + + const novel = { + name: novelName, + cover: defaultCover, // No cover image + path: novelUrl.slice(1), + }; + + novels.push(novel); + }); + + return novels; + } + + async popularNovels( + page: number, + { showLatestNovels, filters }: Plugin.PopularNovelsOptions, + ): Promise { + // Base URL and common parameters + let link = `${this.site}works/search?page=${page}&work_search%5Blanguage_id%5D=en`; + + // Apply sorting based on showLatestNovels + if (showLatestNovels) { + link += `&work_search%5Bsort_column%5D=revised_at&work_search%5Bsort_direction%5D=${filters.sortdir.value}`; + } else if (filters) { + link += `&work_search%5Bsort_column%5D=${filters.sort.value}&work_search%5Bsort_direction%5D=${filters.sortdir.value}`; + } + + // Apply additional filters + if (filters) { + // if (filters.genre.value !== '') link += `&work_search%5Bfandom_names%5D=${filters.genre.value}`; + if (filters.completion.value !== '') + link += `&work_search%5Bcomplete%5D=${filters.completion.value}`; + if (filters.crossover.value !== '') + link += `&work_search%5Bcrossover%5D=${filters.crossover.value}`; + if (filters.categories.value.length > 0) { + filters.categories.value.forEach((category: string) => { + link += `&work_search%5Bcategory_ids%5D%5B%5D=${category}`; + }); + } + if (filters.warningsFilter.value.length > 0) { + filters.warningsFilter.value.forEach((warning: string) => { + link += `&work_search%5Barchive_warning_ids%5D%5B%5D=${warning}`; + }); + } + if (filters.singlechap.value !== '') + link += `&work_search%5Bsingle_chapter%5D=${filters.singlechap.value}`; + if (filters.author.value !== '') + link += `&work_search%5Bcreators%5D=${filters.author.value}`; + if ( + filters.dateFilter.value !== '' && + filters.dateIncrements.value !== '' + ) { + link += `&work_search%5Brevised_at%5D=${filters.dateFilter.value}+${filters.dateIncrements.value}`; + } + if (filters.words.value !== '') + link += `&work_search%5Bword_count%5D=${filters.words.value}`; + if (filters.hits.value !== '') + link += `&work_search%5Bhits%5D=${filters.hits.value}`; + if (filters.bookmarks.value !== '') + link += `&work_search%5Bbookmarks_count%5D=${filters.bookmarks.value}`; + if (filters.comments.value !== '') + link += `&work_search%5Bcomments_count%5D=${filters.comments.value}`; + if (filters.kudos.value !== '') + link += `&work_search%5Bkudos_count%5D=${filters.kudos.value}`; + } + + const body = await fetchApi(link).then(r => r.text()); + const loadedCheerio = parseHTML(body); + return this.parseNovels(loadedCheerio); + } + + async parseNovel(novelUrl: string): Promise { + const result = await fetchApi(new URL(novelUrl, this.site).toString()); + const body = await result.text(); + const loadedCheerio = parseHTML(body); + + const novel: Plugin.SourceNovel = { + path: novelUrl, + name: loadedCheerio('h2.title').text().trim() || 'Untitled', + cover: defaultCover, // No cover image available + status: loadedCheerio('dt.status').text().includes('Updated') + ? 'Ongoing' + : 'Completed', + chapters: [], + }; + + novel.author = loadedCheerio('a[rel="author"]') + .map((i, el) => loadedCheerio(el).text().trim()) + .get() + .join(', '); + novel.genres = Array.from(loadedCheerio('dd.freeform.tags li a.tag')) + .map(el => loadedCheerio(el).text().trim()) + .join(','); + const summary = loadedCheerio('blockquote.userstuff').text().trim(); + const fandom = Array.from(loadedCheerio('dd.fandom.tags li a.tag')) + .map(el => loadedCheerio(el).text().trim()) + .join(','); + const rating = Array.from(loadedCheerio('dd.rating.tags li a.tag')) + .map(el => loadedCheerio(el).text().trim()) + .join(','); + const warning = Array.from(loadedCheerio('dd.warning.tags li a.tag')) + .map(el => loadedCheerio(el).text().trim()) + .join(','); + const series = Array.from(loadedCheerio('dd.series li a.tag')) + .map(el => loadedCheerio(el).text().trim()) + .join(','); + const relation = Array.from(loadedCheerio('dd.relationship.tags li a.tag')) + .map(el => loadedCheerio(el).text().trim()) + .join(','); + const character = Array.from(loadedCheerio('dd.character.tags li a.tag')) + .map(el => loadedCheerio(el).text().trim()) + .join(','); + const stats = Array.from(loadedCheerio('dd.stats li a.tag')) + .map(el => loadedCheerio(el).text().trim()) + .join(','); + novel.summary = `Fandom:\n${fandom}\n\nRating:\n${rating}\n\nWarning:\n${warning}\n\nSummary:\n${summary}\n\nSeries:\n${series}\n\nRelationships:\n${relation}\n\nCharacters:\n${character}\n\nStats:\n${stats}`; + const chapterItems: Plugin.ChapterItem[] = []; + loadedCheerio('#chapter_index select').each((i, selectEl) => { + loadedCheerio(selectEl) + .find('option') + .each((i, el) => { + const chapterName = loadedCheerio(el).text().trim(); + const chapterUrlCode = loadedCheerio(el).attr('value')?.trim(); + const chapterUrl = `${novelUrl}/chapters/${chapterUrlCode}`; + + if (chapterUrl) { + chapterItems.push({ + name: chapterName, + path: new URL(chapterUrl, this.site).toString(), + }); + } + }); + }); + novel.chapters = chapterItems; + + return novel; + } + + async parseChapter(chapterUrl: string): Promise { + const result = await fetchApi(new URL(chapterUrl, this.site).toString()); + const body = await result.text(); + + const loadedCheerio = parseHTML(body); + + loadedCheerio('h3.title').each((i, el) => { + const $h3 = loadedCheerio(el); + const $a = $h3.find('a'); + $a.removeAttr('href'); + const aText = $a.text().trim(); + const nextSiblingText = $h3 + .contents() + .filter((_, node) => node.nodeType === 3) + .text() + .trim(); + $h3.html(`${aText}
${nextSiblingText}`); + }); + loadedCheerio('h3.landmark.heading#work').remove(); + + const chapterText = loadedCheerio('div#chapters > div').html() || ''; + + return chapterText; + } + + async searchNovels( + searchTerm: string, + page: number, + ): Promise { + const searchUrl = `${this.site}works/search?page=${page}&work_search%5Blanguage_id%5D=en&work_search%5Bquery%5D=${searchTerm}`; + + const result = await fetchApi(searchUrl); + const body = await result.text(); + + const loadedCheerio = parseHTML(body); + return this.parseNovels(loadedCheerio); + } + filters = { + sort: { + value: 'hits', + label: 'Sort by', + options: [ + { label: 'Best Match', value: '_score' }, + { label: 'Hits', value: 'hits' }, + { label: 'Kudos', value: 'kudos' }, + { label: 'Comments', value: 'comments' }, + { label: 'Bookmarks', value: 'bookmarks' }, + { label: 'Word Count', value: 'word_count' }, + { label: 'Date Updated', value: 'revised_at' }, + { label: 'Date Posted', value: 'created_at' }, + { label: 'Author', value: 'authors_to_sort_on' }, + { label: 'Title', value: 'title_to_sort_on' }, + ], + type: FilterTypes.Picker, + }, + sortdir: { + value: 'desc', + label: 'Sort direction', + options: [ + { label: 'Descending', value: 'desc' }, + { label: 'Ascending', value: 'asc' }, + ], + type: FilterTypes.Picker, + }, + ratings: { + value: '', + label: 'Ratings', + options: [ + { label: 'Not Rated', value: '9' }, + { label: 'General Audiences', value: '10' }, + { label: 'Teen And Up Audiences', value: '11' }, + { label: 'Mature', value: '12' }, + { label: 'Explicit', value: '13' }, + ], + type: FilterTypes.Picker, + }, + language: { + value: 'en', + label: 'Language', + options: [ + { label: 'None', value: '' }, + { label: 'af Soomaali', value: 'so' }, + { label: 'Afrikaans', value: 'afr' }, + { label: 'Aynu itak | アイヌ イタㇰ', value: 'ain' }, + { label: 'العربية', value: 'ar' }, + { label: 'አማርኛ', value: 'amh' }, + { label: '𓂋𓏺𓈖 𓆎𓅓𓏏𓊖', value: 'egy' }, + { label: 'ܐܪܡܝܐ | ארמיא', value: 'arc' }, + { label: 'հայերեն', value: 'hy' }, + { label: 'American Sign Language', value: 'ase' }, + { label: 'asturianu', value: 'ast' }, + { label: 'Bahasa Indonesia', value: 'id' }, + { label: 'Bahasa Malaysia', value: 'ms' }, + { label: 'Български', value: 'bg' }, + { label: 'বাংলা', value: 'bn' }, + { label: 'Basa Jawa', value: 'jv' }, + { label: 'Башҡорт теле', value: 'ba' }, + { label: 'беларуская', value: 'be' }, + { label: 'Bosanski', value: 'bos' }, + { label: 'Brezhoneg', value: 'br' }, + { label: 'Català', value: 'ca' }, + { label: 'Cebuano', value: 'ceb' }, + { label: 'Čeština', value: 'cs' }, + { label: 'Chinuk Wawa', value: 'chn' }, + { label: 'къырымтатар тили | qırımtatar tili', value: 'crh' }, + { label: 'Cymraeg', value: 'cy' }, + { label: 'Dansk', value: 'da' }, + { label: 'Deutsch', value: 'de' }, + { label: 'eesti keel', value: 'et' }, + { label: 'Ελληνικά', value: 'el' }, + { label: '𒅴𒂠', value: 'sux' }, + { label: 'English', value: 'en' }, + { label: 'Eald Englisċ', value: 'ang' }, + { label: 'Español', value: 'es' }, + { label: 'Esperanto', value: 'eo' }, + { label: 'Euskara', value: 'eu' }, + { label: 'فارسی', value: 'fa' }, + { label: 'Filipino', value: 'fil' }, + { label: 'Français', value: 'fr' }, + { label: 'Friisk', value: 'frr' }, + { label: 'Furlan', value: 'fur' }, + { label: 'Gaeilge', value: 'ga' }, + { label: 'Gàidhlig', value: 'gd' }, + { label: 'Galego', value: 'gl' }, + { label: '𐌲𐌿𐍄𐌹𐍃𐌺𐌰', value: 'got' }, + { label: 'Creolese', value: 'gyn' }, + { label: '中文-客家话', value: 'hak' }, + { label: '한국어', value: 'ko' }, + { label: 'Hausa | هَرْشَن هَوْسَ', value: 'hau' }, + { label: 'हिन्दी', value: 'hi' }, + { label: 'Hrvatski', value: 'hr' }, + { label: 'ʻŌlelo Hawaiʻi', value: 'haw' }, + { label: 'Interlingua', value: 'ia' }, + { label: 'isiZulu', value: 'zu' }, + { label: 'Íslenska', value: 'is' }, + { label: 'Italiano', value: 'it' }, + { label: 'עברית', value: 'he' }, + { label: 'Kalaallisut', value: 'kal' }, + { label: 'ಕನ್ನಡ', value: 'kan' }, + { label: 'ქართული', value: 'kat' }, + { label: 'Kernewek', value: 'cor' }, + { label: 'ភាសាខ្មែរ', value: 'khm' }, + { label: 'Khuzdul', value: 'qkz' }, + { label: 'Kiswahili', value: 'sw' }, + { label: 'kreyòl ayisyen', value: 'ht' }, + { label: 'Kurdî | کوردی', value: 'ku' }, + { label: 'Кыргызча', value: 'kir' }, + { label: 'Langue des signes québécoise', value: 'fcs' }, + { label: 'Latviešu valoda', value: 'lv' }, + { label: 'Lëtzebuergesch', value: 'lb' }, + { label: 'Lietuvių kalba', value: 'lt' }, + { label: 'Lingua latina', value: 'la' }, + { label: 'Magyar', value: 'hu' }, + { label: 'македонски', value: 'mk' }, + { label: 'മലയാളം', value: 'ml' }, + { label: 'Malti', value: 'mt' }, + { label: 'ᠮᠠᠨᠵᡠ ᡤᡳᠰᡠᠨ', value: 'mnc' }, + { label: "Mando'a", value: 'qmd' }, + { label: 'मराठी', value: 'mr' }, + { label: 'Mikisúkî', value: 'mik' }, + { label: 'ᠮᠣᠩᠭᠣᠯ ᠪᠢᠴᠢᠭ᠌ | Монгол Кирилл үсэг', value: 'mon' }, + { label: 'မြန်မာဘာသာ', value: 'my' }, + { label: 'Эрзянь кель', value: 'myv' }, + { label: 'Nāhuatl', value: 'nah' }, + { label: '中文-闽南话 臺語', value: 'nan' }, + { label: 'Nawat', value: 'ppl' }, + { label: 'Nederlands', value: 'nl' }, + { label: '日本語', value: 'ja' }, + { label: 'Norsk', value: 'no' }, + { label: 'Азәрбајҹан дили | آذربایجان دیلی', value: 'azj' }, + { label: 'Нохчийн мотт', value: 'ce' }, + { label: '‘O’odham Ñiok', value: 'ood' }, + { label: 'لسان عثمانى', value: 'ota' }, + { label: 'پښتو', value: 'ps' }, + { label: 'Plattdüütsch', value: 'nds' }, + { label: 'Polski', value: 'pl' }, + { label: 'Português brasileiro', value: 'ptBR' }, + { label: 'Português europeu', value: 'ptPT' }, + { label: 'ਪੰਜਾਬੀ', value: 'pa' }, + { label: 'qazaqşa | қазақша', value: 'kaz' }, + { label: 'Uncategorized Constructed Languages', value: 'qlq' }, + { label: 'Quenya', value: 'qya' }, + { label: 'Română', value: 'ro' }, + { label: 'Русский', value: 'ru' }, + { label: 'Scots', value: 'sco' }, + { label: 'Shqip', value: 'sq' }, + { label: 'Sindarin', value: 'sjn' }, + { label: 'සිංහල', value: 'si' }, + { label: 'Slovenčina', value: 'sk' }, + { label: 'Slovenščina', value: 'slv' }, + { label: 'Sprēkō Þiudiskō', value: 'gem' }, + { label: 'Српски', value: 'sr' }, + { label: 'suomi', value: 'fi' }, + { label: 'Svenska', value: 'sv' }, + { label: 'தமிழ்', value: 'ta' }, + { label: 'татар теле', value: 'tat' }, + { label: 'te reo Māori', value: 'mri' }, + { label: 'తెలుగు', value: 'tel' }, + { label: 'ไทย', value: 'th' }, + { label: 'Thermian', value: 'tqx' }, + { label: 'བོད་སྐད་', value: 'bod' }, + { label: 'Tiếng Việt', value: 'vi' }, + { label: 'ϯⲙⲉⲧⲣⲉⲙⲛ̀ⲭⲏⲙⲓ', value: 'cop' }, + { label: 'tlhIngan-Hol', value: 'tlh' }, + { label: 'toki pona', value: 'tok' }, + { label: 'Trinidadian Creole', value: 'trf' }, + { label: 'τσακώνικα', value: 'tsd' }, + { label: 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ', value: 'chr' }, + { label: 'Türkçe', value: 'tr' }, + { label: 'Українська', value: 'uk' }, + { label: 'اُردُو', value: 'urd' }, + { label: 'ئۇيغۇر تىلى', value: 'uig' }, + { label: 'Volapük', value: 'vol' }, + { label: '中文-吴语', value: 'wuu' }, + { label: 'יידיש', value: 'yi' }, + { label: 'maayaʼ tʼàan', value: 'yua' }, + { label: '中文-广东话 粵語', value: 'yue' }, + { label: '中文-普通话 國語', value: 'zh' }, + ], + type: FilterTypes.Picker, + }, + completion: { + value: '', + label: 'Completion Status', + options: [ + { label: 'All works', value: 'checked' }, + { label: 'Complete works only', value: 'T' }, + { label: 'Works in progress only', value: 'F' }, + ], + type: FilterTypes.Picker, + }, + crossover: { + value: '', + label: 'Crossover Status', + options: [ + { label: 'Include crossovers', value: 'checked' }, + { label: 'Exclude crossovers', value: 'T' }, + { label: 'Only crossovers', value: 'F' }, + ], + type: FilterTypes.Picker, + }, + categories: { + value: [], + label: 'Categories', + options: [ + { label: 'F/F', value: '116' }, + { label: 'F/M', value: '22' }, + { label: 'Gen', value: '21' }, + { label: 'M/M', value: '23' }, + { label: 'Multi', value: '2246' }, + { label: 'Other', value: '24' }, + ], + type: FilterTypes.CheckboxGroup, + }, + warningsFilter: { + value: [], + label: 'Warnings', + options: [ + { label: 'Creator Chose Not To Use Archive Warnings', value: '14' }, + { label: 'Graphic Depictions Of Violence', value: '17' }, + { label: 'Major Character Death', value: '18' }, + { label: 'No Archive Warnings Apply', value: '16' }, + { label: 'Rape/Non-Con', value: '19' }, + { label: 'Underage', value: '20' }, + ], + type: FilterTypes.CheckboxGroup, + }, + singlechap: { + value: '', + label: 'Single Chapter', + options: [{ label: 'Days', value: '1' }], + type: FilterTypes.Picker, + }, + author: { + value: '', + label: 'Author/Artist', + type: FilterTypes.TextInput, + }, + dateFilter: { + value: '', + label: 'Enter single Number only Date', + type: FilterTypes.TextInput, + }, + dateIncrements: { + value: 'days+ago', + label: 'Must choose date type', + options: [ + { label: 'Days', value: 'days+ago' }, + { label: 'Weeks', value: 'weeks+ago' }, + { label: 'Months', value: 'months+ago' }, + { label: 'Years', value: 'years+ago' }, + ], + type: FilterTypes.Picker, + }, + words: { + value: '', + label: + 'Word Count, exact number eg. 40 or less than eg. <40 or greater than eg. >40 or range eg. 10-100', + type: FilterTypes.TextInput, + }, + hits: { + value: '', + label: 'Hits', + type: FilterTypes.TextInput, + }, + bookmarks: { + value: '', + label: 'Bookmarks', + type: FilterTypes.TextInput, + }, + comments: { + value: '', + label: 'Comments', + type: FilterTypes.TextInput, + }, + kudos: { + value: '', + label: 'Kudos', + type: FilterTypes.TextInput, + }, + } satisfies Filters; +} + +export default new ArchiveOfOurOwn();