Files
lnreader-plugins/plugins/english/ao3.ts
T
Rajarshee Chatterjee 20c2b75b01 refactor: Overhaul Plugin Architecture & Playground UI (#1846)
* refactor: Move Plugins Out and Consolidate with Multisrc

* feat: Overhaul Playground UI

* feat: Add Filters to New UI

* feat: Persist Selected Plugin

* feat: Add Direct Parse Novel/Chapter Options and Feedback Toasts

[skip ci]

* feat: UX Improvements

* chore: Update Plugin Path in Publish Workflow

* fix: Add Backward Compatibility

* chore: Update Plugin Publishing Script
2025-10-20 22:54:08 +05:30

580 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/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.3';
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<Filters>,
): Promise<Plugin.NovelItem[]> {
// 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<Plugin.SourceNovel> {
const result = await fetchApi(new URL(novelUrl, this.site).toString());
const urlchapter = novelUrl + '/navigate';
const chapters = await fetchApi(new URL(urlchapter, this.site).toString());
const body = await result.text();
const chapterlisttext = await chapters.text();
const chapterlistload = parseHTML(chapterlisttext);
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[] = [];
const longReleaseDate: string[] = [];
let match: RegExpExecArray | null;
chapterlistload('ol.index').each((i, ele) => {
chapterlistload(ele)
.find('li')
.each((i, el) => {
const chapterNameMatch = chapterlistload(el).find('a').text().trim();
const releaseTimeText = chapterlistload(el)
.find('span.datetime')
.text()
.replace(/\(([^)]+)\)/g, '$1')
.trim();
const releaseTime = releaseTimeText
? new Date(releaseTimeText).toISOString()
: '';
longReleaseDate.push(releaseTime);
});
});
const releaseTimeText = loadedCheerio('.wrapper dd.published')
.text()
.trim();
const releaseTime = releaseTimeText
? new Date(releaseTimeText).toISOString()
: '';
let dateCounter = 0;
if (loadedCheerio('#chapter_index select').length > 0) {
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}`;
const releaseDate: string = longReleaseDate[dateCounter];
dateCounter++;
if (chapterUrl) {
chapterItems.push({
name: chapterName,
path: chapterUrl,
releaseTime: releaseDate,
});
}
});
});
}
if (chapterItems.length === 0) {
loadedCheerio('#chapters h3.title').each((i, titleEl) => {
const fullTitleText = loadedCheerio(titleEl).text().trim();
const chapterNameMatch = fullTitleText.match(/:\s*(.*)$/);
let chapterName = chapterNameMatch ? chapterNameMatch[1].trim() : '';
const chapterUrlRaw = loadedCheerio(titleEl)
.find('a')
.attr('href')
?.trim();
const chapterUrlCode = chapterUrlRaw?.match(/\/chapters\/(\d+)/)?.[1];
const chapterUrl = `${novelUrl}/chapters/${chapterUrlCode}`;
if (chapterUrl) {
if (chapterName === '') {
const novelTitle = loadedCheerio('.work .title.heading')
.text()
.trim();
chapterName = novelTitle;
}
chapterItems.push({
name: chapterName,
path: chapterUrl,
releaseTime: releaseTime,
});
}
});
if (chapterItems.length === 0) {
loadedCheerio('.work.navigation.actions li a').each((i, el) => {
const href = loadedCheerio(el).attr('href');
if (href && href.includes('/downloads/')) {
const chapterUrlCodeMatch = href.match(/updated_at=(\d+)/);
const chapterUrlCode = chapterUrlCodeMatch
? chapterUrlCodeMatch[1]
: null;
let chapterName = loadedCheerio('h2.title.heading').text().trim();
const chapterUrl = `${novelUrl}/chapters/${chapterUrlCode}`;
if (chapterUrl) {
if (chapterName === '') {
const novelTitle = loadedCheerio('.work .title.heading')
.text()
.trim();
chapterName = novelTitle;
}
chapterItems.push({
name: chapterName,
path: chapterUrl,
releaseTime: releaseTime,
});
}
}
});
}
}
novel.chapters = chapterItems;
return novel;
}
async parseChapter(chapterUrl: string): Promise<string> {
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}<br>${nextSiblingText}`);
});
loadedCheerio('h3.landmark.heading#work').remove();
const chapterText = loadedCheerio('div#chapters > div').html() || '';
return chapterText;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const searchUrl = `${this.site}works/search?page=${page}&work_search%5Blanguage_id%5D=en&work_search%5Bquery%5D=${encodeURIComponent(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 Stories',
options: [{ label: 'Single Chapter', 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();