From cca28492b0373deb98e2941911a5448421449e3f Mon Sep 17 00:00:00 2001
From: Martian Potato <2260cse01024@manarat.ac.bd>
Date: Fri, 24 Oct 2025 15:45:32 +0600
Subject: [PATCH] feat: Add Shanghai Fantasy

closes #420
sign me
---
 plugins/english/shanghaifantasy.ts            | 481 ++++++++++++++++++
 public/static/src/en/shanghaifantasy/icon.png | Bin 0 -> 6848 bytes
 2 files changed, 481 insertions(+)
 create mode 100644 plugins/english/shanghaifantasy.ts
 create mode 100644 public/static/src/en/shanghaifantasy/icon.png

diff --git a/plugins/english/shanghaifantasy.ts b/plugins/english/shanghaifantasy.ts
new file mode 100644
index 000000000..51702511a
--- /dev/null
+++ b/plugins/english/shanghaifantasy.ts
@@ -0,0 +1,481 @@
+import { fetchApi } from '@libs/fetch';
+import { Plugin } from '@/types/plugin';
+import { load as loadCheerio } from 'cheerio';
+import { Filters, FilterTypes } from '@libs/filterInputs';
+import { storage } from '@libs/storage';
+import { defaultCover } from '@libs/defaultCover';
+
+class ShanghaiFantasyPlugin implements Plugin.PluginBase {
+  id = 'shanghaifantasy';
+  name = 'Shanghai Fantasy';
+  icon = 'src/en/shanghaifantasy/icon.png';
+  site = 'https://shanghaifantasy.com';
+  version = '1.0.10';
+  imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
+
+  hideLocked = storage.get('hideLocked');
+  pluginSettings = {
+    hideLocked: {
+      value: '',
+      label: 'Hide locked chapters',
+      type: 'Switch',
+    },
+  };
+
+  //flag indicates whether access to LocalStorage, SesesionStorage is required.
+  webStorageUtilized?: boolean;
+
+  async popularNovels(
+    pageNo: number,
+    {
+      showLatestNovels,
+      filters,
+    }: Plugin.PopularNovelsOptions<typeof this.filters>,
+  ): Promise<Plugin.NovelItem[]> {
+    const genre = filters.genres.value.join('*');
+    const status = filters.status.value !== 'all' ? filters.status.value : '';
+    const res = await fetchApi(
+      `${this.site}/wp-json/fiction/v1/novels/?novelstatus=${status}&page=${pageNo}&term=${genre}&${filters.sort.value}`,
+    ).then(r => r.json());
+
+    return res.map(r => this.parseNovelFromApi(r));
+  }
+
+  async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
+    const html = await fetchApi(`${this.site}/novel/${novelPath}/`, {
+      headers: {
+        'User-Agent': '',
+      },
+    }).then(r => r.text());
+    const loadedCheerio = loadCheerio(html);
+
+    const novel: Plugin.SourceNovel = {
+      path: novelPath,
+      name: stripTitle(
+        loadedCheerio('div.ml-5.p-1.flex.flex-col > p.mb-3.text-lg').text(),
+      ),
+      id: loadedCheerio('#chapterList').data('cat'),
+      summary: loadedCheerio('[x-show*="Synopsis"] p')
+        .map((i, el) => loadCheerio(el).text())
+        .get()
+        .join('\n\n')
+        .trim(),
+    };
+    // novel.artist = '';
+    novel.author = loadedCheerio(
+      'header + div:first > div > div > p:eq(2)',
+    ).contents()[1].data;
+    const coverMatch = loadedCheerio('header + div:first > img').attr('src');
+    novel.cover = coverMatch ? coverMatch : defaultCover;
+    novel.genres = loadedCheerio(
+      'header + div:first > div span:has(> a[href*="genre="])',
+    )
+      .map((i, el) => loadCheerio(el).text().trim())
+      .toArray()
+      .join(',');
+    novel.status = loadedCheerio('header + div:first > div > a > p')
+      .first()
+      .text()
+      .trim();
+
+    let chapters = await this.fetchChapters(novel.id, 1);
+
+    if (this.hideLocked) {
+      chapters = chapters.filter(c => !c.locked);
+    }
+    console.log(chapters);
+
+    novel.chapters = chapters
+      .map(c => ({
+        name:
+          (c.locked ? '🔒 ' : '') +
+          'Chapter ' +
+          stripTitle(c.title.replace(/^.+ Chapter /i, ''), false),
+        path: c.permalink.split('/').at(-2),
+        chapterNumber: parseFloat(
+          c.title.replace(/^.+ ([0-9]+.?[0-9]?)$/i, '$1'),
+        ),
+      }))
+      .sort((a, b) => a.chapterNumber - b.chapterNumber) /**/;
+    return novel;
+  }
+
+  async parseChapter(chapterPath: string): Promise<string> {
+    const page = await fetchApi(this.site + '/' + chapterPath, {
+      headers: {
+        'User-Agent': '',
+      },
+    }).then(r => r.text());
+    const loadedCheerio = loadCheerio(page);
+    loadedCheerio('div.ai-viewports').remove();
+    return loadedCheerio('div.contenta').html() || '';
+  }
+
+  async searchNovels(
+    searchTerm: string,
+    pageNo: number,
+  ): Promise<Plugin.NovelItem[]> {
+    return await fetchApi(
+      `${this.site}/wp-json/fiction/v1/novels/?novelstatus=&term=&page=${pageNo}&orderby=&order=&query=${encodeURIComponent(searchTerm)}`,
+    )
+      .then(r => r.json())
+      .then(r => r.map(novel => this.parseNovelFromApi(novel)));
+  }
+
+  parseNovelFromApi(apiData) {
+    return {
+      name: stripTitle(apiData.title),
+      path: apiData.permalink.split('/').at(-2),
+      cover: apiData.novelImage,
+      summary: apiData.novelIntro,
+      status: apiData.novelStat,
+      genres: apiData.novelGenres.join(','),
+    };
+  }
+  async fetchChapters(id, pageNo) {
+    let res = await fetchApi(
+      `${this.site}/wp-json/fiction/v1/chapters/?category=${id}&page=${pageNo}&order=asc`,
+    );
+    let pages = parseInt(res.headers.get('x-wp-totalpages'), 10);
+    let chs = await res.json();
+    return pageNo == pages
+      ? chs
+      : Array.prototype.concat(chs, await this.fetchChapters(id, pageNo + 1));
+  }
+
+  resolveUrl = (path: string, isNovel?: boolean) =>
+    this.site + '/series/' + path;
+
+  filters = {
+    status: {
+      type: FilterTypes.Picker,
+      label: 'Status',
+      value: 'all',
+      options: [
+        { label: 'All', value: 'all' },
+        { label: 'Ongoing', value: 'Ongoing' },
+        { label: 'Completed', value: 'Completed' },
+        { label: 'Dropped', value: 'Dropped' },
+        { label: 'Hiatus', value: 'Hiatus' },
+      ],
+    },
+
+    sort: {
+      type: FilterTypes.Picker,
+      label: 'Sort',
+      value: 'orderby=date&order=desc',
+      options: [
+        { label: 'Recent', value: 'orderby=date&order=desc' },
+        { label: 'Older', value: 'orderby=date&order=asc' },
+        { label: 'Title', value: 'orderby=title&order=asc' },
+        { label: 'Title (Reversed)', value: 'orderby=title&order=desc' },
+      ],
+    },
+    genres: {
+      type: FilterTypes.CheckboxGroup,
+      label: 'Genres',
+      value: [],
+      options: [
+        { 'label': '1960s', 'value': '1960s' },
+        { 'label': '1970s', 'value': '1970s' },
+        { 'label': '1980s', 'value': '1980s' },
+        { 'label': '1990s', 'value': '1990s' },
+        { 'label': '80s Setting', 'value': '80s Setting' },
+        { 'label': 'Abandoned', 'value': 'Abandoned' },
+        { 'label': 'ABO', 'value': 'ABO' },
+        { 'label': 'Action', 'value': 'Action' },
+        { 'label': 'Adopted Children', 'value': 'Adopted Children' },
+        { 'label': 'Adorable Baby', 'value': 'Adorable Baby' },
+        { 'label': 'Adult', 'value': 'Adult' },
+        { 'label': 'Adventure', 'value': 'Adventure' },
+        {
+          'label': 'Affectionate Relationship',
+          'value': 'Affectionate Relationship',
+        },
+        { 'label': 'Age Gap', 'value': 'Age Gap' },
+        { 'label': 'Alternate Universe', 'value': 'Alternate Universe' },
+        { 'label': 'Alternate World', 'value': 'Alternate World' },
+        { 'label': 'Amnesia', 'value': 'Amnesia' },
+        { 'label': 'Ancient China', 'value': 'Ancient China' },
+        { 'label': 'Ancient Farming', 'value': 'Ancient Farming' },
+        { 'label': 'Ancient Romance', 'value': 'Ancient Romance' },
+        { 'label': 'ancient style', 'value': 'ancient style' },
+        { 'label': 'Ancient Times', 'value': 'Ancient Times' },
+        { 'label': 'Angst', 'value': 'Angst' },
+        { 'label': 'Apocalypse', 'value': 'Apocalypse' },
+        { 'label': 'Arranged Marriage', 'value': 'Arranged Marriage' },
+        { 'label': 'Beastmen', 'value': 'Beastmen' },
+        { 'label': 'Beautiful Female Lead', 'value': 'Beautiful Female Lead' },
+        { 'label': 'Beautiful protagonist', 'value': 'Beautiful protagonist' },
+        { 'label': 'BG', 'value': 'BG' },
+        { 'label': 'Bickering Couple', 'value': 'Bickering Couple' },
+        { 'label': 'BL', 'value': 'BL' },
+        { 'label': 'Book Transmigration', 'value': 'Book Transmigration' },
+        { 'label': 'Boys Love', 'value': 'Boys Love' },
+        { 'label': 'Business', 'value': 'Business' },
+        { 'label': 'Business management', 'value': 'Business management' },
+        { 'label': 'Bussiness', 'value': 'Bussiness' },
+        { 'label': 'celebrity', 'value': 'celebrity' },
+        { 'label': 'CEO', 'value': 'CEO' },
+        { 'label': 'Character Growth', 'value': 'Character Growth' },
+        { 'label': 'Charismatic MC', 'value': 'Charismatic MC' },
+        { 'label': 'Charming Protagonist', 'value': 'Charming Protagonist' },
+        { 'label': 'Childcare', 'value': 'Childcare' },
+        { 'label': 'Childhood Love', 'value': 'Childhood Love' },
+        { 'label': 'Comedy', 'value': 'Comedy' },
+        { 'label': 'Contemporary', 'value': 'Contemporary' },
+        { 'label': 'Cooking', 'value': 'Cooking' },
+        { 'label': 'Countryside', 'value': 'Countryside' },
+        { 'label': 'Crime', 'value': 'Crime' },
+        { 'label': 'Crime Investigation', 'value': 'Crime Investigation' },
+        { 'label': 'Crossing', 'value': 'Crossing' },
+        { 'label': 'Cultivation', 'value': 'Cultivation' },
+        { 'label': 'cute baby', 'value': 'cute baby' },
+        { 'label': 'Cute Child', 'value': 'Cute Child' },
+        { 'label': 'Cute Children', 'value': 'Cute Children' },
+        { 'label': 'Cute Protagonist', 'value': 'Cute Protagonist' },
+        { 'label': 'Dimensional Space', 'value': 'Dimensional Space' },
+        { 'label': 'Doting Husband', 'value': 'Doting Husband' },
+        { 'label': 'Doting Love Interest', 'value': 'Doting Love Interest' },
+        { 'label': 'Double Purity', 'value': 'Double Purity' },
+        { 'label': 'Drama', 'value': 'Drama' },
+        { 'label': 'dual male lead', 'value': 'dual male lead' },
+        { 'label': 'Ecchi', 'value': 'Ecchi' },
+        { 'label': 'Educated Youth', 'value': 'Educated Youth' },
+        { 'label': 'Entertainment', 'value': 'Entertainment' },
+        {
+          'label': 'Entertainment Industry',
+          'value': 'Entertainment Industry',
+        },
+        { 'label': 'Era', 'value': 'Era' },
+        { 'label': 'Era Farming', 'value': 'Era Farming' },
+        { 'label': 'Era novel', 'value': 'Era novel' },
+        { 'label': 'Era Romance', 'value': 'Era Romance' },
+        { 'label': 'Face-Slapping', 'value': 'Face-Slapping' },
+        { 'label': 'Familial Love', 'value': 'Familial Love' },
+        { 'label': 'Family', 'value': 'Family' },
+        { 'label': 'Family Bonds', 'value': 'Family Bonds' },
+        { 'label': 'Family conflict', 'value': 'Family conflict' },
+        { 'label': 'Family Doting', 'value': 'Family Doting' },
+        { 'label': 'Family Drama', 'value': 'Family Drama' },
+        { 'label': 'Family Life', 'value': 'Family Life' },
+        { 'label': 'Fanfiction', 'value': 'Fanfiction' },
+        { 'label': 'Fantasy', 'value': 'Fantasy' },
+        { 'label': 'Fantasy Romance', 'value': 'Fantasy Romance' },
+        { 'label': 'Farming', 'value': 'Farming' },
+        { 'label': 'Farming life', 'value': 'Farming life' },
+        { 'label': 'Fate and Destiny', 'value': 'Fate and Destiny' },
+        { 'label': 'Female Lead', 'value': 'Female Lead' },
+        { 'label': 'Female Protagonist', 'value': 'Female Protagonist' },
+        { 'label': 'Flash Marriage', 'value': 'Flash Marriage' },
+        { 'label': 'Free Love', 'value': 'Free Love' },
+        { 'label': 'FREE NOVEL‼️', 'value': 'FREE NOVEL‼️' },
+        { 'label': 'Futuristic', 'value': 'Futuristic' },
+        { 'label': 'Game', 'value': 'Game' },
+        { 'label': 'Game World', 'value': 'Game World' },
+        { 'label': 'Gay', 'value': 'Gay' },
+        { 'label': 'Gay Romance', 'value': 'Gay Romance' },
+        { 'label': 'Gender Bender', 'value': 'Gender Bender' },
+        { 'label': 'Getting Rich', 'value': 'Getting Rich' },
+        { 'label': 'Ghost', 'value': 'Ghost' },
+        { 'label': 'Gourmet Food', 'value': 'Gourmet Food' },
+        { 'label': 'handsome male lead', 'value': 'handsome male lead' },
+        { 'label': 'Happy Ending', 'value': 'Happy Ending' },
+        { 'label': 'Harem', 'value': 'Harem' },
+        { 'label': 'HE (Happy Ending)', 'value': 'HE (Happy Ending)' },
+        { 'label': 'Heartwarming', 'value': 'Heartwarming' },
+        { 'label': 'Historical', 'value': 'Historical' },
+        { 'label': 'Historical BL', 'value': 'Historical BL' },
+        { 'label': 'Historical Fiction', 'value': 'Historical Fiction' },
+        { 'label': 'Historical Romance', 'value': 'Historical Romance' },
+        { 'label': 'Horror', 'value': 'Horror' },
+        { 'label': 'Humor', 'value': 'Humor' },
+        { 'label': 'Interstellar', 'value': 'Interstellar' },
+        { 'label': 'Isekai', 'value': 'Isekai' },
+        { 'label': 'Josei', 'value': 'Josei' },
+        { 'label': 'LGBT+', 'value': 'LGBT+' },
+        { 'label': 'Light-hearted', 'value': 'Light-hearted' },
+        { 'label': 'Lighthearted', 'value': 'Lighthearted' },
+        { 'label': 'Livestream', 'value': 'Livestream' },
+        { 'label': 'livestreaming', 'value': 'livestreaming' },
+        { 'label': 'love', 'value': 'love' },
+        { 'label': 'Love After Marriage', 'value': 'Love After Marriage' },
+        { 'label': 'love at first sight', 'value': 'love at first sight' },
+        { 'label': 'Love Confession', 'value': 'Love Confession' },
+        { 'label': 'loyal male lead', 'value': 'loyal male lead' },
+        { 'label': 'Lucky Protagonist', 'value': 'Lucky Protagonist' },
+        { 'label': 'Magical Realism', 'value': 'Magical Realism' },
+        { 'label': 'magical space', 'value': 'magical space' },
+        { 'label': 'Male Protagonist', 'value': 'Male Protagonist' },
+        { 'label': 'Marriage', 'value': 'Marriage' },
+        { 'label': 'Marriage Before Love', 'value': 'Marriage Before Love' },
+        { 'label': 'Martial Arts', 'value': 'Martial Arts' },
+        { 'label': 'Mature', 'value': 'Mature' },
+        { 'label': 'Mecha', 'value': 'Mecha' },
+        { 'label': 'medical skills', 'value': 'medical skills' },
+        { 'label': 'Metaphysics', 'value': 'Metaphysics' },
+        { 'label': 'Military', 'value': 'Military' },
+        { 'label': 'Military Husband', 'value': 'Military Husband' },
+        { 'label': 'Military Marriage', 'value': 'Military Marriage' },
+        { 'label': 'Military Romance', 'value': 'Military Romance' },
+        { 'label': 'Military Wife', 'value': 'Military Wife' },
+        { 'label': 'mind reading', 'value': 'mind reading' },
+        { 'label': 'Modern', 'value': 'Modern' },
+        { 'label': 'Modern Day', 'value': 'Modern Day' },
+        { 'label': 'Modern Romance', 'value': 'Modern Romance' },
+        { 'label': 'Modern/Contemporary', 'value': 'Modern/Contemporary' },
+        { 'label': 'Mpreg', 'value': 'Mpreg' },
+        { 'label': 'Mutated beast', 'value': 'Mutated beast' },
+        { 'label': 'Mystery', 'value': 'Mystery' },
+        { 'label': 'Mythical Beasts', 'value': 'Mythical Beasts' },
+        { 'label': 'Obsessive love', 'value': 'Obsessive love' },
+        { 'label': 'Older Love Interests', 'value': 'Older Love Interests' },
+        { 'label': 'omegaverse', 'value': 'omegaverse' },
+        { 'label': 'palace fighting', 'value': 'palace fighting' },
+        { 'label': 'Pampering Wife', 'value': 'Pampering Wife' },
+        { 'label': 'Period Novel', 'value': 'Period Novel' },
+        { 'label': 'Poor Protagonist', 'value': 'Poor Protagonist' },
+        { 'label': 'Poor to rich', 'value': 'Poor to rich' },
+        { 'label': 'Power Couple', 'value': 'Power Couple' },
+        { 'label': 'Power Fantasy', 'value': 'Power Fantasy' },
+        { 'label': 'Powers', 'value': 'Powers' },
+        { 'label': 'pregnancy', 'value': 'pregnancy' },
+        { 'label': 'Psychological', 'value': 'Psychological' },
+        { 'label': 'Pursuit of love', 'value': 'Pursuit of love' },
+        { 'label': 'Quick transmigration', 'value': 'Quick transmigration' },
+        { 'label': 'raising a baby', 'value': 'raising a baby' },
+        { 'label': 'Rebirth', 'value': 'Rebirth' },
+        { 'label': 'Reborn', 'value': 'Reborn' },
+        { 'label': 'Redemption', 'value': 'Redemption' },
+        { 'label': 'reincarnation', 'value': 'reincarnation' },
+        { 'label': 'Revenge', 'value': 'Revenge' },
+        { 'label': 'Rich CEO', 'value': 'Rich CEO' },
+        { 'label': 'Rich Family', 'value': 'Rich Family' },
+        { 'label': 'Romance', 'value': 'Romance' },
+        { 'label': 'Romantic Comedy', 'value': 'Romantic Comedy' },
+        { 'label': 'Royal Family', 'value': 'Royal Family' },
+        { 'label': 'Royalty', 'value': 'Royalty' },
+        { 'label': 'Rural', 'value': 'Rural' },
+        { 'label': 'Rural life', 'value': 'Rural life' },
+        { 'label': 'SameSexMarriage', 'value': 'SameSexMarriage' },
+        {
+          'label': 'Schemes and Conspiracies',
+          'value': 'Schemes and Conspiracies',
+        },
+        { 'label': 'Scheming Female Lead', 'value': 'Scheming Female Lead' },
+        { 'label': 'School Life', 'value': 'School Life' },
+        { 'label': 'Sci-fi', 'value': 'Sci-fi' },
+        { 'label': 'Second Chance', 'value': 'Second Chance' },
+        { 'label': 'Secret Crush', 'value': 'Secret Crush' },
+        { 'label': 'Secret Identity', 'value': 'Secret Identity' },
+        { 'label': 'seinen', 'value': 'seinen' },
+        { 'label': 'Short Story', 'value': 'Short Story' },
+        { 'label': 'Shoujo', 'value': 'Shoujo' },
+        { 'label': 'Shoujo Ai', 'value': 'Shoujo Ai' },
+        { 'label': 'Shounen', 'value': 'Shounen' },
+        { 'label': 'Shounen Ai', 'value': 'Shounen Ai' },
+        { 'label': 'Showbiz', 'value': 'Showbiz' },
+        { 'label': 'Slice of Life', 'value': 'Slice of Life' },
+        { 'label': 'Slow Burn', 'value': 'Slow Burn' },
+        { 'label': 'slow romance', 'value': 'slow romance' },
+        { 'label': 'Slow-burn Romance', 'value': 'Slow-burn Romance' },
+        { 'label': 'Smut', 'value': 'Smut' },
+        { 'label': 'Space', 'value': 'Space' },
+        { 'label': 'Space Ability', 'value': 'Space Ability' },
+        { 'label': 'special abilities', 'value': 'special abilities' },
+        { 'label': 'Sports', 'value': 'Sports' },
+        { 'label': 'Stand-in Lover', 'value': 'Stand-in Lover' },
+        { 'label': 'Strong Female Lead', 'value': 'Strong Female Lead' },
+        { 'label': 'Strong Love Interest', 'value': 'Strong Love Interest' },
+        { 'label': 'Supernatural', 'value': 'Supernatural' },
+        { 'label': 'supporting characters', 'value': 'supporting characters' },
+        {
+          'label': 'Supporting Female Character',
+          'value': 'Supporting Female Character',
+        },
+        { 'label': 'Survival', 'value': 'Survival' },
+        { 'label': 'Suspense', 'value': 'Suspense' },
+        { 'label': 'Sweet', 'value': 'Sweet' },
+        { 'label': 'Sweet Doting', 'value': 'Sweet Doting' },
+        { 'label': 'Sweet Love', 'value': 'Sweet Love' },
+        { 'label': 'Sweet Marriage', 'value': 'Sweet Marriage' },
+        { 'label': 'Sweet Pampering', 'value': 'Sweet Pampering' },
+        { 'label': 'sweet pet', 'value': 'sweet pet' },
+        { 'label': 'Sweet Revenge', 'value': 'Sweet Revenge' },
+        { 'label': 'Sweet Romance', 'value': 'Sweet Romance' },
+        { 'label': 'Sweet Story', 'value': 'Sweet Story' },
+        { 'label': 'SweetNovel', 'value': 'SweetNovel' },
+        { 'label': 'system', 'value': 'system' },
+        { 'label': 'Thriller', 'value': 'Thriller' },
+        { 'label': 'Time Travel', 'value': 'Time Travel' },
+        { 'label': 'Tragedy', 'value': 'Tragedy' },
+        { 'label': 'Transformation', 'value': 'Transformation' },
+        { 'label': 'Transmigration', 'value': 'Transmigration' },
+        {
+          'label': 'transmigration into a novel',
+          'value': 'transmigration into a novel',
+        },
+        {
+          'label': 'Transmigration into Books',
+          'value': 'Transmigration into Books',
+        },
+        {
+          'label': 'Traveling through space',
+          'value': 'Traveling through space',
+        },
+        {
+          'label': 'Traveling through time',
+          'value': 'Traveling through time',
+        },
+        { 'label': 'Ugly', 'value': 'Ugly' },
+        { 'label': 'Unlimited Flow', 'value': 'Unlimited Flow' },
+        { 'label': 'Urban', 'value': 'Urban' },
+        { 'label': 'urban life', 'value': 'urban life' },
+        { 'label': 'Village Life', 'value': 'Village Life' },
+        { 'label': 'Villain', 'value': 'Villain' },
+        { 'label': 'Weak to Strong', 'value': 'Weak to Strong' },
+        { 'label': 'wealthy characters', 'value': 'wealthy characters' },
+        { 'label': 'Wealthy Family', 'value': 'Wealthy Family' },
+        { 'label': 'Wuxia', 'value': 'Wuxia' },
+        { 'label': 'Xianxia', 'value': 'Xianxia' },
+        { 'label': 'Xuanhuan', 'value': 'Xuanhuan' },
+        { 'label': 'yandere', 'value': 'yandere' },
+        { 'label': 'Yaoi', 'value': 'Yaoi' },
+        { 'label': 'Younger Love Interest', 'value': 'Younger Love Interest' },
+        { 'label': 'Yuri', 'value': 'Yuri' },
+        { 'label': 'Zombie', 'value': 'Zombie' },
+        {
+          'label': '🔓 Completely Unlocked 🔓',
+          'value': '🔓 Completely Unlocked 🔓',
+        },
+      ],
+    },
+  } satisfies Filters;
+}
+
+export default new ShanghaiFantasyPlugin();
+
+function stripTitle(title, trim = true) {
+  title = title.replace(
+    /&#(\d+);|&#x([0-9a-fA-F]+);|&[a-zA-Z0-9]+;/g,
+    (m, d, h) => {
+      if (d !== undefined) return String.fromCharCode(parseInt(d, 10));
+      if (h !== undefined) return String.fromCharCode(parseInt(h, 16));
+      return m;
+    },
+  );
+  if (trim) title = title.replace(/^[“”‘.]|[“”‘.]{0,2}$/g, '');
+  return title;
+}
+//paste into console on site to load
+async function getUpdatedGenres(pageNo = 1) {
+  const res = await fetch(
+    `https://shanghaifantasy.com/wp-json/wp/v2/genre?page=${pageNo}&per_page=100`,
+  );
+  let data = await res.json();
+  const pages = parseInt(res.headers.get('x-wp-totalpages'), 10);
+  if (pageNo < pages)
+    data = Array.prototype.concat(data, await getUpdatedGenres(pageNo + 1));
+  const genreData = data.map(g => ({ label: g.name, value: g.id.toString() }));
+  console.log(JSON.stringify(genreData));
+}
diff --git a/public/static/src/en/shanghaifantasy/icon.png b/public/static/src/en/shanghaifantasy/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..2bb9ab875959d24dbf116ae4c324a730819cd424
GIT binary patch
literal 6848
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
O8A7(-VOMDzbnxHisy6}v

literal 0
HcmV?d00001

