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
This commit is contained in:
Rajarshee Chatterjee
2025-10-20 22:54:08 +05:30
committed by GitHub
parent 252beff0bf
commit 20c2b75b01
363 changed files with 6848 additions and 3007 deletions
-46
View File
@@ -1,46 +0,0 @@
#root {
margin: 0 auto;
text-align: center;
width: 100vw;
padding-bottom: 40px;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
button:focus {
outline: none !important;
}
+9 -46
View File
@@ -1,53 +1,16 @@
import React from 'react';
import './App.css';
import {
Container,
createTheme,
CssBaseline,
StyledEngineProvider,
ThemeProvider,
} from '@mui/material';
import Appbar from '@components/Appbar';
import AppProvider from './provider';
import PopularNovels from '@components/accordions/PopularNovels';
import SearchNovels from '@components/accordions/SearchNovels';
import ParseNovel from '@components/accordions/ParseNovel';
import ParseChapter from '@components/accordions/ParseChapter';
import HeadersSection from '@components/accordions/Headers';
import { Toaster } from '@/components/ui/sonner';
import Home from './pages/home';
import { TooltipProvider } from './components/ui/tooltip';
function App() {
const theme = createTheme({
components: {
// Name of the component
MuiButtonBase: {
defaultProps: {
// The props to change the default for.
disableRipple: true, // No more ripple, on the whole application 💣!
},
},
},
});
return (
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<CssBaseline />
<Appbar />
<Container
sx={{
mt: 6,
width: '90%',
}}
>
<HeadersSection />
<PopularNovels />
<SearchNovels />
<ParseNovel />
<ParseChapter />
</Container>
<AppProvider />
</ThemeProvider>
</StyledEngineProvider>
<div className="min-h-screen bg-background">
<TooltipProvider>
<Toaster position="bottom-right" />
<Home />
</TooltipProvider>
</div>
);
}
-161
View File
@@ -1,161 +0,0 @@
import React, { useEffect, useState } from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import {
Avatar,
Backdrop,
FormControl,
InputAdornment,
Link,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemText,
TextField,
Typography,
} from '@mui/material';
import { Search } from '@mui/icons-material';
import { searchPlugins } from '@provider/plugins';
import { Plugin } from '@typings/plugin';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useAppStore } from '@store';
const resovleIcon = (iconPath: string) => {
return '/static/' + iconPath;
};
function renderPlugin(setListVisible: (v: boolean) => void) {
return function (props: ListChildComponentProps<Plugin.PluginBase[]>) {
const { index, style, data } = props;
const plugin = data[index];
const { selectPlugin, pluginItem } = useAppStore(state => state);
return (
<ListItem style={style} key={index} component="div" disablePadding>
<ListItemButton
selected={plugin.id === pluginItem?.id}
onClick={() => {
selectPlugin({
id: plugin.id,
name: plugin.name,
version: plugin.version,
icon: plugin.icon,
site: plugin.site,
});
setListVisible(false);
}}
>
<ListItemAvatar>
<Avatar variant="square" src={resovleIcon(plugin.icon)} />
</ListItemAvatar>
<ListItemText primary={plugin.name} />
</ListItemButton>
</ListItem>
);
};
}
export default function Appbar() {
const [searchedPlugins, setSearchPlugins] = useState<Plugin.PluginBase[]>([]);
const [keyword, setKeyword] = useState('');
const [listVisible, setListVisible] = useState(false);
const plugin = useAppStore(state => state.plugin);
useEffect(() => {
const plugins = searchPlugins(keyword);
setSearchPlugins(plugins);
}, [keyword]);
return (
<AppBar
sx={{ width: '100vw' }}
position="sticky"
color="inherit"
elevation={0}
>
<Toolbar>
<FormControl
sx={{
display: 'block',
position: 'relative',
zIndex: 10,
width: {
xs: '80%',
md: '40%',
},
}}
>
<TextField
size="small"
variant="outlined"
placeholder="Seach plugin"
value={keyword}
onChange={e => setKeyword(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
fullWidth
autoComplete="off"
onFocus={() => {
setListVisible(true);
if (searchedPlugins.length === 0)
setSearchPlugins(searchPlugins(keyword));
}}
/>
{listVisible ? (
<FixedSizeList
height={400}
width={'100%'}
itemData={searchedPlugins}
itemSize={46}
itemCount={searchedPlugins.length}
overscanCount={5}
style={{
position: 'absolute',
boxShadow: '2px 10px 20px rgba(0, 0, 0, 0.2)',
backgroundColor: 'white',
}}
>
{renderPlugin(setListVisible)}
</FixedSizeList>
) : null}
</FormControl>
<Backdrop
open={listVisible}
onClick={() => setListVisible(false)}
sx={{ zIndex: 9 }}
invisible={true}
/>
{plugin ? (
<Link
sx={{
pl: 2,
display: 'flex',
alignItems: 'center',
textDecoration: 'none',
}}
href={plugin.site}
target="_blank"
>
<Avatar
sx={{ mr: 1 }}
src={resovleIcon(plugin.icon)}
variant="square"
/>
<Typography
sx={{ display: { xs: 'none', sm: 'none', md: 'block' } }}
>
{plugin.name} - v{plugin.version}
</Typography>
</Link>
) : null}
</Toolbar>
</AppBar>
);
}
-42
View File
@@ -1,42 +0,0 @@
import React, { ReactNode, useEffect, useState } from 'react';
import Alert from '@mui/material/Alert';
export default function CopiableItem({
content,
children,
}: {
content?: string;
children: ReactNode;
}) {
const [alertVisible, setAlertVisble] = useState(false);
useEffect(() => {
if (alertVisible) {
setTimeout(() => setAlertVisble(false), 1000);
}
}, [alertVisible]);
return (
<div
onClick={() => {
if (content) {
navigator.clipboard.writeText(content).then(() => {
setAlertVisble(true);
});
}
}}
>
{alertVisible ? (
<Alert
sx={{
position: 'absolute',
transform: 'translate(100%, -100%)',
zIndex: theme => theme.zIndex.tooltip + 1,
}}
severity="success"
>
Copied
</Alert>
) : null}
{children}
</div>
);
}
-83
View File
@@ -1,83 +0,0 @@
import { Box, Button, Tooltip } from '@mui/material';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import React from 'react';
import CopiableItem from './CopiableItem';
type Validation = {
type?: 'error' | 'warning';
message?: string;
};
const validatePath = (path: string): Validation => {
if (path.includes('http'))
return {
type: 'error',
message: 'Path must be relative, please remove the domain',
};
if (/^\/|\/$/.test(path)) {
return {
type: 'warning',
message: 'your path should not contain leading or trailing "/"',
};
}
return {};
};
export default function PathItem({ path }: { path: string }) {
const validation = validatePath(path);
const errorTheme = createTheme({
components: {
MuiTooltip: {
styleOverrides: {
tooltip: {
color: '#FFF',
backgroundColor: '#BA000D',
},
},
},
},
});
const warningTheme = createTheme({
components: {
MuiTooltip: {
styleOverrides: {
tooltip: {
color: '#FFF',
backgroundColor: 'orange',
},
},
},
},
});
const ValidateWrapper = validation.type ? Tooltip : Box;
return (
<Tooltip arrow={true} title={path} placement="bottom">
<Box>
<ThemeProvider
theme={validation.type === 'error' ? errorTheme : warningTheme}
>
<ValidateWrapper title={validation.message} placement="top">
<Box>
<CopiableItem content={path}>
<Button
sx={{
width: '100%',
borderColor:
validation.type === 'error'
? 'red'
: validation.type === 'warning'
? 'orange'
: undefined,
}}
variant="outlined"
>
Path
</Button>
</CopiableItem>
</Box>
</ValidateWrapper>
</ThemeProvider>
</Box>
</Tooltip>
);
}
-14
View File
@@ -1,14 +0,0 @@
import { styled, Switch } from '@mui/material';
export const StyledSwitch = styled(Switch)(() => ({
padding: 8,
'& .MuiSwitch-track': {
borderRadius: 22 / 2,
},
'& .MuiSwitch-thumb': {
boxShadow: 'none',
width: 16,
height: 16,
margin: 2,
},
}));
-56
View File
@@ -1,56 +0,0 @@
import { styled, TextareaAutosize } from '@mui/material';
const blue = {
100: '#DAECFF',
200: '#b6daff',
400: '#3399FF',
500: '#007FFF',
600: '#0072E5',
900: '#003A75',
};
const grey = {
50: '#F3F6F9',
100: '#E5EAF2',
200: '#DAE2ED',
300: '#C7D0DD',
400: '#B0B8C4',
500: '#9DA8B7',
600: '#6B7A90',
700: '#434D5B',
800: '#303740',
900: '#1C2025',
};
const Textarea = styled(TextareaAutosize)(
({ theme }) => `
box-sizing: border-box;
width: 320px;
font-family: 'IBM Plex Sans', sans-serif;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.5;
padding: 8px 12px;
border-radius: 8px;
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
box-shadow: 0px 2px 2px ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
&:hover {
border-color: ${blue[400]};
}
&:focus {
border-color: ${blue[400]};
box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[600] : blue[200]};
}
// firefox
&:focus-visible {
outline: 0;
}
`,
);
export default Textarea;
-122
View File
@@ -1,122 +0,0 @@
import React, { useEffect, useState } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import {
Alert,
Box,
InputAdornment,
MenuItem,
Select,
TextField,
Tooltip,
} from '@mui/material';
import Checkbox from '@mui/material/Checkbox';
import useDebounce from '@hooks/useDebounce';
import { FetchMode } from '@typings/types';
export default function HeadersSection() {
const [loading, setLoading] = useState(false);
const [cookies, setCookies] = useState('');
const debounceCookies = useDebounce(cookies, 500);
const [fetchMode, setFetchMode] = useState(FetchMode.PROXY);
const [alertVisible, setAlertVisble] = useState(false);
const [useUserAgent, setUseUserAgent] = useState(true);
useEffect(() => {
if (alertVisible) {
const id = setTimeout(() => setAlertVisble(false), 1000);
return () => clearTimeout(id);
}
}, [alertVisible]);
useEffect(() => {
setLoading(true);
fetch('settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cookies: debounceCookies,
fetchMode: fetchMode,
useUserAgent,
}),
})
.then(() => setAlertVisble(true))
.finally(() => setLoading(false));
}, [debounceCookies, fetchMode, useUserAgent]);
return (
<AccordionContainer title="Settings" loading={loading}>
{alertVisible ? (
<Alert
sx={{
position: 'absolute',
zIndex: 2,
top: 0,
right: 0,
}}
>
Settings updated
</Alert>
) : null}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title={navigator.userAgent}>
<TextField
size="small"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">User Agent</InputAdornment>
),
},
}}
disabled
value={navigator.userAgent}
sx={{ width: '100%' }}
/>
</Tooltip>
<Checkbox
size="large"
checked={useUserAgent}
onChange={e => setUseUserAgent(e.target.checked)}
/>
</Box>
<Box sx={{ height: 10 }} />
<TextField
size="small"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">Cookies</InputAdornment>
),
},
}}
value={cookies}
onChange={e => {
setCookies(e.target.value.trim());
}}
sx={{ width: '100%' }}
/>
<Box sx={{ height: 10 }} />
<div
style={{
alignContent: 'flex-start',
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
Fetch Mode:
<Select
variant="outlined"
label="Fetcher"
value={fetchMode}
onChange={e => setFetchMode(e.target.value as FetchMode)}
>
<MenuItem value={FetchMode.PROXY}>Proxy</MenuItem>
<MenuItem value={FetchMode.NODE_FETCH}>Node fetch</MenuItem>
<MenuItem value={FetchMode.CURL}>Curl</MenuItem>
</Select>
</div>
</AccordionContainer>
);
}
@@ -1,50 +0,0 @@
import { Box, Button, Stack, TextField } from '@mui/material';
import React, { useState } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import Textarea from '@components/Textarea';
import { useAppStore } from '@store';
export default function ParseChapter() {
const plugin = useAppStore(state => state.plugin);
const [loading, setLoading] = useState(false);
const [chapterPath, setchapterPath] = useState('');
const [chapterText, setChapterText] = useState('');
const fetchChapter = () => {
if (plugin) {
setLoading(true);
plugin
.parseChapter(chapterPath)
.then(res => setChapterText(res))
.finally(() => setLoading(false));
}
};
return (
<AccordionContainer title="Parse Chapter" loading={loading}>
<Stack direction={'row'} spacing={2}>
<TextField
value={chapterPath}
onChange={e => setchapterPath(e.target.value)}
size="small"
label="Chapter path"
/>
<Button
disabled={plugin === undefined}
onClick={fetchChapter}
variant="contained"
>
Fetch
</Button>
</Stack>
<Box sx={{ height: 10 }} />
{chapterText ? (
<Textarea
style={{
width: '100%',
}}
maxRows={20}
disabled
value={chapterText}
/>
) : null}
</AccordionContainer>
);
}
@@ -1,74 +0,0 @@
import PathItem from '@components/PathItem';
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from '@mui/material';
import { Plugin } from '@typings/plugin';
import dayjs from 'dayjs';
import React from 'react';
import { TableComponents, TableVirtuoso } from 'react-virtuoso';
const VirtuosoTableComponents: TableComponents<Plugin.ChapterItem> = {
Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
<TableContainer component={Paper} {...props} ref={ref} />
)),
Table: props => (
<Table
{...props}
sx={{ borderCollapse: 'separate', tableLayout: 'fixed' }}
/>
),
// @ts-ignore
TableHead,
TableRow: ({ item: _item, ...props }) => <TableRow {...props} />,
TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableBody {...props} ref={ref} />
)),
};
function fixedHeaderContent() {
return (
<TableRow sx={{ backgroundColor: 'background.paper' }}>
<TableCell variant="head">Index</TableCell>
<TableCell variant="head">Name</TableCell>
<TableCell variant="head">Path</TableCell>
<TableCell variant="head">Release Time</TableCell>
<TableCell variant="head">Number</TableCell>
</TableRow>
);
}
function rowContent(_index: number, row: Plugin.ChapterItem) {
return (
<React.Fragment>
<TableCell>{_index}</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell>
<PathItem path={row.path} />
</TableCell>
<TableCell>{dayjs(row.releaseTime).format('YYYY/MM/DD')}</TableCell>
<TableCell>{row.chapterNumber}</TableCell>
</React.Fragment>
);
}
export default function ChapterList({
chapters,
}: {
chapters: Plugin.ChapterItem[];
}) {
return (
<Paper style={{ height: 400, minWidth: 600, textAlign: 'left' }}>
<TableVirtuoso
data={chapters}
components={VirtuosoTableComponents}
fixedHeaderContent={fixedHeaderContent}
itemContent={rowContent}
/>
</Paper>
);
}
@@ -1,117 +0,0 @@
import React, { ReactNode } from 'react';
import { Plugin } from '@typings/plugin';
import {
Box,
Button,
Card,
CardContent,
CardMedia,
Chip,
ListItem,
Stack,
Tooltip,
Typography,
useTheme,
} from '@mui/material';
import CopiableItem from '@components/CopiableItem';
import PathItem from '@components/PathItem';
const FlexRow = ({ children }: { children: ReactNode }) => (
<Stack direction="row" gap={1} sx={{ alignItems: 'center' }}>
{children}
</Stack>
);
export default function SourceNovelCard({
sourceNovel,
}: {
sourceNovel: Plugin.SourceNovel;
}) {
return (
<Box sx={{ display: 'flex', p: 1, width: '100%', textAlign: 'left' }}>
<CopiableItem content={sourceNovel.cover}>
<Tooltip title={sourceNovel.cover}>
<CardMedia
component="img"
sx={{ width: 300, height: 400, objectFit: 'cover' }}
image={sourceNovel.cover}
/>
</Tooltip>
</CopiableItem>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
<CardContent>
<Tooltip title={sourceNovel.name}>
<Box>
<CopiableItem content={sourceNovel.name}>
<Button variant="outlined">
<Typography
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: '5',
WebkitBoxOrient: 'vertical',
fontSize: 14,
}}
>
{sourceNovel.name}
</Typography>
</Button>
</CopiableItem>
</Box>
</Tooltip>
</CardContent>
<CardContent>
<FlexRow>
<Typography sx={{ width: 50 }}>Status:</Typography>
<Chip variant="outlined" label={sourceNovel.status} color="info" />
</FlexRow>
<FlexRow>
<Typography sx={{ width: 50 }}>Author:</Typography>
<Chip variant="outlined" label={sourceNovel.author} color="info" />
</FlexRow>
<FlexRow>
<Typography sx={{ width: 50 }}>Artist: </Typography>
<Chip variant="outlined" label={sourceNovel.artist} color="info" />
</FlexRow>
</CardContent>
<CardContent sx={{ width: '50%' }}>
<PathItem path={sourceNovel.path} />
</CardContent>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<CardContent>
<Typography>Genres</Typography>
<Box
sx={{
width: 600,
overflowX: 'auto',
}}
>
<Stack sx={{ width: 'max-content' }} direction={'row'}>
{sourceNovel.genres?.split(/,\s*/).map((genre, index) => (
<ListItem key={'genre_' + index}>
<Chip label={genre} />
</ListItem>
))}
</Stack>
</Box>
</CardContent>
<CardContent>
<Typography>Summary</Typography>
<Typography
sx={{
overflowY: 'auto',
height: 300,
p: 2,
borderRadius: 2,
backgroundColor: 'rgba(0,0,0,0.1)',
}}
>
{sourceNovel.summary}
</Typography>
</CardContent>
</Box>
</Box>
);
}
@@ -1,70 +0,0 @@
import { Box, Button, Pagination, Stack, TextField } from '@mui/material';
import React, { useState } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import { Plugin } from '@typings/plugin';
import SourceNovelCard from './SourceNovelCard';
import ChapterList from './ChaptertList';
import { useAppStore } from '@store';
export default function ParseNovel() {
const plugin = useAppStore(state => state.plugin);
const [sourceNovel, setSouceNovel] = useState<
(Plugin.SourceNovel & { totalPages?: number }) | undefined
>();
const [chapters, setChapters] = useState<Plugin.ChapterItem[]>([]);
const [loading, setLoading] = useState(false);
const [novelPath, setNovelPath] = useState('');
const fetchNovel = () => {
if (plugin) {
setLoading(true);
plugin
.parseNovel(novelPath)
.then(res => {
setSouceNovel(res);
setChapters(res.chapters || []);
})
.finally(() => setLoading(false));
}
};
const fetchPage = (page: number) => {
if (plugin && page) {
setLoading(true);
(plugin as Plugin.PagePlugin)
.parsePage(novelPath, page.toString())
.then(res => setChapters(res.chapters))
.finally(() => setLoading(false));
}
};
return (
<AccordionContainer title="Parse Novel" loading={loading}>
<Box sx={{ overflowX: 'auto', width: '100%' }}>
<Stack direction={'row'} spacing={2}>
<TextField
value={novelPath}
onChange={e => setNovelPath(e.target.value)}
size="small"
label="Novel path"
/>
<Button
disabled={plugin === undefined}
onClick={fetchNovel}
variant="contained"
>
Fetch
</Button>
</Stack>
{sourceNovel ? <SourceNovelCard sourceNovel={sourceNovel} /> : null}
<Box sx={{ height: 20 }} />
{chapters.length ? <ChapterList chapters={chapters} /> : null}
<Box sx={{ height: 20 }} />
{sourceNovel?.totalPages ? (
<Pagination
count={sourceNovel.totalPages}
onChange={(e, page) => fetchPage(page)}
variant="outlined"
/>
) : null}
</Box>
</AccordionContainer>
);
}
@@ -1,11 +0,0 @@
#filtersContent {
display: flex;
flex-direction: column;
align-content: center;
}
.switch_filter {
display: flex;
flex-direction: column;
align-items: center;
}
@@ -1,158 +0,0 @@
import {
Box,
Button,
Stack,
ToggleButton,
ToggleButtonGroup,
Switch,
FormControlLabel,
FormGroup,
} from '@mui/material';
import React, { useEffect, useState, ChangeEvent, useRef } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import { Plugin } from '@typings/plugin';
import NovelItemCard from '../components/NovelItemCard';
import { Filters, FilterToValues } from '@libs/filterInputs';
import { useAppStore } from '@store';
import './index.css';
import { DialogRef, FiltersDialog } from '../components/filters/FiltersDialog';
export default function PopularNovels() {
const plugin = useAppStore(state => state.plugin);
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
const [filterValues, setFilterValues] = useState<
FilterToValues<Filters> | undefined
>();
const [loading, setLoading] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [maxIndex, setMaxIndex] = useState(0);
const [isLatest, setIsLatest] = useState(true);
const dialogRef = useRef<DialogRef>(null);
const fetchNovelsByIndex = async (index: number) => {
if (plugin && index) {
setLoading(true);
const novels = await plugin.popularNovels(index, {
filters: filterValues || {},
showLatestNovels: isLatest,
});
if (novels.length !== 0) {
setCurrentIndex(index);
if (index > maxIndex) {
setMaxIndex(index);
}
setNovels(novels);
}
setLoading(false);
}
};
const handleSwitchLatestChange = (event: ChangeEvent<HTMLInputElement>) => {
setIsLatest(event.target.checked);
};
useEffect(() => {
if (plugin) {
setCurrentIndex(1);
setMaxIndex(1);
fetchNovelsByIndex(1);
}
}, [isLatest]);
useEffect(() => {
// Reset when changing plugins.
// TODO: Fix crashing on plugin change!
setCurrentIndex(0);
setMaxIndex(0);
setNovels([]);
if (plugin?.filters) {
const filters: FilterToValues<typeof plugin.filters> = {};
for (const fKey in plugin.filters) {
filters[fKey as keyof typeof filters] = {
type: plugin.filters[fKey].type,
value: plugin.filters[fKey].value,
};
}
setFilterValues(filters);
}
}, [plugin]);
return (
<AccordionContainer title="Popular Novels" loading={loading}>
<Stack
direction={'row'}
useFlexGap
sx={{ flexWrap: 'wrap' }}
spacing={2}
alignItems={'center'}
>
<FormGroup>
<FormControlLabel
control={
<Switch
checked={isLatest}
onChange={handleSwitchLatestChange}
color="secondary"
/>
}
label={<Box minWidth={70}>{isLatest ? 'Latest' : 'Popular'}</Box>}
/>
</FormGroup>
<Button
disabled={plugin === undefined}
onClick={() => fetchNovelsByIndex(1)}
variant="contained"
>
Fetch
</Button>
<Button
disabled={currentIndex === 0}
variant="outlined"
onClick={() => fetchNovelsByIndex(currentIndex + 1)}
>
Next Page
</Button>
<Button
disabled={!plugin || isLatest || !plugin.filters}
onClick={() => {
dialogRef.current?.show();
}}
>
Filters
</Button>
<FiltersDialog
ref={dialogRef}
values={filterValues}
filters={plugin?.filters}
setValues={setFilterValues}
refetch={() => fetchNovelsByIndex(1)}
/>
<ToggleButtonGroup
value={currentIndex}
exclusive
onChange={(_event, value) => fetchNovelsByIndex(value)}
>
{Array.from({ length: maxIndex }, (_, index) => index + 1).map(
number => (
<ToggleButton key={`novel_page_index_${number}`} value={number}>
{number}
</ToggleButton>
),
)}
</ToggleButtonGroup>
</Stack>
<Box
sx={{
width: '100%',
overflowX: 'auto',
display: 'flex',
}}
>
{novels.map((novel, index) => (
<NovelItemCard key={'novelItem_' + index} item={novel} />
))}
</Box>
</AccordionContainer>
);
}
@@ -1,77 +0,0 @@
import { Box, Button, Stack, TextField } from '@mui/material';
import React, { useEffect, useState } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import { Plugin } from '@typings/plugin';
import NovelItemCard from '../components/NovelItemCard';
import { useAppStore } from '@store';
export default function SearchNovels() {
const plugin = useAppStore(state => state.plugin);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [novels, setNovels] = useState<Plugin.NovelItem[] | undefined>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState('');
const fetchNovels = (page: number) => {
if (plugin) {
setLoading(true);
plugin
.searchNovels(searchTerm, page)
.then(res => setNovels(res))
.finally(() => setLoading(false))
.catch(e => {
setFetchError(e.message);
});
}
};
useEffect(() => {
setCurrentPage(1);
}, [plugin]);
return (
<AccordionContainer title="Search Novels" loading={loading}>
{fetchError && <span style={{ color: 'red' }}>{fetchError}</span>}
<Stack direction={'row'} spacing={2}>
<TextField
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
size="small"
label="Search term"
/>
<Button
disabled={plugin === undefined}
onClick={() => fetchNovels(currentPage)}
variant="contained"
>
Fetch
</Button>
<Button
disabled={plugin === undefined}
variant="outlined"
onClick={() => {
fetchNovels(currentPage + 1);
setCurrentPage(pre => pre + 1);
}}
>
Next Page
</Button>
</Stack>
<Box
sx={{
width: '100%',
overflowX: 'auto',
}}
>
<Stack sx={{ width: 'max-content' }} direction={'row'} spacing={2}>
{novels?.map((novel, index) => (
<NovelItemCard key={'searchedNovelItem_' + index} item={novel} />
))}
</Stack>
</Box>
</AccordionContainer>
);
}
@@ -1,36 +0,0 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
LinearProgress,
Typography,
} from '@mui/material';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import React, { ReactNode } from 'react';
export default function AccordionContainer({
children,
title,
loading,
}: {
title: string;
loading?: boolean;
children: ReactNode;
}) {
return (
<Accordion sx={{ my: 2 }}>
<AccordionSummary expandIcon={<ArrowDropDownIcon />}>
<Typography sx={{ fontWeight: 600, fontSize: 20 }}>{title}</Typography>
</AccordionSummary>
{loading ? (
<Box sx={{ width: '100%' }}>
<LinearProgress />
</Box>
) : null}
<AccordionDetails sx={{ alignItems: 'flex-start' }}>
{children}
</AccordionDetails>
</Accordion>
);
}
@@ -1,55 +0,0 @@
import {
Box,
Button,
Card,
CardContent,
CardMedia,
Tooltip,
Typography,
} from '@mui/material';
import React from 'react';
import { Plugin } from '@typings/plugin';
import CopiableItem from '@components/CopiableItem';
import PathItem from '@components/PathItem';
export default function NovelItemCard({ item }: { item: Plugin.NovelItem }) {
return (
<Box sx={{ display: 'flex', p: 1 }}>
<CopiableItem content={item.cover}>
<Tooltip title={item.cover}>
<CardMedia
component="img"
sx={{ width: 200, height: '100%' }}
image={item.cover}
/>
</Tooltip>
</CopiableItem>
<Box sx={{ display: 'flex', flexDirection: 'column', width: 200 }}>
<CardContent sx={{ height: 150 }}>
<Tooltip title={item.name}>
<Box>
<CopiableItem content={item.name}>
<Button variant="outlined">
<Typography
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: '5',
WebkitBoxOrient: 'vertical',
fontSize: 14,
}}
>
{item.name}
</Typography>
</Button>
</CopiableItem>
</Box>
</Tooltip>
</CardContent>
<CardContent>
<PathItem path={item.path} />
</CardContent>
</Box>
</Box>
);
}
@@ -1,64 +0,0 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import {
Checkbox,
FormControl,
FormControlLabel,
FormLabel,
Grid,
} from '@mui/material';
type FilterType = FilterTypes.CheckboxGroup;
export const CheckboxFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
return (
<FormControl key={key} variant="outlined">
<FormLabel>{filter.label}</FormLabel>
<Grid
container
columns={{ xs: 3 }}
id={`checkbox_filter_${key}_${label}`}
>
{filter.options.map(option => {
return (
<Grid
key={`${key}_${option.label}`}
id={`checkbox_filter_option_${key}_${option.value.replace(/\s+/gi, '-')}`}
item
xs={1}
justifyContent={'center'}
display={'flex'}
>
<FormControl>
<FormControlLabel
control={
<Checkbox
onChange={() => {
set(
value.includes(option.value)
? value.filter(q => q !== option.value)
: [...value, option.value],
);
}}
checked={value.includes(option.value)}
/>
}
label={option.label}
/>
</FormControl>
</Grid>
);
})}
</Grid>
</FormControl>
);
};
@@ -1,61 +0,0 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, InputLabel, MenuItem, Select } from '@mui/material';
type FilterType = FilterTypes.Picker;
export const PickerFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: ValueOfFilter<FilterType>) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
return (
<FormControl
key={key}
variant="filled"
fullWidth
margin="dense"
style={{ alignSelf: 'center' }}
>
<InputLabel id={`picker_filter_label_${label}_${key}`}>
{filter.label}
</InputLabel>
<Select
size="medium"
labelId={`picker_filter_label_${label}_${key}`}
id={`picker_filter_${label}_${key}`}
value={value || key}
onChange={({ target: { value: newValue } }) => {
if (newValue === key) newValue = '';
set(newValue);
}}
MenuProps={{ slotProps: { paper: { sx: { maxHeight: 36 * 4 } } } }}
label={filter.label}
>
{filter.options.map(option => {
return (
<MenuItem
style={{ minHeight: 36 }}
key={`${key}_${option.value}`}
value={
/** Because Mui treats empty string as `empty value`
* and doesn't show the MenuItem, if the value is empty, just use the key...
* We could do some other weird stuff, but I doubt there will be a filter
* that has the same name as the value being used inside of it.
* We can warn people in the documentation about this behavior */
option.value || key
}
>
{option.label}
</MenuItem>
);
})}
</Select>
</FormControl>
);
};
@@ -1,33 +0,0 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, FormControlLabel } from '@mui/material';
import { StyledSwitch } from '../../../StyledSwitch';
type FilterType = FilterTypes.Switch;
export const SwitchFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
return (
<FormControl
key={key}
className="switch_filter"
fullWidth
variant="outlined"
>
<FormControlLabel
style={{ userSelect: 'none' }}
label={filter.label}
id={`switch_filter_${label}_${key}`}
control={<StyledSwitch onChange={() => set(!value)} />}
/>
</FormControl>
);
};
@@ -1,38 +0,0 @@
import React, { useRef } from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, TextField } from '@mui/material';
type FilterType = FilterTypes.TextInput;
export const TextFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
const textRef = useRef<HTMLInputElement>(null);
return (
<FormControl key={key} className="switch_filter">
<TextField
color="primary"
id={`text_filter_${label}_${key}`}
inputRef={textRef}
label={filter.label}
value={value}
type="text"
variant="filled"
fullWidth
margin="dense"
onInput={() => {
if (textRef.current) {
set(textRef.current.value ?? '');
}
}}
/>
</FormControl>
);
};
@@ -1,140 +0,0 @@
import { styled } from '@mui/system';
import React, { useRef, useState } from 'react';
class TriStateCheckboxClasses {
root = 'TriStateCheckboxRoot';
base = 'TriStateCheckboxBase';
input = 'TriStateCheckboxInput';
svg = 'TriStateCheckboxSVG';
checked: string;
unchecked: string;
neutral: string;
offsets = {
neutral: 16,
checked: 16 * 2,
unchecked: 0,
};
constructor() {
this.checked = `${this.base}-checked`;
this.neutral = `${this.base}-neutral`;
this.unchecked = `${this.base}-unchecked`;
}
}
const classes = new TriStateCheckboxClasses();
const TriStateCheckboxRoot = styled(`span`, {
name: classes.root,
slot: 'root',
})(({ theme }) => ({
[`&.${classes.checked}`]: {
color: theme.palette.primary.main,
},
[`&.${classes.unchecked}`]: {
color: 'red',
},
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxSizing: 'border-box',
outline: 0,
border: 0,
margin: 0,
cursor: 'pointer',
userSelect: 'none',
textDecoration: 'none',
borderRadius: '50%',
padding: '9px',
}));
const TriStateCheckboxInput = styled(`input`, {
name: classes.input,
slot: 'input',
})(() => ({
cursor: 'inherit',
position: 'absolute',
opacity: 0,
height: '100%',
width: '100%',
top: 0,
left: 0,
margin: 0,
padding: 0,
zIndex: 1,
}));
const TriStateCheckboxSVG = styled(`svg`, { name: classes.svg, slot: 'image' })(
() => ({
userSelect: 'none',
width: '1em',
height: '1em',
display: 'inline-block',
fill: 'currentcolor',
flexShrink: 0,
fontSize: '1.5rem',
transition: 'fill 200ms cubic-bezier(0.4,0,0.2,1) 0ms',
}),
);
const emptyPath = (
<path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"></path>
);
const checkmarkPath = (
<path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path>
);
const uncheckedPath = (
<path
d={`M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zM8 6l4 4l4-4l2 2l-4 4l4,4l-2 2l-4-4l-4 4l-2-2l4-4l-4-4z`}
></path>
);
export type TriStateCheckboxState = 'checked' | 'unchecked' | 'neutral';
export const TriStateCheckbox = function ({
value,
onChange,
}: {
value?: TriStateCheckboxState;
onChange?: (newValue: TriStateCheckboxState) => void;
}) {
const [state, sSt] = useState<TriStateCheckboxState>(value ?? 'neutral');
const setState = (v: TriStateCheckboxState) => {
onChange?.(v);
sSt(value ?? v);
};
const root = useRef<HTMLSpanElement>(null);
const curVal = value ?? state;
return (
<>
<TriStateCheckboxRoot
className={classes[curVal]}
data-state={curVal}
ref={root}
onClick={() => {
switch (curVal) {
case 'checked':
return setState('unchecked');
case 'neutral':
return setState('checked');
case 'unchecked':
return setState('neutral');
}
}}
>
<TriStateCheckboxInput />
<TriStateCheckboxSVG>
{curVal === 'checked'
? checkmarkPath
: curVal === 'unchecked'
? uncheckedPath
: emptyPath}
</TriStateCheckboxSVG>
</TriStateCheckboxRoot>
</>
);
};
@@ -1,191 +0,0 @@
import { styled } from '@mui/system';
import React, { useRef, useState } from 'react';
class TriStateSwitchClasses {
root = 'TriStateSwitchRoot';
base = 'TriStateSwitchBase';
track = 'TriStateSwitchTrack';
thumb = 'TriStateSwitchThumb';
input = 'TriStateSwitchInput';
checked: string;
unchecked: string;
neutral: string;
offsets = {
neutral: 18,
checked: 18 * 2,
unchecked: 0,
};
width = 16 * 4 + 10;
constructor() {
this.base;
this.checked = `${this.base}-checked`;
this.neutral = `${this.base}-neutral`;
this.unchecked = `${this.base}-unchecked`;
}
}
const classes = new TriStateSwitchClasses();
const TriStateSwitchRoot = styled(`span`, {
name: classes.root,
slot: 'root',
})(() => ({
display: 'inline-flex',
width: `${classes.width}px`,
height: '38px',
overflow: 'hidden',
boxSizing: 'border-box',
position: 'relative',
flexShrink: 0,
zIndex: 0,
verticalAlign: 'middle',
padding: '8px',
}));
const TriStateSwitchTrack = styled(`span`, {
name: classes.track,
slot: 'track',
})(() => ({
// Android12 visuals:
borderRadius: 22 / 2,
// base Track
width: '100%',
height: '100%',
zIndex: -1,
content: `''`,
transition:
'opacity 150ms cubic-bezier(0.4,0,0.2,1) 0ms, background-color 150ms cubic-bezier(0.4,0,0.2,1) 0ms',
backgroundColor: '#000',
opacity: 0.38,
}));
const TriStateSwitchBase = styled(`span`, {
name: classes.base,
slot: 'base',
})(({ theme }) => ({
[`&.${classes.unchecked}`]: {
transform: `translateX(${classes.offsets.unchecked}px)`,
color: '#f33',
},
[`&.${classes.checked}`]: {
transform: `translateX(${classes.offsets.checked}px)`,
color: theme.palette.primary.main,
},
[`&.${classes.neutral}`]: {
transform: `translateX(${classes.offsets.neutral}px)`,
color: 'gray',
},
[`&.${classes.checked} + span`]: {
opacity: 0.5,
backgroundColor: theme.palette.primary.main,
},
[`&.${classes.unchecked} + span`]: {
opacity: 0.5,
backgroundColor: '#f33',
},
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
outline: 0,
border: 0,
margin: 0,
cursor: 'pointer',
userSelect: 'none',
textDecoration: 'none',
padding: '9px',
borderRadius: '50%',
position: 'absolute',
top: 0,
left: 0,
zIndex: 1,
color: '#fff',
transition:
'left 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;',
}));
const TriStateSwitchInput = styled(`input`, {
name: classes.input,
slot: 'input',
})(() => ({
left: '-100%',
width: '300%',
cursor: 'inherit',
position: 'absolute',
opacity: 0,
height: '100%',
top: 0,
margin: 0,
padding: 0,
zIndex: 1,
}));
const TriStateSwitchThumb = styled(`span`, {
name: classes.thumb,
slot: 'thumb',
})(() => ({
boxShadow: 'none',
width: '16px',
height: '16px',
margin: '2px',
backgroundColor: 'currentColor',
borderRadius: '50%',
}));
type TriStateSwitchState = 'checked' | 'unchecked' | 'neutral';
export const TriStateSwitch = function ({
value,
onChange,
}: {
value?: TriStateSwitchState;
onChange?: (newValue: TriStateSwitchState) => void;
}) {
const [state, sSt] = useState<TriStateSwitchState>(value ?? 'neutral');
const setState = (v: TriStateSwitchState) => {
onChange?.(v);
sSt(value ?? v);
};
const root = useRef<HTMLSpanElement>(null);
return (
<>
<TriStateSwitchRoot
data-state={state}
ref={root}
onClick={e => {
const offsetX =
e.clientX - (root.current?.getBoundingClientRect().x ?? 0);
const inputWidth = parseFloat(
getComputedStyle(e.currentTarget).width,
);
const clickPercentage = offsetX / inputWidth;
const sidePressed =
clickPercentage < 0.33
? 'left'
: clickPercentage > 0.66
? 'right'
: 'center';
console.log(inputWidth, offsetX);
switch (sidePressed) {
case 'center':
return setState('neutral');
case 'left':
return setState('unchecked');
case 'right':
return setState('checked');
}
}}
>
<TriStateSwitchBase className={classes[value ?? state]}>
<TriStateSwitchInput />
<TriStateSwitchThumb />
</TriStateSwitchBase>
<TriStateSwitchTrack />
</TriStateSwitchRoot>
</>
);
};
@@ -1,88 +0,0 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, FormControlLabel, FormLabel, Grid } from '@mui/material';
import { useAppStore } from '@store';
import { TriStateCheckbox, TriStateCheckboxState } from './TriStateCheckbox';
import { TriStateSwitch } from './TriStateSwitch';
type FilterType = FilterTypes.ExcludableCheckboxGroup;
export const XCheckboxFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const useSwitches = useAppStore(state => state.useSwitches);
const getCheckboxState = (v: string): TriStateCheckboxState => {
if (value.include?.includes(v)) return 'checked';
if (value.exclude?.includes(v)) return 'unchecked';
return 'neutral';
};
const handleChange = (key: string, v: TriStateCheckboxState) => {
console.log(`setting ${key} to`, v);
if (v === 'unchecked')
return set({
exclude: value.exclude
? Array.from(new Set([...value.exclude, key]))
: [key],
include: value.include?.filter(f => f !== key),
});
if (v === 'checked')
return set({
include: value.include
? Array.from(new Set([...value.include, key]))
: [key],
exclude: value.exclude?.filter(f => f !== key),
});
return set({
include: value.include?.filter(f => f !== key),
exclude: value.exclude?.filter(f => f !== key),
});
};
return (
<FormControl key={key} variant="outlined">
<FormLabel>{filter.label}</FormLabel>
<Grid container columns={{ xs: 3 }}>
{filter.options.map(option => {
return (
<Grid
key={`${key}_${option.label}`}
item
xs={1}
justifyContent={'center'}
display={'flex'}
>
<FormControl>
<FormControlLabel
labelPlacement={useSwitches ? 'top' : 'end'}
style={{ userSelect: 'none' }}
control={
useSwitches ? (
<TriStateSwitch
value={getCheckboxState(option.value)}
onChange={c => handleChange(option.value, c)}
/>
) : (
<TriStateCheckbox
value={getCheckboxState(option.value)}
onChange={c => handleChange(option.value, c)}
/>
)
}
label={option.label}
/>
</FormControl>
</Grid>
);
})}
</Grid>
</FormControl>
);
};
@@ -0,0 +1,47 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FilterTypes } from '@libs/filterInputs';
import type { Filter } from '@libs/filterInputs';
type CheckboxFilterProps = {
filter: {
key: string;
filter: Filter<FilterTypes.CheckboxGroup>;
};
value: string[];
set: (value: string[]) => void;
};
export function CheckboxFilter({ filter, value, set }: CheckboxFilterProps) {
const toggleOption = (optionValue: string) => {
if (value.includes(optionValue)) {
set(value.filter(v => v !== optionValue));
} else {
set([...value, optionValue]);
}
};
return (
<div className="space-y-3">
<Label className="text-sm font-medium">{filter.filter.label}</Label>
<div className="space-y-2">
{filter.filter.options.map(option => (
<div key={option.value} className="flex items-center space-x-2">
<Checkbox
id={`${filter.key}-${option.value}`}
checked={value.includes(option.value)}
onCheckedChange={() => toggleOption(option.value)}
/>
<Label
htmlFor={`${filter.key}-${option.value}`}
className="text-sm font-normal cursor-pointer"
>
{option.label}
</Label>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { FilterTypes } from '@libs/filterInputs';
import type { Filter } from '@libs/filterInputs';
type ExcludableCheckboxFilterProps = {
filter: {
key: string;
filter: Filter<FilterTypes.ExcludableCheckboxGroup>;
};
value: {
include?: string[];
exclude?: string[];
};
set: (value: { include?: string[]; exclude?: string[] }) => void;
};
type CheckState = 'unchecked' | 'included' | 'excluded';
export function ExcludableCheckboxFilter({
filter,
value,
set,
}: ExcludableCheckboxFilterProps) {
const getState = (optionValue: string): CheckState => {
if (value.include?.includes(optionValue)) return 'included';
if (value.exclude?.includes(optionValue)) return 'excluded';
return 'unchecked';
};
const toggleOption = (optionValue: string) => {
const currentState = getState(optionValue);
let newInclude = value.include || [];
let newExclude = value.exclude || [];
switch (currentState) {
case 'unchecked':
// Unchecked -> Included
newInclude = [...newInclude, optionValue];
break;
case 'included':
// Included -> Excluded
newInclude = newInclude.filter(v => v !== optionValue);
newExclude = [...newExclude, optionValue];
break;
case 'excluded':
// Excluded -> Unchecked
newExclude = newExclude.filter(v => v !== optionValue);
break;
}
set({ include: newInclude, exclude: newExclude });
};
return (
<div className="space-y-3">
<Label className="text-sm font-medium">{filter.filter.label}</Label>
<div className="space-y-2">
{filter.filter.options.map(option => {
const state = getState(option.value);
return (
<button
key={option.value}
type="button"
onClick={() => toggleOption(option.value)}
className="flex items-center justify-between w-full px-3 py-2 text-sm rounded-md border border-border hover:bg-muted/50 transition-colors"
>
<span className="text-foreground">{option.label}</span>
{state === 'included' && (
<Badge variant="default" className="text-xs">
Include
</Badge>
)}
{state === 'excluded' && (
<Badge variant="destructive" className="text-xs">
Exclude
</Badge>
)}
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Click once to include, twice to exclude, three times to reset
</p>
</div>
);
}
@@ -1,28 +1,25 @@
import React, {
forwardRef,
ReactNode,
useEffect,
useImperativeHandle,
} from 'react';
import React, { useEffect, useState, ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from '@mui/material';
import { useState } from 'react';
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
AnyFilterValue,
Filters,
FilterToValues,
FilterTypes,
} from '@libs/filterInputs';
import { PickerFilter } from './PickerFilter';
import { SwitchFilter } from './SwitchFilter';
import { TextFilter } from './TextFilter';
import { CheckboxFilter } from './CheckboxFilter';
import { XCheckboxFilter } from './XCheckboxFilter';
import { PickerFilter } from './picker-filter';
import { SwitchFilter } from './switch-filter';
import { TextFilter } from './text-filter';
import { CheckboxFilter } from './checkbox-filter';
import { ExcludableCheckboxFilter } from './excludable-checkbox-filter';
import { RotateCcw } from 'lucide-react';
const renderFilters = (
filters: Filters | undefined,
@@ -41,7 +38,8 @@ const renderFilters = (
return typeof o === typeof f || checkIfIsCorrectObjectType(o, f);
};
if (!filters || !values) return false;
if (!filters || !values) return null;
return (
<>
{Object.entries(filters).map(([key, filter]) => {
@@ -51,17 +49,13 @@ const renderFilters = (
}
switch (filter.type) {
case FilterTypes.Picker: {
// Check if filterValues have correct type
// this needs to be inside of every case in this switch to get correct value type
const value = values[key].value; // here value has every possible value type
// We could just do `as typeof filter.value` but just to be sure I made a typeguard
const value = values[key].value;
if (!isValueCorrectType<typeof filter.value>(value, filter.value)) {
console.error(
`FilterValue for filter [${key}] has a wrong type!`,
);
return null;
}
// value; // here value has only Picker's value
return (
<PickerFilter
key={`picker_filter_${key}`}
@@ -72,17 +66,13 @@ const renderFilters = (
);
}
case FilterTypes.Switch: {
// Check if filterValues have correct type
// this needs to be inside of every case in this switch to get correct value type
const value = values[key].value; // here value has every possible value type
// We could just do `as typeof filter.value` but just to be sure I made a typeguard
const value = values[key].value;
if (!isValueCorrectType<typeof filter.value>(value, filter.value)) {
console.error(
`FilterValue for filter [${key}] has a wrong type!`,
);
return null;
}
// value; // here value has only Picker's value
return (
<SwitchFilter
filter={{ key, filter }}
@@ -135,11 +125,11 @@ const renderFilters = (
return null;
}
return (
<XCheckboxFilter
<ExcludableCheckboxFilter
filter={{ key, filter }}
set={newValue => set(key, newValue)}
value={value}
key={`checkbox_filter_${key}`}
key={`xcheсkbox_filter_${key}`}
/>
);
}
@@ -149,21 +139,27 @@ const renderFilters = (
);
};
export type DialogRef = { show(): void; hide(): void };
type FiltersSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
values: FilterToValues<Filters> | undefined;
filters: Filters | undefined;
setValues: React.Dispatch<
React.SetStateAction<FilterToValues<Filters> | undefined>
>;
refetch: () => void;
};
export function FiltersSheet({
open,
onOpenChange,
values,
setValues,
filters,
refetch,
}: FiltersSheetProps) {
const [filterElements, setFilterElements] = useState<ReactNode>(null);
export const FiltersDialog = forwardRef<
DialogRef,
{
values: FilterToValues<Filters> | undefined;
filters: Filters | undefined;
setValues: React.Dispatch<
React.SetStateAction<FilterToValues<Filters> | undefined>
>;
refetch(): void;
}
>(function FiltersDialog({ values, setValues, filters, refetch }, ref) {
const [dialogOpen, setDialogOpen] = useState(false);
const [filterElements, setFilterElements] = useState<ReactNode>(false);
const setFilterWithKey = (key: string, newValue: AnyFilterValue) =>
setValues(fValues =>
!fValues
@@ -177,35 +173,58 @@ export const FiltersDialog = forwardRef<
},
);
useImperativeHandle(ref, () => ({
show() {
setDialogOpen(true);
},
hide() {
setDialogOpen(false);
},
}));
const resetFilters = () => {
if (filters) {
const resetValues: FilterToValues<typeof filters> = {};
for (const fKey in filters) {
resetValues[fKey as keyof typeof resetValues] = {
type: filters[fKey].type,
value: filters[fKey].value,
};
}
setValues(resetValues);
}
};
useEffect(() => {
setFilterElements(renderFilters(filters, values, setFilterWithKey));
}, [values]);
}, [values, filters]);
const handleApply = () => {
refetch();
onOpenChange(false);
};
return (
<Dialog
scroll="paper"
open={dialogOpen}
onClose={() => setDialogOpen(false)}
>
<DialogTitle>Filters</DialogTitle>
<DialogContent dividers id="filtersContent">
{filterElements}
</DialogContent>
<DialogActions>
<Button onClick={() => refetch()}>Refetch</Button>
<Button style={{ color: 'red' }} onClick={() => setDialogOpen(false)}>
Close
</Button>
</DialogActions>
</Dialog>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
<SheetDescription>
Customize your search with these filter options
</SheetDescription>
</SheetHeader>
<div className="py-6 space-y-4">
{filterElements || (
<p className="text-sm text-muted-foreground text-center py-8">
No filters available
</p>
)}
</div>
<SheetFooter className="flex-col sm:flex-col gap-2">
<Button onClick={handleApply} className="w-full">
Apply Filters
</Button>
<Button
variant="outline"
onClick={resetFilters}
className="w-full gap-2"
>
<RotateCcw className="w-4 h-4" />
Reset to Default
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
});
}
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FilterTypes } from '@libs/filterInputs';
import type { Filter } from '@libs/filterInputs';
type PickerFilterProps = {
filter: {
key: string;
filter: Filter<FilterTypes.Picker>;
};
value: string;
set: (value: string) => void;
};
const EMPTY_VALUE_PLACEHOLDER = '__lnreader_empty__';
export function PickerFilter({ filter, value, set }: PickerFilterProps) {
// Map empty string to placeholder for Radix UI Select
const displayValue = value === '' ? EMPTY_VALUE_PLACEHOLDER : value;
// Handle change and map placeholder back to empty string
const handleChange = (newValue: string) => {
set(newValue === EMPTY_VALUE_PLACEHOLDER ? '' : newValue);
};
return (
<div className="space-y-2">
<Label className="text-sm font-medium">{filter.filter.label}</Label>
<Select value={displayValue} onValueChange={handleChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{filter.filter.options.map(option => {
// Map empty string values to placeholder
const optionValue =
option.value === '' ? EMPTY_VALUE_PLACEHOLDER : option.value;
return (
<SelectItem key={option.value || 'empty'} value={optionValue}>
{option.label || option.value || 'All'}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { FilterTypes } from '@libs/filterInputs';
import type { Filter } from '@libs/filterInputs';
type SwitchFilterProps = {
filter: {
key: string;
filter: Filter<FilterTypes.Switch>;
};
value: boolean;
set: (value: boolean) => void;
};
export function SwitchFilter({ filter, value, set }: SwitchFilterProps) {
return (
<div className="flex items-center justify-between space-x-2 py-2">
<Label
htmlFor={filter.key}
className="text-sm font-medium cursor-pointer"
>
{filter.filter.label}
</Label>
<Switch id={filter.key} checked={value} onCheckedChange={set} />
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { FilterTypes } from '@libs/filterInputs';
import type { Filter } from '@libs/filterInputs';
type TextFilterProps = {
filter: {
key: string;
filter: Filter<FilterTypes.TextInput>;
};
value: string;
set: (value: string) => void;
};
export function TextFilter({ filter, value, set }: TextFilterProps) {
return (
<div className="space-y-2">
<Label htmlFor={filter.key} className="text-sm font-medium">
{filter.filter.label}
</Label>
<Input
id={filter.key}
value={value}
onChange={e => set(e.target.value)}
placeholder={`Enter ${filter.filter.label.toLowerCase()}...`}
/>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import React from 'react';
import { Copy, Zap } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Plugin } from '@/types/plugin';
type NovelCardProps = {
novel: Plugin.NovelItem;
onParse: (path: string) => void;
};
export function NovelCard({ novel, onParse }: NovelCardProps) {
const handleCopyPath = () => {
navigator.clipboard.writeText(novel.path);
toast.success('Novel path copied!');
};
return (
<div className="group cursor-pointer flex flex-col h-full">
<div className="relative mb-2 overflow-hidden rounded-lg bg-muted aspect-[3/4]">
<img
src={novel.cover || '/static/coverNotAvailable.webp'}
alt={novel.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="flex flex-col flex-1 min-h-0">
<h3 className="text-sm font-medium text-foreground line-clamp-2 mb-2 leading-tight h-10">
{novel.name}
</h3>
<div className="flex gap-1.5 mt-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-1 h-8 px-2 bg-transparent"
onClick={handleCopyPath}
>
<Copy className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy path</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
className="flex-1 h-8 px-2"
onClick={() => onParse(novel.path)}
>
<Zap className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Parse novel</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
}
+247
View File
@@ -0,0 +1,247 @@
import React, { useState, useEffect } from 'react';
import { Copy, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppStore } from '@/store';
export default function ParseChapterSection() {
const plugin = useAppStore(state => state.plugin);
const parseChapterPath = useAppStore(state => state.parseChapterPath);
const shouldAutoSubmitChapter = useAppStore(
state => state.shouldAutoSubmitChapter,
);
const clearParseChapterPath = useAppStore(
state => state.clearParseChapterPath,
);
const [chapterPath, setChapterPath] = useState('');
const [chapterText, setChapterText] = useState('');
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState('');
const fetchChapterByPath = async (path: string) => {
if (plugin && path.trim()) {
setLoading(true);
setFetchError('');
try {
const result = await plugin.parseChapter(path);
setChapterText(result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch chapter';
setFetchError(errorMessage);
console.error('Error parsing chapter:', error);
} finally {
setLoading(false);
}
}
};
const fetchChapter = async () => {
await fetchChapterByPath(chapterPath);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && chapterPath.trim()) {
fetchChapter();
}
};
const copyToClipboard = (text?: string, label?: string) => {
if (text) {
navigator.clipboard.writeText(text);
toast.success(`${label || 'Text'} copied to clipboard!`);
}
};
// Handle pre-filled path from navigation
useEffect(() => {
if (parseChapterPath) {
setChapterPath(parseChapterPath);
if (shouldAutoSubmitChapter && plugin) {
fetchChapterByPath(parseChapterPath);
}
clearParseChapterPath();
}
}, [
parseChapterPath,
shouldAutoSubmitChapter,
plugin,
clearParseChapterPath,
]);
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-foreground">
Parse Chapter
</h2>
<p className="text-sm text-muted-foreground mt-1">
{plugin
? 'Enter a chapter path to fetch content'
: 'Select a plugin to parse chapters'}
</p>
</div>
</div>
<div className="flex gap-3 mb-6">
<Input
placeholder="Enter chapter path..."
value={chapterPath}
onChange={e => setChapterPath(e.target.value)}
onKeyPress={handleKeyPress}
className="flex-1"
disabled={!plugin}
/>
<Button
onClick={fetchChapter}
disabled={!plugin || !chapterPath.trim() || loading}
>
{loading ? 'Fetching...' : 'Fetch'}
</Button>
</div>
{fetchError && (
<div className="p-4 mb-6 border border-destructive/50 bg-destructive/10 rounded-lg">
<p className="text-sm text-destructive">{fetchError}</p>
</div>
)}
{loading && !chapterText ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-4 w-2/3" />
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
</div>
<div className="border border-border rounded-lg">
<Skeleton className="h-10 w-full rounded-t-lg" />
<div className="p-6 space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
</div>
</div>
</div>
) : !chapterText ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<FileText className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{plugin ? 'Ready to parse' : 'No plugin selected'}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
{plugin
? 'Enter a chapter path in the field above and click "Fetch" to load the chapter content.'
: 'Please select a plugin from the sidebar to get started.'}
</p>
</div>
) : chapterText ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground">
Chapter Content
</h3>
<p className="text-sm text-muted-foreground mt-1">
{chapterPath}
</p>
</div>
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
onClick={() =>
copyToClipboard(chapterPath, 'Chapter path')
}
>
<Copy className="w-4 h-4" />
Copy Path
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy chapter path to clipboard</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
onClick={() =>
copyToClipboard(chapterText, 'Chapter text')
}
>
<Copy className="w-4 h-4" />
Copy Text
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy chapter text to clipboard</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="border border-border rounded-lg">
<div className="bg-muted/50 rounded-t-lg px-4 py-2 border-b border-border">
<p className="text-xs text-muted-foreground font-medium">
CHAPTER CONTENT ({chapterText.length} characters)
</p>
</div>
<div className="bg-background rounded-b-lg p-6 max-h-[600px] overflow-y-auto">
<div
className="prose prose-sm dark:prose-invert max-w-none text-foreground"
dangerouslySetInnerHTML={{
__html: chapterText,
}}
/>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-border">
<p className="text-sm text-muted-foreground">
Content loaded successfully
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setChapterText('');
setChapterPath('');
}}
>
Clear
</Button>
</div>
</div>
) : null}
</Card>
</div>
);
}
+499
View File
@@ -0,0 +1,499 @@
import React, { useState, useEffect } from 'react';
import { BookOpen, ChevronLeft, ChevronRight, Copy, Zap } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppStore } from '@/store';
import { Plugin } from '@/types/plugin';
type ParseNovelSectionProps = {
onNavigateToParseChapter?: () => void;
};
export default function ParseNovelSection({
onNavigateToParseChapter,
}: ParseNovelSectionProps) {
const plugin = useAppStore(state => state.plugin);
const parseNovelPath = useAppStore(state => state.parseNovelPath);
const shouldAutoSubmitNovel = useAppStore(
state => state.shouldAutoSubmitNovel,
);
const clearParseNovelPath = useAppStore(state => state.clearParseNovelPath);
const setParseChapterPath = useAppStore(state => state.setParseChapterPath);
const [novelPath, setNovelPath] = useState('');
const [sourceNovel, setSourceNovel] = useState<
(Plugin.SourceNovel & { totalPages?: number }) | undefined
>();
const [chapters, setChapters] = useState<Plugin.ChapterItem[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [fetchError, setFetchError] = useState('');
const fetchNovelByPath = async (path: string) => {
if (plugin && path.trim()) {
setLoading(true);
setFetchError('');
try {
const result = await plugin.parseNovel(path);
setSourceNovel(result);
setChapters(result.chapters || []);
setCurrentPage(1);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch novel';
setFetchError(errorMessage);
console.error('Error parsing novel:', error);
} finally {
setLoading(false);
}
}
};
const fetchNovel = async () => {
await fetchNovelByPath(novelPath);
};
const fetchPage = async (page: number) => {
if (plugin && novelPath && sourceNovel?.totalPages) {
setLoading(true);
setFetchError('');
try {
const result = await (plugin as Plugin.PagePlugin).parsePage(
novelPath,
page.toString(),
);
setChapters(result.chapters);
setCurrentPage(page);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch page';
setFetchError(errorMessage);
console.error('Error fetching page:', error);
} finally {
setLoading(false);
}
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && novelPath.trim()) {
fetchNovel();
}
};
const copyToClipboard = (text?: string, label?: string) => {
if (text) {
navigator.clipboard.writeText(text);
toast.success(`${label || 'Text'} copied to clipboard!`);
}
};
const handleParseChapter = (path: string) => {
setParseChapterPath(path, true);
onNavigateToParseChapter?.();
};
useEffect(() => {
if (parseNovelPath) {
setNovelPath(parseNovelPath);
if (shouldAutoSubmitNovel && plugin) {
fetchNovelByPath(parseNovelPath);
}
clearParseNovelPath();
}
}, [parseNovelPath, shouldAutoSubmitNovel, plugin, clearParseNovelPath]);
const formatDate = (dateString?: string | null) => {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return dateString;
}
};
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-foreground">
Parse Novel
</h2>
<p className="text-sm text-muted-foreground mt-1">
{plugin
? 'Enter a novel path to fetch details'
: 'Select a plugin to parse novels'}
</p>
</div>
</div>
<div className="flex gap-3 mb-6">
<Input
placeholder="Enter novel path..."
value={novelPath}
onChange={e => setNovelPath(e.target.value)}
onKeyPress={handleKeyPress}
className="flex-1"
disabled={!plugin}
/>
<Button
onClick={fetchNovel}
disabled={!plugin || !novelPath.trim() || loading}
>
{loading ? 'Fetching...' : 'Fetch'}
</Button>
</div>
{fetchError && (
<div className="p-4 mb-6 border border-destructive/50 bg-destructive/10 rounded-lg">
<p className="text-sm text-destructive">{fetchError}</p>
</div>
)}
{loading && !sourceNovel ? (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-4">
<div className="flex gap-4">
<Skeleton className="w-32 h-48 rounded-lg" />
<div className="flex-1 space-y-3">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
<Skeleton className="h-32 w-full" />
</div>
<Skeleton className="h-64" />
</div>
</div>
) : !sourceNovel ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<BookOpen className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{plugin ? 'Ready to parse' : 'No plugin selected'}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
{plugin
? 'Enter a novel path in the field above and click "Fetch" to load detailed information, including chapters, metadata, and more.'
: 'Please select a plugin from the sidebar to get started.'}
</p>
</div>
) : sourceNovel ? (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Novel Info */}
<div className="md:col-span-2 space-y-4">
<div className="flex gap-4">
<div
className="cursor-pointer"
onClick={() =>
copyToClipboard(sourceNovel.cover, 'Cover URL')
}
>
<img
src={
sourceNovel.cover || '/static/coverNotAvailable.webp'
}
alt={sourceNovel.name}
className="w-32 h-48 rounded-lg object-cover hover:opacity-80 transition-opacity"
title="Click to copy cover URL"
/>
</div>
<div className="flex-1">
<h3 className="text-2xl font-bold text-foreground mb-3 line-clamp-3">
{sourceNovel.name}
</h3>
<div className="grid grid-cols-2 gap-3 mb-4">
{sourceNovel.status && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Status
</p>
<Badge className="text-xs">
{sourceNovel.status}
</Badge>
</div>
)}
{sourceNovel.author && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Author
</p>
<p className="text-sm text-foreground line-clamp-1">
{sourceNovel.author}
</p>
</div>
)}
{sourceNovel.artist && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Artist
</p>
<p className="text-sm text-foreground line-clamp-1">
{sourceNovel.artist}
</p>
</div>
)}
{sourceNovel.rating && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Rating
</p>
<p className="text-sm text-foreground">
{sourceNovel.rating.toFixed(1)} / 5.0
</p>
</div>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
onClick={() =>
copyToClipboard(sourceNovel.path, 'Novel path')
}
>
<Copy className="w-4 h-4" />
Copy Path
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy novel path to clipboard</p>
</TooltipContent>
</Tooltip>
</div>
</div>
{sourceNovel.genres && (
<div>
<h4 className="font-semibold text-foreground mb-2">
Genres
</h4>
<div className="flex flex-wrap gap-2">
{sourceNovel.genres.split(/,\s*/).map((genre, index) => (
<Badge key={`genre-${index}`} variant="secondary">
{genre}
</Badge>
))}
</div>
</div>
)}
{sourceNovel.summary && (
<div>
<h4 className="font-semibold text-foreground mb-2">
Summary
</h4>
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
{sourceNovel.summary}
</p>
</div>
)}
</div>
{/* Sidebar Info */}
<div className="bg-muted/50 rounded-lg p-4 h-fit">
<h4 className="font-semibold text-foreground mb-4">Metadata</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Total Chapters
</p>
<p className="font-semibold text-foreground">
{chapters.length}
</p>
</div>
{sourceNovel.totalPages && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Total Pages
</p>
<p className="font-semibold text-foreground">
{sourceNovel.totalPages}
</p>
</div>
)}
{chapters.length > 0 && chapters[0].releaseTime && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
First Chapter
</p>
<p className="font-semibold text-foreground">
{formatDate(chapters[0].releaseTime)}
</p>
</div>
)}
{chapters.length > 0 &&
chapters[chapters.length - 1].releaseTime && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Last Updated
</p>
<p className="font-semibold text-foreground">
{formatDate(
chapters[chapters.length - 1].releaseTime,
)}
</p>
</div>
)}
</div>
</div>
</div>
{/* Chapters Table */}
{chapters.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-foreground">
Chapters ({chapters.length})
</h4>
{sourceNovel.totalPages && sourceNovel.totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fetchPage(currentPage - 1)}
disabled={currentPage === 1 || loading}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {sourceNovel.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => fetchPage(currentPage + 1)}
disabled={
currentPage === sourceNovel.totalPages || loading
}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
<div className="overflow-x-auto border border-border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr className="border-b border-border">
<th className="text-left py-3 px-4 font-semibold text-foreground w-16">
#
</th>
<th className="text-left py-3 px-4 font-semibold text-foreground">
Name
</th>
<th className="text-left py-3 px-4 font-semibold text-foreground w-28">
Actions
</th>
<th className="text-left py-3 px-4 font-semibold text-foreground w-32">
Release Time
</th>
{chapters.some(ch => ch.chapterNumber) && (
<th className="text-left py-3 px-4 font-semibold text-foreground w-24">
Chapter #
</th>
)}
</tr>
</thead>
<tbody>
{chapters.map((chapter, index) => (
<tr
key={`${chapter.path}-${index}`}
className={`border-b border-border hover:bg-muted/70 transition-colors ${
index % 2 === 0 ? 'bg-background' : 'bg-muted/20'
}`}
>
<td className="py-2.5 px-4 text-muted-foreground text-xs">
{index}
</td>
<td className="py-2.5 px-4 text-foreground">
{chapter.name}
</td>
<td className="py-2.5 px-4">
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() =>
copyToClipboard(
chapter.path,
'Chapter path',
)
}
>
<Copy className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy chapter path</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() =>
handleParseChapter(chapter.path)
}
>
<Zap className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Parse chapter</p>
</TooltipContent>
</Tooltip>
</div>
</td>
<td className="py-2.5 px-4 text-muted-foreground text-xs">
{formatDate(chapter.releaseTime)}
</td>
{chapters.some(ch => ch.chapterNumber) && (
<td className="py-2.5 px-4 text-muted-foreground text-xs">
{chapter.chapterNumber || '-'}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
) : null}
</Card>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import { Plugin } from '@/types/plugin';
type PluginHeaderProps = {
selectedPlugin?: Plugin.PluginBase;
};
export default function PluginHeader({ selectedPlugin }: PluginHeaderProps) {
return (
<header className="border-b">
<div className="px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-sm bg-[var(--color-accent-soft)] text-xl font-semibold text-[var(--color-accent-strong)] transition-colors">
読
</span>
<div>
<h1 className="text-sm font-semibold text-foreground">
Plugin Playground
</h1>
<p className="text-xs text-muted-foreground">
{selectedPlugin?.name}
</p>
</div>
</div>
</div>
</header>
);
}
+219
View File
@@ -0,0 +1,219 @@
import React, { useState, useEffect } from 'react';
import { Filter, Zap } from 'lucide-react';
import { FiltersSheet } from '@/components/filters/filters-sheet';
import { NovelCard } from '@/components/novel-card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppStore } from '@/store';
import { FilterToValues, Filters } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
type PopularNovelsSectionProps = {
onNavigateToParseNovel?: () => void;
};
export default function PopularNovelsSection({
onNavigateToParseNovel,
}: PopularNovelsSectionProps) {
const plugin = useAppStore(state => state.plugin);
const setParseNovelPath = useAppStore(state => state.setParseNovelPath);
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
const [loading, setLoading] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [maxIndex, setMaxIndex] = useState(0);
const [isLatest, setIsLatest] = useState(true);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filterValues, setFilterValues] = useState<
FilterToValues<Filters> | undefined
>();
const fetchNovelsByIndex = async (index: number) => {
if (plugin && index) {
setLoading(true);
try {
const fetchedNovels = await plugin.popularNovels(index, {
filters: filterValues || {},
showLatestNovels: isLatest,
});
if (fetchedNovels.length !== 0) {
setCurrentIndex(index);
if (index > maxIndex) {
setMaxIndex(index);
}
setNovels(fetchedNovels);
}
} catch (error) {
console.error('Error fetching novels:', error);
} finally {
setLoading(false);
}
}
};
useEffect(() => {
if (plugin) {
setCurrentIndex(1);
setMaxIndex(1);
fetchNovelsByIndex(1);
}
}, [isLatest]);
useEffect(() => {
// Reset when changing plugins
setCurrentIndex(0);
setMaxIndex(0);
setNovels([]);
if (plugin?.filters) {
const filters: FilterToValues<typeof plugin.filters> = {};
for (const fKey in plugin.filters) {
filters[fKey as keyof typeof filters] = {
type: plugin.filters[fKey].type,
value: plugin.filters[fKey].value,
};
}
setFilterValues(filters);
}
}, [plugin]);
const handleParseNovel = (path: string) => {
setParseNovelPath(path, true);
onNavigateToParseNovel?.();
};
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-foreground">
Popular Novels
</h2>
<p className="text-sm text-muted-foreground mt-1">
{plugin
? `Browse ${isLatest ? 'latest' : 'popular'} novels`
: 'Select a plugin to browse novels'}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
disabled={!plugin || isLatest || !plugin.filters}
onClick={() => setFiltersOpen(true)}
>
<Filter className="w-4 h-4" />
Filters
</Button>
<Button
size="sm"
disabled={!plugin || loading}
onClick={() => fetchNovelsByIndex(1)}
>
{loading ? 'Loading...' : 'Fetch'}
</Button>
<Button
variant="outline"
size="sm"
disabled={currentIndex === 0 || loading}
onClick={() => fetchNovelsByIndex(currentIndex + 1)}
>
Next Page
</Button>
</div>
</div>
<div className="flex items-center gap-2 mb-6">
{['Latest', 'Popular'].map(option => (
<Badge
key={option}
variant={
isLatest === (option === 'Latest') ? 'default' : 'outline'
}
className="cursor-pointer"
onClick={() => setIsLatest(option === 'Latest')}
>
{option}
</Badge>
))}
{currentIndex > 0 && (
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted-foreground">Page</span>
<Input
type="number"
min="1"
max={maxIndex}
value={currentIndex}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const page = parseInt(e.target.value);
if (page > 0 && page <= maxIndex) {
fetchNovelsByIndex(page);
}
}}
className="w-16 h-7 text-center text-xs"
disabled={loading}
/>
<span className="text-xs text-muted-foreground">
of {maxIndex}+
</span>
</div>
)}
</div>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 lg:gap-6">
{Array.from({ length: 12 }).map((_, index) => (
<div key={index} className="space-y-3">
<Skeleton className="w-full aspect-[3/4] rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="flex gap-2">
<Skeleton className="h-8 flex-1" />
<Skeleton className="h-8 flex-1" />
</div>
</div>
))}
</div>
) : novels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<Zap className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{plugin ? 'No novels to display' : 'No plugin selected'}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
{plugin
? 'Click the "Fetch" button above to load the latest or popular novels from this source.'
: 'Please select a plugin from the sidebar to get started.'}
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3 lg:gap-4">
{novels.map((novel, index) => (
<NovelCard
key={`${novel.path}-${index}`}
novel={novel}
onParse={handleParseNovel}
/>
))}
</div>
)}
</Card>
<FiltersSheet
open={filtersOpen}
onOpenChange={setFiltersOpen}
values={filterValues}
filters={plugin?.filters}
setValues={setFilterValues}
refetch={() => fetchNovelsByIndex(1)}
/>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import { Search as SearchIcon } from 'lucide-react';
import { NovelCard } from '@/components/novel-card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppStore } from '@/store';
import { Plugin } from '@/types/plugin';
type SearchNovelsSectionProps = {
onNavigateToParseNovel?: () => void;
};
export default function SearchNovelsSection({
onNavigateToParseNovel,
}: SearchNovelsSectionProps) {
const plugin = useAppStore(state => state.plugin);
const setParseNovelPath = useAppStore(state => state.setParseNovelPath);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState('');
const fetchNovels = async (page: number) => {
if (plugin && searchTerm.trim()) {
setLoading(true);
setFetchError('');
try {
const results = await plugin.searchNovels(searchTerm, page);
setNovels(results);
setCurrentPage(page);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch novels';
setFetchError(errorMessage);
console.error('Error searching novels:', error);
} finally {
setLoading(false);
}
}
};
useEffect(() => {
setCurrentPage(1);
setNovels([]);
setSearchTerm('');
setFetchError('');
}, [plugin]);
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && searchTerm.trim()) {
fetchNovels(1);
}
};
const handleParseNovel = (path: string) => {
setParseNovelPath(path, true);
onNavigateToParseNovel?.();
};
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-foreground">
Search Novels
</h2>
<p className="text-sm text-muted-foreground mt-1">
{plugin
? 'Search for novels by title or keywords'
: 'Select a plugin to search novels'}
</p>
</div>
{currentPage > 1 && (
<Badge variant="secondary">Page {currentPage}</Badge>
)}
</div>
<div className="flex gap-3 mb-6">
<Input
placeholder="Enter search term..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
onKeyPress={handleKeyPress}
className="flex-1"
disabled={!plugin}
/>
<Button
onClick={() => fetchNovels(1)}
disabled={!plugin || !searchTerm.trim() || loading}
>
{loading ? 'Searching...' : 'Search'}
</Button>
<Button
variant="outline"
onClick={() => fetchNovels(currentPage + 1)}
disabled={!plugin || novels.length === 0 || loading}
>
Next Page
</Button>
</div>
{fetchError && (
<div className="p-4 mb-6 border border-destructive/50 bg-destructive/10 rounded-lg">
<p className="text-sm text-destructive">{fetchError}</p>
</div>
)}
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3 lg:gap-4">
{Array.from({ length: 12 }).map((_, index) => (
<div key={index} className="space-y-3">
<Skeleton className="w-full aspect-[3/4] rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="flex gap-1.5">
<Skeleton className="h-8 flex-1" />
<Skeleton className="h-8 flex-1" />
</div>
</div>
))}
</div>
) : novels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<SearchIcon className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{searchTerm
? 'No results found'
: plugin
? 'Ready to search'
: 'No plugin selected'}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
{searchTerm
? 'Try adjusting your search term or check the spelling. Different sources may have different availability.'
: plugin
? 'Enter a search term in the field above and click "Search" to find novels.'
: 'Please select a plugin from the sidebar to get started.'}
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3 lg:gap-4">
{novels.map((novel, index) => (
<NovelCard
key={`${novel.path}-${index}`}
novel={novel}
onParse={handleParseNovel}
/>
))}
</div>
)}
</Card>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import React, { useState, useEffect } from 'react';
import { CheckedState } from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import useDebounce from '@/hooks/useDebounce';
import { FetchMode } from '@/types/types';
export default function SettingsSection() {
const [cookies, setCookies] = useState('');
const debouncedCookies = useDebounce(cookies, 500);
const [fetchMode, setFetchMode] = useState<FetchMode>(FetchMode.PROXY);
const [useUserAgent, setUseUserAgent] = useState<CheckedState>(true);
const [loading, setLoading] = useState(false);
const [alertVisible, setAlertVisible] = useState(false);
useEffect(() => {
if (alertVisible) {
const id = setTimeout(() => setAlertVisible(false), 2000);
return () => clearTimeout(id);
}
}, [alertVisible]);
useEffect(() => {
setLoading(true);
fetch('settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cookies: debouncedCookies,
fetchMode: fetchMode,
useUserAgent: useUserAgent === true,
}),
})
.then(() => setAlertVisible(true))
.catch(error => console.error('Failed to save settings:', error))
.finally(() => setLoading(false));
}, [debouncedCookies, fetchMode, useUserAgent]);
const getFetchModeLabel = (mode: FetchMode) => {
switch (mode) {
case FetchMode.PROXY:
return 'Proxy';
case FetchMode.NODE_FETCH:
return 'Node Fetch';
case FetchMode.CURL:
return 'Curl';
default:
return 'Proxy';
}
};
return (
<div className="space-y-6">
<Card className="p-6 relative">
{alertVisible && (
<div className="absolute top-4 right-4 z-10 bg-green-500/90 text-white px-4 py-2 rounded-md flex items-center gap-2 shadow-lg animate-in fade-in slide-in-from-top-2">
<Check className="w-4 h-4" />
Settings updated
</div>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-foreground">Settings</h2>
<p className="text-sm text-muted-foreground mt-1">
Settings are automatically saved
</p>
</div>
{loading && (
<div className="text-sm text-muted-foreground">Saving...</div>
)}
</div>
<div className="space-y-6">
{/* Request Configuration Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-border"></div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Request Configuration
</h3>
<div className="h-px flex-1 bg-border"></div>
</div>
<div className="space-y-6">
{/* User Agent */}
<div className="space-y-2">
<Label className="font-semibold text-foreground">
Browser User Agent
</Label>
<div className="flex items-center gap-3">
<Input
value={navigator.userAgent}
disabled
className="font-mono text-xs flex-1 opacity-60"
title={navigator.userAgent}
/>
<div className="flex items-center gap-2 whitespace-nowrap">
<Checkbox
id="use-ua"
checked={useUserAgent}
onCheckedChange={setUseUserAgent}
/>
<Label
htmlFor="use-ua"
className="text-sm text-foreground cursor-pointer"
>
Use
</Label>
</div>
</div>
<p className="text-xs text-muted-foreground">
Enable to send your browser's user agent with requests
</p>
</div>
{/* Cookies */}
<div className="space-y-2">
<Label
htmlFor="cookies"
className="font-semibold text-foreground"
>
Cookies
</Label>
<Input
id="cookies"
value={cookies}
onChange={e => setCookies(e.target.value.trim())}
placeholder="Enter cookies (optional)..."
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Additional cookies to send with requests (optional)
</p>
</div>
</div>
</div>
{/* Fetch Settings Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-border"></div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Fetch Settings
</h3>
<div className="h-px flex-1 bg-border"></div>
</div>
<div className="space-y-2">
<Label
htmlFor="fetch-mode"
className="font-semibold text-foreground"
>
Fetch Mode
</Label>
<Select
value={fetchMode.toString()}
onValueChange={value =>
setFetchMode(parseInt(value) as FetchMode)
}
>
<SelectTrigger id="fetch-mode">
<SelectValue>{getFetchModeLabel(fetchMode)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={FetchMode.PROXY.toString()}>
Proxy
</SelectItem>
<SelectItem value={FetchMode.NODE_FETCH.toString()}>
Node Fetch
</SelectItem>
<SelectItem value={FetchMode.CURL.toString()}>
Curl
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select the method used to fetch data from sources
</p>
</div>
</div>
</div>
</Card>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+60
View File
@@ -0,0 +1,60 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+92
View File
@@ -0,0 +1,92 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+32
View File
@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };
+22
View File
@@ -0,0 +1,22 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
);
}
export { Label };
+185
View File
@@ -0,0 +1,185 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+131
View File
@@ -0,0 +1,131 @@
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
type SheetContentProps = React.ComponentPropsWithoutRef<
typeof SheetPrimitive.Content
> & {
side?: 'top' | 'right' | 'bottom' | 'left';
};
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
side === 'top' &&
'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
side === 'bottom' &&
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
side === 'left' &&
'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
side === 'right' &&
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+16
View File
@@ -0,0 +1,16 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted/50', className)}
{...props}
/>
);
}
export { Skeleton };
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from 'lucide-react';
import { Toaster as Sonner, type ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="system"
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+64
View File
@@ -0,0 +1,64 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow,border-color] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm data-[state=active]:border-b-2 data-[state=active]:border-accent [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
+39
View File
@@ -0,0 +1,39 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger {...props} />;
}
function TooltipContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+36
View File
@@ -0,0 +1,36 @@
import { useCallback } from 'react';
export const useQueryParams = () => {
const getParam = useCallback((key: string): string | null => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(key);
}, []);
const setParam = useCallback((key: string, value: string) => {
const url = new URL(window.location.href);
url.searchParams.set(key, value);
window.history.pushState({}, '', url);
}, []);
const removeParam = useCallback((key: string) => {
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.pushState({}, '', url);
}, []);
const getAllParams = useCallback((): Record<string, string> => {
const urlParams = new URLSearchParams(window.location.search);
const result: Record<string, string> = {};
urlParams.forEach((value, key) => {
result[key] = value;
});
return result;
}, []);
return {
getParam,
setParam,
removeParam,
getAllParams,
};
};
+126 -60
View File
@@ -1,70 +1,136 @@
body::-webkit-scrollbar {
display: none;
}
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@fontsource/geist-sans/index.css';
@import '@fontsource/geist-mono/index.css';
@custom-variant dark (&:is(.dark *));
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--accent-strong: #1d6378;
--accent-soft: rgba(43, 127, 150, 0.18);
--outline: rgba(45, 52, 64, 0.45);
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--accent-strong: #2b7f96;
--accent-soft: rgba(43, 127, 150, 0.25);
--outline: rgba(43, 127, 150, 0.45);
}
body {
margin: 0;
display: flex;
min-width: 320px;
min-height: 100vh;
@theme inline {
--font-sans: 'Geist Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-accent-strong: var(--accent-strong);
--color-accent-soft: var(--accent-soft);
--color-outline: var(--outline);
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
@layer base {
* {
@apply border-border outline-ring/50;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
body {
@apply bg-background text-foreground font-sans;
}
}
+156
View File
@@ -0,0 +1,156 @@
/* global Buffer, RequestInit */
import { parse as parseProto } from 'protobufjs';
type FetchInit = {
headers?: Record<string, string | undefined> | Headers;
method?: string;
body?: FormData | string;
[x: string]:
| string
| Record<string, string | undefined>
| undefined
| FormData
| Headers;
};
const makeInit = async (init?: FetchInit) => {
const defaultHeaders: Record<string, string> = {
'Connection': 'keep-alive',
'Accept': '*/*',
'Accept-Language': '*',
'Sec-Fetch-Mode': 'cors',
'Accept-Encoding': 'gzip, deflate',
};
if (init?.headers) {
if (init.headers instanceof Headers) {
for (const [name, value] of Object.entries(defaultHeaders)) {
if (!init.headers.get(name)) init.headers.set(name, value);
}
} else {
init.headers = {
...defaultHeaders,
...init.headers,
};
}
} else {
init = {
...init,
headers: defaultHeaders,
};
}
return init;
};
/**
* Fetch with (Android) User Agent
* @param url
* @param init
* @returns response as normal fetch
*/
export async function fetchApi(url: string, init?: FetchInit) {
init = await makeInit(init);
console.log(url, init);
return await fetch(url, init as RequestInit);
}
/**
*
* @param url
* @param init
* @returns base64 string of file
* @example fetchFile('https://avatars.githubusercontent.com/u/81222734?s=48&v=4');
*/
export const fetchFile = async function (url: string, init?: FetchInit) {
init = await makeInit(init);
console.log(url, init);
try {
const res = await fetch(url, init as RequestInit);
if (!res.ok) return '';
const arrayBuffer = await res.arrayBuffer();
return Buffer.from(arrayBuffer).toString('base64');
} catch (e) {
return '';
}
};
/**
*
* @param url
* @param init
* @param encoding default: `utf-8`. link: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/encoding
* @returns plain text
* @example fetchText('https://github.com/LNReader/lnreader', {}, 'gbk');
*/
export const fetchText = async function (
url: string,
init?: FetchInit,
encoding?: string,
): Promise<string> {
init = await makeInit(init);
console.log(url, init);
try {
const res = await fetch(url, init as RequestInit);
if (!res.ok) return '';
const arrayBuffer = await res.arrayBuffer();
const decoder = new TextDecoder(encoding);
return decoder.decode(arrayBuffer);
} catch (e) {
return '';
}
};
type ProtoRequestInit = {
// merged .proto file
proto: string;
requestType: string;
requestData?: any;
responseType: string;
};
const BYTE_MARK = BigInt((1 << 8) - 1);
export const fetchProto = async function <ReturnType>(
protoInit: ProtoRequestInit,
url: string,
init?: FetchInit,
) {
const protoRoot = parseProto(protoInit.proto).root;
const RequestMessge = protoRoot.lookupType(protoInit.requestType);
if (RequestMessge.verify(protoInit.requestData)) {
throw new Error('Invalid Proto');
}
// encode request data
const encodedrequest = RequestMessge.encode(protoInit.requestData).finish();
const requestLength = BigInt(encodedrequest.length);
const headers = new Uint8Array(
Array(5)
.fill(0)
.map((v, idx) => {
if (idx === 0) return 0;
return Number((requestLength >> BigInt(8 * (5 - idx - 1))) & BYTE_MARK);
}),
);
init = await makeInit(init);
const bodyArray = new Uint8Array(headers.length + encodedrequest.length);
bodyArray.set(headers, 0);
bodyArray.set(encodedrequest, headers.length);
return fetch(url, {
method: 'POST',
...init,
body: bodyArray,
} as RequestInit)
.then(r => r.arrayBuffer())
.then(arr => {
// decode response data
const payload = new Uint8Array(arr);
const length = Number(
BigInt(payload[1] << 24) |
BigInt(payload[2] << 16) |
BigInt(payload[3] << 8) |
BigInt(payload[4]),
);
const ResponseMessage = protoRoot.lookupType(protoInit.responseType);
return ResponseMessage.decode(payload.slice(5, 5 + length));
}) as ReturnType;
};
+36
View File
@@ -0,0 +1,36 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merges Tailwind CSS classes with clsx
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Checks if a URL is absolute
*/
export const isUrlAbsolute = (url: string) => {
if (url) {
if (url.indexOf('//') === 0) {
return true;
} // URL is protocol-relative (= absolute)
if (url.indexOf('://') === -1) {
return false;
} // URL has no protocol (= relative)
if (url.indexOf('.') === -1) {
return false;
} // URL does not contain a dot, i.e. no TLD (= relative, possibly REST)
if (url.indexOf('/') === -1) {
return false;
} // URL does not contain a single slash (= relative)
if (url.indexOf(':') > url.indexOf('/')) {
return false;
} // The first colon comes after the first slash (= relative)
if (url.indexOf('://') < url.indexOf('.')) {
return true;
} // Protocol is defined before first dot (= absolute)
}
return false; // Anything else must be relative
};
+5 -2
View File
@@ -1,2 +1,5 @@
export const defaultCover =
'https://github.com/LNReader/lnreader-plugins/blob/main/icons/src/coverNotAvailable.jpg?raw=true';
/**
* Backward compatibility for 3.0.0 - Re-exports from new location
* TODO: Remove in 4.0.0
*/
export { defaultCover } from '../types/constants';
+3 -154
View File
@@ -1,156 +1,5 @@
/* global Buffer, RequestInit */
import { parse as parseProto } from 'protobufjs';
type FetchInit = {
headers?: Record<string, string | undefined> | Headers;
method?: string;
body?: FormData | string;
[x: string]:
| string
| Record<string, string | undefined>
| undefined
| FormData
| Headers;
};
const makeInit = async (init?: FetchInit) => {
const defaultHeaders: Record<string, string> = {
'Connection': 'keep-alive',
'Accept': '*/*',
'Accept-Language': '*',
'Sec-Fetch-Mode': 'cors',
'Accept-Encoding': 'gzip, deflate',
};
if (init?.headers) {
if (init.headers instanceof Headers) {
for (const [name, value] of Object.entries(defaultHeaders)) {
if (!init.headers.get(name)) init.headers.set(name, value);
}
} else {
init.headers = {
...defaultHeaders,
...init.headers,
};
}
} else {
init = {
...init,
headers: defaultHeaders,
};
}
return init;
};
/**
* Fetch with (Android) User Agent
* @param url
* @param init
* @returns response as normal fetch
* Backward compatibility for 3.0.0 - Re-exports from new location
* TODO: Remove in 4.0.0
*/
export async function fetchApi(url: string, init?: FetchInit) {
init = await makeInit(init);
console.log(url, init);
return await fetch(url, init as RequestInit);
}
/**
*
* @param url
* @param init
* @returns base64 string of file
* @example fetchFile('https://avatars.githubusercontent.com/u/81222734?s=48&v=4');
*/
export const fetchFile = async function (url: string, init?: FetchInit) {
init = await makeInit(init);
console.log(url, init);
try {
const res = await fetch(url, init as RequestInit);
if (!res.ok) return '';
const arrayBuffer = await res.arrayBuffer();
return Buffer.from(arrayBuffer).toString('base64');
} catch (e) {
return '';
}
};
/**
*
* @param url
* @param init
* @param encoding default: `utf-8`. link: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/encoding
* @returns plain text
* @example fetchText('https://github.com/LNReader/lnreader', {}, 'gbk');
*/
export const fetchText = async function (
url: string,
init?: FetchInit,
encoding?: string,
): Promise<string> {
init = await makeInit(init);
console.log(url, init);
try {
const res = await fetch(url, init as RequestInit);
if (!res.ok) return '';
const arrayBuffer = await res.arrayBuffer();
const decoder = new TextDecoder(encoding);
return decoder.decode(arrayBuffer);
} catch (e) {
return '';
}
};
type ProtoRequestInit = {
// merged .proto file
proto: string;
requestType: string;
requestData?: any;
responseType: string;
};
const BYTE_MARK = BigInt((1 << 8) - 1);
export const fetchProto = async function <ReturnType>(
protoInit: ProtoRequestInit,
url: string,
init?: FetchInit,
) {
const protoRoot = parseProto(protoInit.proto).root;
const RequestMessge = protoRoot.lookupType(protoInit.requestType);
if (RequestMessge.verify(protoInit.requestData)) {
throw new Error('Invalid Proto');
}
// encode request data
const encodedrequest = RequestMessge.encode(protoInit.requestData).finish();
const requestLength = BigInt(encodedrequest.length);
const headers = new Uint8Array(
Array(5)
.fill(0)
.map((v, idx) => {
if (idx === 0) return 0;
return Number((requestLength >> BigInt(8 * (5 - idx - 1))) & BYTE_MARK);
}),
);
init = await makeInit(init);
const bodyArray = new Uint8Array(headers.length + encodedrequest.length);
bodyArray.set(headers, 0);
bodyArray.set(encodedrequest, headers.length);
return fetch(url, {
method: 'POST',
...init,
body: bodyArray,
} as RequestInit)
.then(r => r.arrayBuffer())
.then(arr => {
// decode response data
const payload = new Uint8Array(arr);
const length = Number(
BigInt(payload[1] << 24) |
BigInt(payload[2] << 16) |
BigInt(payload[3] << 8) |
BigInt(payload[4]),
);
const ResponseMessage = protoRoot.lookupType(protoInit.responseType);
return ResponseMessage.decode(payload.slice(5, 5 + length));
}) as ReturnType;
};
export { fetchApi, fetchText, fetchProto, fetchFile } from '../lib/fetch';
+4 -128
View File
@@ -1,130 +1,6 @@
/**
* Filter's Options user can choose from
* Backward compatibility for 3.0.0 - Re-exports from new location
* TODO: Remove in 4.0.0
*/
export type FilterOption = {
readonly label: string;
readonly value: string;
};
/**
* Every currently implemented FilterType
*/
export enum FilterTypes {
TextInput = 'Text',
Picker = 'Picker',
CheckboxGroup = 'Checkbox',
Switch = 'Switch',
ExcludableCheckboxGroup = 'XCheckbox',
}
type SwitchFilter = {
type: FilterTypes.Switch;
/** Default value */
value: boolean;
};
type TextFilter = {
type: FilterTypes.TextInput;
/** Default value */
value: string;
};
type CheckboxFilter = {
type: FilterTypes.CheckboxGroup;
options: readonly FilterOption[];
/** Default value */
value: string[];
};
type PickerFilter = {
type: FilterTypes.Picker;
options: readonly FilterOption[];
/** Default value */
value: string;
};
type ExcludableCheckboxFilter = {
type: FilterTypes.ExcludableCheckboxGroup;
options: readonly FilterOption[];
/** Default value */
value: {
/** Checkboxes marked as included */
include?: string[];
/** Checkboxes marked as excluded */
exclude?: string[];
};
};
/**
* key - filter pairs
*/
export type Filters = Record<string, Filter<FilterTypes>>;
/** Mapping of each FilterType to a Filter */
type FilterFromType = {
[FilterTypes.CheckboxGroup]: CheckboxFilter;
[FilterTypes.ExcludableCheckboxGroup]: ExcludableCheckboxFilter;
[FilterTypes.Picker]: PickerFilter;
[FilterTypes.Switch]: SwitchFilter;
[FilterTypes.TextInput]: TextFilter;
};
/**
* Get type of a single filter type from the {@link FilterType}
*/
export type Filter<Type extends FilterTypes> = {
label: string;
} & FilterFromType[Type];
/**
* Strip {@link FilterObject} object from 'label' and 'options' to get key - filter_value pairs
* @see {@link ValueOfFilter}
*/
export type FilterToValues<
FilterObject extends Record<string, { type: FilterTypes }> | undefined,
> = FilterObject extends undefined
? undefined
: {
// copy the Filters object, but just get {value,type} pairs instead of the whole Filter object
[SingleFilter in keyof FilterObject]: FilterValueWithType<
FilterType<NonNullable<FilterObject>[SingleFilter]>
>;
};
/**
* Get value type for a {@link Filter} given it's FilterType
* @see {@link FilterTypes}
*/
export type ValueOfFilter<T extends FilterTypes> =
T extends FilterTypes.CheckboxGroup
? CheckboxFilter['value']
: T extends FilterTypes.Picker
? PickerFilter['value']
: T extends FilterTypes.Switch
? SwitchFilter['value']
: T extends FilterTypes.TextInput
? TextFilter['value']
: T extends FilterTypes.ExcludableCheckboxGroup
? ExcludableCheckboxFilter['value']
: never;
/** Get {@link Filter}'s type */
export type FilterType<T extends { type: unknown }> = T extends {
type: infer K;
}
? K extends FilterTypes
? K
: never
: never;
/** Get {type, value} types for given FilterType
* @see {@link ValueOfFilter}
*/
export type FilterValueWithType<T extends FilterTypes> = {
type: T;
value: ValueOfFilter<T>;
};
/**
* Any possible filter value
*/
export type AnyFilterValue = ValueOfFilter<FilterTypes>;
export { FilterTypes } from '../types/filters';
export type { Filters, Filter } from '../types/filters';
+5 -23
View File
@@ -1,23 +1,5 @@
export const isUrlAbsolute = (url: string) => {
if (url) {
if (url.indexOf('//') === 0) {
return true;
} // URL is protocol-relative (= absolute)
if (url.indexOf('://') === -1) {
return false;
} // URL has no protocol (= relative)
if (url.indexOf('.') === -1) {
return false;
} // URL does not contain a dot, i.e. no TLD (= relative, possibly REST)
if (url.indexOf('/') === -1) {
return false;
} // URL does not contain a single slash (= relative)
if (url.indexOf(':') > url.indexOf('/')) {
return false;
} // The first colon comes after the first slash (= relative)
if (url.indexOf('://') < url.indexOf('.')) {
return true;
} // Protocol is defined before first dot (= absolute)
}
return false; // Anything else must be relative
};
/**
* Backward compatibility for 3.0.0 - Re-exports from new location
* TODO: Remove in 4.0.0
*/
export { isUrlAbsolute } from '../lib/utils';
+5 -9
View File
@@ -1,9 +1,5 @@
export const NovelStatus = {
Unknown: 'Unknown',
Ongoing: 'Ongoing',
Completed: 'Completed',
Licensed: 'Licensed',
PublishingFinished: 'Publishing Finished',
Cancelled: 'Cancelled',
OnHiatus: 'On Hiatus',
} as const;
/**
* Backward compatibility for 3.0.0 - Re-exports from new location
* TODO: Remove in 4.0.0
*/
export { NovelStatus } from '../types/constants';
+7 -8
View File
@@ -1,17 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import 'cheerio';
import 'htmlparser2';
import 'dayjs';
import 'protobufjs';
import './index.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const { fetch: originalFetch } = window;
window.fetch = async (...args) => {
const [resource, config] = args;
if (resource.toString().includes('localhost'))
+160
View File
@@ -0,0 +1,160 @@
import React, { useMemo, useState } from 'react';
import { BookOpen, Search, Settings, Zap } from 'lucide-react';
import PluginHeader from '../components/plugin-header';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import plugins from '@plugins/index';
import { useAppStore } from '@/store';
import PopularNovelsSection from '@/components/popular-novels';
import SearchNovelsSection from '@/components/search-novels';
import ParseNovelSection from '@/components/parse-novel';
import SettingsSection from '@/components/settings';
import ParseChapterSection from '@/components/parse-chapter';
function Home() {
const { plugin, selectPlugin } = useAppStore(state => state);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState('popular');
const filteredPlugins = useMemo(
() =>
plugins.filter(plugin =>
plugin.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
[searchQuery],
);
return (
<div className="min-h-screen bg-background">
<PluginHeader selectedPlugin={plugin} />
<div className="flex h-[calc(100vh-64px)]">
<aside className="w-64 border-r bg-background flex flex-col">
<div className="p-6 flex-shrink-0 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Plugins
</h2>
<span className="text-xs text-muted-foreground">
{filteredPlugins.length}
</span>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search plugin..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="pl-10 h-9"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<div className="space-y-2">
{filteredPlugins.map(filteredPlugin => (
<button
key={filteredPlugin.id}
onClick={() => selectPlugin(filteredPlugin)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
filteredPlugin.id === plugin?.id
? 'bg-primary text-primary-foreground'
: 'text-foreground hover:bg-muted'
}`}
>
{filteredPlugin.name}
</button>
))}
</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-auto">
<div className="p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">
Plugin Playground
</h1>
<p className="text-muted-foreground">
Explore and test {plugin?.name || 'plugin'} features
</p>
</div>
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="grid w-full grid-cols-5 mb-8">
<TabsTrigger
value="popular"
className="flex items-center gap-2"
>
<BookOpen className="w-4 h-4" />
<span className="hidden sm:inline">Popular</span>
</TabsTrigger>
<TabsTrigger value="search" className="flex items-center gap-2">
<Search className="w-4 h-4" />
<span className="hidden sm:inline">Search</span>
</TabsTrigger>
<TabsTrigger
value="parse-novel"
className="flex items-center gap-2"
>
<Zap className="w-4 h-4" />
<span className="hidden sm:inline">Parse Novel</span>
</TabsTrigger>
<TabsTrigger
value="parse-chapter"
className="flex items-center gap-2"
>
<Zap className="w-4 h-4" />
<span className="hidden sm:inline">Parse Chapter</span>
</TabsTrigger>
<TabsTrigger
value="settings"
className="flex items-center gap-2"
>
<Settings className="w-4 h-4" />
<span className="hidden sm:inline">Settings</span>
</TabsTrigger>
</TabsList>
<TabsContent value="popular" className="space-y-6">
<PopularNovelsSection
onNavigateToParseNovel={() => setActiveTab('parse-novel')}
/>
</TabsContent>
<TabsContent value="search" className="space-y-6">
<SearchNovelsSection
onNavigateToParseNovel={() => setActiveTab('parse-novel')}
/>
</TabsContent>
<TabsContent value="parse-novel" className="space-y-6">
<ParseNovelSection
onNavigateToParseChapter={() => setActiveTab('parse-chapter')}
/>
</TabsContent>
<TabsContent value="parse-chapter" className="space-y-6">
<ParseChapterSection />
</TabsContent>
<TabsContent value="settings" className="space-y-6">
<SettingsSection />
</TabsContent>
</Tabs>
</div>
</main>
</div>
</div>
);
}
export default Home;
View File
-424
View File
@@ -1,424 +0,0 @@
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 dilartube implements Plugin.PluginBase {
id = 'dilartube';
name = 'dilar tube';
version = '1.0.1';
icon = 'src/ar/dilartube/icon.png';
site = 'https://golden.rest/';
parseNovels(data: ApiResponse): Plugin.NovelItem[] {
const novels: Plugin.NovelItem[] = [];
const seenTitles = new Set<string>();
if (data.data && data.data.length > 0) {
data.data
.filter(dataItem => dataItem.is_novel)
.forEach(dataItem => {
const manga = dataItem;
if (!seenTitles.has(dataItem.title)) {
seenTitles.add(manga.title);
novels.push({
name: dataItem.title || 'novel',
path: `mangas/${manga.id}`,
cover: manga.cover
? `${this.site}uploads/manga/cover/${manga.id}/${manga.cover}`
: defaultCover,
});
}
});
}
if (data.releases && data.releases.length > 0) {
data.releases
.filter(release => release.manga.is_novel)
.forEach(release => {
const manga = release.manga;
if (!seenTitles.has(manga.title)) {
seenTitles.add(manga.title);
novels.push({
name: manga.title || 'novel',
path: `mangas/${manga.id}`,
cover: manga.cover
? `${this.site}uploads/manga/cover/${manga.id}/${manga.cover}`
: defaultCover,
});
}
});
}
return novels;
}
async popularNovels(
page: number,
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions<Filters>,
): Promise<Plugin.NovelItem[]> {
let link = `${this.site}api/releases?page=${page}`;
if (showLatestNovels) {
link = `${this.site}api/releases?page=${page}`;
}
// if (filters) {
// if (
// Array.isArray(filters.categories.value) &&
// filters.categories.value.length > 0
// ) {
// filters.categories.value.forEach((genre: string) => {
// link += `&category=${genre}`;
// });
// }
// if (filters.status.value !== '') {
// link += `&status=${filters.status.value}`;
// }
// }
// link += `?page=${page}`;
// const body = await fetchApi(link).then(r => r.text());
const response = await fetchApi(link).then(r => r.json());
return this.parseNovels(response);
}
async parseNovel(novelUrl: string): Promise<Plugin.SourceNovel> {
const chapterItems: Plugin.ChapterItem[] = [];
let fullUrl = this.site + 'api/' + novelUrl;
let chapterUrl = this.site + 'api/' + novelUrl + '/releases';
const manga = await fetchApi(fullUrl).then(r => r.json());
const chapters = await fetchApi(chapterUrl).then(r => r.json());
const mangaData = manga.mangaData;
const chapterData = chapters.releases;
const novel: Plugin.SourceNovel = {
path: novelUrl,
name: mangaData.arabic_title || 'Untitled',
author:
(mangaData.authors.length > 0 ? mangaData.authors[0].name : '') ||
'Unknown',
summary: mangaData.summary || '',
cover: `${this.site}uploads/manga/cover/${mangaData.id}/${mangaData.cover}`,
chapters: [],
};
const translationStatusId: string = mangaData.translation_status;
const translationText =
{
'1': 'مستمره',
'0': 'منتهية',
'2': 'متوقفة',
'3': 'غير مترجمه',
}[translationStatusId] || 'غير معروف';
const statusWords = new Set(['مكتمل', 'جديد', 'مستمر']);
const mainGenres = mangaData.categories
.map((category: { name: any }) => category.name)
.join(',');
novel.genres = `${translationText},${mainGenres}`;
const statusId: string = mangaData.story_status;
const statusText =
{
'2': 'Ongoing',
'3': 'Completed',
}[statusId] || 'Unknown';
novel.status = statusText;
chapterData.map((item: ChapterRelease) => {
chapterItems.push({
name: item.title,
releaseTime: new Date(item.created_at).toISOString(),
path: `${novelUrl}/${mangaData.title.replace(' ', '-')}/${item.chapter}`,
chapterNumber: item.chapter,
});
});
novel.chapters = chapterItems.reverse();
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);
const jsonData = loadedCheerio('script.js-react-on-rails-component').html();
const parsedData = JSON.parse(jsonData as string);
const chapterText = parsedData.readerDataAction.readerData.release.content;
return chapterText;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const formData = new FormData();
formData.append('query', searchTerm);
formData.append('includes', '["Manga","Team","Member"]');
const response = await fetchApi('https://dilar.tube/api/quick_search', {
method: 'POST',
body: formData,
}).then(r => r.json());
const data: ApiResponse = response[0];
return this.parseNovels(data);
}
// filters = {
// types: {
// value: [],
// label: 'الأنواع',
// options: [
// { label: 'يابانية', value: '1' }, // Japanese
// { label: 'كورية', value: '2' }, // Korean
// { label: 'صينية', value: '3' }, // Chinese
// { label: 'عربية', value: '4' }, // Arabic
// { label: 'كوميك', value: '5' }, // Comic
// { label: 'هواة', value: '6' }, // Amateur
// { label: 'إندونيسية', value: '7' }, // Indonesian
// { label: 'روسية', value: '8' }, // Russian
// ],
// type: FilterTypes.CheckboxGroup,
// },
// status: {
// value: '',
// label: 'الحالة',
// options: [
// { label: 'مستمرة', value: '2' }, // Ongoing
// { label: 'منتهية', value: '3' }, // Completed
// ],
// type: FilterTypes.CheckboxGroup,
// },
// translation: {
// value: [],
// label: 'الترجمة',
// options: [
// { label: 'منتهية', value: '0' }, // Completed
// { label: 'مستمرة', value: '1' }, // Ongoing
// { label: 'متوقفة', value: '2' }, // Paused
// { label: 'غير مترجمة', value: '3' }, // Not Translated
// ],
// type: FilterTypes.CheckboxGroup,
// },
// } satisfies Filters;
}
export default new dilartube();
interface Category {
id: number;
name: string;
icon: string | null;
manga_id: number;
}
interface Type {
id: number;
name: string;
reading_direction: string;
title: string;
}
interface Manga {
id: number;
title: string;
summary: string;
is_novel: boolean;
is_oneshot: boolean;
genre_id: number;
cover: string;
cover_pos: number;
rectangle_cover_pos: number;
banner: string;
manga_type_id: number;
reading_direction: string;
story_status: number;
translation_status: number;
vols: number;
chaps: number;
reviewed: boolean;
banned: boolean;
rating: string;
rates_count: number;
commentable: boolean;
show_comments: boolean;
deleted_at: string | null;
delete_reason: string;
time_stamp: number;
latest_chapterization_id: number;
uniq_visitors_count: number;
publisher_id: number | null;
publisher_name: string | null;
discord_url: string | null;
mobile_exclusive: boolean;
authors: any[];
artists: any[];
categories: Category[];
type: Type;
}
interface Release {
id: number;
manga_id: number;
created_at: string;
time_stamp: number;
views: number;
encoded: boolean;
showable: boolean;
link_control: number;
support_link: string;
creator_id: number;
chapterization_id: number;
chapter: number;
volume: number;
title: string;
team_id: number;
team_name: string;
team_rating: string;
team_settings: string;
team_paypal: string | null;
has_rev_link: boolean;
manga: Manga;
}
interface ApiResponse {
releases: Release[];
data: searchManga[];
}
type MangaCategory = {
id: number;
name: string;
icon: string | null;
manga_id: number;
};
type AuthorArtist = {
id: number;
name: string;
manga_id: number;
role: string;
};
type MangaType = {
id: number;
name: string;
reading_direction: string;
title: string;
};
type MangaData = {
id: number;
title: string;
summary: string;
is_novel: boolean;
is_oneshot: boolean;
genre_id: number;
cover: string;
cover_pos: number;
rectangle_cover_pos: number;
banner: string;
manga_type_id: number;
reading_direction: string;
story_status: number;
translation_status: number;
vols: number;
chaps: number;
reviewed: boolean;
banned: boolean;
rating: string;
rates_count: number;
commentable: boolean;
show_comments: boolean;
deleted_at: string | null;
delete_reason: string | null;
time_stamp: number;
latest_chapterization_id: number;
uniq_visitors_count: number;
publisher_id: number | null;
publisher_name: string | null;
arabic_title: string;
english: string;
synonyms: string;
japanese: string;
over17: boolean;
s_date: string;
e_date: string | null;
mal: string;
mangaupdates: string;
mangadex: string | null;
anilist: string;
official_site: string | null;
manga_fox: string | null;
wikia: string | null;
mangarock_oid: string | null;
comicvine_id: string | null;
external_source: string | null;
discord_url: string | null;
discord_id: string | null;
anime_start_end: string | null;
creator_id: number;
creator_nick: string;
editor_id: number;
editor_nick: string;
mobile_exclusive: boolean;
authors: AuthorArtist[];
artists: AuthorArtist[];
categories: MangaCategory[];
type: MangaType;
memberRating: number;
};
type MangaResponse = {
membersMentioning: any[];
memberRates: any | null;
mangaLogs: Record<string, any>;
mangaData: MangaData;
};
type ChapterRelease = {
id: number;
manga_id: number;
created_at: string; // ISO 8601 date string
time_stamp: number;
views: number;
link_control: number;
support_link: string;
init_team: number;
creator_id: number;
chapterization_id: number;
chapterization_time_stamp: number;
chapter: number;
volume: number;
title: string;
team_id: number;
team_name: string;
has_rev_link: boolean;
};
interface searchManga {
filter: any;
id: number;
title: string;
summary: string;
is_novel: boolean;
is_oneshot: boolean;
genre_id: number;
cover: string;
cover_pos: number;
rectangle_cover_pos: number;
banner: string;
manga_type_id: number;
reading_direction: string;
story_status: number;
translation_status: number;
vols: number;
chaps: number;
reviewed: boolean;
banned: boolean;
rating: string;
rates_count: number;
commentable: boolean;
show_comments: boolean;
deleted_at: string | null;
delete_reason: string;
time_stamp: number;
latest_chapterization_id: number;
uniq_visitors_count: number;
publisher_id: number | null;
publisher_name: string | null;
discord_url: string | null;
mobile_exclusive: boolean;
authors: any[];
artists: any[];
categories: Category[];
type: Type;
}
-264
View File
@@ -1,264 +0,0 @@
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 RewayatClub implements Plugin.PagePlugin {
id = 'rewayatclub';
name = 'Rewayat Club';
version = '1.0.2';
icon = 'src/ar/rewayatclub/icon.png';
site = 'https://rewayat.club/';
parseNovels(data: NovelData): Plugin.NovelItem[] {
const novels: Plugin.NovelItem[] = [];
if (data.results === undefined) return novels;
data.results.map((item: NovelEntry) => {
novels.push({
name: item.arabic || item.novel?.arabic || 'novel',
path: item.slug
? `novel/${item.slug}`
: item.novel
? `novel/${item.novel.slug}`
: 'novel',
cover: item.poster_url
? `https://api.rewayat.club/${item.poster_url.slice(1)}`
: item.novel
? `https://api.rewayat.club/${item.novel.poster_url.slice(1)}`
: defaultCover,
});
});
return novels;
}
async popularNovels(
page: number,
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions<Filters>,
): Promise<Plugin.NovelItem[]> {
let link = `https://api.rewayat.club/api/novels/`;
let body: NovelData = {
count: 0,
next: '',
previous: '',
results: [],
};
if (showLatestNovels) {
link = `${this.site}api/chapters/weekly/list/?page=${page}`;
body = await fetchApi(link).then(r => r.json());
} else if (filters) {
if (filters.categories.value !== '') {
link += `?type=${filters.categories.value}`;
}
if (filters.sortOptions.value !== '') {
link += `&ordering=${filters.sortOptions.value}`;
}
if (filters.genre.value.length > 0) {
filters.genre.value.forEach((genre: string) => {
link += `&genre=${genre}`;
});
}
link += `&page=${page}`;
body = await fetchApi(link).then(r => r.json());
}
return this.parseNovels(body);
}
async parseNovel(
novelUrl: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const result = await fetchApi(new URL(novelUrl, this.site).toString());
const body = await result.text();
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelUrl,
name: loadedCheerio('h1.primary--text span').text().trim() || 'Untitled',
author: loadedCheerio('.novel-author').text().trim(),
summary: loadedCheerio('div.text-pre-line span').text().trim(),
totalPages: 1,
chapters: [],
};
const statusWords = new Set(['مكتملة', 'متوقفة', 'مستمرة']);
const mainGenres = Array.from(loadedCheerio('.v-slide-group__content a'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
const statusGenre = Array.from(
loadedCheerio('div.v-slide-group__content span.v-chip__content'),
)
.map(el => loadedCheerio(el).text().trim())
.filter(text => statusWords.has(text));
novel.genres = `${statusGenre},${mainGenres}`;
const statusText = Array.from(
loadedCheerio('div.v-slide-group__content span.v-chip__content'),
)
.map(el => loadedCheerio(el).text().trim())
.filter(text => statusWords.has(text))
.join();
novel.status =
{
'متوقفة': 'On Hiatus',
'مكتملة': 'Completed',
'مستمرة': 'Ongoing',
}[statusText] || 'Unknown';
const imageRaw = loadedCheerio('body script:contains("__NUXT__")')
.first()
.text();
const imageUrlRegex = /poster_url:"(\\u002F[^"]+)"/;
const imageUrlMatch = imageRaw?.match(imageUrlRegex);
const ImageUrlShort = imageUrlMatch
? imageUrlMatch[1].replace(/\\u002F/g, '/').replace(/^\/*/, '')
: defaultCover;
const imageUrl = `https://api.rewayat.club/${ImageUrlShort}`;
novel.cover = imageUrl;
const chapterNumberStr = loadedCheerio('div.v-tab--active span.mr-1')
.text()
.replace(/[^\d]/g, '');
const chapterNumber = parseInt(chapterNumberStr, 10);
const pageNumber = Math.ceil(chapterNumber / 24);
novel.totalPages = pageNumber;
return novel;
}
parseChapters(data: ChapterData, novelPath: string) {
const chapter: Plugin.ChapterItem[] = [];
data.results.map((item: ChapterEntry) => {
chapter.push({
name: item.title,
releaseTime: new Date(item.date).toISOString(),
path: `${novelPath}/${item.number}`,
chapterNumber: item.number,
});
});
return chapter;
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const pagePath = novelPath.slice(6);
const pageUrl = `https://api.rewayat.club/api/chapters/${pagePath}/?ordering=number&page=${page}`;
const dataJson = await fetchApi(pageUrl).then(r => r.json());
const chapters = this.parseChapters(dataJson, novelPath);
return {
chapters,
};
}
async parseChapter(chapterUrl: string): Promise<string> {
let link = this.site + 'api/chapters/' + chapterUrl.slice(6);
const result = await fetchApi(link).then(r => r.json());
let chapterText = result.content
.flat()
.join('<br>')
.replace(/\n/g, '')
.replace(/<p>/g, '\n');
chapterText = chapterText.trim();
return chapterText;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const searchUrl = `https://api.rewayat.club/api/novels/?type=0&ordering=-num_chapters&page=${page}&search=${searchTerm}`;
const result = await fetchApi(searchUrl).then(r => r.json());
return this.parseNovels(result);
}
filters = {
genre: {
value: [],
label: 'Genres',
options: [
{ label: 'كوميديا', value: '1' }, // Comedy
{ label: 'أكشن', value: '2' }, // Action
{ label: 'دراما', value: '3' }, // Drama
{ label: 'فانتازيا', value: '4' }, // Fantasy
{ label: 'مهارات القتال', value: '5' }, // Combat Skills
{ label: 'مغامرة', value: '6' }, // Adventure
{ label: 'رومانسي', value: '7' }, // Romance
{ label: 'خيال علمي', value: '8' }, // Science Fiction
{ label: 'الحياة المدرسية', value: '9' }, // School Life
{ label: 'قوى خارقة', value: '10' }, // Super Powers
{ label: 'سحر', value: '11' }, // Magic
{ label: 'رياضة', value: '12' }, // Sports
{ label: 'رعب', value: '13' }, // Horror
{ label: 'حريم', value: '14' }, // Harem
],
type: FilterTypes.CheckboxGroup,
},
categories: {
value: '0',
label: 'الفئات',
options: [
{ label: 'جميع الروايات', value: '0' },
{ label: 'مترجمة', value: '1' },
{ label: 'مؤلفة', value: '2' },
{ label: 'مكتملة', value: '3' },
],
type: FilterTypes.Picker,
},
sortOptions: {
value: '-num_chapters',
label: 'الترتيب',
options: [
{ label: 'عدد الفصول - من أقل ﻷعلى', value: 'num_chapters' },
{ label: 'عدد الفصول - من أعلى ﻷقل', value: '-num_chapters' },
{ label: 'الاسم - من أقل ﻷعلى', value: 'english' },
{ label: 'الاسم - من أعلى ﻷقل', value: '-english' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new RewayatClub();
interface NovelEntry {
arabic: string;
english: string;
about: string;
poster_url: string;
slug: string;
original: boolean;
complete: boolean;
num_chapters: number;
genre: {
id: number;
arabic: string;
english: string;
};
novel?: {
arabic: string;
english: string;
slug: string;
poster: string;
id: number;
poster_url: string;
original: boolean;
};
}
interface NovelData {
count?: number;
next?: string;
previous?: string;
results: NovelEntry[];
}
interface ChapterEntry {
number: number;
title: string;
date: string;
uploader: {
username: string;
id: number;
};
hitcounts: {
hits: number;
id: number;
};
read: any[];
}
interface ChapterData {
count: number;
next: string;
previous: string;
results: ChapterEntry[];
}
-306
View File
@@ -1,306 +0,0 @@
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 Sunovels implements Plugin.PagePlugin {
id = 'sunovels';
name = 'Sunovels';
version = '1.0.0';
icon = 'src/ar/sunovels/icon.png';
site = 'https://sunovels.com/';
parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] {
const novels: Plugin.NovelItem[] = [];
const imageUrlList: string[] = [];
loadedCheerio('script').each((idx, ele) => {
const regax = /\/uploads\/[^\s"]+/g;
const scriptText = loadedCheerio(ele).text();
const imageUrlMatched = scriptText.match(regax);
if (imageUrlMatched) {
imageUrlList.push(...imageUrlMatched);
}
});
let counter: number = 0;
loadedCheerio('.list-item').each((idx, ele) => {
loadedCheerio(ele)
.find('a')
.each((idx, ele) => {
const novelName = loadedCheerio(ele).find('h4').text().trim();
const novelUrl =
loadedCheerio(ele).attr('href')?.trim().replace(/^\/*/, '') || '';
let novelCover = defaultCover;
if (imageUrlList.length > 0) {
novelCover = this.site + imageUrlList[counter].slice(1);
} else {
const imageUrl = loadedCheerio(ele).find('img').attr('src');
novelCover = this.site + imageUrl?.slice(1);
}
const novel = {
name: novelName,
cover: novelCover,
path: novelUrl,
};
counter++;
novels.push(novel);
});
});
return novels;
}
async popularNovels(
page: number,
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions<Filters>,
): Promise<Plugin.NovelItem[]> {
const pageCorrected = page - 1;
let link = `${this.site}library?`;
if (filters) {
if (
Array.isArray(filters.categories.value) &&
filters.categories.value.length > 0
) {
filters.categories.value.forEach((genre: string) => {
link += `&category=${genre}`;
});
}
if (filters.status.value !== '') {
link += `&status=${filters.status.value}`;
}
}
link += `&page=${pageCorrected}`;
const body = await fetchApi(link).then(r => r.text());
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
async parseNovel(
novelUrl: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const result = await fetchApi(new URL(novelUrl, this.site).toString());
const body = await result.text();
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelUrl,
name: loadedCheerio('div.main-head h3').text().trim() || 'Untitled',
author: loadedCheerio('.novel-author').text().trim(),
summary: loadedCheerio('section.info-section div.description p')
.text()
.trim(),
totalPages: 1,
chapters: [],
};
const statusWords = new Set(['مكتمل', 'جديد', 'مستمر']);
const mainGenres = Array.from(loadedCheerio('div.categories li.tag'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
const statusGenre = Array.from(
loadedCheerio('div.header-stats span').eq(3).find('strong'),
)
.map(el => loadedCheerio(el).text().trim())
.filter(text => statusWords.has(text));
novel.genres = `${statusGenre},${mainGenres}`;
const statusText = Array.from(
loadedCheerio('div.header-stats span').eq(3).find('strong'),
)
.map(el => loadedCheerio(el).text().trim())
.filter(text => statusWords.has(text))
.join();
novel.status =
{
'جديد': 'Ongoing',
'مكتمل': 'Completed',
'مستمر': 'Ongoing',
}[statusText] || 'Unknown';
const imageUrl = loadedCheerio('div.img-container figure.cover img').attr(
'src',
);
const imageUrlFull = this.site + imageUrl?.slice(1);
novel.cover = imageUrlFull;
const chapterNumberStr = loadedCheerio('div.header-stats span')
.first()
.text()
.replace(/[^\d]/g, '');
const chapterNumber = parseInt(chapterNumberStr, 10);
const pageNumber = Math.ceil(chapterNumber / 50);
novel.totalPages = pageNumber;
return novel;
}
parseChapters(data: { chapters: ChapterEntry[] }) {
const chapter: Plugin.ChapterItem[] = [];
data.chapters.map((item: ChapterEntry) => {
chapter.push({
name: item.chapterName,
releaseTime: new Date(item.releaseTime).toISOString(),
path: item.chapterUrl,
chapterNumber: item.chapterNumber,
});
});
return chapter;
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const numPage = parseInt(page, 10);
const pageCorrected = numPage - 1;
const pagePath = novelPath;
const firstUrl = this.site + pagePath;
const pageUrl = firstUrl + '?activeTab=chapters&page=' + pageCorrected;
const body = await fetchApi(pageUrl).then(r => r.text());
const loadedCheerio = parseHTML(body);
let dataJson: {
pages_count: string;
chapters: ChapterEntry[];
} = { pages_count: '', chapters: [] };
const chaptersinfo: {
chapterName: string;
chapterUrl: string;
releaseTime: string;
chapterNumber: string | number;
}[] = [];
loadedCheerio('ul.chaptersList a').each((i, el) => {
const chapterName: string = loadedCheerio(el).attr('title') ?? '';
const chapterUrl = loadedCheerio(el)
.attr('href')
?.trim()
.replace(/^\/*/, '');
const dateAttr = loadedCheerio(el)
.find('time.chapter-update')
.attr('datetime');
const date = new Date(dateAttr);
const releaseTime = date.toISOString();
const chapternumber = loadedCheerio(el)
.find('strong.chapter-title')
.text()
.replace(/[^\d٠-٩]/g, '');
const chapterNumber = parseInt(chapternumber, 10);
chaptersinfo.push({
chapterName: chapterName,
chapterUrl: chapterUrl || '',
releaseTime: releaseTime || '',
chapterNumber: chapterNumber || '',
});
});
const pagecount = loadedCheerio('ul.pagination a.active').text();
dataJson.pages_count = pagecount;
dataJson.chapters = chaptersinfo;
const chapters = this.parseChapters(dataJson);
return {
chapters,
};
}
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);
let chapterText = '';
loadedCheerio('div.chapter-content').each((idx, ele) => {
loadedCheerio(ele)
.find('p')
.not('.d-none')
.each((idx, textEle) => {
chapterText +=
loadedCheerio(textEle)
.map((_, pEle) => loadedCheerio(pEle).text().trim())
.get()
.join(' ') + ' ';
});
});
chapterText = chapterText.trim();
return chapterText;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const searchUrl = `${this.site}search?page=${page}&title=${searchTerm}`;
const result = await fetchApi(searchUrl);
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
filters = {
categories: {
value: [],
label: 'التصنيفات',
options: [
{ label: 'Wuxia', value: 'Wuxia' },
{ label: 'Xianxia', value: 'Xianxia' },
{ label: 'XUANHUAN', value: 'XUANHUAN' },
{ label: 'أصلية', value: 'أصلية' }, // Original
{ label: 'أكشن', value: 'أكشن' }, // Action
{ label: 'إثارة', value: 'إثارة' }, // Thriller
{ label: 'إنتقال الى عالم أخر', value: 'إنتقال+الى+عالم+أخر' }, // Isekai
{ label: 'إيتشي', value: 'إيتشي' }, // Ecchi
{ label: 'الخيال العلمي', value: 'الخيال+العلمي' }, // Science Fiction
{ label: 'بوليسي', value: 'بوليسي' }, // Detective
{ label: 'تاريخي', value: 'تاريخي' }, // Historical
{ label: 'تقمص شخصيات', value: 'تقمص+شخصيات' }, // Roleplaying
{ label: 'جريمة', value: 'جريمة' }, // Crime
{ label: 'جوسى', value: 'جوسى' }, // Josei
{ label: 'حريم', value: 'حريم' }, // Harem
{ label: 'حياة مدرسية', value: 'حياة+مدرسية' }, // School Life
{ label: 'خارقة للطبيعة', value: 'خارقة+للطبيعة' }, // Supernatural
{ label: 'خيالي', value: 'خيالي' }, // Fantasy
{ label: 'دراما', value: 'دراما' }, // Drama
{ label: 'رعب', value: 'رعب' }, // Horror
{ label: 'رومانسي', value: 'رومانسي' }, // Romance
{ label: 'سحر', value: 'سحر' }, // Magic
{ label: 'سينن', value: 'سينن' }, // Seinen
{ label: 'شريحة من الحياة', value: 'شريحة+من+الحياة' }, // Slice of Life
{ label: 'شونين', value: 'شونين' }, // Shounen
{ label: 'غموض', value: 'غموض' }, // Mystery
{ label: 'فنون القتال', value: 'فنون+القتال' }, // Martial Arts
{ label: 'قوى خارقة', value: 'قوى+خارقة' }, // Super Powers
{ label: 'كوميدى', value: 'كوميدى' }, // Comedy
{ label: 'مأساوي', value: 'مأساوي' }, // Tragedy
{ label: 'ما بعد الكارثة', value: 'ما+بعد+الكارثة' }, // Post-Apocalypse
{ label: 'مغامرة', value: 'مغامرة' }, // Adventure
{ label: 'ميكا', value: 'ميكا' }, // Mecha
{ label: 'ناضج', value: 'ناضج' }, // Mature
{ label: 'نفسي', value: 'نفسي' }, // Psychological
{ label: 'فانتازيا', value: 'فانتازيا' }, // Fantasy
{ label: 'رياضة', value: 'رياضة' }, // Sports
{ label: 'ابراج', value: 'ابراج' }, // Astrology
{ label: 'الالهة', value: 'الالهة' }, // Deities
{ label: 'شياطين', value: 'شياطين' }, // Demons
{ label: 'السفر عبر الزمن', value: 'السفر+عبر+الزمن' }, // Time Travel
{ label: 'رواية صينية', value: 'رواية+صينية' }, // Chinese Novel
{ label: 'رواية ويب', value: 'رواية+ويب' }, // Web Novel
{ label: 'لايت نوفل', value: 'لايت+نوفل' }, // Light Novel
{ label: 'كوري', value: 'كوري' }, // Korean
{ label: '+18', value: '%2B18' }, // +18
{ label: 'إيسكاي', value: 'إيسكاي' }, // Isekai
{ label: 'ياباني', value: 'ياباني' }, // Japanese
{ label: 'مؤلفة', value: 'مؤلفة' }, // Authored
],
type: FilterTypes.CheckboxGroup,
},
status: {
value: '',
label: 'الحالة',
options: [
{ label: 'جميع الروايات', value: '' },
{ label: 'مكتمل', value: 'Completed' },
{ label: 'جديد', value: 'New' },
{ label: 'مستمر', value: 'Ongoing' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Sunovels();
interface ChapterEntry {
chapterName: string;
chapterUrl: string;
releaseTime: string;
chapterNumber: string | number;
}
-297
View File
@@ -1,297 +0,0 @@
import { load as parseHTML } from 'cheerio';
import { fetchText } from '@libs/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@typings/plugin';
import { NovelStatus } from '@libs/novelStatus';
class Shu69 implements Plugin.PluginBase {
private fetchOptions = {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0',
'Accept':
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,us;q=0.5',
'Referer': 'https://www.69shu.xyz/', // Referer
'DNT': '1', // Do Not Track
'Upgrade-Insecure-Requests': '1', // Upgrade-Insecure-Requests
},
};
id = '69shu';
name = '69书吧';
icon = 'src/cn/69shu/icon.png';
site = 'https://www.69shu.xyz';
version = '0.2.2';
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let url: string;
if (showLatestNovels) {
url = `${this.site}/rank/lastupdate/${pageNo}.html`;
} else if (filters.sort.value === 'none') {
url = `${this.site}/rank/${filters.rank.value}/${pageNo}.html`;
} else {
url = `${this.site}/sort/${filters.sort.value}/${pageNo}.html`;
}
const body = await fetchText(url, this.fetchOptions);
if (body === '') throw Error('无法获取小说列表,请检查网络');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('div.book-coverlist').each((i, el) => {
const url = loadedCheerio(el).find('a.cover').attr('href');
const novelName = loadedCheerio(el).find('h4.name').text().trim();
const novelCover = loadedCheerio(el).find('a.cover > img').attr('src');
if (!url) return;
const novel = {
name: novelName,
cover: novelCover,
path: url.replace(this.site, ''),
};
novels.push(novel);
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const body = await fetchText(url, this.fetchOptions);
if (body === '') throw Error('无法获取小说内容,请检查网络');
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
chapters: [],
name: loadedCheerio('h1').text().trim(),
};
novel.cover = loadedCheerio('div.cover > img').attr('src');
novel.summary = loadedCheerio('#bookIntro').text().trim();
const bookInfo = loadedCheerio('div.caption-bookinfo > p');
novel.author = bookInfo.find('a').attr('title');
novel.artist = undefined;
novel.status = bookInfo.text().includes('连载')
? NovelStatus.Ongoing
: NovelStatus.Completed;
novel.genres = '';
// Table of Content is on a different page than the summary page
const chapters: Plugin.ChapterItem[] = [];
const allUrl = loadedCheerio('dd.all > a').attr('href');
if (allUrl) {
// --- Start: Fetch chapters with pagination (Sequential) ---
let currentChaptersUrl = new URL(allUrl, this.site).toString();
let hasMorePages = true;
while (hasMorePages) {
const chaptersBody = await fetchText(
currentChaptersUrl,
this.fetchOptions,
);
const chaptersLoadedCheerio = parseHTML(chaptersBody);
// Extract chapters from the current page
chaptersLoadedCheerio('dl.panel-chapterlist dd').each((i, el) => {
const chapterUrl = chaptersLoadedCheerio(el).find('a').attr('href');
const chapterName = chaptersLoadedCheerio(el).find('a').text().trim();
if (chapterUrl) {
// Ensure relative path, handle both absolute/relative cases
const relativeChapterUrl = chapterUrl.startsWith('http')
? chapterUrl.replace(this.site, '')
: chapterUrl;
// Avoid duplicates if the same chapter appears on multiple pages (unlikely but safe)
if (!chapters.some(chap => chap.path === relativeChapterUrl)) {
chapters.push({
name: chapterName,
path: relativeChapterUrl,
});
}
}
});
// Find the link to the next page using the text "下一页"
const nextPageLinkElement = chaptersLoadedCheerio(
'div.listpage a.onclick',
).filter((i, el) =>
chaptersLoadedCheerio(el).text().includes('下一页'),
);
const nextPageLink = nextPageLinkElement.attr('href');
if (nextPageLink && nextPageLink !== 'javascript:void(0);') {
// Check if it's a valid relative or absolute URL before creating the URL object
try {
const absoluteNextPageUrl = new URL(
nextPageLink,
this.site,
).toString();
if (absoluteNextPageUrl === currentChaptersUrl) {
// Break if the next page URL is the same as the current one (prevents infinite loops)
hasMorePages = false;
} else {
currentChaptersUrl = absoluteNextPageUrl;
}
} catch (e) {
// Handle cases where the link might be invalid or unexpected
console.warn(`Invalid next page link found: ${nextPageLink}`);
hasMorePages = false;
}
} else {
hasMorePages = false;
}
}
// --- End: Fetch chapters with pagination (Sequential) ---
} else {
// Fallback if no "all chapters" link is found
loadedCheerio(
'div.panel.hidden-xs > dl.panel-chapterlist:nth-child(2) > dd',
).each((i, el) => {
const chapterUrl = loadedCheerio(el).find('a').attr('href');
const chapterName = loadedCheerio(el).find('a').text().trim();
if (chapterUrl) {
const relativeChapterUrl = chapterUrl.startsWith('http')
? chapterUrl.replace(this.site, '')
: chapterUrl;
chapters.push({
name: chapterName,
path: relativeChapterUrl,
});
}
});
}
// Remove duplicates just in case (though less likely with sequential fetching)
const uniqueChapters = chapters.filter(
(chapter, index, self) =>
index === self.findIndex(c => c.path === chapter.path),
);
novel.chapters = uniqueChapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const chapterUrl = new URL(chapterPath, this.site).toString();
const body = await fetchText(chapterUrl, this.fetchOptions); // Header hinzugefügt
const loadedCheerio = parseHTML(body);
const chapterText = loadedCheerio('#chaptercontent p')
.map((i, el) => loadedCheerio(el).text())
.get()
// remove empty lines and 69shu ads
.map((line: string) => line.trim())
.filter((line: string) => line !== '' && !line.includes('69书吧'))
.map((line: string) => `<p>${line}</p>`)
.join('\n');
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo > 1) return [];
const searchUrl = `${this.site}/search`;
const formData = new FormData();
formData.append('searchkey', searchTerm);
const searchOptions = {
...this.fetchOptions,
method: 'post',
body: formData,
headers: {
...this.fetchOptions.headers,
},
};
const body = await fetchText(searchUrl, searchOptions);
if (body === '') throw Error('无法获取搜索结果,请检查网络');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('div.book-coverlist').each((i, el) => {
const url = loadedCheerio(el).find('a.cover').attr('href');
const novelName = loadedCheerio(el).find('h4.name').text().trim();
const novelCover = loadedCheerio(el).find('a.cover > img').attr('src');
if (!url) return;
const novel = {
name: novelName,
cover: novelCover,
path: url.replace(this.site, ''),
};
novels.push(novel);
});
return novels;
}
filters = {
rank: {
label: '排行榜',
value: 'allvisit',
options: [
{ label: '总排行榜', value: 'allvisit' },
{ label: '月排行榜', value: 'monthvisit' },
{ label: '周排行榜', value: 'weekvisit' },
{ label: '日排行榜', value: 'dayvisit' },
{ label: '收藏榜', value: 'goodnum' },
{ label: '字数榜', value: 'words' },
{ label: '推荐榜', value: 'allvote' },
{ label: '新书榜', value: 'postdate' },
{ label: '更新榜', value: 'lastupdate' },
],
type: FilterTypes.Picker,
},
sort: {
label: '分类',
value: 'none',
options: [
{ label: '无', value: 'none' },
{ label: '全部', value: 'all' },
{ label: '玄幻', value: 'xuanhuan' },
{ label: '仙侠', value: 'xianxia' },
{ label: '都市', value: 'dushi' },
{ label: '历史', value: 'lishi' },
{ label: '游戏', value: 'youxi' },
{ label: '科幻', value: 'kehuan' },
{ label: '灵异', value: 'kongbu' },
{ label: '言情', value: 'nvsheng' },
{ label: '其它', value: 'qita' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Shu69();
-201
View File
@@ -1,201 +0,0 @@
import { load as parseHTML } from 'cheerio';
import { fetchText } from '@libs/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@typings/plugin';
import { encode } from 'urlencode';
import { NovelStatus } from '@libs/novelStatus';
class XinShu69 implements Plugin.PluginBase {
id = '69xinshu';
name = '69书吧';
icon = 'src/cn/69xinshu/icon.png';
site = 'https://69shu.biz';
version = '0.1.2';
async popularNovels(
pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let url: string;
if (pageNo === 1) {
url = `${this.site}/novels/class/${filters.class.value}.htm`;
} else {
url = `${this.site}/ajax_novels/class/${filters.class.value}/${pageNo}.htm`;
}
const body = await fetchText(url, {}, 'gbk');
if (body === '') throw Error('无法获取小说列表,请检查网络');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
const novelsList =
pageNo === 1
? loadedCheerio('.newlistbox > ul > li')
: loadedCheerio('li');
novelsList.each((i, e) => {
const novelUrl = loadedCheerio(e).find('div > h3 > a').attr('href');
if (novelUrl) {
const novelName = loadedCheerio(e).find('div > h3 > a').text();
const novelCover = loadedCheerio(e).find('a > img').attr('data-src');
const novel = {
name: novelName,
cover: novelCover,
path: novelUrl.replace(this.site, ''),
};
novels.push(novel);
}
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const body = await fetchText(url, {}, 'gbk');
if (body === '') throw Error('无法获取小说内容,请检查网络');
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
chapters: [],
name: loadedCheerio('h1 > a').text(),
};
novel.cover = loadedCheerio('div.bookimg2 > img').attr('src');
novel.summary = loadedCheerio('div.navtxt').text().trim();
const bookInfo = loadedCheerio('div.booknav2').text();
const authorMatch = bookInfo.match(/作者:(.*)/);
if (authorMatch) {
novel.author = authorMatch[1];
}
novel.artist = undefined;
novel.status = bookInfo.includes('连载')
? NovelStatus.Ongoing
: NovelStatus.Completed;
const genresMatch = bookInfo.match(/分类:(.*)/);
if (genresMatch) {
novel.genres = genresMatch[1];
}
// Table of Content is on a different page than the summary page
const chapters: Plugin.ChapterItem[] = [];
const chaptersUrl = loadedCheerio('a.more-btn').attr('href');
if (chaptersUrl) {
const chaptersBody = await fetchText(chaptersUrl, {}, 'gbk');
const chaptersLoadedCheerio = parseHTML(chaptersBody);
chaptersLoadedCheerio('li').each((i, e) => {
const chapterUrl = chaptersLoadedCheerio(e).find('a').attr('href');
if (chapterUrl?.startsWith('https://')) {
const chapterName = chaptersLoadedCheerio(e).find('a').text().trim();
chapters.push({
name: chapterName,
path: chapterUrl.replace(this.site, ''),
});
}
});
}
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const body = await fetchText(this.site + chapterPath, {}, 'gbk');
const loadedCheerio = parseHTML(body);
const chapterText = (loadedCheerio('div.txtnav').prop('innerText') ?? '')
.split('\n')
// remove empty lines
.map((line: string) => line.trim())
.filter((line: string) => line !== '')
// remove the first two lines which are the chapter name and author name
.slice(2)
.map((line: string) => `<p>${line}</p>`)
.join('\n');
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo > 1) return [];
const searchUrl = `${this.site}/modules/article/search.php`;
const formData = new FormData();
formData.append('searchkey', encode(searchTerm, 'gbk'));
formData.append('searchtype', 'all');
const body = await fetchText(
searchUrl,
{ method: 'post', body: formData },
'gbk',
);
if (body === '') throw Error('无法获取搜索结果,请检查网络');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.newbox > ul > li').each((i, e) => {
const novelUrl = loadedCheerio(e).find('a').attr('href')!;
const novelName = loadedCheerio(e).find('div.newnav > h3').text();
const novelCover = loadedCheerio(e).find('img').attr('data-src');
const novel = {
name: novelName,
path: novelUrl.replace(this.site, ''),
cover: novelCover,
};
novels.push(novel);
});
return novels;
}
filters = {
class: {
label: '分类',
value: '0',
options: [
{ label: '全部分类', value: '0' },
{ label: '言情小说', value: '3' },
{ label: '玄幻魔法', value: '1' },
{ label: '修真武侠', value: '2' },
{ label: '穿越时空', value: '11' },
{ label: '都市小说', value: '9' },
{ label: '历史军事', value: '4' },
{ label: '游戏竞技', value: '5' },
{ label: '科幻空间', value: '6' },
{ label: '悬疑惊悚', value: '7' },
{ label: '同人小说', value: '8' },
{ label: '官场职场', value: '10' },
{ label: '青春校园', value: '12' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new XinShu69();
-415
View File
@@ -1,415 +0,0 @@
import { Plugin } from '@typings/plugin';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { fetchApi, fetchFile } from '@libs/fetch';
import { NovelStatus } from '@libs/novelStatus';
import { load as parseHTML } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
const getStandardNovelPath = (url: string | undefined): string | undefined => {
if (!url) return undefined;
try {
const parsedUrl = new URL(url);
const match = parsedUrl.pathname.match(/^(\/amp)?(\/n\/[^\/]+\/)/);
return match?.[2];
} catch (error) {
return undefined;
}
};
const getChapterFileName = (url: string | undefined): string | undefined => {
if (!url) return undefined;
try {
const parsedUrl = new URL(url);
const pathParts = parsedUrl.pathname.split('/');
const fileName = pathParts[pathParts.length - 1];
if (fileName && /^\d+\.html$/.test(fileName)) {
return fileName;
}
return undefined;
} catch (error) {
return undefined;
}
};
const makeAbsolute = (
relativeUrl: string | undefined,
baseUrl: string,
): string | undefined => {
if (!relativeUrl) return undefined;
try {
if (relativeUrl.startsWith('//')) {
return 'https:' + relativeUrl;
}
if (
relativeUrl.startsWith('http://') ||
relativeUrl.startsWith('https://')
) {
return relativeUrl;
}
return new URL(relativeUrl, baseUrl).href;
} catch (e) {
return undefined;
}
};
class QuanbenPlugin implements Plugin.PluginBase {
id = 'quanben';
name = 'Quanben';
site = 'https://www.quanben.io/';
version = '1.0.0';
icon = 'src/cn/quanben/icon.png';
defaultCover = defaultCover;
filters = {} satisfies Filters;
async popularNovels(_pageNo: number): Promise<Plugin.NovelItem[]> {
const url = this.site + 'amp/';
const result = await fetchApi(url);
if (!result.ok) {
throw new Error(
`[Quanben] Failed to fetch AMP popular novels page: ${url} - Status: ${result.status}`,
);
}
const body = await result.text();
const $ = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
const processedAmpPaths = new Set<string>();
$('div.box').each((_i, box) => {
const $box = $(box);
// 1. Process the featured novel (div.list2) if it exists
const $featured = $box.find('div.list2');
if ($featured.length > 0) {
const $link = $featured.find('h3 > a');
const ampPath = $link.attr('href')?.trim();
const name = $link.text().trim();
const rawCoverSrc = $featured.find('amp-img').attr('src')?.trim();
const cover = makeAbsolute(rawCoverSrc, this.site) || this.defaultCover;
if (ampPath && name && !processedAmpPaths.has(ampPath)) {
novels.push({ name, path: ampPath, cover });
processedAmpPaths.add(ampPath);
}
}
// 2. Process novels in the list (ul.list)
$box.find('ul.list li').each((_j, listItem) => {
const $listItem = $(listItem);
const $link = $listItem.find('a');
const ampPath = $link.attr('href')?.trim();
const name = $link.find('span').first().text().trim(); // Name is inside a span within the link
if (ampPath && name && !processedAmpPaths.has(ampPath)) {
novels.push({ name, path: ampPath, cover: this.defaultCover }); // Use default cover for list items
processedAmpPaths.add(ampPath);
}
});
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
// ** Expects AMP path: /amp/n/novel-name/ **
if (
!novelPath ||
!novelPath.startsWith('/amp/n/') ||
!novelPath.endsWith('/')
) {
throw new Error(
`[Quanben parseNovel] Invalid novelPath received: "${novelPath}". Expected AMP format: "/amp/n/novel-name/"`,
);
}
// Construct full AMP novel URL
const fullNovelUrl = makeAbsolute(novelPath, this.site);
if (!fullNovelUrl) {
throw new Error(
`[Quanben parseNovel] Could not construct full AMP novel URL from path: ${novelPath}`,
);
}
// 1. Fetch AMP Novel Page HTML
const novelPageResult = await fetchApi(fullNovelUrl);
if (!novelPageResult.ok) {
throw new Error(
`[Quanben parseNovel] Failed to fetch AMP novel page: ${fullNovelUrl} - Status: ${novelPageResult.status}`,
);
}
const novelPageHtml = await novelPageResult.text();
const $ = parseHTML(novelPageHtml);
// 2. Parse Novel Details from AMP page - **Refined Selectors**
const $infoBox = $('div.list2'); // Base element for many details
const $descriptionBox = $('div.description'); // Separate element for summary
const novel: Plugin.SourceNovel = {
path: novelPath, // Use the input AMP path
name:
$infoBox.find('h3').text().trim() || // **Use h3 inside list2**
$('h1[itemprop="name headline"]').text().trim() || // Fallback H1
'Unknown Novel Name',
cover:
makeAbsolute($infoBox.find('amp-img').attr('src'), this.site) || // **amp-img inside list2**
this.defaultCover,
summary:
$descriptionBox.find('p').text().trim() || // **Use p inside div.description**
$descriptionBox.text().trim() || // Fallback to div.description text
undefined,
// Use :contains() for more robust selection within the info box
author:
$infoBox.find("p:contains('作者:') span").text().trim() || undefined,
status: NovelStatus.Unknown, // Parsed below
genres:
$infoBox.find("p:contains('类别:') span").text().trim() || undefined,
chapters: [], // Parsed below
};
// Parse Status from AMP page
const statusText =
$infoBox.find("p:contains('状态:') span").text().trim() || // Try specific span first
$infoBox.text(); // Fallback to full text
if (statusText.includes('完结') || statusText.includes('已完成')) {
novel.status = NovelStatus.Completed;
} else if (statusText.includes('连载中') || statusText.includes('进行中')) {
novel.status = NovelStatus.Ongoing;
}
// 3. Fetch and Parse Chapter List
novel.chapters = await this.parseChapterList(novelPath);
return novel;
}
// Separate function to handle chapter list parsing - **Using AMP**
async parseChapterList(novelPath: string): Promise<Plugin.ChapterItem[]> {
// ** Expects AMP path: /amp/n/novel-name/ **
if (
!novelPath ||
!novelPath.startsWith('/amp/n/') ||
!novelPath.endsWith('/')
) {
return [];
}
// Construct the AMP chapter list URL
const ampChapterListUrl = makeAbsolute(novelPath + 'list.html', this.site);
if (!ampChapterListUrl) {
return [];
}
const chapterListResult = await fetchApi(ampChapterListUrl);
if (!chapterListResult.ok) {
return [];
}
const chapterListHtml = await chapterListResult.text();
const $ = parseHTML(chapterListHtml);
let chapters: Plugin.ChapterItem[] = [];
// Extract standard novel name from AMP path for chapter path storage
const standardNovelPathMatch = novelPath.match(/(\/n\/[^\/]+\/)/);
if (!standardNovelPathMatch || !standardNovelPathMatch[1]) {
return [];
}
const novelNameOnly = standardNovelPathMatch[1].replace(/^\/n\/|\/$/g, '');
// 1. Parse chapters from ALL list3 uls in the AMP HTML
$('ul.list3 li a').each((_i, el) => {
const $el = $(el);
const chapterName = $el.text().trim();
const chapterHref = $el.attr('href');
if (chapterName && chapterHref) {
// URLs in AMP page usually point to the standard chapter URLs
const absoluteUrl = makeAbsolute(chapterHref, this.site); // Use base site URL
const chapterFileName = getChapterFileName(absoluteUrl);
if (chapterFileName) {
// ** Store chapter path in standard format for parseChapter **
const chapterPathForStorage = novelNameOnly + '/' + chapterFileName;
chapters.push({
name: chapterName,
path: chapterPathForStorage,
// chapterNumber assigned later
});
}
}
});
// 2. Deduplicate chapters based on path
const chapterMap = new Map<string, Plugin.ChapterItem>();
chapters.forEach(chapter => {
if (!chapterMap.has(chapter.path)) {
chapterMap.set(chapter.path, chapter);
}
});
const uniqueChapters = Array.from(chapterMap.values());
// 3. Sort chapters numerically based on filename
uniqueChapters.sort((a, b) => {
const numA = parseInt(a.path.match(/(\d+)\.html$/)?.[1] || '0', 10);
const numB = parseInt(b.path.match(/(\d+)\.html$/)?.[1] || '0', 10);
return numA - numB;
});
// 4. Assign chapter numbers
return uniqueChapters.map((chapter, index) => ({
...chapter,
chapterNumber: index + 1,
}));
}
async parseChapter(chapterPath: string): Promise<string> {
// ** Expects standard path: novel-name/chapterFileName.html **
if (
!chapterPath ||
!chapterPath.includes('/') ||
chapterPath.endsWith('/')
) {
throw new Error(
`[Quanben] Invalid chapterPath format received in parseChapter: "${chapterPath}". Expected format: "novel-name/chapterFileName.html"`,
);
}
// Construct the standard chapter URL using /n/ prefix
const chapterUrl = `${this.site}n/${chapterPath}`;
const result = await fetchApi(chapterUrl);
if (!result.ok) {
// Handle potential redirects (e.g., chapter moved) - check Location header if status is 3xx
if (result.status >= 300 && result.status < 400) {
const redirectUrl = result.headers.get('Location');
if (redirectUrl) {
const absoluteRedirectUrl = makeAbsolute(redirectUrl, chapterUrl);
if (!absoluteRedirectUrl) {
throw new Error(
`[Quanben] Failed to make redirected URL absolute: ${redirectUrl}`,
);
}
const redirectResult = await fetchApi(absoluteRedirectUrl);
if (!redirectResult.ok) {
throw new Error(
`[Quanben] Failed to fetch redirected chapter content: ${absoluteRedirectUrl} - Status: ${redirectResult.status}`,
);
}
return this.extractChapterContent(
await redirectResult.text(),
absoluteRedirectUrl,
);
}
}
throw new Error(
`[Quanben] Failed to fetch chapter content: ${chapterUrl} - Status: ${result.status}`,
);
}
const body = await result.text();
return this.extractChapterContent(body, chapterUrl);
}
// Helper function to extract and clean chapter content from HTML body
private extractChapterContent(body: string, urlForLog: string): string {
const $ = parseHTML(body);
let $content = $('#contentbody');
if (!$content.length) {
$content = $('#content');
}
if (!$content.length) {
$content = $('.content');
}
if (!$content.length) {
return 'Error: Could not find chapter content container.';
}
$content
.find(
'script, style, ins, iframe, [class*="ads"], [id*="ads"], [class*="google"], [id*="google"], [class*="recommend"], div[align="center"]',
)
.remove();
$content.find('p').each((_i, el) => {
const $p = $(el);
const pText = $p.text().trim();
if (
pText.includes('请记住本书首发域名') ||
pText.includes('手机版阅读网址') ||
pText.includes('quanben') ||
pText.includes('最新网址') ||
pText.includes('章节报错') ||
pText.match(/app|APP|下载|客户端/) ||
pText.length === 0 ||
($p
.html()
?.replace(/&nbsp;/g, '')
.trim() === '' &&
$p.find('img').length === 0)
) {
$p.remove();
}
});
$content
.contents()
.filter(function () {
return this.type === 'comment';
})
.remove();
let chapterText = $content.html();
if (!chapterText) {
return 'Error: Chapter content was empty after cleaning.';
}
chapterText = chapterText.replace(/<\s*p[^>]*>/gi, '\n\n');
chapterText = chapterText.replace(/<\s*br[^>]*>/gi, '\n');
chapterText = chapterText.replace(/<[^>]+>/g, '');
chapterText = parseHTML(`<div>${chapterText}</div>`).text();
chapterText = chapterText.replace(/[\t ]+/g, ' ');
chapterText = chapterText.replace(/\n{3,}/g, '\n\n');
chapterText = chapterText.trim();
return chapterText;
}
async searchNovels(
searchTerm: string,
_pageNo: number,
): Promise<Plugin.NovelItem[]> {
const searchUrl = `${this.site}index.php?c=book&a=search&keywords=${encodeURIComponent(searchTerm)}`;
const result = await fetchApi(searchUrl);
if (!result.ok) {
return [];
}
const body = await result.text();
const $ = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
$('div.list2').each((_i, element) => {
const $el = $(element);
const nameLink = $el.find('h3 > a').first();
const img = $el.find('img').first();
const novelName = nameLink.text().trim();
const novelHref = nameLink.attr('href');
let novelCover = img.attr('src') || img.attr('data-src');
if (novelHref && novelName) {
const absoluteUrl = makeAbsolute(novelHref, this.site);
const standardPath = getStandardNovelPath(absoluteUrl);
if (standardPath) {
// **Construct AMP Path for storage**
const ampPath = '/amp' + standardPath;
const absoluteCover = makeAbsolute(novelCover, this.site);
novels.push({
name: novelName,
path: ampPath, // **Store the AMP path**
cover: absoluteCover || this.defaultCover,
});
}
}
});
return novels;
}
// Use fetchApi for fetchImage as it handles potential errors and returns Response
async fetchImage(url: string): Promise<Response> {
return fetchApi(url);
}
}
export default new QuanbenPlugin();
-300
View File
@@ -1,300 +0,0 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi, fetchFile } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';
const makeAbsolute = (
relativeUrl: string | undefined,
baseUrl: string,
): string | undefined => {
if (!relativeUrl) return undefined;
try {
if (relativeUrl.startsWith('//')) {
return new URL(baseUrl).protocol + relativeUrl;
}
if (
relativeUrl.startsWith('http://') ||
relativeUrl.startsWith('https://')
) {
return relativeUrl;
}
return new URL(relativeUrl, baseUrl).href;
} catch {
return undefined;
}
};
class ixdzs8Plugin implements Plugin.PluginBase {
id = 'ixdzs8';
name = '爱下电子书';
site = 'https://ixdzs8.com/';
version = '2.2.8';
icon = 'src/cn/ixdzs8/favicon.png';
imageRequestInit = {
headers: {
Referer: this.site,
},
};
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
let url = `${this.site}hot/?page=${pageNo}`;
const result = await fetchApi(url);
if (!result.ok) return [];
const $ = parseHTML(await result.text());
const novels: Plugin.NovelItem[] = [];
const processedPaths = new Set<string>();
$('ul.u-list > li.burl').each((_i, el) => {
const $el = $(el);
let novelPath: string | undefined;
let novelName: string | undefined;
let novelCover: string | undefined;
const $link = $el.find('.l-info h3 a');
novelPath = $link.attr('href')?.trim();
novelName = ($link.attr('title') || $link.text() || '').trim();
novelCover = $el.find('.l-img img').attr('src')?.trim();
if (novelPath && novelName) {
novels.push({
name: novelName,
path: novelPath,
cover: makeAbsolute(novelCover, this.site) || defaultCover,
});
processedPaths.add(novelPath);
}
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novelUrl = makeAbsolute(novelPath, this.site);
if (!novelUrl) throw new Error('Invalid novel URL');
const result = await fetchApi(novelUrl);
if (!result.ok) throw new Error('Failed to fetch novel');
const $ = parseHTML(await result.text());
const $novel = $('div.novel');
const $intro = $('p#intro.pintro');
// Remove any child elements you don't want, e.g., the "read more" icon
$intro.find('span.icon').remove();
// Get text and normalize whitespace
const summary = $intro
.html() // get inner HTML
?.replace(/<br\s*\/?>/gi, '\n') // convert <br> to line breaks
.replace(/<[^>]+>/g, '') // remove remaining tags like <font>
.replace(/\u3000/g, ' ') // replace full-width spaces
.replace(/&nbsp;/g, ' ') // replace spaces
.split('\n') // split into lines
.map(line => line.trim()) // trim each line
.filter(line => line.length > 0) // remove empty lines
.join(' '); // join into one paragraph
const $tagsDiv = $('div.panel div.tags');
// find all <a> inside <em> and get their text
// Select <p> that contains <a.nsort>
const statOngoing = $novel.find('.n-text p span.lz').text().trim();
const statEnd = $novel.find('.n-text p span.end').text().trim();
let detail: 'Ongoing' | 'Completed' | 'Unknown' = 'Unknown';
if (statEnd.length > 0) {
detail = 'Completed';
} else if (statOngoing.length > 0) {
detail = 'Ongoing';
} else {
detail = 'Unknown';
}
const novel: Plugin.SourceNovel = {
path: novelPath,
name: $novel.find('.n-text h1').text().trim() || 'Untitled',
cover:
makeAbsolute($novel.find('.n-img img').attr('src'), this.site) ||
defaultCover,
summary: summary,
author: $novel.find('.n-text p a.bauthor').text().trim() || undefined,
genres: $tagsDiv
.find('em a')
.map((_i, el) => $(el).text().trim())
.get()
.filter(tag => tag.length > 0) // remove empty strings
.join(', '),
status:
detail === 'Ongoing'
? NovelStatus.Ongoing
: detail === 'Completed'
? NovelStatus.Completed
: NovelStatus.Unknown,
chapters: [],
};
const chapterListPath = $('#bid').attr('value'); // get bid from page
if (chapterListPath) {
novel.chapters = await this.parseChapterList(chapterListPath);
}
return novel;
}
async parseChapterList(bid: string | number): Promise<Plugin.ChapterItem[]> {
// Convert bid to string just in case
const bookId = String(bid);
// POST request to fetch chapter list JSON
let url = `${this.site}novel/clist/`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `bid=${bookId}`,
});
if (!res.ok) {
throw new Error(`Failed to fetch chapters for bid=${bookId}`);
}
const json = await res.json();
if (json.rs !== 200 || !Array.isArray(json.data)) {
throw new Error('Invalid response format from chapter list');
}
// Build chapters array
const chapters: Plugin.ChapterItem[] = json.data.map((ch: any) => {
return {
name: ch.title,
path: ch.ctype === '0' ? `read/${bookId}/p${ch.ordernum}.html` : '', // only normal chapters get link
releaseTime: undefined, // optional, not provided here
};
});
return chapters;
}
async parseChapter(chapterPath: string): Promise<string> {
const chapterUrl = makeAbsolute(chapterPath, this.site);
if (!chapterUrl) throw new Error('Invalid chapter URL');
// --- 1st request ---
let result = await fetchApi(chapterUrl);
if (!result.ok) throw new Error(`Failed to fetch chapter at ${chapterUrl}`);
let html = await result.text();
// --- Check if we got challenge page ---
if (html.includes('正在進行安全驗證') || html.includes('challenge')) {
const tokenMatch = html.match(/let token\s*=\s*"([^"]+)"/);
if (tokenMatch) {
const challengeUrl =
chapterUrl + '?challenge=' + encodeURIComponent(tokenMatch[1]);
result = await fetchApi(challengeUrl);
if (!result.ok)
throw new Error(`Failed after challenge redirect: ${challengeUrl}`);
html = await result.text();
}
}
// --- Parse content ---
const $ = parseHTML(html);
const $content = $('article section');
if (!$content.length) {
return `Error: Could not find chapter content at ${chapterUrl}`;
}
// Remove ads & junk
$content
.find(
'script, style, ins, iframe, [class*="abg"], [class*="ads"], [id*="ads"], [class*="google"], [id*="google"], [class*="recommend"], div[align="center"], p:contains("推薦本書"), a[href*="javascript:"]',
)
.remove();
// Drop empty <p>
$content.find('p').each((_i, el) => {
const $p = $(el);
if (!$p.text().trim()) $p.remove();
});
// Remove HTML comments
$content
.contents()
.filter(function () {
return this.type === 'comment';
})
.remove();
// Unwrap <font>
$content.find('font').each((_i, el) => {
const $el = $(el);
$el.replaceWith($el.html() || '');
});
// Extract cleaned text
let chapterText = $content.html();
if (!chapterText) return 'Error: Chapter content was empty';
chapterText = chapterText
.replace(/<\s*p[^>]*>/gi, '\n\n')
.replace(/<\s*br[^>]*>/gi, '\n');
chapterText = parseHTML(`<div>${chapterText}</div>`).text();
return chapterText
.replace(/[\t ]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const searchUrl = `${this.site}bsearch?q=${encodeURIComponent(searchTerm)}`;
let body = '';
try {
const result = await fetchApi(searchUrl);
if (!result.ok) {
throw new Error(
`Failed to fetch search results: HTTP ${result.status}`,
);
}
body = await result.text();
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to fetch search results: ${error.message}`);
}
throw error;
}
const $ = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
$('ul.u-list li.burl').each((_i, el) => {
const $el = $(el);
const novelPath = $el.attr('data-url')?.trim();
const novelName = $el.find('h3.bname a').text().trim();
const novelCover = $el.find('.l-img img').attr('src')?.trim();
if (novelPath && novelName) {
novels.push({
name: novelName,
path: novelPath,
cover: makeAbsolute(novelCover, this.site) || defaultCover,
});
}
});
return novels;
}
}
export default new ixdzs8Plugin();
-166
View File
@@ -1,166 +0,0 @@
import { fetchText } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { Filters } from '@libs/filterInputs';
import { load as parseHTML } from 'cheerio';
import { NovelStatus } from '@libs/novelStatus';
class LinovelPlugin implements Plugin.PluginBase {
id = 'linovel';
name = 'linovel';
icon = 'src/cn/linovel/icon.png';
site = 'https://www.linovel.net';
version = '1.0.0';
filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//flag indicates whether access to LocalStorage, SesesionStorage is required.
webStorageUtilized?: boolean;
private userAgent =
'Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0';
private async fetchHTML(url: string) {
const body = await fetchText(url, {
headers: { 'User-Agent': this.userAgent },
});
if (body === '') throw Error('无法获取内容,请检查网络');
return parseHTML(body);
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
const loadedCheerio = await this.fetchHTML(this.site);
loadedCheerio('a.book-item-inner').each((i, elem) => {
const name = loadedCheerio(elem).find('.book-item-name').text().trim();
const path = loadedCheerio(elem).attr('href') ?? '';
const cover = (
loadedCheerio(elem).find('img').attr('data-original') ?? ''
).replace(/!min300jpg$/, '');
novels.push({ name, path, cover });
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novel: Plugin.SourceNovel = {
path: novelPath,
name: 'Untitled',
};
const loadedCheerio = await this.fetchHTML(this.site + novelPath);
loadedCheerio('div.name')
.find('a')
.each((_i, elem) => {
novel.author = loadedCheerio(elem).text().trim();
});
loadedCheerio('.book-title').each((_i, elem) => {
novel.name = loadedCheerio(elem).text().trim();
});
loadedCheerio('.book-data')
.find('span')
.each((_i, elem) => {
const text = loadedCheerio(elem).text().trim();
if (text.includes('字数')) {
// Handle word count
} else if (text.includes('热度')) {
// Handle popularity
} else if (text.includes('收藏')) {
// Handle favorites
} else if (text === '连载中') {
novel.status = NovelStatus.Ongoing;
} else if (text === '已完结') {
novel.status = NovelStatus.Completed;
}
});
loadedCheerio('.book-cover')
.find('img')
.each((_i, elem) => {
novel.cover = loadedCheerio(elem).attr('src') ?? '';
});
novel.genres = loadedCheerio('.book-cats')
.children('a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(',');
novel.summary = loadedCheerio('.about-text').text().trim();
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('div.chapter')
.find('a')
.each((i, elem) => {
const name = loadedCheerio(elem).text().trim();
const path = loadedCheerio(elem).attr('href') ?? '';
chapters.push({ name, path });
});
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const loadedCheerio = await this.fetchHTML(this.site + chapterPath);
let chapterText = '';
if (loadedCheerio('.fufei-app-download-hint').length) {
throw Error('本章节需订阅后才能阅览,请下载轻之文库App订阅');
}
loadedCheerio('.article-text')
.find('p, img')
.each((i, elem) => {
if (elem.tagName === 'img') {
const imgSrc = loadedCheerio(elem).attr('src');
chapterText += `<img src="${imgSrc}">\n`;
} else {
const text = loadedCheerio(elem).text().trim();
if (text === '&nbsp;') {
chapterText += '<br>\n';
} else {
chapterText += `<p>${text}</p>\n`;
}
}
});
return chapterText.trim();
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const loadedCheerio = await this.fetchHTML(
this.site + '/search/?kw=' + searchTerm,
);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.rank-book-list')
.find('a.search-book')
.each((i, elem) => {
const name = loadedCheerio(elem).find('.book-name').text().trim();
const path = loadedCheerio(elem).attr('href') ?? '';
const cover = (
loadedCheerio(elem).find('img').attr('src') ?? ''
).replace(/!min300jpg$/, '');
novels.push({ name, path, cover });
});
return novels;
}
}
export default new LinovelPlugin();
-548
View File
@@ -1,548 +0,0 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchText } from '@libs/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@typings/plugin';
import { NovelStatus } from '@libs/novelStatus';
class Linovelib implements Plugin.PluginBase {
id = 'linovelib';
name = 'Linovelib';
icon = 'src/cn/linovelib/icon.png';
site = 'https://www.bilinovel.com';
version = '1.1.3';
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const rank = showLatestNovels ? 'lastupdate' : filters.rank.value;
const url = `${this.site}/top/${rank}/${pageNo}.html`;
const body = await fetchText(url);
if (body === '') throw Error('无法获取小说列表,请检查网络');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.module-rank-booklist .book-layout').each((i, el) => {
const url = loadedCheerio(el).attr('href');
const novelName = loadedCheerio(el).find('.book-title').text();
const novelCover = loadedCheerio(el)
.find('div.book-cover > img')
.attr('data-src');
if (!url) return;
const novel = {
name: novelName,
cover: novelCover,
path: url,
};
novels.push(novel);
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const body = await fetchText(url);
if (body === '') throw Error('无法获取小说内容,请检查网络');
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
chapters: [],
name: loadedCheerio('#bookDetailWrapper .book-title').text(),
};
novel.cover = loadedCheerio('#bookDetailWrapper img.book-cover').attr(
'src',
);
novel.summary = loadedCheerio('#bookSummary content').text();
novel.author = loadedCheerio('#bookDetailWrapper .book-rand-a a').text();
const meta = loadedCheerio('#bookDetailWrapper .book-meta').text();
novel.status = meta.includes('完结')
? NovelStatus.Completed
: NovelStatus.Ongoing;
novel.genres = loadedCheerio('.tag-small.red')
.children('a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(',');
// Table of Content is on a different page than the summary page
const chapter: Plugin.ChapterItem[] = [];
const idPattern = /\/(\d+)\.html/;
const novelId = url.match(idPattern)?.[1];
const chaptersUrl = this.site + loadedCheerio('#btnReadBook').attr('href');
const chaptersBody = await fetchText(chaptersUrl);
const chaptersLoadedCheerio = parseHTML(chaptersBody);
let volumeName: string, chapterId: number;
chaptersLoadedCheerio('#volumes .chapter-li:not(.volume-cover)').each(
(i, el) => {
if (chaptersLoadedCheerio(el).hasClass('chapter-bar')) {
volumeName = chaptersLoadedCheerio(el).text();
return;
} else {
const urlPart = chaptersLoadedCheerio(el)
.find('.chapter-li-a')
.attr('href');
const chapterIdMatch = urlPart?.match(idPattern);
// Sometimes the href attribute does not contain the url, but javascript:cid(0).
// Increment the previous chapter ID should result in the right URL
if (chapterIdMatch) {
chapterId = +chapterIdMatch[1];
} else {
chapterId++;
}
}
const chapterUrl = `/novel/${novelId}/${chapterId}.html`;
const chapterName =
volumeName +
' — ' +
chaptersLoadedCheerio(el).find('.chapter-index').text().trim();
const releaseDate = null;
if (!chapterId) return;
chapter.push({
name: chapterName,
releaseTime: releaseDate,
path: chapterUrl,
});
},
);
novel.chapters = chapter;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
let chapterName,
chapterText = '',
hasNextPage,
pageHasNextPage,
pageText = '';
let pageNumber = 1;
/*
* TODO: Maybe there are other ways to get the translation table
* It is embed and encrypted inside readtool.js
* UPDATE: Decrypted, see skillgg
*/
// const mapping_dict = {
// '“': '「',
// '’': '』',
// '': '是',
// '': '不',
// '': '好',
// '': '个',
// '': '开',
// '': '样',
// '': '想',
// '': '说',
// '': '年',
// '': '那',
// '': '她',
// '': '美',
// '': '自',
// '': '家',
// '': '而',
// '': '去',
// '': '都',
// '': '于',
// '': '舔',
// '': '他',
// '': '只',
// '': '看',
// '': '来',
// '': '用',
// '': '道',
// '': '得',
// '': '乳',
// '': '茎',
// '': '肉',
// '': '胸',
// '': '淫',
// '': '性',
// '': '骚',
// '”': '」',
// '': '的',
// '': '当',
// '': '人',
// '': '有',
// '': '上',
// '': '到',
// '': '地',
// '': '中',
// '': '生',
// '': '着',
// '': '和',
// '': '起',
// '': '交',
// '': '以',
// '': '可',
// '': '过',
// '': '能',
// '': '多',
// '': '心',
// '': '小',
// '': '成',
// '': '了',
// '': '把',
// '': '发',
// '': '第',
// '': '子',
// '': '事',
// '': '阴',
// '': '欲',
// '': '里',
// '': '私',
// '': '臀',
// '': '脱',
// '': '唇',
// '‘': '『',
// '': '一',
// '': '我',
// '': '在',
// '': '这',
// '': '们',
// '': '时',
// '': '为',
// '': '你',
// '': '国',
// '': '就',
// '': '要',
// '': '也',
// '': '后',
// '': '没',
// '': '下',
// '': '天',
// '': '对',
// '': '然',
// '': '学',
// '': '之',
// '': '出',
// '': '没',
// '': '如',
// '': '还',
// '': '大',
// '': '作',
// '': '种',
// '': '液',
// '': '呻',
// '': '射',
// '': '穴',
// '': '么',
// '': '裸',
// };
const skillgg: Record<string, string> = {
'\u201c': '\u300c',
'\u201d': '\u300d',
'\u2018': '\u300e',
'\u2019': '\u300f',
'\ue82c': '\u7684',
'\ue852': '\u4e00',
'\ue82d': '\u662f',
'\ue819': '\u4e86',
'\ue856': '\u6211',
'\ue857': '\u4e0d',
'\ue816': '\u4eba',
'\ue83c': '\u5728',
'\ue830': '\u4ed6',
'\ue82e': '\u6709',
'\ue836': '\u8fd9',
'\ue859': '\u4e2a',
'\ue80a': '\u4e0a',
'\ue855': '\u4eec',
'\ue842': '\u6765',
'\ue858': '\u5230',
'\ue80b': '\u65f6',
'\ue81f': '\u5927',
'\ue84a': '\u5730',
'\ue853': '\u4e3a',
'\ue81e': '\u5b50',
'\ue822': '\u4e2d',
'\ue813': '\u4f60',
'\ue85b': '\u8bf4',
'\ue807': '\u751f',
'\ue818': '\u56fd',
'\ue810': '\u5e74',
'\ue812': '\u7740',
'\ue851': '\u5c31',
'\ue801': '\u90a3',
'\ue80c': '\u548c',
'\ue815': '\u8981',
'\ue84c': '\u5979',
'\ue840': '\u51fa',
'\ue848': '\u4e5f',
'\ue835': '\u5f97',
'\ue800': '\u91cc',
'\ue826': '\u540e',
'\ue863': '\u81ea',
'\ue861': '\u4ee5',
'\ue854': '\u4f1a',
'\ue827': '\u5bb6',
'\ue83b': '\u53ef',
'\ue85d': '\u4e0b',
'\ue84d': '\u800c',
'\ue862': '\u8fc7',
'\ue81c': '\u5929',
'\ue81d': '\u53bb',
'\ue860': '\u80fd',
'\ue843': '\u5bf9',
'\ue82f': '\u5c0f',
'\ue802': '\u591a',
'\ue831': '\u7136',
'\ue84b': '\u4e8e',
'\ue837': '\u5fc3',
'\ue829': '\u5b66',
'\ue85e': '\u4e48',
'\ue83a': '\u4e4b',
'\ue832': '\u90fd',
'\ue808': '\u597d',
'\ue841': '\u770b',
'\ue821': '\u8d77',
'\ue845': '\u53d1',
'\ue803': '\u5f53',
'\ue828': '\u6ca1',
'\ue81b': '\u6210',
'\ue83e': '\u53ea',
'\ue820': '\u5982',
'\ue84e': '\u4e8b',
'\ue85a': '\u628a',
'\ue806': '\u8fd8',
'\ue83f': '\u7528',
'\ue833': '\u7b2c',
'\ue811': '\u6837',
'\ue804': '\u9053',
'\ue814': '\u60f3',
'\ue80f': '\u4f5c',
'\ue84f': '\u79cd',
'\ue80e': '\u5f00',
'\ue823': '\u7f8e',
'\ue849': '\u4e73',
'\ue805': '\u9634',
'\ue809': '\u6db2',
'\ue81a': '\u830e',
'\ue844': '\u6b32',
'\ue847': '\u547b',
'\ue850': '\u8089',
'\ue824': '\u4ea4',
'\ue85f': '\u6027',
'\ue817': '\u80f8',
'\ue85c': '\u79c1',
'\ue838': '\u7a74',
'\ue82a': '\u6deb',
'\ue83d': '\u81c0',
'\ue82b': '\u8214',
'\ue80d': '\u5c04',
'\ue839': '\u8131',
'\ue834': '\u88f8',
'\ue846': '\u9a9a',
'\ue825': '\u5507',
};
const addPage = async (pageCheerio: CheerioAPI) => {
const formatPage = async () => {
// Remove JS and notice of the website
pageCheerio(
'#acontent .adsbygoogle, #acontent script, center',
).remove();
// Load lazyloaded images
pageCheerio('#acontent img.imagecontent').each((i, el) => {
// Sometimes images are either in data-src or src
const imgSrc =
pageCheerio(el).attr('data-src') || pageCheerio(el).attr('src');
if (imgSrc) {
// Clean up img element
pageCheerio(el)
.attr('src', imgSrc)
.removeAttr('data-src')
.removeClass('lazyload');
}
});
// Recover the original character
pageText = pageCheerio('#acontent').html() || '';
pageText = pageText.replace(/./g, char => skillgg[char] || char);
return Promise.resolve();
};
await formatPage();
chapterName =
pageCheerio('#atitle + h3').text() +
' — ' +
pageCheerio('#atitle').text();
if (chapterText === '') {
chapterText = '<h2>' + chapterName + '</h2>';
}
chapterText += pageText;
};
const loadPage = async (url: string) => {
const headers = {
'Accept':
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language':
'zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en;q=0.6,en-GB;q=0.5,en-US;q=0.4',
'Cache-Control': 'no-cache',
};
const body = await fetchText(url, { headers });
const pageCheerio = parseHTML(body);
await addPage(pageCheerio);
pageHasNextPage =
pageCheerio('#footlink a:last').text() === '下一页' ||
pageCheerio('#footlink a:last').text() === '下一頁'
? true
: false;
return { pageCheerio, pageHasNextPage };
};
let url = this.site + chapterPath;
const baseUrl = url;
do {
const page = await loadPage(url);
hasNextPage = page.pageHasNextPage;
if (hasNextPage === true) {
pageNumber++;
url = baseUrl.replace(/\.html/gi, `_${pageNumber}` + '.html');
}
} while (hasNextPage === true);
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}/search/${encodeURI(searchTerm)}_${pageNo}.html`;
const body = await fetchText(url);
if (body === '') throw Error('无法获取搜索结果,请检查网络');
const pageCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
// const addPage = async (pageCheerio: CheerioAPI, redirect: string) => {
// const loadSearchResults = () => {
// pageCheerio(".book-ol .book-layout").each((i, el) => {
// let nUrl = pageCheerio(el).attr("href");
// const novelName = pageCheerio(el)
// .find(".book-title")
// .text();
// const novelCover = pageCheerio(el)
// .find("div.book-cover > img")
// .attr("data-src");
// const novelUrl = this.site + nUrl;
// if (!nUrl) return;
// novels.push({
// name: novelName,
// url: novelUrl,
// cover: novelCover,
// });
// });
// };
// const novelResults = pageCheerio(".book-ol a.book-layout");
// if (novelResults.length === 0) {
// } else {
// loadSearchResults();
// }
// if (redirect.length) {
// novels.length = 0;
// const novelName = pageCheerio(
// "#bookDetailWrapper .book-title"
// ).text();
// const novelCover = pageCheerio(
// "#bookDetailWrapper div.book-cover > img"
// ).attr("src");
// const novelUrl =
// this.site +
// pageCheerio("#btnReadBook").attr("href")?.slice(0, -8) +
// ".html";
// novels.push({
// name: novelName,
// url: novelUrl,
// cover: novelCover,
// });
// }
// };
// NOTE: don't know redirect is for what, comment out for now
// const redirect = pageCheerio("div.book-layout").text();
// await addPage(pageCheerio, redirect);
pageCheerio('.book-ol .book-layout').each((i, el) => {
const nUrl = pageCheerio(el).attr('href');
const novelName = pageCheerio(el).find('.book-title').text();
const novelCover = pageCheerio(el)
.find('div.book-cover > img')
.attr('data-src');
if (!nUrl) return;
novels.push({
name: novelName,
path: nUrl,
cover: novelCover,
});
});
return novels;
}
filters = {
rank: {
label: '排行榜',
value: 'monthvisit',
options: [
{ label: '月点击榜', value: 'monthvisit' },
{ label: '周点击榜', value: 'weekvisit' },
{ label: '月推荐榜', value: 'monthvote' },
{ label: '周推荐榜', value: 'weekvote' },
{ label: '月鲜花榜', value: 'monthflower' },
{ label: '周鲜花榜', value: 'weekflower' },
{ label: '月鸡蛋榜', value: 'monthegg' },
{ label: '周鸡蛋榜', value: 'weekegg' },
{ label: '最近更新', value: 'lastupdate' },
{ label: '最新入库', value: 'postdate' },
{ label: '收藏榜', value: 'goodnum' },
{ label: '新书榜', value: 'newhot' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Linovelib();
-546
View File
@@ -1,546 +0,0 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchText } from '@libs/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@typings/plugin';
import { NovelStatus } from '@libs/novelStatus';
class Linovelib_tw implements Plugin.PluginBase {
id = 'linovelib_tw';
name = 'Linovelib(繁體)';
icon = 'src/cn/linovelib/icon.png';
site = 'https://tw.linovelib.com/';
version = '1.0.0';
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const rank = showLatestNovels ? 'lastupdate' : filters.rank.value;
const url = `${this.site}/top/${rank}/${pageNo}.html`;
const body = await fetchText(url);
if (body === '') throw Error('無法獲取小説列表, 請檢查網絡');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.module-rank-booklist .book-layout').each((i, el) => {
const url = loadedCheerio(el).attr('href');
const novelName = loadedCheerio(el).find('.book-title').text();
const novelCover = loadedCheerio(el)
.find('div.book-cover > img')
.attr('data-src');
if (!url) return;
const novel = {
name: novelName,
cover: novelCover,
path: url,
};
novels.push(novel);
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const body = await fetchText(url);
if (body === '') throw Error('無法獲取小説信息, 請檢查網絡');
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
chapters: [],
name: loadedCheerio('#bookDetailWrapper .book-title').text(),
};
novel.cover = loadedCheerio('#bookDetailWrapper img.book-cover').attr(
'src',
);
novel.summary = loadedCheerio('#bookSummary content').text();
novel.author = loadedCheerio('#bookDetailWrapper .book-rand-a a').text();
const meta = loadedCheerio('#bookDetailWrapper .book-meta').text();
novel.status = meta.includes('完結')
? NovelStatus.Completed
: NovelStatus.Ongoing;
novel.genres = loadedCheerio('.tag-small.red')
.children('a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(',');
// Table of Content is on a different page than the summary page
const chapter: Plugin.ChapterItem[] = [];
const idPattern = /\/(\d+)\.html/;
const novelId = url.match(idPattern)?.[1];
const chaptersUrl = this.site + loadedCheerio('#btnReadBook').attr('href');
const chaptersBody = await fetchText(chaptersUrl);
const chaptersLoadedCheerio = parseHTML(chaptersBody);
let volumeName: string, chapterId: number;
chaptersLoadedCheerio('#volumes .chapter-li:not(.volume-cover)').each(
(i, el) => {
if (chaptersLoadedCheerio(el).hasClass('chapter-bar')) {
volumeName = chaptersLoadedCheerio(el).text();
return;
} else {
const urlPart = chaptersLoadedCheerio(el)
.find('.chapter-li-a')
.attr('href');
const chapterIdMatch = urlPart?.match(idPattern);
// Sometimes the href attribute does not contain the url, but javascript:cid(0).
// Increment the previous chapter ID should result in the right URL
if (chapterIdMatch) {
chapterId = +chapterIdMatch[1];
} else {
chapterId++;
}
}
const chapterUrl = `/novel/${novelId}/${chapterId}.html`;
const chapterName =
volumeName +
' — ' +
chaptersLoadedCheerio(el).find('.chapter-index').text().trim();
const releaseDate = null;
if (!chapterId) return;
chapter.push({
name: chapterName,
releaseTime: releaseDate,
path: chapterUrl,
});
},
);
novel.chapters = chapter;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
let chapterName,
chapterText = '',
hasNextPage,
pageHasNextPage,
pageText = '';
let pageNumber = 1;
/*
* TODO: Maybe there are other ways to get the translation table
* It is embed and encrypted inside readtool.js
* UPDATE: Decrypted, see skillgg
*/
// const mapping_dict = {
// '“': '「',
// '’': '』',
// '': '是',
// '': '不',
// '': '好',
// '': '个',
// '': '开',
// '': '样',
// '': '想',
// '': '说',
// '': '年',
// '': '那',
// '': '她',
// '': '美',
// '': '自',
// '': '家',
// '': '而',
// '': '去',
// '': '都',
// '': '于',
// '': '舔',
// '': '他',
// '': '只',
// '': '看',
// '': '来',
// '': '用',
// '': '道',
// '': '得',
// '': '乳',
// '': '茎',
// '': '肉',
// '': '胸',
// '': '淫',
// '': '性',
// '': '骚',
// '”': '」',
// '': '的',
// '': '当',
// '': '人',
// '': '有',
// '': '上',
// '': '到',
// '': '地',
// '': '中',
// '': '生',
// '': '着',
// '': '和',
// '': '起',
// '': '交',
// '': '以',
// '': '可',
// '': '过',
// '': '能',
// '': '多',
// '': '心',
// '': '小',
// '': '成',
// '': '了',
// '': '把',
// '': '发',
// '': '第',
// '': '子',
// '': '事',
// '': '阴',
// '': '欲',
// '': '里',
// '': '私',
// '': '臀',
// '': '脱',
// '': '唇',
// '‘': '『',
// '': '一',
// '': '我',
// '': '在',
// '': '这',
// '': '们',
// '': '时',
// '': '为',
// '': '你',
// '': '国',
// '': '就',
// '': '要',
// '': '也',
// '': '后',
// '': '没',
// '': '下',
// '': '天',
// '': '对',
// '': '然',
// '': '学',
// '': '之',
// '': '出',
// '': '没',
// '': '如',
// '': '还',
// '': '大',
// '': '作',
// '': '种',
// '': '液',
// '': '呻',
// '': '射',
// '': '穴',
// '': '么',
// '': '裸',
// };
const skillgg: Record<string, string> = {
'\u201c': '\u300c',
'\u201d': '\u300d',
'\u2018': '\u300e',
'\u2019': '\u300f',
'\ue82c': '\u7684',
'\ue852': '\u4e00',
'\ue82d': '\u662f',
'\ue819': '\u4e86',
'\ue856': '\u6211',
'\ue857': '\u4e0d',
'\ue816': '\u4eba',
'\ue83c': '\u5728',
'\ue830': '\u4ed6',
'\ue82e': '\u6709',
'\ue836': '\u8fd9',
'\ue859': '\u4e2a',
'\ue80a': '\u4e0a',
'\ue855': '\u4eec',
'\ue842': '\u6765',
'\ue858': '\u5230',
'\ue80b': '\u65f6',
'\ue81f': '\u5927',
'\ue84a': '\u5730',
'\ue853': '\u4e3a',
'\ue81e': '\u5b50',
'\ue822': '\u4e2d',
'\ue813': '\u4f60',
'\ue85b': '\u8bf4',
'\ue807': '\u751f',
'\ue818': '\u56fd',
'\ue810': '\u5e74',
'\ue812': '\u7740',
'\ue851': '\u5c31',
'\ue801': '\u90a3',
'\ue80c': '\u548c',
'\ue815': '\u8981',
'\ue84c': '\u5979',
'\ue840': '\u51fa',
'\ue848': '\u4e5f',
'\ue835': '\u5f97',
'\ue800': '\u91cc',
'\ue826': '\u540e',
'\ue863': '\u81ea',
'\ue861': '\u4ee5',
'\ue854': '\u4f1a',
'\ue827': '\u5bb6',
'\ue83b': '\u53ef',
'\ue85d': '\u4e0b',
'\ue84d': '\u800c',
'\ue862': '\u8fc7',
'\ue81c': '\u5929',
'\ue81d': '\u53bb',
'\ue860': '\u80fd',
'\ue843': '\u5bf9',
'\ue82f': '\u5c0f',
'\ue802': '\u591a',
'\ue831': '\u7136',
'\ue84b': '\u4e8e',
'\ue837': '\u5fc3',
'\ue829': '\u5b66',
'\ue85e': '\u4e48',
'\ue83a': '\u4e4b',
'\ue832': '\u90fd',
'\ue808': '\u597d',
'\ue841': '\u770b',
'\ue821': '\u8d77',
'\ue845': '\u53d1',
'\ue803': '\u5f53',
'\ue828': '\u6ca1',
'\ue81b': '\u6210',
'\ue83e': '\u53ea',
'\ue820': '\u5982',
'\ue84e': '\u4e8b',
'\ue85a': '\u628a',
'\ue806': '\u8fd8',
'\ue83f': '\u7528',
'\ue833': '\u7b2c',
'\ue811': '\u6837',
'\ue804': '\u9053',
'\ue814': '\u60f3',
'\ue80f': '\u4f5c',
'\ue84f': '\u79cd',
'\ue80e': '\u5f00',
'\ue823': '\u7f8e',
'\ue849': '\u4e73',
'\ue805': '\u9634',
'\ue809': '\u6db2',
'\ue81a': '\u830e',
'\ue844': '\u6b32',
'\ue847': '\u547b',
'\ue850': '\u8089',
'\ue824': '\u4ea4',
'\ue85f': '\u6027',
'\ue817': '\u80f8',
'\ue85c': '\u79c1',
'\ue838': '\u7a74',
'\ue82a': '\u6deb',
'\ue83d': '\u81c0',
'\ue82b': '\u8214',
'\ue80d': '\u5c04',
'\ue839': '\u8131',
'\ue834': '\u88f8',
'\ue846': '\u9a9a',
'\ue825': '\u5507',
};
const addPage = async (pageCheerio: CheerioAPI) => {
const formatPage = async () => {
// Remove JS and notice of the website
pageCheerio('div.cgo, center').remove();
// Load lazyloaded images
pageCheerio('#acontentl img.imagecontent').each((i, el) => {
// Sometimes images are either in data-src or src
const imgSrc =
pageCheerio(el).attr('data-src') || pageCheerio(el).attr('src');
if (imgSrc) {
// Clean up img element
pageCheerio(el)
.attr('src', imgSrc)
.removeAttr('data-src')
.removeClass('lazyload');
}
});
// Recover the original character
pageText = pageCheerio('#acontentl').html() || '';
pageText = pageText.replace(/./g, char => skillgg[char] || char);
return Promise.resolve();
};
await formatPage();
chapterName =
pageCheerio('#atitle + h3').text() +
' — ' +
pageCheerio('#atitle').text();
if (chapterText === '') {
chapterText = '<h2>' + chapterName + '</h2>';
}
chapterText += pageText;
};
const loadPage = async (url: string) => {
const headers = {
'Accept':
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language':
'zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en;q=0.6,en-GB;q=0.5,en-US;q=0.4',
'Cache-Control': 'no-cache',
};
const body = await fetchText(url, { headers });
const pageCheerio = parseHTML(body);
await addPage(pageCheerio);
pageHasNextPage =
pageCheerio('#footlink a:last').text() === '下一页' ||
pageCheerio('#footlink a:last').text() === '下一頁'
? true
: false;
return { pageCheerio, pageHasNextPage };
};
let url = this.site + chapterPath;
const baseUrl = url;
do {
const page = await loadPage(url);
hasNextPage = page.pageHasNextPage;
if (hasNextPage === true) {
pageNumber++;
url = baseUrl.replace(/\.html/gi, `_${pageNumber}` + '.html');
}
} while (hasNextPage === true);
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}/search/${encodeURI(searchTerm)}_${pageNo}.html`;
const body = await fetchText(url);
if (body === '') throw Error('無法獲取搜索結果, 請檢查網絡');
const pageCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
// const addPage = async (pageCheerio: CheerioAPI, redirect: string) => {
// const loadSearchResults = () => {
// pageCheerio(".book-ol .book-layout").each((i, el) => {
// let nUrl = pageCheerio(el).attr("href");
// const novelName = pageCheerio(el)
// .find(".book-title")
// .text();
// const novelCover = pageCheerio(el)
// .find("div.book-cover > img")
// .attr("data-src");
// const novelUrl = this.site + nUrl;
// if (!nUrl) return;
// novels.push({
// name: novelName,
// url: novelUrl,
// cover: novelCover,
// });
// });
// };
// const novelResults = pageCheerio(".book-ol a.book-layout");
// if (novelResults.length === 0) {
// } else {
// loadSearchResults();
// }
// if (redirect.length) {
// novels.length = 0;
// const novelName = pageCheerio(
// "#bookDetailWrapper .book-title"
// ).text();
// const novelCover = pageCheerio(
// "#bookDetailWrapper div.book-cover > img"
// ).attr("src");
// const novelUrl =
// this.site +
// pageCheerio("#btnReadBook").attr("href")?.slice(0, -8) +
// ".html";
// novels.push({
// name: novelName,
// url: novelUrl,
// cover: novelCover,
// });
// }
// };
// NOTE: don't know redirect is for what, comment out for now
// const redirect = pageCheerio("div.book-layout").text();
// await addPage(pageCheerio, redirect);
pageCheerio('.book-ol .book-layout').each((i, el) => {
const nUrl = pageCheerio(el).attr('href');
const novelName = pageCheerio(el).find('.book-title').text();
const novelCover = pageCheerio(el)
.find('div.book-cover > img')
.attr('data-src');
if (!nUrl) return;
novels.push({
name: novelName,
path: nUrl,
cover: novelCover,
});
});
return novels;
}
filters = {
rank: {
label: '排行榜',
value: 'monthvisit',
options: [
{ label: '月點擊榜', value: 'monthvisit' },
{ label: '周點擊榜', value: 'weekvisit' },
{ label: '月推薦榜', value: 'monthvote' },
{ label: '周推薦榜', value: 'weekvote' },
{ label: '月鮮花榜', value: 'monthflower' },
{ label: '周鮮花榜', value: 'weekflower' },
{ label: '月鷄蛋榜', value: 'monthegg' },
{ label: '周鷄蛋榜', value: 'weekegg' },
{ label: '最近更新', value: 'lastupdate' },
{ label: '最新入庫', value: 'postdate' },
{ label: '收藏榜', value: 'goodnum' },
{ label: '新書榜', value: 'newhot' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Linovelib_tw();
-328
View File
@@ -1,328 +0,0 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi, fetchFile } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';
const makeAbsolute = (
relativeUrl: string | undefined,
baseUrl: string,
): string | undefined => {
if (!relativeUrl) return undefined;
try {
if (relativeUrl.startsWith('//')) {
return new URL(baseUrl).protocol + relativeUrl;
}
if (
relativeUrl.startsWith('http://') ||
relativeUrl.startsWith('https://')
) {
return relativeUrl;
}
return new URL(relativeUrl, baseUrl).href;
} catch {
return undefined;
}
};
class Novel543Plugin implements Plugin.PluginBase {
id = 'novel543';
name = 'Novel543';
site = 'https://www.novel543.com/';
version = '1.0.0';
icon = 'src/cn/novel543/icon.png';
imageRequestInit = {
headers: {
Referer: this.site,
},
};
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
if (pageNo > 1) return [];
const result = await fetchApi(this.site);
if (!result.ok) return [];
const $ = parseHTML(await result.text());
const novels: Plugin.NovelItem[] = [];
const processedPaths = new Set<string>();
$('ul.list > li.media, ul.list li > a[href^="/"][href$="/"]').each(
(_i, el) => {
const $el = $(el);
let novelPath: string | undefined;
let novelName: string | undefined;
let novelCover: string | undefined;
if ($el.is('li.media')) {
const $link = $el.find('.media-content h3 a');
novelPath = $link.attr('href')?.trim();
novelName = $link.text().trim();
novelCover = $el.find('.media-left img').attr('src')?.trim();
} else if ($el.is('a')) {
novelPath = $el.attr('href')?.trim();
novelName =
$el.find('h3, b, span').first().text().trim() ||
$el.parent().find('h3').text().trim() ||
$el.text().trim();
novelCover =
$el.find('img').attr('src')?.trim() ||
$el.parent().find('img').attr('src')?.trim();
}
if (
novelPath &&
novelName &&
novelPath.match(/^\/\d+\/$/) &&
!processedPaths.has(novelPath)
) {
novels.push({
name: novelName,
path: novelPath,
cover: makeAbsolute(novelCover, this.site) || defaultCover,
});
processedPaths.add(novelPath);
}
},
);
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novelUrl = makeAbsolute(novelPath, this.site);
if (!novelUrl) throw new Error('Invalid novel URL');
const result = await fetchApi(novelUrl);
if (!result.ok) throw new Error('Failed to fetch novel');
const $ = parseHTML(await result.text());
const $infoSection = $('section#detail div.media-content.info');
const $modSection = $('section#detail div.mod');
const novel: Plugin.SourceNovel = {
path: novelPath,
name: $infoSection.find('h1.title').text().trim() || 'Untitled',
cover:
makeAbsolute(
$('section#detail div.cover img').attr('src'),
this.site,
) || defaultCover,
summary: $modSection.find('div.intro').text().trim() || undefined,
author:
$infoSection.find('p.meta span.author').text().trim() || undefined,
genres:
$infoSection
.find('p.meta a[href*="/bookstack/"]')
.map((_i, el) => $(el).text().trim())
.get()
.join(', ') || undefined,
status: NovelStatus.Unknown,
chapters: [],
};
const chapterListPath =
$modSection
.find('p.action.buttons a.button.is-info[href$="/dir"]')
.attr('href') ||
$infoSection.find('a.button.is-info[href$="/dir"]').attr('href');
if (chapterListPath) {
novel.chapters = await this.parseChapterList(chapterListPath);
}
return novel;
}
async parseChapterList(
chapterListPath: string,
): Promise<Plugin.ChapterItem[]> {
const chapterListUrl = makeAbsolute(chapterListPath, this.site);
if (!chapterListUrl) return [];
const result = await fetchApi(chapterListUrl);
if (!result.ok) return [];
const $ = parseHTML(await result.text());
const chapters: Plugin.ChapterItem[] = [];
$('div.chaplist ul.all li a').each((index, el) => {
const $el = $(el);
const chapterName = $el.text().trim();
const chapterUrl = $el.attr('href')?.trim();
if (chapterName && chapterUrl) {
chapters.push({
name: chapterName,
path: chapterUrl,
chapterNumber: index + 1,
});
}
});
const sortButtonText = $('div.chaplist .header button.reverse span')
.last()
.text()
.trim();
if (sortButtonText === '倒序') {
chapters.reverse();
chapters.forEach((chap, index) => (chap.chapterNumber = index + 1));
}
return chapters;
}
async parseChapter(chapterPath: string): Promise<string> {
const chapterUrl = makeAbsolute(chapterPath, this.site);
if (!chapterUrl) throw new Error('Invalid chapter URL');
const result = await fetchApi(chapterUrl);
if (!result.ok) throw new Error('Failed to fetch chapter');
const $ = parseHTML(await result.text());
const $content = $('div.content.py-5');
if (!$content.length) return 'Error: Could not find chapter content';
$content
.find(
'script, style, ins, iframe, [class*="ads"], [id*="ads"], [class*="google"], [id*="google"], [class*="recommend"], div[align="center"], p:contains("推薦本書"), a[href*="javascript:"]',
)
.remove();
$content.find('p').each((_i, el) => {
const $p = $(el);
const pText = $p.text().trim();
if (
pText.includes('請記住本站域名') ||
pText.includes('手機版閱讀網址') ||
pText.includes('novel543') ||
pText.includes('稷下書院') ||
pText.includes('最快更新') ||
pText.includes('最新章節') ||
pText.includes('章節報錯') ||
pText.match(/app|APP|下載|客户端|关注微信|公众号/i) ||
pText.length === 0 ||
($p
.html()
?.replace(/&nbsp;/g, '')
.trim() === '' &&
$p.find('img').length === 0) ||
pText.includes('溫馨提示')
) {
$p.remove();
}
});
$content
.contents()
.filter(function () {
return this.type === 'comment';
})
.remove();
let chapterText = $content.html();
if (!chapterText) return 'Error: Chapter content was empty';
chapterText = chapterText
.replace(/<\s*p[^>]*>/gi, '\n\n')
.replace(/<\s*br[^>]*>/gi, '\n');
chapterText = parseHTML(`<div>${chapterText}</div>`).text();
return chapterText
.replace(/[\t ]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo > 1) return [];
if (/^\d+$/.test(searchTerm)) {
try {
const novelPath = `/${searchTerm}/`;
const novel = await this.parseNovel(novelPath);
return [
{
name: novel.name,
path: novelPath,
cover: novel.cover,
},
];
} catch {
return [];
}
}
const searchUrl = `${this.site}search/${encodeURIComponent(searchTerm)}`;
let body = '';
try {
const result = await fetchApi(searchUrl);
if (!result.ok) {
if (result.status === 403 || result.status === 503) {
throw new Error(
'Cloudflare protection detected (HTTP error). Please try opening the plugin in WebView first to solve the challenge.',
);
}
return [];
}
body = await result.text();
const $ = parseHTML(body);
const pageTitle = $('title').text().toLowerCase();
// Check for various Cloudflare challenge indicators
if (
pageTitle.includes('attention required') ||
pageTitle.includes('just a moment') ||
pageTitle.includes('please wait') ||
pageTitle.includes('verifying') ||
body.includes('Verifying you are human') ||
body.includes('cf-browser-verification') ||
body.includes('cf_captcha_container')
) {
throw new Error(
'Cloudflare protection detected. Please try opening the plugin in WebView first to solve the challenge.',
);
}
} catch (error) {
if (error instanceof Error) {
// If it's already our custom error, re-throw it
if (error.message.includes('Cloudflare protection detected')) {
throw error;
}
// For other errors, throw a generic error
throw new Error(`Failed to fetch search results: ${error.message}`);
}
throw error;
}
const $ = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
$('div.search-list ul.list > li.media').each((_i, el) => {
const $el = $(el);
const $link = $el.find('.media-content h3 a');
const novelPath = $link.attr('href')?.trim();
const novelName = $link.text().trim();
const novelCover = $el.find('.media-left img').attr('src')?.trim();
if (novelPath && novelName && novelPath.match(/^\/\d+\/$/)) {
novels.push({
name: novelName,
path: novelPath,
cover: makeAbsolute(novelCover, this.site) || defaultCover,
});
}
});
return novels;
}
}
export default new Novel543Plugin();
-201
View File
@@ -1,201 +0,0 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
class NovelsOnline implements Plugin.PluginBase {
id = 'NO.net';
name = 'novelsOnline';
site = 'https://novelsonline.net';
icon = 'src/en/novelsonline/icon.png';
version = '1.0.0';
async popularNovels(
page: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let link = this.site;
if (filters.genre.value !== '') link += `/category/${filters.genre.value}/`;
else link += `/top-novel/`;
link += page;
link += `?change_type=${filters.sort.value}`;
const body = await fetchApi(link).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novel: Plugin.NovelItem[] = [];
loadedCheerio('.top-novel-block').each((i, el) => {
const novelName = loadedCheerio(el).find('h2').text();
const novelCover = loadedCheerio(el)
.find('.top-novel-cover img')
.attr('src');
const novelUrl = loadedCheerio(el).find('h2 a').attr('href');
if (!novelUrl) return;
novel.push({
name: novelName,
cover: novelCover,
path: novelUrl.replace(this.site, ''),
});
});
return novel;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const result = await fetchApi(this.site + novelPath).then(res =>
res.text(),
);
const $ = parseHTML(result);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: $('h1').text() || 'Untitled',
cover: $('.novel-cover').find('a > img').attr('src'),
chapters: [],
};
$('.novel-detail-item').each((i, el) => {
const detailName = $(el).find('h6').text();
const detail = $(el).find('.novel-detail-body');
switch (detailName) {
case 'Description':
novel.summary = detail.text();
break;
case 'Genre':
novel.genres = detail
.find('li')
.map((_, el) => $(el).text())
.get()
.join(', ');
break;
case 'Author(s)':
novel.author = detail
.find('li')
.map((_, el) => $(el).text())
.get()
.join(', ');
break;
case 'Status':
novel.status = detail.text().trim();
break;
}
});
novel.chapters = $('ul.chapter-chs > li > a')
.map((_, el) => {
const chapterUrl = $(el).attr('href');
const chapterName = $(el).text();
return {
name: chapterName,
path: chapterUrl?.replace(this.site, ''),
} as Plugin.ChapterItem;
})
.get();
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(this.site + chapterPath).then(res =>
res.text(),
);
const loadedCheerio = parseHTML(result);
const chapterText = loadedCheerio('#contentall').html() || '';
return chapterText;
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const result = await fetchApi(this.site + '/sResults.php', {
headers: {
Accept: '*/*',
'Accept-Language': 'pl,en-US;q=0.7,en;q=0.3',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
method: 'POST',
body: new URLSearchParams({ q: searchTerm }).toString(),
}).then(res => res.text());
const $ = parseHTML(result);
const headers = $('li');
return headers
.map((i, h) => {
const novelName = $(h).text();
const novelUrl = $(h).find('a').attr('href');
const novelCover = $(h).find('img').attr('src');
if (!novelUrl) return;
return {
name: novelName,
cover: novelCover,
path: novelUrl.replace(this.site, ''),
};
})
.get()
.filter(sr => sr !== null);
}
filters = {
sort: {
value: 'top_rated',
label: 'Sort by',
options: [
{ label: 'Top Rated', value: 'top_rated' },
{ label: 'Most Viewed', value: 'view' },
],
type: FilterTypes.Picker,
},
genre: {
value: '',
label: 'Category',
options: [
{ label: 'None', value: '' },
{ label: 'Action', value: 'action' },
{ label: 'Adventure', value: 'adventure' },
{ label: 'Celebrity', value: 'celebrity' },
{ label: 'Comedy', value: 'comedy' },
{ label: 'Drama', value: 'drama' },
{ label: 'Ecchi', value: 'ecchi' },
{ label: 'Fantasy', value: 'fantasy' },
{ label: 'Gender Bender', value: 'gender-bender' },
{ label: 'Harem', value: 'harem' },
{ label: 'Historical', value: 'historical' },
{ label: 'Horror', value: 'horror' },
{ label: 'Josei', value: 'josei' },
{ label: 'Martial Arts', value: 'martial-arts' },
{ label: 'Mature', value: 'mature' },
{ label: 'Mecha', value: 'mecha' },
{ label: 'Mystery', value: 'mystery' },
{ label: 'Psychological', value: 'psychological' },
{ label: 'Romance', value: 'romance' },
{ label: 'School Life', value: 'school-life' },
{ label: 'Sci-fi', value: 'sci-fi' },
{ label: 'Seinen', value: 'seinen' },
{ label: 'Shotacon', value: 'shotacon' },
{ label: 'Shoujo', value: 'shoujo' },
{ label: 'Shoujo Ai', value: 'shoujo-ai' },
{ label: 'Shounen', value: 'shounen' },
{ label: 'Shounen Ai', value: 'shounen-ai' },
{ label: 'Slice of Life', value: 'slice-of-life' },
{ label: 'Sports', value: 'sports' },
{ label: 'Supernatural', value: 'supernatural' },
{ label: 'Tragedy', value: 'tragedy' },
{ label: 'Wuxia', value: 'wuxia' },
{ label: 'Xianxia', value: 'xianxia' },
{ label: 'Xuanhuan', value: 'xuanhuan' },
{ label: 'Yaoi', value: 'yaoi' },
{ label: 'Yuri', value: 'yuri' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new NovelsOnline();
-223
View File
@@ -1,223 +0,0 @@
import { CheerioAPI, load } from 'cheerio';
import { Plugin } from '@typings/plugin';
import { fetchApi } from '@libs/fetch';
import { NovelStatus } from '@libs/novelStatus';
import { defaultCover } from '@libs/defaultCover';
class StorySeedlingPlugin implements Plugin.PluginBase {
id = 'storyseedling';
name = 'StorySeedling';
icon = 'src/en/storyseedling/icon.png';
site = 'https://storyseedling.com/';
version = '1.0.3';
async getCheerio(url: string, search: boolean): Promise<CheerioAPI> {
const r = await fetchApi(url);
if (!r.ok && search != true)
throw new Error(
'Could not reach site (' + r.status + ') try to open in webview.',
);
const $ = load(await r.text());
return $;
}
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
const body = await fetchApi(this.site + 'browse').then(r => r.text());
const loadedCheerio = load(body);
const postValue = loadedCheerio('div[ax-load][x-data]')
.attr('x-data')
?.replace("browse('", '')
.replace("')", '') as string;
const data = new FormData();
data.append('search', '');
data.append('orderBy', 'recent');
data.append('curpage', pageNo.toString());
data.append('post', postValue);
data.append('action', 'fetch_browse');
const response: any = await fetchApi(this.site + 'ajax', {
body: data,
method: 'POST',
}).then(res => res.json());
response.data.posts.forEach((element: any) =>
novels.push({
name: element.title,
cover: element.thumbnail,
path: element.permalink.replace(this.site, ''),
}),
);
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const $ = await this.getCheerio(this.site + novelPath, false);
const baseUrl = this.site;
const novel: Partial<Plugin.SourceNovel> = {
path: novelPath,
};
novel.name = $('h1').text().trim();
const coverUrl = $('img[x-ref="art"].w-full.rounded.shadow-md').attr('src');
if (coverUrl) {
novel.cover = new URL(coverUrl, baseUrl).href;
}
const genres: string[] = [];
$(
'section[x-data="{ tab: location.hash.substr(1) || \'chapters\' }"].relative > div > div > div.flex.flex-wrap > a',
).each(function () {
genres.push($(this).text().trim());
});
novel.genres = genres.join(', ');
novel.author = $('div.mb-1 a').text().trim();
const rawStatus = $('div.gap-2 span.text-sm').text().trim();
const map: Record<string, string> = {
ongoing: NovelStatus.Ongoing,
hiatus: NovelStatus.OnHiatus,
dropped: NovelStatus.Cancelled,
cancelled: NovelStatus.Cancelled,
completed: NovelStatus.Completed,
};
novel.status = map[rawStatus.toLowerCase()] ?? NovelStatus.Unknown;
const summaryDiv = $('div.mb-4.order-2:not(.lg\\:grid-in-buttons)');
const pTagSummary = summaryDiv.find('p');
novel.summary =
pTagSummary.length > 0
? pTagSummary
.map((_, el) => $(el).text().trim())
.get()
.join('\n\n')
: summaryDiv.text().trim(); // --- Else (no <p> tags) ---
const chapters: Plugin.ChapterItem[] = [];
const xdata = $('.bg-accent div[ax-load][x-data]').attr('x-data');
// expected data format: toc('000000', 'xxxxxxxxxx') (6 numbers, 10 hex)
if (xdata) {
const listXdata = xdata?.split("'");
const dataNovelId = listXdata[1];
const dataNovelN = listXdata[3];
const idMatch = novelPath.match(/\d+/); // Error checking in case of extraneous slashes
const novelId = dataNovelId || (idMatch ? idMatch[0] : null);
if (novelId) {
const chapterListing = 'ajax'; // Currently URL is only ajax
const formData = new FormData();
formData.append('post', dataNovelN);
formData.append('id', dataNovelId);
formData.append('action', 'series_toc');
const chaptersUrl = `${this.site}${chapterListing}`;
const refererUrl = `${this.site}${novel.path}`;
let results = await fetchApi(chaptersUrl, {
method: 'POST',
referrer: refererUrl,
referrerPolicy: 'origin',
body: formData,
})
.then(r => r.json())
.catch(
e =>
(novel.summary =
'Chapter Parse Error: ' + e + '\n\n' + novel.summary),
);
if (results.data) {
results = results.data;
results.forEach(function (chap: any) {
if (chap.url == null) {
return;
}
const name = chap.title;
const url = chap.url as string;
const releaseTime = chap.date;
const chapterNumber = chap.slug;
chapters.push({
name: name,
path: url.replace(baseUrl, ''),
releaseTime,
chapterNumber: parseInt(chapterNumber),
});
});
}
}
}
novel.chapters = chapters;
return novel as Plugin.SourceNovel;
}
async parseChapter(chapterPath: string): Promise<string> {
const $ = await this.getCheerio(this.site + chapterPath, false);
const xdata = $('div[ax-load][x-data]').attr('x-data');
const t = $('div.justify-center > div.mb-4');
let chapterText = t.html() || '';
if (xdata) {
chapterText =
chapterText +
"\n\n Error parsing chapter: Turnstile detected. Advise just reading in web view until there's a fix.";
// const listXdata = xdata?.split("'");
// const dataNovelId = listXdata[1];
// const dataNovelN = listXdata[3];
}
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
const body = await fetchApi(this.site + 'browse').then(r => r.text());
const loadedCheerio = load(body);
const postValue = loadedCheerio('div[ax-load][x-data]')
.attr('x-data')
?.replace("browse('", '')
.replace("')", '') as string;
const data = new FormData();
data.append('search', searchTerm);
data.append('orderBy', 'recent');
data.append('curpage', pageNo.toString());
data.append('post', postValue);
data.append('action', 'fetch_browse');
const response: any = await fetchApi(this.site + 'ajax', {
body: data,
method: 'POST',
}).then(res => res.json());
response.data.posts.forEach((element: any) =>
novels.push({
name: element.title,
cover: element.thumbnail,
path: element.permalink.replace(this.site, ''),
}),
);
return novels;
}
}
export default new StorySeedlingPlugin();
-579
View File
@@ -1,579 +0,0 @@
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.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: number = 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();
-272
View File
@@ -1,272 +0,0 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { Plugin } from '@typings/plugin';
import { defaultCover } from '@libs/defaultCover';
import { fetchApi } from '@libs/fetch';
import { NovelStatus } from '@libs/novelStatus';
import { FilterTypes, Filters } from '@libs/filterInputs';
class BLN implements Plugin.PluginBase {
id = 'BLN';
name = 'BestLightNovel';
icon = 'src/en/bestlightnovel/icon.png';
site = 'https://bestlightnovel.com/';
version = '1.0.1';
parseNovels(loadedCheerio: CheerioAPI) {
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.update_item.list_category').each((i, el) => {
const novelUrl = loadedCheerio(el).find('h3 > a').attr('href');
if (!novelUrl) {
// TODO: Handle error
console.error('No novel url!');
return;
}
const novelName = loadedCheerio(el).find('h3 > a').text();
const novelCover = loadedCheerio(el).find('img').attr('src');
const novel = {
name: novelName,
path: novelUrl.replace(this.site, ''),
cover: novelCover,
};
novels.push(novel);
});
return novels;
}
async popularNovels(
page: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let link = this.site + 'novel_list?';
link += 'type=' + filters.type.value;
link += '&category=' + filters.category.value;
link += '&state=' + filters.status.value;
link += '&page=' + page;
const result = await fetchApi(link);
if (!result.ok) {
console.error(await result.text());
// TODO: Cloudflare protection or other error
return [];
}
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const result = await fetchApi(this.site + novelPath);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('.truyen_info_right h1').text().trim() || '',
cover: loadedCheerio('.info_image img').attr('src') || defaultCover,
summary: loadedCheerio('#noidungm').text()?.trim() || '',
chapters: [],
};
loadedCheerio('ul.truyen_info_right > li').each(function () {
const detailName = loadedCheerio(this).find('span').text();
const detail = loadedCheerio(this)
.find('a')
.map((a, ex) => loadedCheerio(ex).text())
.toArray()
.join(', ');
switch (detailName) {
case 'Author(s): ':
novel.author = detail;
break;
case 'GENRES: ':
novel.genres = detail;
break;
case 'STATUS : ':
novel.status =
detail === 'Ongoing'
? NovelStatus.Ongoing
: detail === 'Completed'
? NovelStatus.Completed
: NovelStatus.Unknown;
}
});
const chapter: Plugin.ChapterItem[] = [];
loadedCheerio('.chapter-list div.row').each((i, el) => {
const chapterName = loadedCheerio(el).find('a').text().trim();
const releaseDate = loadedCheerio(el).find('span:last').text().trim();
const months = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
].join('|');
const rx = new RegExp(`(${months})-(\\d{1,2})-(\\d{2})`, 'i').exec(
releaseDate,
);
if (!rx) return;
const year = 2000 + +rx[3];
const month = months.indexOf(rx[1].toLowerCase());
const day = +rx[2];
const chapterUrl = loadedCheerio(el).find('a').attr('href');
if (!chapterUrl) {
// TODO: Handle error
console.error('No chapter url!');
return;
}
chapter.push({
name: chapterName,
releaseTime: new Date(year, month, day).toISOString(),
path: chapterUrl.replace(this.site, ''),
});
});
novel.chapters = chapter.reverse();
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(this.site + chapterPath);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const chapterText = loadedCheerio('#vung_doc').html() || '';
return chapterText;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}search_novels/${encodeURIComponent(searchTerm)}?page=${page}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
filters = {
status: {
label: 'Status',
value: 'all',
options: [
{ label: 'ALL', value: 'all' },
{ label: 'Completed', value: 'completed' },
{ label: 'Ongoing', value: 'ongoing' },
],
type: FilterTypes.Picker,
},
type: {
value: 'topview',
label: 'Type',
options: [
{ label: 'Recently updated', value: 'latest' },
{ label: 'Newest', value: 'newest' },
{ label: 'Top view', value: 'topview' },
],
type: FilterTypes.Picker,
},
category: {
label: 'Category',
value: 'all',
options: [
{ label: 'ALL', value: 'all' },
{ label: 'Action', value: '1' },
{ label: 'Adventure', value: '2' },
{ label: 'Animals', value: '65' },
{ label: 'Arts', value: '40' },
{ label: 'Biographies', value: '41' },
{ label: 'Business', value: '42' },
{ label: 'Chinese', value: '3' },
{ label: 'Comedy', value: '4' },
{ label: 'Computers', value: '43' },
{ label: 'Crafts, Hobbies', value: '45' },
{ label: 'Drama', value: '5' },
{ label: 'Education', value: '46' },
{ label: 'English', value: '6' },
{ label: 'Entertainment', value: '47' },
{ label: 'Fantasy', value: '7' },
{ label: 'Fiction', value: '48' },
{ label: 'Gender Bender', value: '8' },
{ label: 'Harem', value: '9' },
{ label: 'Historical', value: '10' },
{ label: 'History', value: '49' },
{ label: 'Home', value: '50' },
{ label: 'Horror', value: '11' },
{ label: 'Humor', value: '51' },
{ label: 'Investing', value: '52' },
{ label: 'Josei', value: '12' },
{ label: 'Korean', value: '13' },
{ label: 'Literature', value: '53' },
{ label: 'Lolicon', value: '14' },
{ label: 'Martial Arts', value: '15' },
{ label: 'Mature', value: '16' },
{ label: 'Mecha', value: '17' },
{ label: 'Memoirs', value: '54' },
{ label: 'Mystery', value: '18' },
{ label: 'Original', value: '19' },
{ label: 'Other Books', value: '66' },
{ label: 'Philosophy', value: '55' },
{ label: 'Photography', value: '56' },
{ label: 'Politics', value: '57' },
{ label: 'Professional', value: '58' },
{ label: 'Psychological', value: '20' },
{ label: 'Reference', value: '59' },
{ label: 'Reincarnation', value: '21' },
{ label: 'Religion', value: '60' },
{ label: 'Romance', value: '22' },
{ label: 'School Life', value: '23' },
{ label: 'School Stories', value: '67' },
{ label: 'Sci-Fi', value: '24' },
{ label: 'Seinen', value: '25' },
{ label: 'Short Stories', value: '68' },
{ label: 'Shotacon', value: '26' },
{ label: 'Shoujo', value: '27' },
{ label: 'Shoujo Ai', value: '28' },
{ label: 'Shounen', value: '29' },
{ label: 'Shounen Ai', value: '30' },
{ label: 'Slice Of Life', value: '31' },
{ label: 'Smut', value: '32' },
{ label: 'Social Science', value: '61' },
{ label: 'Spirituality', value: '62' },
{ label: 'Sports', value: '33' },
{ label: 'Supernatural', value: '34' },
{ label: 'Teaser', value: '69' },
{ label: 'Technical', value: '63' },
{ label: 'Technology', value: '64' },
{ label: 'Tragedy', value: '35' },
{ label: 'Virtual Reality', value: '36' },
{ label: 'Wuxia', value: '37' },
{ label: 'Xianxia', value: '38' },
{ label: 'Xuanhuan', value: '39' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new BLN();
-156
View File
@@ -1,156 +0,0 @@
import { fetchApi, fetchProto, fetchText } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { Filters } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
class Chrysanthemumgarden implements Plugin.PluginBase {
id = 'chrysanthemumgarden';
name = 'Chrysanthemum Garden';
icon = 'src/en/chrysanthemumgarden/icon.png';
site = 'https://chrysanthemumgarden.com';
version = '1.0.1';
filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//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 req = await fetchApi(
this.site + (pageNo === 1 ? '/books' : '/books/page/' + pageNo) + '/',
);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio('article')
.map((i, el) => {
if (
loadedCheerio(el)
.find('div.series-genres > a')
.text()
.includes('Manhua')
)
return;
const href = loadedCheerio(el).find('h2.novel-title > a').attr('href');
if (!href) return;
return {
name: loadedCheerio(el).find('h2.novel-title > a').text(),
path: href
.replace(this.site, '')
.replace(/^\//, '')
.replace(/\/$/, ''),
cover:
loadedCheerio(el)
.find('div.novel-cover > img')
.attr('data-breeze') || defaultCover,
};
})
.toArray()
.filter(Boolean);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const req = await fetchApi(this.site + '/' + novelPath + '/');
const body = await req.text();
const loadedCheerio = loadCheerio(body);
loadedCheerio('h1.novel-title > span.novel-raw-title').remove();
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1.novel-title').text(),
cover: loadedCheerio('div.novel-cover > img').attr('data-breeze'),
summary: loadedCheerio('div.entry-content > p')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join('\n\n'),
};
const novelInfoHtml = loadedCheerio('div.novel-info').html();
novel.author = novelInfoHtml
? novelInfoHtml.match(/Author:\s*([^<]*)<br>/)?.[1]?.trim()
: undefined;
novel.genres = [
...loadedCheerio('div.series-genres > a')
.map((i, el) => loadedCheerio(el).text())
.toArray(),
...loadedCheerio('a.series-tag')
.map((i, el) => loadedCheerio(el).text().split('(')[0].trim())
.toArray(),
].join(', ');
novel.chapters = loadedCheerio('div.chapter-item > a')
.map((i, el) => {
const href = loadedCheerio(el).attr('href');
if (!href) return;
return {
name: loadedCheerio(el).text().trim(),
path: href
.replace(this.site, '')
.replace(/^\//, '')
.replace(/\/$/, ''),
};
})
.toArray();
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const req = await fetchApi(this.site + '/' + chapterPath + '/');
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio('div#novel-content').html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) return [];
const req = await fetchApi(this.site + '/wp-json/cg/novels');
const body = await req.json();
const allNovels: ChrysanthemumGardenNovelItem[] = (
body as ChrysanthemumGardenNovel[]
).map(
(novel: ChrysanthemumGardenNovel): ChrysanthemumGardenNovelItem => ({
name: novel.name,
path: novel.link
.replace(this.site, '')
.replace(/\/$/, '')
.replace(/^\//, ''),
cover: defaultCover,
}),
);
if (!allNovels) return [];
return allNovels.filter(novel =>
novel.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
async getAllNovels() {}
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + (isNovel ? '/book/' : '/chapter/') + path;
}
export default new Chrysanthemumgarden();
interface ChrysanthemumGardenNovel {
name: string;
link: string;
}
interface ChrysanthemumGardenNovelItem {
name: string;
path: string;
cover: string;
}
-301
View File
@@ -1,301 +0,0 @@
import { defaultCover } from '@libs/defaultCover';
import { fetchApi } from '@libs/fetch';
import { Filters, FilterTypes, FilterValueWithType } from '@libs/filterInputs';
import { Plugin } from '@typings/plugin';
import { load as parseHTML } from 'cheerio';
//TODO: this looks similar to fictioneer source? maybe use multisrc someday
class DDLPlugin implements Plugin.PluginBase {
id = 'DDL.com';
name = 'Divine Dao Library';
site = 'https://www.divinedaolibrary.com/';
version = '1.1.1';
icon = 'src/en/divinedaolibrary/icon.png';
filters = {
category: {
type: FilterTypes.CheckboxGroup,
label: 'State',
value: ['Completed', 'Translating', 'Lost in Voting Poll', 'Dropped'],
options: [
{ label: 'Completed', value: 'Completed' },
{ label: 'Translating', value: 'Translating' },
{ label: 'Lost in Voting Poll', value: 'Lost in Voting Poll' },
{ label: 'Dropped', value: 'Dropped' },
{ label: 'Personally Written', value: 'Personally Written' },
],
},
} satisfies Filters;
allNovelsCache: readonly (readonly [string, string, string])[] | undefined;
novelItemCache = new Map<string, Required<Plugin.NovelItem>>();
/**
* Safely extract the pathname from any URL on {@link site}. Check the root
* site as there are novels linking off-site (to Patreon).
*
* @private
*/
getPath(url: string): string | undefined {
if (!url.startsWith(this.site)) {
return undefined;
}
const trimmed = url.substring(this.site.length).replace(/(^\/+|\/+$)/g, '');
if (trimmed.length === 0) {
return undefined;
}
return trimmed;
}
/**
* Map an array with an asynchronous function and only return the array items
* that successfully were fulfilled with values other than undefined.
*
* @private
*/
async asyncMap<T, U>(
collection: readonly T[],
callbackfn: (value: T, index: number, array: readonly T[]) => Promise<U>,
): Promise<Exclude<U, undefined>[]> {
return (await Promise.allSettled(collection.map(callbackfn)))
.filter(
<U>(
p: PromiseSettledResult<U>,
): p is PromiseFulfilledResult<Exclude<U, undefined>> =>
p.status === 'fulfilled' && p.value !== undefined,
)
.map(({ value }) => value);
}
/**
* DDL links to future (unpublished) chapters from its novel pages. To be
* able to report updates correctly, try to figure out which chapter was the
* actual latest one to be published.
*
* @private
* @returns the path value of the latest published chapter (or undefined)
*/
async findLatestChapter(novelPath: string): Promise<string | undefined> {
const link = `${this.site}wp-json/wp/v2/categories?slug=${novelPath}`;
const guessCategory = await fetchApi(link).then(res => res.json());
if (guessCategory.length !== 1) {
return undefined;
}
const categoryId = guessCategory[0].id;
const chapterLink = `${this.site}wp-json/wp/v2/posts?categories=${categoryId}&per_page=1`;
const lastChapter = await fetchApi(chapterLink).then(res => res.json());
if (lastChapter.length !== 1) {
return undefined;
}
return lastChapter[0].slug;
}
/**
* Based on the path, grab all the available information about a novel. Used
* to seed the {@link novelItemCache} as well as to fetch the actual single
* novel views with chapter list.
*
* @private
*/
async grabNovel(
path: string,
getChapters = false,
): Promise<(Plugin.SourceNovel & Required<Plugin.NovelItem>) | undefined> {
const link = `${this.site}wp-json/wp/v2/pages?slug=${path}`;
const data = await fetchApi(link).then(res => res.json());
if (data.length !== 1) {
return undefined;
}
const content = parseHTML(data[0].content.rendered);
const excerpt = parseHTML(data[0].excerpt.rendered);
const image = content('img').first();
let chapters: Plugin.ChapterItem[] = [];
if (getChapters) {
const linkedChapters = content('li > span > a')
.map((_, anchorEl) => {
const chapterPath = this.getPath(anchorEl.attribs['href']);
if (!chapterPath) return;
return {
name: content(anchorEl).text(),
path: chapterPath,
} satisfies Plugin.ChapterItem;
})
.toArray();
const lastChapterPath = await this.findLatestChapter(path);
if (lastChapterPath) {
chapters = linkedChapters.slice(
0,
1 +
linkedChapters.findIndex(
chapter => chapter.path === lastChapterPath,
),
);
} else {
chapters = linkedChapters;
}
}
return {
name: data[0].title.rendered,
path,
cover: image.attr('data-lazy-src') ?? image.attr('src') ?? defaultCover,
author: content('h3')
.first()
.text()
.replace(/^Author:\s*/g, ''),
summary: excerpt('p')
.first()
.text()
.replace(/^.+Description\s*/g, ''),
chapters,
};
}
/**
* Based on the path, grab the {@link Plugin.NovelItem} information from the
* cache. If not available in the cache, it will fetch the information.
*
* @private
*/
async grabCachedNovel(path: string): Promise<Plugin.NovelItem | undefined> {
const fromCache = this.novelItemCache.get(path);
if (fromCache) {
return fromCache;
}
const sourceNovel = await this.grabNovel(path, false);
if (sourceNovel === undefined) {
return undefined;
}
const novel = {
name: sourceNovel.name,
path: sourceNovel.path,
cover: sourceNovel.cover,
};
this.novelItemCache.set(path, novel);
return novel;
}
/**
* Get the list of all novels listed on the novels page. Cache it so filters
* do not have to refetch the same HTML again.
*
* @private
*/
async grabCachedNovels(): Promise<
readonly (readonly [string, string, string])[] | undefined
> {
if (this.allNovelsCache) {
return this.allNovelsCache;
}
const body = await fetchApi(this.site + 'novels').then(res => res.text());
const loadedCheerio = parseHTML(body);
const novels = loadedCheerio('.entry-content ul')
.map((_, listEl) => {
const list = loadedCheerio(listEl);
const category = list.prev().text();
return list
.find('a')
.map((_, anchorEl) => {
const path = this.getPath(anchorEl.attribs['href']);
if (!path) return;
const name = loadedCheerio(anchorEl).text();
return [[category, name, path]] as const;
})
.toArray();
})
.toArray();
this.allNovelsCache = novels;
return novels;
}
/**
* Parse list of paths of recently updated novels from the homepage.
*
* @private
*/
async latestNovels(): Promise<readonly string[]> {
const body = await fetchApi(this.site).then(res => res.text());
const loadedCheerio = parseHTML(body);
const novelPaths = loadedCheerio('#main')
.find('a[rel="category tag"]')
.map((_, anchorEl) => {
const path = this.getPath(anchorEl.attribs['href']);
return path;
})
.toArray();
const uniqueNovelPaths = new Set(novelPaths);
return Array.from(uniqueNovelPaths);
}
/**
* Parse list of paths from novels list, optionally filtered.
*
* @private
*/
async allNovels(
categoryFilter: FilterValueWithType<FilterTypes.CheckboxGroup>,
) {
const allNovels = await this.grabCachedNovels();
if (!allNovels) return [];
return allNovels
.filter(([category]) => categoryFilter.value.includes(category))
.map(([, , path]) => path);
}
async popularNovels(
pageNo: number,
options: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) {
return [];
}
const novelsList = options.showLatestNovels
? this.latestNovels()
: this.allNovels(options.filters.category);
return await this.asyncMap(
await novelsList,
this.grabCachedNovel.bind(this),
);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const sourceNovel = await this.grabNovel(novelPath, true);
if (sourceNovel === undefined) {
throw new Error(`The path "${novelPath}" could not be resolved.`);
}
return sourceNovel;
}
async parseChapter(chapterPath: string): Promise<string> {
const chapterLink = `${this.site}wp-json/wp/v2/posts?slug=${chapterPath}`;
const chapter = await fetchApi(chapterLink).then(res => res.json());
if (chapter.length !== 1) {
return '';
}
const title = `<h1>${chapter[0].title.rendered}</h1>`;
const content = chapter[0].content.rendered;
return `${title}${content}`;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) {
return [];
}
const allNovels = await this.grabCachedNovels();
if (!allNovels) return [];
const foundNovels = allNovels
.filter(([, name]) =>
name.toLocaleLowerCase().includes(searchTerm.toLocaleLowerCase()),
)
.map(([, , path]) => path);
return await this.asyncMap(
await foundNovels,
this.grabCachedNovel.bind(this),
);
}
}
export default new DDLPlugin();
-188
View File
@@ -1,188 +0,0 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
class DreamBigTL implements Plugin.PluginBase {
id = 'dreambigtl';
name = 'Dream Big Translations';
version = '1.0.0';
site = 'https://www.dreambigtl.com/';
icon = 'src/en/dreambigtl/icon.png';
async popularNovels(
pageNo: number,
{ showLatestNovels }: Plugin.PopularNovelsOptions,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}p/disclaimer.html`;
const response = await fetchApi(url);
const body = await response.text();
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
const categories = ['New Novels', 'Ongoing Novels', 'Completed Novels'];
loadedCheerio('#webify-pro-main-nav-menu > li.has-sub').each(
(_, categoryElem) => {
const categoryName = loadedCheerio(categoryElem)
.children('a')
.first()
.text()
.trim();
if (categories.includes(categoryName)) {
const subMenu = loadedCheerio(categoryElem).find('ul.sub-menu.m-sub');
subMenu.find('li > a').each((_, novelElem) => {
const novelName = loadedCheerio(novelElem).text().trim();
const novelUrl = loadedCheerio(novelElem).attr('href');
if (novelUrl) {
novels.push({
name: novelName,
path: novelUrl.replace(this.site, ''),
cover: '',
});
}
});
}
},
);
novels.forEach(novel => console.log('Novel:', novel.name, novel.path));
if (novels.length === 0) {
// Fallback: try to parse everything...
loadedCheerio('a').each((_, elem) => {
const href = loadedCheerio(elem).attr('href');
if (href && href.includes('/p/') && !href.includes('disclaimer')) {
const novelName = loadedCheerio(elem).text().trim();
novels.push({
name: novelName,
path: href.replace(this.site, ''),
cover: '',
});
}
});
}
if (showLatestNovels) {
novels.sort((a, b) => b.path.localeCompare(a.path));
}
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1.entry-title').text().trim(),
cover: loadedCheerio('.post-body img').first().attr('src') || '',
summary: loadedCheerio('.post-body p').first().text().trim(),
author:
loadedCheerio('.post-body')
.text()
.match(/Author:\s*(.+)/i)?.[1]
?.trim() || 'Unknown',
status: this.getNovelStatus(novelPath),
chapters: await this.parseChapters(loadedCheerio),
};
return novel;
}
async parseChapters(
loadedCheerio: CheerioAPI,
): Promise<Plugin.ChapterItem[]> {
const chapters: Plugin.ChapterItem[] = [];
// Parse "Free Tier" chapters
loadedCheerio('.chapter-panel').each((_, panel) => {
const panelTitle = loadedCheerio(panel).find('summary').text().trim();
if (panelTitle === 'Free Tier') {
loadedCheerio(panel)
.find('ul li a')
.each((_, chapterEle) => {
const chapterName = loadedCheerio(chapterEle).text().trim();
const chapterUrl = loadedCheerio(chapterEle).attr('href');
if (chapterUrl) {
chapters.push({
name: chapterName,
path: chapterUrl.replace(this.site, ''),
});
}
});
}
});
// Parse "List of Chapters"
if (chapters.length === 0) {
loadedCheerio(
'h2:contains("List of Chapters"), span:contains("List of Chapters")',
).each((_, header) => {
const chapterList = loadedCheerio(header).next('ul');
chapterList.find('li a').each((_, chapterEle) => {
const chapterName = loadedCheerio(chapterEle).text().trim();
const chapterUrl = loadedCheerio(chapterEle).attr('href');
if (chapterUrl) {
chapters.push({
name: chapterName,
path: chapterUrl.replace(this.site, ''),
});
}
});
});
}
return chapters.reverse();
}
getNovelStatus(novelPath: string): string {
if (novelPath.includes('completed')) return 'Completed';
return 'Ongoing';
}
async parseChapter(chapterPath: string): Promise<string> {
const url = this.site + chapterPath;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const chapterTitle = loadedCheerio('h1.entry-title').text().trim();
const chapterContent = loadedCheerio('.post-body').html() || '';
return `<h1>${chapterTitle}</h1>${chapterContent}`;
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const url = `${this.site}search?q=${encodeURIComponent(searchTerm)}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio(
'.blog-posts.index-post-wrap .blog-post.hentry.index-post',
).each((_, ele) => {
const novelName = loadedCheerio(ele).find('.entry-title a').text().trim();
const novelUrl = loadedCheerio(ele).find('.entry-title a').attr('href');
const novelCover =
loadedCheerio(ele).find('.entry-image').attr('data-image') || '';
if (novelUrl) {
novels.push({
name: novelName,
path: novelUrl.replace(this.site, ''),
cover: novelCover,
});
}
});
return novels;
}
}
export default new DreamBigTL();
-199
View File
@@ -1,199 +0,0 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@typings/plugin';
class EarlyNovelPlugin implements Plugin.PagePlugin {
id = 'earlynovel';
name = 'Early Novel';
version = '1.0.1';
icon = 'src/en/earlynovel/icon.png';
site = 'https://earlynovel.net/';
parseNovels(loadedCheerio: CheerioAPI) {
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.col-truyen-main > .list-truyen > .row').each((i, el) => {
const novelUrl = loadedCheerio(el)
.find('h3.truyen-title > a')
.attr('href');
const novelName = loadedCheerio(el).find('h3.truyen-title > a').text();
const novelCover = loadedCheerio(el).find('.lazyimg').attr('data-image');
if (!novelUrl) return;
novels.push({
path: novelUrl,
name: novelName,
cover: novelCover,
});
});
return novels;
}
parseChapters(loadedCheerio: CheerioAPI) {
const chapter: Plugin.ChapterItem[] = [];
loadedCheerio('ul.list-chapter > li').each((i, el) => {
const chapterName = loadedCheerio(el).find('.chapter-text').text().trim();
const chapterUrl = loadedCheerio(el).find('a').attr('href')?.slice(1);
if (!chapterUrl) return;
chapter.push({
name: chapterName,
path: chapterUrl,
});
});
return chapter;
}
async popularNovels(
pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let link = this.site;
if (filters.genres.value.length) link += filters.genres.value;
else link += filters.order.value;
link += `?page=${pageNo}`;
const body = await fetchApi(link).then(res => res.text());
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const result = await fetchApi(this.site + novelPath);
const body = await result.text();
const loadedCheerio = parseHTML(body);
loadedCheerio('.glyphicon-menu-right').closest('li').remove();
const pagenav = loadedCheerio('.page-nav').prev().find('a');
const lastPageStr = pagenav.attr('title')?.match(/(\d+)/);
const lastPage = Number(lastPageStr?.[1] || '0');
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelPath,
name: loadedCheerio('.book > img').attr('alt') || 'Untitled',
cover: loadedCheerio('.book > img').attr('src'),
summary: loadedCheerio('.desc-text').text().trim(),
chapters: [],
totalPages: lastPage,
};
loadedCheerio('.info > div > h3').each(function () {
const detailName = loadedCheerio(this).text();
const detail = loadedCheerio(this)
.siblings()
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(',');
switch (detailName) {
case 'Author:':
novel.author = detail;
break;
case 'Status:':
novel.status = detail;
break;
case 'Genre:':
novel.genres = detail;
break;
}
});
novel.chapters = this.parseChapters(loadedCheerio);
return novel;
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const url = this.site + novelPath + '?page=' + page;
const body = await fetchApi(url).then(res => res.text());
const loadedCheerio = parseHTML(body);
const chapters = this.parseChapters(loadedCheerio);
return { chapters };
}
async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(this.site + chapterPath);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const chapterText = loadedCheerio('#chapter-c').html() || '';
return chapterText;
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const url = `${this.site}search?keyword=${encodeURIComponent(searchTerm)}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
filters = {
order: {
value: '/most-popular',
label: 'Order by',
options: [
{ label: 'Latest Release', value: '/latest-release-novel' },
{ label: 'Hot Novel', value: '/hot-novel' },
{ label: 'Completed Novel', value: '/completed-novel' },
{ label: 'Most Popular', value: '/most-popular' },
],
type: FilterTypes.Picker,
},
genres: {
value: '',
label: 'Genre',
options: [
{ label: 'None', value: '' },
{ label: 'Action', value: '/genre/action-1' },
{ label: 'Adult', value: '/genre/adult-2' },
{ label: 'Adventure', value: '/genre/adventure-3' },
{ label: 'Comedy', value: '/genre/comedy-4' },
{ label: 'Drama', value: '/genre/drama-5' },
{ label: 'Ecchi', value: '/genre/ecchi-6' },
{ label: 'Fantasy', value: '/genre/fantasy-7' },
{ label: 'Gender Bender', value: '/genre/gender-bender-8' },
{ label: 'Harem', value: '/genre/harem-9' },
{ label: 'Historical', value: '/genre/historical-10' },
{ label: 'Horror', value: '/genre/horror-11' },
{ label: 'Josei', value: '/genre/josei-12' },
{ label: 'Martial Arts', value: '/genre/martial-arts-13' },
{ label: 'Mature', value: '/genre/mature-14' },
{ label: 'Mecha', value: '/genre/mecha-15' },
{ label: 'Mystery', value: '/genre/mystery-16' },
{ label: 'Psychological', value: '/genre/psychological-17' },
{ label: 'Romance', value: '/genre/romance-18' },
{ label: 'School Life', value: '/genre/school-life-19' },
{ label: 'Sci-fi', value: '/genre/sci-fi-20' },
{ label: 'Seinen', value: '/genre/seinen-21' },
{ label: 'Shoujo', value: '/genre/shoujo-22' },
{ label: 'Shoujo Ai', value: '/genre/shoujo-ai-23' },
{ label: 'Shounen', value: '/genre/shounen-24' },
{ label: 'Shounen Ai', value: '/genre/shounen-ai-25' },
{ label: 'Slice of Life', value: '/genre/slice-of-life-26' },
{ label: 'Smut', value: '/genre/smut-27' },
{ label: 'Sports', value: '/genre/sports-28' },
{ label: 'Supernatural', value: '/genre/supernatural-29' },
{ label: 'Tragedy', value: '/genre/tragedy-30' },
{ label: 'Wuxia', value: '/genre/wuxia-31' },
{ label: 'Xianxia', value: '/genre/xianxia-32' },
{ label: 'Xuanhuan', value: '/genre/xuanhuan-33' },
{ label: 'Yaoi', value: '/genre/yaoi-34' },
{ label: 'Yuri', value: '/genre/yuri-35' },
{ label: 'Video Games', value: '/genre/video-games-36' },
{ label: 'Magical Realism', value: '/genre/magical-realism-37' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new EarlyNovelPlugin();
-188
View File
@@ -1,188 +0,0 @@
import { Plugin } from '@typings/plugin';
import { fetchApi } from '@libs/fetch';
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { NovelStatus } from '@libs/novelStatus';
class FaqWikiUs implements Plugin.PluginBase {
id = 'FWK.US';
name = 'Faq Wiki';
site = 'https://faqwiki.us/';
version = '2.0.1';
icon = 'src/en/faqwikius/icon.png';
parseNovels(loadedCheerio: CheerioAPI, searchTerm?: string) {
let novels: Plugin.NovelItem[] = [];
loadedCheerio('.plt-page-item').each((index, element) => {
const name = loadedCheerio(element)
.text()
.replace('Novel – All Chapters', '')
.trim();
let cover = loadedCheerio(element).find('img').attr('data-ezsrc');
// Remove the appended query string
if (cover) {
cover = cover.replace(/\?ezimgfmt=.*$/, ''); // Regular expression magic!
} else {
cover = loadedCheerio(element).find('img').attr('src');
}
const path = loadedCheerio(element)
.find('a')
.attr('href')
?.replace('tp:', 'tps:')
?.replace(this.site, '')
?.replace(/\/+$/, '');
if (!path) return;
novels.push({ name, cover, path });
});
if (searchTerm) {
novels = novels.filter(novel =>
novel.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
return novels;
}
async popularNovels(): Promise<Plugin.NovelItem[]> {
const body = await fetchApi(this.site).then(res => res.text());
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
async parseNovel(path: string): Promise<Plugin.SourceNovel> {
const body = await fetchApi(this.site + path).then(res => res.text());
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path,
name: '',
};
novel.name = loadedCheerio('.entry-title')
.text()
.replace('Novel – All Chapters', '')
.trim();
const img = loadedCheerio('.wp-block-image img');
const cover = img.attr('data-ezsrc') || img.attr('src');
novel.cover = cover?.replace(/\?ezimgfmt=.*$/, ''); // Regular expression magic!
const status = loadedCheerio(
"#lcp_instance_0 +:icontains('complete')",
).text();
novel.status = status ? NovelStatus.Completed : NovelStatus.Ongoing;
loadedCheerio('.entry-content strong').each((i, el) => {
const key = loadedCheerio(el).text().trim().toLowerCase();
const parent = loadedCheerio(el).parent();
const values = [parent.text().slice(key.length).trim()].concat(
parent
.nextUntil('p:has(strong)')
.map((e, ax) => loadedCheerio(ax).text().trim())
.get(),
);
let genreText = values.join(' ').trim();
const multiWordGenres = [
//add more when found
'Slice of Life',
'School Life',
];
multiWordGenres.forEach(genre => {
genreText = genreText.replace(
new RegExp(`\\b${genre}\\b`, 'g'),
genre.replace(/ /g, '_'),
);
});
const genres = genreText
.split(/\s+/)
.map(word => word.replace(/_/g, ' '))
.join(', ');
switch (key) {
case 'description:':
novel.summary = values.join('\n');
break;
case 'author(s):':
novel.author = values[0];
break;
case 'genre:':
novel.genres = genres;
}
});
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('.lcp_catlist li a').each((chapterIndex, element) => {
const name = loadedCheerio(element)
.text()
.replace(novel.name + '', '')
.replace('Novel' + '', '')
.trim();
const path = loadedCheerio(element)
.attr('href')
?.replace(this.site, '')
?.replace(/\/+$/, '');
const chapterNumber = chapterIndex + 1;
if (!path) return;
chapters.push({
name,
path,
chapterNumber,
});
});
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const body = await fetchApi(this.site + chapterPath).then(res =>
res.text(),
);
const loadedCheerio = parseHTML(body);
const removal = ['.entry-content span', '.entry-content div', 'script'];
removal.map(e => {
loadedCheerio(e).remove();
});
const chapterText = loadedCheerio('.entry-content').html()!;
// const chapterParagraphs = loadedCheerio('.entry-content p');
// let chapterContent; // Save this code in case needed
// if (chapterParagraphs.length < 5) {
// //some chapter in this site store their whole text in 1-4 <p>,
// chapterContent = chapterParagraphs
// .map((index, element) => {
// const text = loadedCheerio(element).html();
// return text;
// })
// .get()
// .join('\n\n');
// } else {
// // Multiple paragraphs case
// chapterContent = chapterParagraphs
// .map((index, element) => {
// const text = loadedCheerio(element).text().trim();
// return `<p>${text}</p>`;
// })
// .get()
// .join('\n\n');
// }
return chapterText;
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const body = await fetchApi(this.site).then(res => res.text());
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio, searchTerm);
}
}
export default new FaqWikiUs();
-257
View File
@@ -1,257 +0,0 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { load as loadCheerio } from 'cheerio';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { storage } from '@libs/storage';
import { defaultCover } from '@libs/defaultCover';
class FenrirRealmPlugin implements Plugin.PluginBase {
id = 'fenrir';
name = 'Fenrir Realm';
icon = 'src/en/fenrirrealm/icon.png';
site = 'https://fenrirealm.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[]> {
// let sort = "updated";
let sort = filters.sort.value;
if (showLatestNovels) sort = 'latest';
const genresFilter = filters.genres.value
.map(g => '&genres%5B%5D=' + g)
.join('');
const res = await fetchApi(
`${this.site}/api/series/filter?page=${pageNo}&per_page=20&status=${filters.status.value}&order=${sort}${genresFilter}`,
).then(r => r.json());
return res.data.map(r => this.parseNovelFromApi(r));
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const html = await fetchApi(`${this.site}/series/${novelPath}`, {
headers: {
'User-Agent': '',
},
}).then(r => r.text());
const loadedCheerio = loadCheerio(html);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1.my-2').text(),
summary: loadedCheerio(
'div.overflow-hidden.transition-all.max-h-\\[108px\\] p',
)
.map((i, el) => loadCheerio(el).text())
.get()
.join('\n\n'),
};
// novel.artist = '';
novel.author = loadedCheerio(
'div.flex-1 > div.mb-3 > a.inline-flex',
).text();
const coverMatch = html.match(/,cover:"storage\/(.+?)",cover_data_url/);
novel.cover = coverMatch
? this.site + '/storage/' + coverMatch[1]
: defaultCover;
novel.genres = loadedCheerio('div.flex-1 > div.flex:not(.mb-3, .mt-5) > a')
.map((i, el) => loadCheerio(el).text())
.toArray()
.join(',');
novel.status = loadedCheerio('div.flex-1 > div.mb-3 > span.rounded-md')
.first()
.text();
let chapters = await fetchApi(
this.site + '/api/novels/chapter-list/' + novelPath,
).then(r => r.json());
if (this.hideLocked) {
chapters = chapters.filter(c => !c.locked?.price);
}
novel.chapters = chapters
.map(c => ({
name:
(c.locked?.price ? '🔒 ' : '') +
(c.group?.index === null ? '' : 'Vol ' + c.group.index + ' ') +
'Chapter ' +
c.number +
(c.title && c.title.trim() != 'Chapter ' + c.number
? ' - ' + c.title.replace(/^chapter [0-9]+ . /i, '')
: ''),
path:
novelPath +
(c.group?.index === null ? '' : '/' + c.group.slug) +
'/chapter-' +
c.number,
releaseTime: c.created_at,
chapterNumber: c.number + c.group?.index * 1000000000000,
}))
.sort((a, b) => a.chapterNumber - b.chapterNumber);
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const page = await fetchApi(this.site + '/series/' + chapterPath, {
headers: {
'User-Agent': '',
},
}).then(r => r.text());
return loadCheerio(page)('#reader-area').html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
return await fetchApi(
`${this.site}/api/series/filter?page=${pageNo}&per_page=20&search=${encodeURIComponent(searchTerm)}`,
)
.then(r => r.json())
.then(r => r.data.map(novel => this.parseNovelFromApi(novel)));
}
parseNovelFromApi(apiData) {
return {
name: apiData.title,
path: apiData.slug,
cover: this.site + '/' + apiData.cover,
summary: apiData.description,
status: apiData.status,
genres: apiData.genres.map(g => g.name).join(','),
};
}
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + '/series/' + path;
filters = {
status: {
type: FilterTypes.Picker,
label: 'Status',
value: 'any',
options: [
{ label: 'All', value: 'any' },
{ label: 'Ongoing', value: 'ongoing' },
{
label: 'Completed',
value: 'completed',
},
],
},
sort: {
type: FilterTypes.Picker,
label: 'Sort',
value: 'popular',
options: [
{ label: 'Popular', value: 'popular' },
{ label: 'Latest', value: 'latest' },
{ label: 'Updated', value: 'updated' },
],
},
genres: {
type: FilterTypes.CheckboxGroup,
label: 'Genres',
value: [],
options: [
{ 'label': 'Action', 'value': '1' },
{ 'label': 'Adult', 'value': '2' },
{
'label': 'Adventure',
'value': '3',
},
{ 'label': 'Comedy', 'value': '4' },
{ 'label': 'Drama', 'value': '5' },
{
'label': 'Ecchi',
'value': '6',
},
{ 'label': 'Fantasy', 'value': '7' },
{ 'label': 'Gender Bender', 'value': '8' },
{
'label': 'Harem',
'value': '9',
},
{ 'label': 'Historical', 'value': '10' },
{ 'label': 'Horror', 'value': '11' },
{
'label': 'Josei',
'value': '12',
},
{ 'label': 'Martial Arts', 'value': '13' },
{ 'label': 'Mature', 'value': '14' },
{
'label': 'Mecha',
'value': '15',
},
{ 'label': 'Mystery', 'value': '16' },
{ 'label': 'Psychological', 'value': '17' },
{
'label': 'Romance',
'value': '18',
},
{ 'label': 'School Life', 'value': '19' },
{ 'label': 'Sci-fi', 'value': '20' },
{
'label': 'Seinen',
'value': '21',
},
{ 'label': 'Shoujo', 'value': '22' },
{ 'label': 'Shoujo Ai', 'value': '23' },
{
'label': 'Shounen',
'value': '24',
},
{ 'label': 'Shounen Ai', 'value': '25' },
{ 'label': 'Slice of Life', 'value': '26' },
{
'label': 'Smut',
'value': '27',
},
{ 'label': 'Sports', 'value': '28' },
{ 'label': 'Supernatural', 'value': '29' },
{
'label': 'Tragedy',
'value': '30',
},
{ 'label': 'Wuxia', 'value': '31' },
{ 'label': 'Xianxia', 'value': '32' },
{
'label': 'Xuanhuan',
'value': '33',
},
{ 'label': 'Yaoi', 'value': '34' },
{ 'label': 'Yuri', 'value': '35' },
],
},
} satisfies Filters;
}
export default new FenrirRealmPlugin();
//paste into console on site to load
async function getUpdatedGenres() {
const data = await fetch(
'https://fenrirealm.com/api/novels/taxonomy/genres',
).then(d => d.json());
const genreData = data.map(g => ({ label: g.name, value: g.id.toString() }));
console.log(JSON.stringify(genreData));
}
-201
View File
@@ -1,201 +0,0 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { Filters } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { NovelStatus } from '@libs/novelStatus';
import dayjs from 'dayjs';
class FictionZonePlugin implements Plugin.PagePlugin {
id = 'fictionzone';
name = 'Fiction Zone';
icon = 'src/en/fictionzone/icon.png';
site = 'https://fictionzone.net';
version = '1.0.1';
filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//flag indicates whether access to LocalStorage, SesesionStorage is required.
webStorageUtilized?: boolean;
cachedNovelIds: Map<string, string> = new Map();
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
return await this.getPage(this.site + '/library?page=' + pageNo);
}
async getPage(url: string) {
const req = await fetchApi(url);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio('div.novel-card')
.map((i, el) => {
const novelName = loadedCheerio(el).find('a > div.title > h1').text();
const novelCover = loadedCheerio(el).find('img').attr('src');
const novelUrl = loadedCheerio(el).find('a').attr('href');
return {
name: novelName,
cover: novelCover,
path: novelUrl!.replace(/^\//, '').replace(/\/$/, ''),
};
})
.toArray();
}
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const req = await fetchApi(this.site + '/' + novelPath);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelPath,
name: loadedCheerio('div.novel-title > h1').text(),
totalPages: 1,
};
// novel.artist = '';
novel.author = loadedCheerio('div.novel-author > content').text();
novel.cover = loadedCheerio('div.novel-img > img').attr('src');
novel.genres = [
...loadedCheerio('div.genres > .items > span')
.map((i, el) => loadedCheerio(el).text())
.toArray(),
...loadedCheerio('div.tags > .items > a')
.map((i, el) => loadedCheerio(el).text())
.toArray(),
].join(',');
const status = loadedCheerio('div.novel-status > div.content')
.text()
.trim();
if (status === 'Ongoing') novel.status = NovelStatus.Ongoing;
novel.summary = loadedCheerio('#synopsis > div.content').text();
let nuxtData = loadedCheerio('script#__NUXT_DATA__').html();
let parsed = JSON.parse(nuxtData!);
let last = null;
for (let a of parsed) {
if (typeof a === 'string' && a.startsWith('novel_covers/')) break;
last = a;
}
this.cachedNovelIds.set(novelPath, last.toString());
// @ts-ignore
novel.chapters = loadedCheerio(
'div.chapters > div.list-wrapper > div.items > a.chapter',
)
.map((i, el) => {
const chapterName = loadedCheerio(el).find('span.chapter-title').text();
const chapterUrl = loadedCheerio(el)
.attr('href')
?.replace(/^\//, '')
.replace(/\/$/, '');
const uploadTime = this.parseAgoDate(
loadedCheerio(el).find('span.update-date').text(),
);
return {
name: chapterName,
releaseTime: uploadTime,
path: chapterUrl?.replace(/^\//, '').replace(/\/$/, ''),
};
})
.toArray()
.filter(chap => !!chap.path);
novel.totalPages = parseInt(
loadedCheerio('div.chapters ul.el-pager > li:last-child').text(),
);
return novel;
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
let id = this.cachedNovelIds.get(novelPath);
if (!id) {
await this.parseNovel(novelPath);
id = this.cachedNovelIds.get(novelPath);
}
const data = await fetchApi(this.site + '/api/__api_party/api-v1', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
'path': '/chapter/all/' + id,
'query': { 'page': parseInt(page) },
'headers': { 'content-type': 'application/json' },
'method': 'get',
}),
}).then(r => r.json());
return {
chapters: data._data.map((c: any) => ({
name: c.title,
releaseTime: new Date(c.created_at).toISOString(),
path: novelPath + '/' + c.slug,
})),
};
}
async parseChapter(chapterPath: string): Promise<string> {
const req = await fetchApi(this.site + '/' + chapterPath);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio('div.chapter-content').html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
return await this.getPage(
this.site +
'/library?query=' +
encodeURIComponent(searchTerm) +
'&page=' +
pageNo +
'&sort=views-all',
);
}
parseAgoDate(date: string | undefined) {
//parseMadaraDate
if (date?.includes('ago')) {
const dayJSDate = dayjs(new Date()); // today
const timeAgo = date.match(/\d+/)?.[0] || '';
const timeAgoInt = parseInt(timeAgo, 10);
if (!timeAgo) return null; // there is no number!
if (date.includes('hours ago') || date.includes('hour ago')) {
dayJSDate.subtract(timeAgoInt, 'hours'); // go back N hours
}
if (date.includes('days ago') || date.includes('day ago')) {
dayJSDate.subtract(timeAgoInt, 'days'); // go back N days
}
if (date.includes('months ago') || date.includes('month ago')) {
dayJSDate.subtract(timeAgoInt, 'months'); // go back N months
}
if (date.includes('years ago') || date.includes('year ago')) {
dayJSDate.subtract(timeAgoInt, 'years'); // go back N years
}
return dayJSDate.toISOString();
}
return null; // there is no "ago" so give up
}
resolveUrl = (path: string, isNovel?: boolean) => this.site + '/' + path;
}
export default new FictionZonePlugin();
-331
View File
@@ -1,331 +0,0 @@
import { Plugin } from '@typings/plugin';
import { Parser } from 'htmlparser2';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { defaultCover } from '@libs/defaultCover';
import { fetchApi } from '@libs/fetch';
import { NovelStatus } from '@libs/novelStatus';
class Foxteller implements Plugin.PluginBase {
id = 'foxteller';
name = 'Foxteller';
site = 'https://www.foxteller.com';
version = '1.0.2';
icon = 'src/en/foxteller/icon.png';
async safeFecth(url: string, init: any = {}): Promise<string> {
const r = await fetchApi(url, init);
if (!r.ok)
throw new Error(
'Could not reach site (' + r.status + ') try to open in webview.',
);
const data = await r.text();
const title = data.match(/<title>(.*?)<\/title>/)?.[1]?.trim();
if (
title &&
(title == 'Bot Verification' ||
title == 'You are being redirected...' ||
title == 'Un instant...' ||
title == 'Just a moment...' ||
title == 'Redirecting...')
)
throw new Error('Captcha error, please open in webview');
return data;
}
async popularNovels(
pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const url =
this.site + '/library?sort=' + filters?.order?.value || 'popularity';
const body = await this.safeFecth(url);
const novels: Plugin.NovelItem[] = [];
const div = body.match(/<div class="col-md-6">([\s\S]*?)<\/div>/g) || [];
div.forEach(elements => {
const [, novelUrl, novelName] =
elements.match(/<a href="(.*?)" title="(.*?)">/) || [];
if (novelName && novelUrl) {
const novelCover = elements.match(
/<img class="img-fluid" src="(.*?)".*>/,
);
novels.push({
name: novelName,
cover: novelCover?.[1] || defaultCover,
path: novelUrl.split('/')[4],
});
}
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const body = await this.safeFecth(this.resolveUrl(novelPath));
const novel: Plugin.SourceNovel = {
path: novelPath,
name: '',
genres: '',
summary: '',
status: '',
chapters: [] as Plugin.ChapterItem[],
};
let isParsingGenres = false;
let isReadingGenre = false;
let isReadingSummary = false;
let isParsingInfo = false;
let isReadingInfo = false;
let isParsingChapterList = false;
let isReadingChapter = false;
let isPaidChapter = false;
const chapters: Plugin.ChapterItem[] = [];
let tempChapter = {} as Plugin.ChapterItem;
const parser = new Parser({
onopentag(name, attribs) {
// name and cover
if (!novel.cover && attribs['class'] === 'img-fluid') {
novel.name = attribs['alt'];
novel.cover = attribs['src'] || defaultCover;
} // genres
else if (name === 'div' && attribs['class'] === 'novel-genres') {
isParsingGenres = true;
} else if (isParsingGenres && name === 'li') {
isReadingGenre = true;
} // summary
else if (name === 'div' && attribs['class'] === 'novel-description') {
isReadingSummary = true;
} // status
else if (name === 'div' && attribs['class'] === 'novel-tags') {
isParsingInfo = true;
} else if (isParsingInfo && name === 'li') {
isReadingInfo = true;
}
// chapters
else if (name === 'div' && attribs['class'] === 'col-md-6') {
isParsingChapterList = true;
} else if (isParsingChapterList && name === 'a') {
isReadingChapter = true;
tempChapter.chapterNumber = chapters.length + 1;
tempChapter.path = novelPath + '/' + attribs['href'].split('/')[5];
} else if (
isReadingChapter &&
name === 'i' &&
attribs['class']?.includes('lock')
) {
isPaidChapter = true;
}
},
ontext(data) {
// genres
if (isParsingGenres) {
if (isReadingGenre) {
novel.genres += data + ', ';
}
} // summary
else if (isReadingSummary) {
novel.summary += data.trim();
} // status
else if (isParsingInfo) {
if (isReadingInfo) {
const detailName = data.toLowerCase().trim();
switch (detailName) {
case 'completed':
novel.status = NovelStatus.Completed;
break;
case 'ongoing':
novel.status = NovelStatus.Ongoing;
break;
case 'hiatus':
novel.status = NovelStatus.OnHiatus;
break;
default:
novel.status = NovelStatus.Unknown;
break;
}
}
}
// chapters
else if (isParsingChapterList) {
if (isReadingChapter) {
tempChapter.name = (tempChapter.name || '') + data;
}
}
},
onclosetag(name) {
// genres
if (isParsingGenres) {
if (isReadingGenre) {
isReadingGenre = false; // stop reading genre
} else {
isParsingGenres = false; // stop parsing genres
novel.genres = novel.genres?.slice(0, -2); // remove trailing comma
}
} // summary
else if (isReadingSummary) {
if (name === 'hr' || name === 'p') {
novel.summary += '\n';
} else if (name === 'div') {
isReadingSummary = false;
}
} // status
else if (isParsingInfo) {
if (name === 'li') {
isReadingInfo = false;
} else if (name === 'div') {
isParsingInfo = false;
}
} // chapters
else if (isParsingChapterList) {
if (isReadingChapter) {
if (name === 'li') {
if (isPaidChapter) {
isPaidChapter = false;
} else {
tempChapter.name = tempChapter.name.trim();
chapters.push(tempChapter);
}
isReadingChapter = false;
tempChapter = {} as Plugin.ChapterItem;
}
} else if (name === 'ul') {
isParsingChapterList = false;
}
}
},
});
parser.write(body);
parser.end();
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const res = await this.safeFecth(this.resolveUrl(chapterPath));
const novelID = chapterPath.split('/')[0];
const chapterID = res.match(/'chapter_id': '([\d]+)'/)?.[1];
if (!chapterID) throw new Error('No chapter found');
const { aux } = await fetchApi(this.site + '/aux_dem', {
method: 'post',
headers: {
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3',
'Content-Type': 'application/json;charset=utf-8',
},
Referer: this.resolveUrl(chapterPath),
body: JSON.stringify({ 'x1': novelID, 'x2': chapterID }),
}).then(res => res.json());
if (aux && typeof aux === 'string') {
const base64 = aux.replace(/%R([a-f])&/g, (match, code) => {
switch (code) {
case 'a':
return 'A';
case 'c':
return 'B';
case 'b':
return 'C';
case 'd':
return 'D';
case 'f':
return 'E';
case 'e':
return 'F';
default:
return match;
}
});
return decodeURIComponent(decodeBase64(base64));
}
throw new Error('This chapter is closed');
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const body = await this.safeFecth(this.site + '/search', {
method: 'post',
headers: {
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3',
'Content-Type': 'application/json;charset=utf-8',
},
Referer: this.site,
body: JSON.stringify({ query: searchTerm }),
});
const novels: Plugin.NovelItem[] = [];
const items = body.match(/<a.*>([\s\S]*?)<\/a>/g) || [];
items.forEach(elements => {
const novelUrl = elements.match(/<a href="(.*?)"/)?.[1] || '';
const path = novelUrl.split('/')[4];
const name = elements.match(
/<span class="ellipsis-1">(.*?)<\/span>/,
)?.[1];
if (name && path) {
const cover =
elements.match(/<img src="(.*?)".*>/)?.[1] || defaultCover;
novels.push({ name, cover, path });
}
});
return novels;
}
resolveUrl = (path: string) => this.site + '/novel/' + path;
filters = {
order: {
value: 'popularity',
label: 'Order by',
options: [
{ label: 'Popular Novels', value: 'popularity' },
{ label: 'New Novels', value: 'newest' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Foxteller();
const base64Characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
function decodeBase64(encodedString: string) {
const hexcode = [];
let i = 0;
while (i < encodedString.length) {
const enc1 = base64Characters.indexOf(encodedString.charAt(i++));
const enc2 = base64Characters.indexOf(encodedString.charAt(i++));
const enc3 = base64Characters.indexOf(encodedString.charAt(i++));
const enc4 = base64Characters.indexOf(encodedString.charAt(i++));
const chr1 = (enc1 << 2) | (enc2 >> 4);
const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
const chr3 = ((enc3 & 3) << 6) | enc4;
hexcode.push(chr1.toString(16).padStart(2, '0'));
if (enc3 !== 64) {
hexcode.push(chr2.toString(16).padStart(2, '0'));
}
if (enc4 !== 64) {
hexcode.push(chr3.toString(16).padStart(2, '0'));
}
}
return '%' + hexcode.join('%');
}
-512
View File
@@ -1,512 +0,0 @@
import { fetchApi } from '@libs/fetch';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { Plugin } from '@typings/plugin';
/**
* Example for novel API:
* https://genesistudio.com/novels/dlh/__data.json?x-sveltekit-invalidated=001
* -> to view novel remove '__data.json?x-sveltekit-invalidated=001'
*
* Example for chapter API:
* https://genesistudio.com/viewer/2443/__data.json?x-sveltekit-invalidated=001
* -> to view chapter remove '__data.json?x-sveltekit-invalidated=001'
*/
class Genesis implements Plugin.PluginBase {
id = 'genesistudio';
name = 'Genesis';
icon = 'src/en/genesis/icon.png';
customCSS = 'src/en/genesis/customCSS.css';
site = 'https://genesistudio.com';
version = '1.1.2';
imageRequestInit?: Plugin.ImageRequestInit | undefined = {
headers: {
'referrer': this.site,
},
};
async parseNovels(json: any[]): Promise<Plugin.SourceNovel[]> {
return json.map((novel: any) => ({
name: novel.novel_title,
path: `/novels/${novel.abbreviation}`,
cover: novel.cover,
}));
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) return [];
let link = `${this.site}/api/novels/search?`;
if (showLatestNovels) {
link += 'serialization=All&sort=Recent';
} else {
if (filters?.genres.value.length) {
link += 'genres=' + filters.genres.value.join('&genres=') + '&';
}
if (filters?.storyStatus.value) {
link += 'serialization=' + `${filters.storyStatus.value}` + '&';
}
if (filters?.sort.value) {
link += 'sort=' + `${filters.sort.value}`;
}
}
const json = await fetchApi(link).then(r => r.json());
return this.parseNovels(json);
}
// Helper function to extract novel data from nodes
extractData(nodes: any[]): any {
return nodes
.filter((node: { type: string }) => node.type === 'data')
.map((node: { data: any }) => node.data)[0];
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = `${this.site}${novelPath}/__data.json?x-sveltekit-invalidated=001`;
// Fetch the novel's data in JSON format
const json = await fetchApi(url).then(r => r.json());
const nodes = json.nodes;
// Extract the main novel data from the nodes
const data = this.extractData(nodes);
// Initialize the novel object with default values
const novel: Plugin.SourceNovel = {
path: novelPath,
name: '',
cover: '',
summary: '',
author: '',
status: 'Unknown',
chapters: [],
};
// Parse and assign novel metadata (title, cover, summary, author, etc.)
this.populateNovelMetadata(novel, data);
// Parse the chapters if available and assign them to the novel object
novel.chapters = this.extractChapters(data);
return novel;
}
// Helper function to populate novel metadata
populateNovelMetadata(novel: Plugin.SourceNovel, data: any): void {
for (const key in data) {
const value = data[key];
if (
typeof value === 'object' &&
value !== null &&
'novel_title' in value
) {
novel.name = data[value.novel_title] || 'Unknown Title';
novel.cover = data[value.cover] || '';
novel.summary = data[value.synopsis] || '';
novel.author = data[value.author] || 'Unknown Author';
novel.genres =
(data[value.genres] as number[])
.map((genreId: number) => data[genreId])
.join(', ') || 'Unknown Genre';
novel.status = value.release_days ? 'Ongoing' : 'Completed';
break; // Break the loop once metadata is found
}
}
}
// Helper function to extract and format chapters
extractChapters(data: any): Plugin.ChapterItem[] {
const chapterKey = 'chapters';
const chapters: Plugin.ChapterItem[] = [];
// Iterate over each property in data to find chapter containers
for (const key in data) {
const value = data[key];
// Look for an object with a 'chapters' key
if (value && typeof value === 'object' && chapterKey in value) {
const chaptersIndexKey = value[chapterKey];
const chapterData = data[chaptersIndexKey];
if (!chapterData || typeof chapterData !== 'object') continue;
// Dynamically get chapter indices from every category (free, premium, etc.)
let allChapterIndices: number[] = [];
for (const category of Object.keys(chapterData)) {
const chapterIndices = data[chapterData[category]];
if (Array.isArray(chapterIndices)) {
allChapterIndices = allChapterIndices.concat(chapterIndices);
}
}
// Format each chapter and add only valid ones
const formattedChapters = allChapterIndices
.map((index: number) => data[index])
.map((chapter: any) => this.formatChapter(chapter, data))
.filter((chapter): chapter is Plugin.ChapterItem => chapter !== null);
chapters.push(...formattedChapters);
}
}
return chapters;
}
// Helper function to format an individual chapter
formatChapter(chapter: any, data: any): Plugin.ChapterItem | null {
const { id, chapter_title, chapter_number, required_tier, date_created } =
chapter;
// Destructure from data using computed property names based on chapter keys
const {
[id]: chapterId,
[chapter_title]: chapterTitle,
[chapter_number]: chapterNumberVal,
[required_tier]: requiredTierVal,
[date_created]: dateCreated,
} = data;
// Validate required fields
if (
chapterId &&
chapterTitle &&
chapterNumberVal >= 0 &&
requiredTierVal !== null &&
dateCreated
) {
const chapterNumber = parseInt(String(chapterNumberVal), 10) || 0;
const requiredTier = parseInt(String(requiredTierVal), 10) || 0;
// Only process chapters that do not require a premium tier
if (requiredTier === 0) {
return {
name: `Chapter ${chapterNumber} - ${chapterTitle}`,
path: `/viewer/${chapterId}`,
releaseTime: dateCreated,
chapterNumber: chapterNumber,
};
}
}
return null;
}
// // Helper function to extract and format chapters
// extractChapters(data: any): Plugin.ChapterItem[] {
// for (const key in data) {
// const value = data[key];
//
// // Change string here if the chapters are stored under a different key
// const chapterKey = 'chapters';
// if (typeof value === 'object' && value !== null && chapterKey in value) {
// const chapterData = this.decodeData(data[value[chapterKey]]);
//
// // Object.values will give us an array of arrays (any[][])
// const chapterArrays: any[][] = Object.values(chapterData);
//
// // Flatten and format the chapters
// return chapterArrays.flatMap((chapters: any[]) => {
// return chapters
// .map((chapter: any) => this.formatChapter(chapter))
// .filter(
// (chapter): chapter is Plugin.ChapterItem => chapter !== null,
// );
// });
// }
// }
//
// return [];
// }
//
// // Helper function to format an individual chapter
// formatChapter(chapter: any): Plugin.ChapterItem | null {
// const { id, chapter_title, chapter_number, required_tier, date_created } =
// chapter;
//
// // Ensure required fields are present and valid
// if (
// id &&
// chapter_title &&
// chapter_number >= 0 &&
// required_tier !== null &&
// date_created
// ) {
// const number = parseInt(chapter_number, 10) || 0;
// const requiredTier = parseInt(required_tier, 10) || 0;
//
// // Only process chapters with a 'requiredTier' of 0
// if (requiredTier === 0) {
// return {
// name: `Chapter ${number}: ${chapter_title}`,
// path: `/viewer/${id}`,
// releaseTime: date_created,
// chapterNumber: number,
// };
// }
// }
//
// return null;
// }
//
// decodeData(code: any) {
// const offset = this.getOffsetIndex(code);
// const params = this.getDecodeParams(code);
// const constant = this.getConstant(code);
// const data = this.getStringsArrayRaw(code);
//
// const getDataAt = (x: number) => data[x - offset];
//
// //reshuffle data array
// // eslint-disable-next-line no-constant-condition
// while (true) {
// try {
// const some_number = this.applyDecodeParams(params, getDataAt);
// if (some_number === constant) break;
// else data.push(data.shift());
// } catch (err) {
// data.push(data.shift());
// }
// }
//
// return this.getChapterData(code, getDataAt);
// }
//
// getOffsetIndex(code: string) {
// // @ts-ignore
// const string = /{(\w+)=\1-0x(?<offset>[0-9a-f]+);/.exec(code).groups.offset;
// return parseInt(string, 16);
// }
//
// /**
// * @returns {string[]}
// */
// getStringsArrayRaw(code: string) {
// // @ts-ignore
// let json = /function \w+\(\){var \w+=(?<array>\['.+']);/.exec(code).groups
// .array;
//
// //replace string single quotes with double quotes and add escaped chars
// json = json.replace(/'(.+?)'([,\]])/g, (match, p1, p2) => {
// return `"${p1.replace(/\\x([0-9a-z]{2})/g, (match: any, p1: string) => {
// //hexadecimal unicode escape chars
// return String.fromCharCode(parseInt(p1, 16));
// })}"${p2}`;
// });
//
// return JSON.parse(json);
// }
//
// /**
// * @returns {{offset: number, divider: number, negated: boolean}[][]}
// */
// getDecodeParams(code: string) {
// // @ts-ignore
// const jsDecodeInt = /while\(!!\[]\){try{var \w+=(?<code>.+?);/.exec(code)
// .groups.code;
// const decodeSections = jsDecodeInt.split('+');
// const params = [];
// for (const section of decodeSections) {
// params.push(this.decodeParamSection(section));
// }
// return params;
// }
//
// /**
// * @param {string} section
// * @returns {{offset: number, divider: number, negated: boolean}[]}
// */
// decodeParamSection(section: string) {
// const sections = section.split('*');
// const params = [];
// for (const section of sections) {
// // @ts-ignore
// const offsetStr = /parseInt\(\w+\(0x(?<offset>[0-9a-f]+)\)\)/.exec(
// section,
// ).groups.offset;
// const offset = parseInt(offsetStr, 16);
// // @ts-ignore
// const dividerStr = /\/0x(?<divider>[0-9a-f]+)/.exec(section).groups
// .divider;
// const divider = parseInt(dividerStr, 16);
// const negated = section.includes('-');
// params.push({ offset, divider, negated });
// }
// return params;
// }
//
// getConstant(code: string) {
// // @ts-ignore
// const constantStr = /}}}\(\w+,0x(?<constant>[0-9a-f]+)\),/.exec(code).groups
// .constant;
// return parseInt(constantStr, 16);
// }
//
// getChapterData(
// code: string,
// getDataAt: { (x: number): any; (arg0: number): any },
// ) {
// let chapterDataStr =
// // @ts-ignore
// /\),\(function\(\){var \w+=\w+;return(?<data>{.+?});/.exec(code).groups
// .data;
//
// //replace hex with decimal
// chapterDataStr = chapterDataStr.replace(/:0x([0-9a-f]+)/g, (match, p1) => {
// const hex = parseInt(p1, 16);
// return `: ${hex}`;
// });
//
// //replace ![] with false and !![] with true
// chapterDataStr = chapterDataStr
// .replace(/:!!\[]/g, ':true')
// .replace(/:!\[]/g, ':false');
//
// //replace string single quotes with double quotes and add escaped chars
// chapterDataStr = chapterDataStr.replace(
// /'(.+?)'([,\]}:])/g,
// (match, p1, p2) => {
// return `"${p1.replace(/\\x([0-9a-z]{2})/g, (match: any, p1: string) => {
// //hexadecimal unicode escape chars
// return String.fromCharCode(parseInt(p1, 16));
// })}"${p2}`;
// },
// );
//
// //parse the data getting methods
// chapterDataStr = chapterDataStr.replace(
// // @ts-ignore
// /:\w+\(0x(?<offset>[0-9a-f]+)\)/g,
// (match, p1) => {
// const offset = parseInt(p1, 16);
// return `:${JSON.stringify(getDataAt(offset))}`;
// },
// );
//
// return JSON.parse(chapterDataStr);
// }
//
// /**
// * @param {{offset: number, divider: number, negated: boolean}[][]} params
// * @param {function(number): string} getDataAt
// */
// applyDecodeParams(
// params: { offset: number; divider: number; negated: boolean }[][],
// getDataAt: { (x: number): any; (arg0: any): string },
// ) {
// let res = 0;
// for (const paramAdd of params) {
// let resInner = 1;
// for (const paramMul of paramAdd) {
// resInner *= parseInt(getDataAt(paramMul.offset)) / paramMul.divider;
// if (paramMul.negated) resInner *= -1;
// }
// res += resInner;
// }
// return res;
// }
async parseChapter(chapterPath: string): Promise<string> {
const url = `${this.site}${chapterPath}/__data.json?x-sveltekit-invalidated=001`;
// Fetch the novel's data in JSON format
const json = await fetchApi(url).then(r => r.json());
const nodes = json.nodes;
// Extract the main novel data from the nodes
const data = this.extractData(nodes);
// Look for chapter container with required fields
const contentKey = 'content';
const notesKey = 'notes';
const footnotesKey = 'footnotes';
// Iterate over each property in data to find chapter containers
for (const key in data) {
const mapping = data[key];
// Check container for keys that match the required fields
if (
mapping &&
typeof mapping === 'object' &&
contentKey in mapping &&
notesKey in mapping &&
footnotesKey in mapping
) {
// Retrieve the chapter's content, notes, and footnotes using the mapping.
const content = data[mapping[contentKey]];
const notes = data[mapping[notesKey]];
const footnotes = data[mapping[footnotesKey]];
// Return the combined parts with appropriate formatting
return (
content +
(notes ? `<h2>Notes</h2><br>${notes}` : '') +
(footnotes ?? '')
);
}
}
// If no mapping object was found, return an empty string or handle appropriately.
return '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.SourceNovel[]> {
if (pageNo !== 1) return [];
const url = `${this.site}/api/novels/search?serialization=All&sort=Popular&title=${encodeURIComponent(searchTerm)}`;
const json = await fetchApi(url).then(r => r.json());
return this.parseNovels(json);
}
filters = {
sort: {
label: 'Sort Results By',
value: 'Popular',
options: [
{ label: 'Popular', value: 'Popular' },
{ label: 'Recent', value: 'Recent' },
{ label: 'Views', value: 'Views' },
],
type: FilterTypes.Picker,
},
storyStatus: {
label: 'Status',
value: 'All',
options: [
{ label: 'All', value: 'All' },
{ label: 'Ongoing', value: 'Ongoing' },
{ label: 'Completed', value: 'Completed' },
],
type: FilterTypes.Picker,
},
genres: {
label: 'Genres',
value: [],
options: [
{ label: 'Action', value: 'Action' },
{ label: 'Comedy', value: 'Comedy' },
{ label: 'Drama', value: 'Drama' },
{ label: 'Fantasy', value: 'Fantasy' },
{ label: 'Harem', value: 'Harem' },
{ label: 'Martial Arts', value: 'Martial Arts' },
{ label: 'Modern', value: 'Modern' },
{ label: 'Mystery', value: 'Mystery' },
{ label: 'Psychological', value: 'Psychological' },
{ label: 'Romance', value: 'Romance' },
{ label: 'Slice of life', value: 'Slice of Life' },
{ label: 'Tragedy', value: 'Tragedy' },
],
type: FilterTypes.CheckboxGroup,
},
} satisfies Filters;
}
export default new Genesis();
-162
View File
@@ -1,162 +0,0 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { NovelStatus } from '@libs/novelStatus';
class InkittPlugin implements Plugin.PluginBase {
id = 'inkitt';
name = 'Inkitt';
icon = 'src/en/inkitt/icon.png';
site = 'https://www.inkitt.com';
version = '1.0.1';
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//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[]> {
let req;
if (filters?.genres?.value) {
req = await fetchApi(
this.site +
`/genre/${filters.genres.value}/${pageNo}?period=alltime&sort=popular`,
);
} else {
req = await fetchApi(
this.site + `/trending_stories?page=${pageNo}&period=alltime`,
);
}
let data;
try {
data = await req.json();
} catch (e) {
throw new Error('Failed to load novels, try opening in webview.');
}
return data.stories.map((novel: any) => {
return {
name: novel.title,
path: this.getPath(novel),
cover:
novel.vertical_cover.url ||
novel.vertical_cover.iphone ||
novel.cover.url,
};
});
}
getPath(novel: any) {
return (novel.category_one || novel.genres[0]) + '/' + novel.id;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
let req = await fetchApi(this.site + `/stories/${novelPath}`);
let text = await req.text();
let loadedCheerio = loadCheerio(text);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1.story-title').text(),
};
novel.author = loadedCheerio('dl > dd > a.author-link').text();
novel.genres = loadedCheerio('dd.genres > a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(', ');
const status = loadedCheerio('div.dlc > dl:has(dt:contains("Status")) > dd')
.text()
.trim();
if (status === 'Complete') novel.status = NovelStatus.Completed;
if (status === 'Ongoing') novel.status = NovelStatus.Ongoing;
const apiReq = await fetchApi(
this.site + `/api/stories/${novelPath.split('/')[1]}`,
);
const apiData = await apiReq.json();
novel.cover = apiData.vertical_cover.url;
novel.summary = loadedCheerio('p.story-summary').text();
novel.chapters = apiData.chapters.map((c: any) => {
return {
name: c.name,
path: novelPath + '/chapters/' + c.chapter_number,
chapterNumber: c.chapter_number,
};
});
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const req = await fetchApi(this.site + '/stories/' + chapterPath);
const text = await req.text();
const loadedCheerio = loadCheerio(text);
return loadedCheerio('div#chapterText').html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const req = await fetchApi(
this.site +
`/api/2/search/title?q=${encodeURIComponent(searchTerm)}&page=${pageNo}`,
);
let data;
try {
data = await req.json();
} catch (e) {
throw new Error('Failed to search novels, try opening in webview.');
}
return data.stories.map((novel: any) => {
return {
name: novel.title,
path: this.getPath(novel),
cover:
novel.vertical_cover.url ||
novel.vertical_cover.iphone ||
novel.cover.url,
};
});
}
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + '/stories/' + path;
filters = {
genres: {
type: FilterTypes.Picker,
label: 'Genre',
value: '',
options: [
{ 'label': 'Sci-Fi', 'value': 'scifi' },
{ 'label': 'Fantasy', 'value': 'fantasy' },
{ 'label': 'Adventure', 'value': 'adventure' },
{ 'label': 'Mystery', 'value': 'mystery' },
{ 'label': 'Action', 'value': 'action' },
{ 'label': 'Horror', 'value': 'horror' },
{ 'label': 'Humor', 'value': 'humor' },
{ 'label': 'Erotica', 'value': 'erotica' },
{ 'label': 'Poetry', 'value': 'poetry' },
{ 'label': 'Other', 'value': 'other' },
{ 'label': 'Thriller', 'value': 'thriller' },
{ 'label': 'Romance', 'value': 'romance' },
{ 'label': 'Children', 'value': 'children' },
{ 'label': 'Drama', 'value': 'drama' },
],
},
} satisfies Filters;
}
export default new InkittPlugin();
-377
View File
@@ -1,377 +0,0 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';
class KDTNovels implements Plugin.PluginBase {
id = 'kdtnovels';
name = 'KDT Novels';
version = '1.0.0';
icon = 'src/en/kdtnovels/icon.png';
site = 'https://kdtnovels.com/';
/**
* Parse novel items from HTML using common selectors
*/
private parseNovelItems(html: string): Plugin.NovelItem[] {
const $ = parseHTML(html);
const novels: Plugin.NovelItem[] = [];
$('div.c-tabs-item__content').each((_, element) => {
const $element = $(element);
// Extract cover image
const coverImg = $element.find('div.tab-thumb img').first();
const cover =
coverImg.attr('data-src') || coverImg.attr('src') || defaultCover;
// Extract title and URL
const titleLink = $element.find('div.post-title > h3 > a').first();
const name = titleLink.text().trim();
const href = titleLink.attr('href');
// Only add if we have required data
if (name && href) {
// Convert full URL to relative path, remove leading and trailing slashes
const path = href.replace(this.site, '').replace(/^\/+|\/+$/g, '');
novels.push({
name,
path,
cover: cover || defaultCover,
});
}
});
return novels;
}
/**
* Parse status text and map it to NovelStatus enum
*/
private parseNovelStatus(statusText: string): string {
if (!statusText) {
return NovelStatus.Unknown;
}
const normalizedStatus = statusText.toLowerCase().trim();
// Map common status patterns to NovelStatus enum values
if (
normalizedStatus.includes('ongoing') ||
normalizedStatus.includes('on going')
) {
return NovelStatus.Ongoing;
}
if (
normalizedStatus.includes('completed') ||
normalizedStatus.includes('complete')
) {
return NovelStatus.Completed;
}
if (normalizedStatus.includes('licensed')) {
return NovelStatus.Licensed;
}
if (
normalizedStatus.includes('finished') ||
normalizedStatus.includes('publishing finished')
) {
return NovelStatus.PublishingFinished;
}
if (
normalizedStatus.includes('cancelled') ||
normalizedStatus.includes('canceled')
) {
return NovelStatus.Cancelled;
}
if (
normalizedStatus.includes('hiatus') ||
normalizedStatus.includes('on hiatus')
) {
return NovelStatus.OnHiatus;
}
// If no pattern matches, return the original text or Unknown
return statusText || NovelStatus.Unknown;
}
async popularNovels(
pageNo: number,
options: Plugin.PopularNovelsOptions,
): Promise<Plugin.NovelItem[]> {
// Construct URL based on whether we want latest or popular novels
const orderBy = options.showLatestNovels ? 'latest' : 'views';
const url = `${this.site}/page/${pageNo}/?s&post_type=wp-manga&m_orderby=${orderBy}`;
try {
// Fetch the search results page
const response = await fetchApi(url);
const html = await response.text();
// Use common parsing method
return this.parseNovelItems(html);
} catch (error) {
console.error('Error fetching popular novels:', error);
return [];
}
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
// Construct search URL with proper query parameters
const url = `${this.site}/page/${pageNo}/?s=${encodeURIComponent(searchTerm)}&post_type=wp-manga&op&author&artist&release&adult`;
try {
// Fetch the search results page
const response = await fetchApi(url);
const html = await response.text();
// Use common parsing method
const novels = this.parseNovelItems(html);
// Handle empty search results gracefully
if (novels.length === 0) {
console.log(
`No search results found for term: "${searchTerm}" on page ${pageNo}`,
);
}
return novels;
} catch (error) {
console.error('Error searching novels:', error);
return [];
}
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novelUrl = this.site + novelPath;
try {
// Fetch the novel details page
const response = await fetchApi(novelUrl);
const html = await response.text();
// Parse HTML with Cheerio
const $ = parseHTML(html);
const novel: Plugin.SourceNovel = {
name: '',
path: novelPath,
cover: defaultCover,
author: '',
artist: '',
genres: '',
summary: '',
status: '',
chapters: [],
};
// Extract novel metadata using provided CSS selectors
// Extract novel title
const titleElement = $('.manga-title').first();
novel.name = titleElement.text().trim() || 'Unknown Title';
// Extract cover image
const coverElement = $('div.summary_image img').first();
const coverSrc =
coverElement.attr('data-src') || coverElement.attr('src');
novel.cover = coverSrc || defaultCover;
// Extract genres
const genreElements = $('div.genres-content a');
const genres: string[] = [];
genreElements.each((_, element) => {
const genre = $(element).text().trim();
if (genre) {
genres.push(genre);
}
});
novel.genres = genres.join(',');
// Extract summary/synopsis
const summaryElements = $('div.manga-excerpt p');
const summaryParts: string[] = [];
summaryElements.each((_, element) => {
const text = $(element).text().trim();
if (text) {
summaryParts.push(text);
}
});
novel.summary = summaryParts.join('\n\n');
// Extract status
const statusElement = $('div.manga-status span:nth-child(2)').first();
const rawStatus = statusElement.text().trim();
novel.status = this.parseNovelStatus(rawStatus);
// Extract chapter list - chapters are loaded dynamically via AJAX
let chapters: Plugin.ChapterItem[] = [];
try {
// Construct AJAX URL for chapter list
const ajaxUrl = `${novelUrl}/ajax/chapters/?t=1`;
// Make POST request to get chapter list HTML fragment
const chapterResponse = await fetchApi(ajaxUrl, {
method: 'POST',
});
const chapterHtml = await chapterResponse.text();
// Parse the HTML fragment
const $chapters = parseHTML(chapterHtml);
// Extract chapters using existing parsing logic
const chapterElements = $chapters('li.free-chap');
chapterElements.each((index, element) => {
const $element = $chapters(element);
// Extract chapter link and name
const chapterLink = $element.find('a').first();
const chapterName = chapterLink.text().trim();
const chapterHref = chapterLink.attr('href');
// Extract release date
const releaseDateElement = $element
.find('span.chapter-release-date')
.first();
const releaseTime = releaseDateElement.text().trim() || null;
// Extract chapter number from title (opportunistic parsing)
let chapterNumber = chapterElements.length - index; // Default fallback (reverse order)
// Try multiple patterns for chapter number extraction (supporting decimals)
let chapterNumberMatch = chapterName.match(/Ch\s*(\d+(?:\.\d+)?)/i);
if (!chapterNumberMatch) {
chapterNumberMatch = chapterName.match(/c(\d+(?:\.\d+)?)/i);
}
if (chapterNumberMatch) {
chapterNumber = parseFloat(chapterNumberMatch[1]);
}
if (chapterName && chapterHref) {
// Extract just the pathname from the URL, removing leading and trailing slashes
let chapterPath: string;
if (chapterHref.startsWith('http')) {
const url = new URL(chapterHref);
chapterPath = url.pathname.replace(/^\/+|\/+$/g, '');
} else {
chapterPath = chapterHref.replace(/^\/+|\/+$/g, '');
}
chapters.push({
name: chapterName,
path: chapterPath,
releaseTime: releaseTime,
chapterNumber: chapterNumber,
});
}
});
// Reverse the chapters array since they come in reverse order (latest first)
chapters.reverse();
} catch (chapterError) {
console.error('Error fetching chapter list:', chapterError);
// Fall back to empty chapters array if AJAX request fails
chapters = [];
}
novel.chapters = chapters;
return novel;
} catch (error) {
console.error('Error parsing novel:', error);
// Return basic novel object with minimal data on error
return {
name: 'Error loading novel',
path: novelPath,
cover: defaultCover,
chapters: [],
};
}
}
async parseChapter(chapterPath: string): Promise<string> {
const chapterUrl = this.site + chapterPath;
try {
// Implement HTTP request to fetch chapter page
const response = await fetchApi(chapterUrl);
const html = await response.text();
// Parse HTML with Cheerio
const $ = parseHTML(html);
// Identify main chapter content container using CSS selectors
const contentContainer = $('div.reading-content').first();
if (contentContainer.length === 0) {
console.warn('Chapter content container not found');
return '';
}
// Remove hidden input elements used for tracking (infinite scrolling)
contentContainer.find('input[type="hidden"]').remove();
// Handle embedded images - ensure they have proper attributes
contentContainer.find('img').each((_, img) => {
const $img = $(img);
// Handle lazy-loaded images by moving data-src to src if needed
const dataSrc = $img.attr('data-src');
if (dataSrc && !$img.attr('src')) {
$img.attr('src', dataSrc);
}
// Ensure images have alt text for accessibility
if (!$img.attr('alt')) {
$img.attr('alt', 'Chapter image');
}
});
// Handle special formatting elements - preserve paragraph structure
// Ensure proper spacing between paragraphs and other block elements
contentContainer.find('p, div, br').each((_, element) => {
const $element = $(element);
// Ensure paragraphs have proper spacing
if (element.tagName?.toLowerCase() === 'p' && $element.text().trim()) {
// Paragraph already has proper HTML structure
}
// Handle div elements that might contain text
if (
element.tagName?.toLowerCase() === 'div' &&
$element.text().trim()
) {
// Preserve div structure for special formatting
}
});
// Extract the HTML content while preserving formatting
let chapterContent = contentContainer.html();
// Filter out HTML comments
if (chapterContent) {
chapterContent = chapterContent.replace(/<!--[\s\S]*?-->/g, '');
}
// Return properly formatted HTML string
return chapterContent?.trim() || '';
} catch (error) {
console.error('Error parsing chapter:', error);
return '';
}
}
}
export default new KDTNovels();
-499
View File
@@ -1,499 +0,0 @@
import { Parser } from 'htmlparser2';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import dayjs from 'dayjs';
class LightNovelPub implements Plugin.PagePlugin {
id = 'lightnovelpub';
name = 'LightNovelPub';
version = '2.2.0';
icon = 'src/en/lightnovelpub/icon.png';
site = 'https://www.lightnovelpub.com/';
headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
parseNovels(html: string) {
const novels: Plugin.NovelItem[] = [];
let tempNovel: Partial<Plugin.NovelItem> = {};
let state: ParsingState = ParsingState.Idle;
const parser = new Parser({
onopentag(name, attribs) {
if (attribs['class'] === 'novel-item') {
state = ParsingState.Novel;
}
if (state !== ParsingState.Novel) return;
switch (name) {
case 'a':
tempNovel.path = attribs['href'].slice(1);
tempNovel.name = attribs['title'];
break;
case 'img':
tempNovel.cover = attribs['data-src'] || attribs['src'];
break;
}
},
onclosetag(name) {
if (name === 'li') {
if (tempNovel.path && tempNovel.cover) {
novels.push(tempNovel as Plugin.NovelItem);
tempNovel = {};
}
state = ParsingState.Idle;
}
},
});
parser.write(html);
parser.end();
return novels;
}
async popularNovels(
page: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const linkParts = [
this.site + 'browse',
filters.genres.value,
filters.order.value,
filters.status.value,
page.toString(),
];
const body = await fetchApi(linkParts.join('/')).then(r => r.text());
return this.parseNovels(body);
}
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const body = await fetchApi(this.site + novelPath).then(r => r.text());
const novel: Partial<Plugin.SourceNovel> & Partial<{ totalPages: number }> =
{
path: novelPath,
chapters: [],
};
let state: ParsingState = ParsingState.Idle;
const summaryParts: string[] = [];
const genreArray: string[] = [];
const parser = new Parser({
onopentag(name, attribs) {
switch (name) {
case 'h1':
if (attribs['class']?.includes('novel-title')) {
state = ParsingState.NovelName;
}
break;
case 'figure':
if (attribs['class'] === 'cover') {
state = ParsingState.Cover;
}
break;
case 'img':
if (state === ParsingState.Cover) {
novel.cover = attribs['data-src'] || attribs['src'];
}
break;
case 'strong':
if (state === ParsingState.HeaderStats) {
if (attribs['class']) {
state = ParsingState.Status;
} else {
state = ParsingState.TotalChapters;
}
}
break;
case 'br':
if (state === ParsingState.Summary) {
summaryParts.push('<LINE_BREAK>');
}
break;
case 'a':
if (state === ParsingState.Genres) {
state = ParsingState.Tags;
}
break;
case 'div':
if (attribs['class']) {
if (attribs['class'].includes('content')) {
state = ParsingState.Summary;
} else {
const map: Record<string, ParsingState> = {
'categories': ParsingState.Genres,
'header-stats': ParsingState.HeaderStats,
'expand': ParsingState.Idle,
};
state = map[attribs['class']] ?? state;
}
}
break;
default:
if (attribs['itemprop'] === 'author') {
state = ParsingState.AuthorName;
}
break;
}
},
ontext(data) {
switch (state) {
case ParsingState.TotalChapters:
if (!novel.totalPages) {
novel.totalPages = Math.ceil(parseInt(data, 10) / 100);
}
break;
case ParsingState.Status:
novel.status = data.trim();
break;
case ParsingState.NovelName:
novel.name = (novel.name || '') + data.trim();
break;
case ParsingState.AuthorName:
novel.author = data;
break;
case ParsingState.Summary:
summaryParts.push(data);
break;
case ParsingState.Tags:
genreArray.push(data);
break;
}
},
onclosetag(name) {
switch (name) {
case 'strong':
if (state === ParsingState.TotalChapters) {
state = ParsingState.HeaderStats;
} else if (state === ParsingState.Status) {
state = ParsingState.Idle;
}
break;
case 'i':
if (state === ParsingState.Status) {
state = ParsingState.Idle;
}
break;
case 'h1':
if (state === ParsingState.NovelName) {
state = ParsingState.Idle;
}
break;
case 'span':
if (state === ParsingState.AuthorName) {
state = ParsingState.Idle;
}
break;
case 'div':
if (
state === ParsingState.Summary ||
state === ParsingState.Genres
) {
state = ParsingState.Idle;
}
break;
case 'a':
if (state === ParsingState.Tags) {
state = ParsingState.Genres;
}
break;
case 'figure':
if (state === ParsingState.Cover) {
state = ParsingState.Idle;
}
break;
case 'p':
if (state === ParsingState.Summary) {
summaryParts.push('<PARAGRAPH_BREAK>');
}
break;
}
},
onend() {
const text = summaryParts
.join('')
.replace(/<PARAGRAPH_BREAK>/g, '\n\n')
.replace(/<LINE_BREAK>/g, '\n')
.replace(/\r\n/g, '\n')
.replace(/&nbsp;/g, ' ');
const paragraphs = text
.split('\n\n')
.map(p => p.trim().replace(/[ \t]+/g, ' '))
.filter(p => p.length > 0);
novel.summary = paragraphs.join('\n\n');
summaryParts.length = 0;
novel.genres = genreArray.join(', ');
},
});
parser.write(body);
parser.end();
return novel as Plugin.SourceNovel & { totalPages: number };
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const url = this.site + novelPath + '/chapters/page-' + page;
const body = await fetchApi(url).then(res => res.text());
const chapters: Plugin.ChapterItem[] = [];
let tempChapter: Partial<Plugin.ChapterItem> = {};
let state: ParsingState = ParsingState.Idle;
const parser = new Parser({
onopentag(name, attribs) {
if (attribs['class'] === 'chapter-list') {
state = ParsingState.ChapterList;
return;
}
switch (state) {
case ParsingState.ChapterList:
if (name === 'li') {
state = ParsingState.ChapterItem;
tempChapter = {
chapterNumber: Number(attribs['data-orderno'] || 0),
};
}
break;
case ParsingState.ChapterItem:
switch (name) {
case 'a':
tempChapter.name = attribs['title'];
tempChapter.path = attribs['href']?.slice(1);
break;
case 'time':
tempChapter.releaseTime = dayjs(
attribs['datetime'],
).toISOString();
break;
}
break;
}
},
onclosetag(name) {
switch (state) {
case ParsingState.ChapterItem:
if (name === 'li') {
if (
tempChapter.chapterNumber !== undefined &&
tempChapter.path &&
tempChapter.releaseTime
) {
chapters.push(tempChapter as Plugin.ChapterItem);
}
state = ParsingState.ChapterList;
}
break;
case ParsingState.ChapterList:
if (name === 'ul') {
state = ParsingState.Idle;
}
break;
}
},
});
parser.write(body);
parser.end();
return { chapters };
}
async parseChapter(chapterPath: string): Promise<string> {
const html = await fetchApi(this.site + chapterPath).then(r => r.text());
let depth = 0;
let state: ParsingState = ParsingState.Idle;
const chapterHtml: string[] = [];
type EscapeChar = '&' | '<' | '>' | '"' | "'";
const escapeRegex = /[&<>"']/g;
const escapeMap: Record<EscapeChar, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const escapeHtml = (text: string): string =>
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]);
const parser = new Parser({
onopentag(name, attribs) {
switch (state) {
case ParsingState.Idle:
if (name === 'div' && attribs['id'] === 'chapter-container') {
state = ParsingState.Chapter;
depth++;
}
break;
case ParsingState.Chapter:
if (name === 'div') depth++;
break;
default:
return;
}
if (state === ParsingState.Chapter) {
const attr = Object.keys(attribs).map(key => {
const value = attribs[key].replace(/"/g, '&quot;');
return ` ${key}="${value}"`;
});
chapterHtml.push(`<${name}${attr.join('')}>`);
}
},
ontext(text) {
if (state === ParsingState.Chapter) {
chapterHtml.push(escapeHtml(text));
}
},
onclosetag(name) {
if (state === ParsingState.Chapter) {
if (!parser['isVoidElement'](name)) {
chapterHtml.push(`</${name}>`);
}
if (name === 'div') depth--;
if (depth === 0) {
state = ParsingState.Stopped;
}
}
},
});
parser.write(html);
parser.end();
return chapterHtml.join('');
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const url = `${this.site}lnsearchlive`;
const link = `${this.site}search`;
const response = await fetchApi(link).then(r => r.text());
let verifytoken = '';
const parser = new Parser({
onopentag(name, attribs) {
if (
name === 'input' &&
attribs['name']?.includes('LNRequestVerifyToken')
) {
verifytoken = attribs['value'];
}
},
});
parser.write(response);
parser.end();
const formData = new FormData();
formData.append('inputContent', searchTerm);
const body = await fetchApi(url, {
method: 'POST',
headers: { LNRequestVerifyToken: verifytoken! },
body: formData,
}).then(r => r.json());
return this.parseNovels(body.resultview);
}
filters = {
order: {
value: 'popular',
label: 'Order by',
options: [
{ label: 'New', value: 'new' },
{ label: 'Popular', value: 'popular' },
{ label: 'Updates', value: 'updated' },
],
type: FilterTypes.Picker,
},
status: {
value: 'all',
label: 'Status',
options: [
{ label: 'All', value: 'all' },
{ label: 'Completed', value: 'completed' },
{ label: 'Ongoing', value: 'ongoing' },
],
type: FilterTypes.Picker,
},
genres: {
value: 'all',
label: 'Genre',
options: [
{ label: 'All', value: 'all' },
{ label: 'Action', value: 'action' },
{ label: 'Adventure', value: 'adventure' },
{ label: 'Drama', value: 'drama' },
{ label: 'Fantasy', value: 'fantasy' },
{ label: 'Harem', value: 'harem' },
{ label: 'Martial Arts', value: 'martial-arts' },
{ label: 'Mature', value: 'mature' },
{ label: 'Romance', value: 'romance' },
{ label: 'Tragedy', value: 'tragedy' },
{ label: 'Xuanhuan', value: 'xuanhuan' },
{ label: 'Ecchi', value: 'ecchi' },
{ label: 'Comedy', value: 'comedy' },
{ label: 'Slice of Life', value: 'slice-of-life' },
{ label: 'Mystery', value: 'mystery' },
{ label: 'Supernatural', value: 'supernatural' },
{ label: 'Psychological', value: 'psychological' },
{ label: 'Sci-fi', value: 'sci-fi' },
{ label: 'Xianxia', value: 'xianxia' },
{ label: 'School Life', value: 'school-life' },
{ label: 'Josei', value: 'josei' },
{ label: 'Wuxia', value: 'wuxia' },
{ label: 'Shounen', value: 'shounen' },
{ label: 'Horror', value: 'horror' },
{ label: 'Mecha', value: 'mecha' },
{ label: 'Historical', value: 'historical' },
{ label: 'Shoujo', value: 'shoujo' },
{ label: 'Adult', value: 'adult' },
{ label: 'Seinen', value: 'seinen' },
{ label: 'Sports', value: 'sports' },
{ label: 'Lolicon', value: 'lolicon' },
{ label: 'Gender Bender', value: 'gender-bender' },
{ label: 'Shounen Ai', value: 'shounen-ai' },
{ label: 'Yaoi', value: 'yaoi' },
{ label: 'Video Games', value: 'video-games' },
{ label: 'Smut', value: 'smut' },
{ label: 'Magical Realism', value: 'magical-realism' },
{ label: 'Eastern Fantasy', value: 'eastern-fantasy' },
{ label: 'Contemporary Romance', value: 'contemporary-romance' },
{ label: 'Fantasy Romance', value: 'fantasy-romance' },
{ label: 'Shoujo Ai', value: 'shoujo-ai' },
{ label: 'Yuri', value: 'yuri' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new LightNovelPub();
enum ParsingState {
Idle,
Novel,
HeaderStats,
Status,
Stopped,
Chapter,
ChapterItem,
ChapterList,
TotalChapters,
NovelName,
AuthorName,
Summary,
Genres,
Tags,
Cover,
}
@@ -1,208 +0,0 @@
import { fetchApi, fetchProto, fetchText } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { Filters } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';
// import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
// import { storage, localStorage, sessionStorage } from '@libs/storage';
// import { encode, decode } from 'urlencode';
// import dayjs from 'dayjs';
import { Parser } from 'htmlparser2';
class LNTPlugin implements Plugin.PluginBase {
id = 'lightnoveltranslations';
name = 'Light Novel Translations';
icon = 'src/en/lightnoveltranslations/icon.png';
site = 'https://lightnovelstranslations.com/';
version = '1.0.0';
filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//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[]> {
let link = this.site + 'read/';
link += `page/${pageNo}`;
link += `?sortby=${showLatestNovels ? 'most-recent' : 'most-liked'}`;
const body = await fetchApi(link);
const html = await body.text();
const loadedCheerio = loadCheerio(html);
const baseUrl = this.site;
const novels: Plugin.NovelItem[] = [];
loadedCheerio('div.read_list-story-item').each((i, el) => {
let tempNovel = {} as Plugin.NovelItem;
let img = loadedCheerio(el)
.find('.item_thumb')
.find('img')
.first()
.attr('src');
let path = loadedCheerio(el)
.find('.item_thumb')
.find('a')
.first()
.attr('href');
path = path ? path.slice(baseUrl.length) : '';
let title = loadedCheerio(el)
.find('.item_thumb')
.find('a')
.first()
.attr('title');
tempNovel.name = title ? title : '';
tempNovel.path = path;
tempNovel.cover = img;
novels.push(tempNovel);
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
let url = this.site + novelPath;
const body = await fetchApi(url);
const html = await body.text().then(r => r.replace(/>\s+</g, '><'));
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelPath,
name: '',
totalPages: 1,
summary: '',
author: '',
status: '',
chapters: [],
};
const loadedCheerio = loadCheerio(html);
novel.cover = loadedCheerio('div.novel-image').find('img').attr('src');
novel.status = loadedCheerio('div.novel_status').text().trim();
switch (novel.status) {
case 'Ongoing':
novel.status = NovelStatus.Ongoing;
break;
case 'Hiatus':
novel.status = NovelStatus.OnHiatus;
break;
case 'Completed':
novel.status = NovelStatus.Completed;
break;
default:
novel.status = NovelStatus.Unknown;
}
novel.name = loadedCheerio('div.novel_title')
.find('h3')
.first()
.text()
.trim();
novel.author = loadedCheerio('div.novel_detail_info')
.find('li')
.filter(function () {
return loadedCheerio(this).text().includes('Author');
})
.first()
.text()
.trim();
const body2 = await fetchApi(url.replace('?tab=table_contents', ''));
const html2 = await body2.text().then(r => r.replace(/>\s+</g, '><'));
const loadedCheerio2 = loadCheerio(html2);
novel.summary = loadedCheerio2('div.novel_text')
.find('p')
.first()
.text()
.trim();
const baseUrl = this.site;
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('li.chapter-item.unlock').each((i, el) => {
const chapterName = loadedCheerio(el).find('a').text().trim();
const chapterPath = loadedCheerio(el).find('a').attr('href');
if (chapterPath) {
const chapter: Plugin.ChapterItem = {
name: chapterName,
path: chapterPath.slice(baseUrl.length),
};
chapters.push(chapter);
}
});
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
// parse chapter text here
const body = await fetchApi(this.site + chapterPath);
const html = await body.text().then(r => r.replace(/>\s+</g, '><'));
const loadedCheerio = loadCheerio(html);
const chapterText = loadedCheerio('div.text_story');
chapterText.find('div.ads_content').remove();
return chapterText.html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
// get novels using the search term
if (pageNo !== 1) return [];
const searchUrl = this.site + '/read';
const formData = new FormData();
formData.append('field-search', searchTerm);
let results = await fetchApi(searchUrl, {
method: 'POST',
body: formData,
});
const body = await results.text();
const loadedCheerio = loadCheerio(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('div.read_list-story-item').each((i, el) => {
let tempNovel = {} as Plugin.NovelItem;
let img = loadedCheerio(el)
.find('.item_thumb')
.find('img')
.first()
.attr('src');
let path = loadedCheerio(el)
.find('.item_thumb')
.find('a')
.first()
.attr('href');
path = path ? path.slice(this.site.length) : '';
let title = loadedCheerio(el)
.find('.item_thumb')
.find('a')
.first()
.attr('title');
tempNovel.name = title ? title : '';
tempNovel.path = path;
tempNovel.cover = img;
novels.push(tempNovel);
});
type SearchEntry = {
title: string;
thumbnail: string;
permalink: string;
};
return novels;
}
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + (isNovel ? '/book/' : '/chapter/') + path;
}
export default new LNTPlugin();
-435
View File
@@ -1,435 +0,0 @@
import { Parser } from 'htmlparser2';
import { fetchApi } from '@libs/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@typings/plugin';
class LnMTLPlugin implements Plugin.PagePlugin {
id = 'lnmtl';
name = 'LnMTL';
icon = 'src/en/lnmtl/icon.png';
site = 'https://lnmtl.com/';
version = '2.1.0';
async sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async popularNovels(
page: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const params = new URLSearchParams({
orderBy: filters.order.value,
order: filters.sort.value,
filter: filters.storyStatus.value,
page: page.toString(),
});
const link = `${this.site}novel?${params.toString()}`;
const response = await fetchApi(link);
const html = await response.text();
const baseUrl = this.site;
let state: ParsingState = ParsingState.Idle;
let tempNovel: Partial<Plugin.NovelItem> = {};
const novels: Plugin.NovelItem[] = [];
const parser = new Parser({
onopentag(name, attribs) {
if (attribs['class']?.includes('media-left')) {
state = ParsingState.Novel;
}
if (state !== ParsingState.Novel) return;
switch (name) {
case 'a':
tempNovel.path = attribs['href'].replace(baseUrl, '');
break;
case 'img':
tempNovel.name = attribs['alt'];
tempNovel.cover = attribs['src'];
break;
}
},
onclosetag(name) {
if (name === 'div') {
if (tempNovel.path && tempNovel.name) {
novels.push(tempNovel as Plugin.NovelItem);
tempNovel = {};
}
state = ParsingState.Idle;
}
},
});
parser.write(html);
parser.end();
return novels;
}
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const body = await fetchApi(this.site + novelPath);
const html = await body.text().then(r => r.replace(/>\s+</g, '><'));
const novel: Partial<Plugin.SourceNovel> & { totalPages: number } = {
path: novelPath,
totalPages: 1,
chapters: [],
};
let state = ParsingState.Idle;
let panelValueCount = 0;
let listCount = 0;
let isAuthorKey = false;
let isStatusKey = false;
const summaryParts: string[] = [];
const genreArray: string[] = [];
const parser = new Parser({
onopentag(name, attribs) {
switch (name) {
case 'img':
if (attribs['class']?.includes('img-rounded')) {
novel.name = attribs['title'];
novel.cover = attribs['src'];
}
break;
case 'dt':
state = ParsingState.InPanelKey;
break;
case 'dd':
state = ParsingState.InPanelValue;
panelValueCount++;
break;
case 'ul':
if (attribs['class']?.includes('list-inline')) {
listCount++;
}
break;
case 'li':
if (listCount === 1) {
state = ParsingState.InGenres;
}
break;
default:
if (attribs['class']) {
const map: Record<string, ParsingState> = {
description: ParsingState.InDescription,
source: ParsingState.InSource,
progress: ParsingState.Idle,
};
state = map[attribs['class']] ?? state;
}
}
},
ontext(data) {
switch (state) {
case ParsingState.InScript:
const volume = JSON.parse(
data.match(/lnmtl.volumes = (.+])(?=;)/)![1] || '',
);
novel.totalPages = volume.length;
break;
case ParsingState.InDescription:
summaryParts.push(data.trim());
summaryParts.push('\n\n');
break;
case ParsingState.InSource:
summaryParts.push(data);
break;
case ParsingState.InPanelKey:
switch (data) {
case 'Authors':
isAuthorKey = true;
break;
case 'Current status':
isStatusKey = true;
break;
}
break;
case ParsingState.InPanelValue:
if (isAuthorKey && panelValueCount === 1) {
novel.author = (novel.author || '') + data.trim();
isAuthorKey = false;
} else if (isStatusKey && panelValueCount === 2) {
novel.status = (novel.status || '') + data.trim();
isStatusKey = false;
}
break;
case ParsingState.InGenres:
genreArray.push(data.trim());
break;
}
},
onclosetag(name) {
switch (name) {
case 'ul':
if (state === ParsingState.InGenres) {
state = ParsingState.Idle;
}
break;
case 'main':
state = ParsingState.InScript;
break;
case 'script':
if (state === ParsingState.InScript) {
state = ParsingState.Idle;
}
break;
}
},
onend() {
novel.summary = summaryParts.join('');
novel.genres = genreArray.join(', ');
},
});
parser.write(html);
parser.end();
return novel as Plugin.SourceNovel & { totalPages: number };
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const result = await fetchApi(this.site + novelPath);
const html = await result.text().then(r => r.replace(/>\s+</g, '><'));
let state: ParsingState = ParsingState.Idle;
let volume: VolumeEntry = {
id: '',
title: '',
};
const parser = new Parser({
ontext(data) {
if (state === ParsingState.InScript) {
const volumes = JSON.parse(
data.match(/lnmtl.volumes = (.+])(?=;)/)![1] || '',
);
volume = volumes[+page - 1];
}
},
onclosetag(name) {
if (name === 'main') {
state = ParsingState.InScript;
}
if (name === 'script') {
state = ParsingState.Idle;
}
},
});
parser.write(html);
parser.end();
const chapter: Plugin.ChapterItem[] = [];
await this.sleep(1000);
const volumeData = await fetchApi(
`${this.site}chapter?volumeId=${volume.id}`,
);
const volumePage = await volumeData.json();
const firstPage = volumePage.data.map((chapter: ChapterEntry) => ({
name: `#${chapter.number} - ${chapter.title}`,
path: `chapter/${chapter.slug}`,
releaseTime: new Date(chapter.created_at).toISOString(), //converts time obtained to UTC +0, TODO: Make it not convert
}));
chapter.push(...firstPage);
for (let i = 2; i <= volumePage.last_page; i++) {
await this.sleep(1000);
const chapterData = await fetchApi(
`${this.site}chapter?page=${i}&volumeId=${volume.id}`,
);
const chapterInfo = await chapterData.json();
const chapterDetails = chapterInfo.data.map((chapter: ChapterEntry) => ({
name: `#${chapter.number} ${chapter.title}`,
path: `chapter/${chapter.slug}`,
releaseTime: new Date(chapter.created_at).toISOString(), //converts time obtained to UTC +0, TODO: Make it not convert
}));
chapter.push(...chapterDetails);
}
const chapters = chapter;
return { chapters };
}
async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(this.site + chapterPath);
const html = await result.text();
let state: ParsingState = ParsingState.Idle;
const chapterTextParts: string[] = [];
type EscapeChar = '&' | '<' | '>' | '"' | "'";
const escapeRegex = /[&<>"']/g;
const escapeMap: Record<EscapeChar, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const escapeHtml = (text: string): string =>
escapeRegex.test(text)
? ((escapeRegex.lastIndex = 0),
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]))
: text;
const parser = new Parser({
onopentag(name, attribs) {
if (name === 'sentence' && attribs['class']?.includes('translated')) {
state = ParsingState.Chapter;
chapterTextParts.push('<p>');
}
},
onopentagname(name) {
if (name === 'nav') {
state = ParsingState.Idle;
}
},
ontext(data) {
if (state === ParsingState.Chapter) {
chapterTextParts.push(escapeHtml(data));
}
},
onclosetag(name) {
if (name === 'sentence' && state === ParsingState.Chapter) {
chapterTextParts.push('</p>');
state = ParsingState.Idle;
}
},
});
parser.write(html);
parser.end();
const chapterText = chapterTextParts.join('');
return chapterText.replace(/„/g, '“');
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const html = await fetchApi(this.site)
.then(b => b.text())
.then(r => r.replace(/>\s+</g, '><'));
let state = ParsingState.Idle;
const listPart = new Set<string>();
const parser = new Parser({
onopentag(name, attribs) {
if (
state === ParsingState.InFooter &&
name === 'script' &&
attribs['type']?.includes('application/javascript')
) {
state = ParsingState.InScript;
}
},
ontext(data) {
if (state === ParsingState.InScript) {
const match = data.match(/prefetch: '\/(.*?\.json)/);
if (match) {
listPart.add(match[1]);
}
}
},
onclosetag(name) {
switch (name) {
case 'footer':
state = ParsingState.InFooter;
break;
case 'script':
if (state === ParsingState.InScript) {
state = ParsingState.Idle;
}
break;
}
},
});
parser.write(html);
parser.end();
const search = await fetchApi(
`${this.site}${Array.from(listPart).join('')}`,
);
const data = await search.json();
const nov = data.filter((res: { name: string }) =>
res.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
const novels: Plugin.NovelItem[] = [];
nov.map((res: { name: string; slug: string; image: string }) => {
const novelName = res.name;
const novelUrl = `novel/${res.slug}`;
const novelCover = res.image;
const novel = {
path: novelUrl,
name: novelName,
cover: novelCover,
};
novels.push(novel);
});
return novels;
}
filters = {
order: {
value: 'favourites',
label: 'Order by',
options: [
{ label: 'Favourites', value: 'favourites' },
{ label: 'Name', value: 'name' },
{ label: 'Addition Date', value: 'date' },
],
type: FilterTypes.Picker,
},
sort: {
value: 'desc',
label: 'Sort by',
options: [
{ label: 'Descending', value: 'desc' },
{ label: 'Ascending', value: 'asc' },
],
type: FilterTypes.Picker,
},
storyStatus: {
value: 'all',
label: 'Status',
options: [
{ label: 'All', value: 'all' },
{ label: 'Ongoing', value: 'ongoing' },
{ label: 'Finished', value: 'finished' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new LnMTLPlugin();
type ChapterEntry = {
number: number;
title: string;
slug: string;
created_at: string;
};
type VolumeEntry = {
id: string;
title: string;
};
enum ParsingState {
Idle,
InFooter,
InScript,
InDescription,
InSource,
InPanelKey,
InPanelValue,
InGenres,
Novel,
Chapter,
}
-101
View File
@@ -1,101 +0,0 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
class MTLReader implements Plugin.PluginBase {
id = 'mtlreader';
name = 'MTL Reader';
version = '1.0.1';
icon = 'src/en/mtlreader/icon.png';
site = 'https://mtlreader.com/';
parseNovels(loadedCheerio: CheerioAPI) {
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.col-md-4').each((i, el) => {
const novelName = loadedCheerio(el).find('h5').text();
const novelCover = loadedCheerio(el).find('img').attr('src');
const novelUrl = loadedCheerio(el).find('h5 > a').attr('href');
if (!novelUrl) return;
const novel = {
name: novelName,
cover: novelCover,
path: novelUrl.replace(this.site, ''),
};
novels.push(novel);
});
return novels;
}
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
const url = `${this.site}novels?page=${pageNo}`;
const body = await fetchApi(url).then(r => r.text());
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const body = await fetchApi(this.site + novelPath).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('.agent-title').text().trim() || 'Untitled',
cover: loadedCheerio('.agent-p-img > img').attr('src'),
summary: loadedCheerio('#editdescription').text().trim(),
chapters: [],
};
novel.author = loadedCheerio('i.fa-user')
.parent()
.text()
.replace('Author: ', '')
.trim();
const chapter: Plugin.ChapterItem[] = [];
loadedCheerio('tr.spaceUnder').each((i, el) => {
const chapterName = loadedCheerio(el).find('a').text().trim();
const chapterUrl = loadedCheerio(el).find('a').attr('href');
if (!chapterUrl) return;
chapter.push({
name: chapterName,
path: chapterUrl.replace(this.site, ''),
});
});
novel.chapters = chapter;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
const loadedCheerio = parseHTML(body);
loadedCheerio('.container ins,script,p.mtlreader').remove();
const chapterText = loadedCheerio('.container').html() || '';
return chapterText;
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const body = await fetchApi(this.site).then(r => r.text());
const tokenCheerio = parseHTML(body);
const token = tokenCheerio('input[name="_token"]').attr('value');
const searchUrl = `${this.site}search?_token=${token}&input=${encodeURIComponent(searchTerm)}`;
const seacrhBody = await fetchApi(searchUrl).then(r => r.text());
const loadedCheerio = parseHTML(seacrhBody);
return this.parseNovels(loadedCheerio);
}
}
export default new MTLReader();
File diff suppressed because it is too large Load Diff
-294
View File
@@ -1,294 +0,0 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
class NovelBuddy implements Plugin.PluginBase {
id = 'novelbuddy';
name = 'NovelBuddy.io';
site = 'https://novelbuddy.io/';
version = '1.0.3';
icon = 'src/en/novelbuddy/icon.png';
parseNovels(loadedCheerio: CheerioAPI) {
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.book-item').each((idx, ele) => {
const novelName = loadedCheerio(ele).find('.title').text();
const novelCover =
'https:' + loadedCheerio(ele).find('img').attr('data-src');
const novelUrl = loadedCheerio(ele)
.find('.title a')
.attr('href')
?.substring(1);
if (!novelUrl) return;
const novel = { name: novelName, cover: novelCover, path: novelUrl };
novels.push(novel);
});
return novels;
}
async popularNovels(
pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<Filters>,
): Promise<Plugin.NovelItem[]> {
// create empty query params
const params = new URLSearchParams();
// apply all filters
params.append('sort', filters.orderBy.value.toString());
params.append('status', filters.status.value.toString());
if (filters.genre.value instanceof Array) {
filters.genre.value.forEach(genre => {
params.append('genre[]', genre.toString());
});
}
params.append('q', filters.keyword.value.toString());
params.append('page', pageNo.toString());
const url = `${this.site}search?${params.toString()}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const result = await fetchApi(this.site + novelPath);
const body = await result.text();
let loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('.name h1').text().trim() || 'Untitled',
cover: 'https:' + loadedCheerio('.img-cover img').attr('data-src'),
summary: loadedCheerio('.section-body.summary .content').text().trim(),
chapters: [],
};
loadedCheerio('.meta.box p').each((i, el) => {
const detailName = loadedCheerio(el).find('strong').text();
const detail = loadedCheerio(el).find('a');
switch (detailName) {
case 'Authors :':
novel.author = detail
.find('span')
.map((a, ex) => loadedCheerio(ex).text())
.toArray()
.join(', ');
break;
case 'Status :':
novel.status = detail.text();
break;
case 'Genres :':
novel.genres = detail.text().trim();
break;
}
});
const novelId = loadedCheerio('script')
.text()
.match(/bookId = (\d+);/)![1];
const chapter: Plugin.ChapterItem[] = [];
const getChapters = async (id: string) => {
const chapterListUrl = `${this.site}api/manga/${id}/chapters?source=detail`;
const data = await fetchApi(chapterListUrl);
const chapterlist = await data.text();
loadedCheerio = parseHTML(chapterlist);
loadedCheerio('li').each((i, el) => {
const chapterName = loadedCheerio(el)
.find('.chapter-title')
.text()
.trim();
const releaseDate = loadedCheerio(el)
.find('.chapter-update')
.text()
.trim();
const months = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
];
const monthsJoined = months.join('|');
const rx = new RegExp(
`(${monthsJoined}) (\\d{1,2}), (\\d{4})`,
'i',
).exec(releaseDate);
if (!rx) return;
const year = +rx[3];
const month = months.indexOf(rx[1].toLowerCase());
const day = +rx[2];
const chapterUrl = loadedCheerio(el).find('a').attr('href')?.slice(1);
if (!chapterUrl) return;
chapter.push({
name: chapterName,
releaseTime: new Date(year, month, day).toISOString(),
path: chapterUrl,
});
});
return chapter;
};
novel.chapters = (await getChapters(novelId)).reverse();
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(this.site + chapterPath);
const body = await result.text();
const loadedCheerio = parseHTML(body);
loadedCheerio('#listen-chapter').remove();
loadedCheerio('#google_translate_element').remove();
const chapterText = loadedCheerio('.chapter__content').html() || '';
return chapterText;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}search?q=${encodeURIComponent(searchTerm)}&page=${page}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
filters = {
orderBy: {
value: 'views',
label: 'Order by',
options: [
{ label: 'Views', value: 'views' },
{ label: 'Updated At', value: 'updated_at' },
{ label: 'Created At', value: 'created_at' },
{ label: 'Name', value: 'name' },
{ label: 'Rating', value: 'rating' },
],
type: FilterTypes.Picker,
},
keyword: {
value: '',
label: 'Keywords',
type: FilterTypes.TextInput,
},
status: {
value: 'all',
label: 'Status',
options: [
{ label: 'All', value: 'all' },
{ label: 'Ongoing', value: 'ongoing' },
{ label: 'Completed', value: 'completed' },
],
type: FilterTypes.Picker,
},
genre: {
value: [],
label: 'Genres (OR, not AND)',
options: [
{ label: 'Action', value: 'action' },
{ label: 'Action Adventure', value: 'action-adventure' },
{ label: 'Adult', value: 'adult' },
{ label: 'Adventcure', value: 'adventcure' },
{ label: 'Adventure', value: 'adventure' },
{ label: 'Adventurer', value: 'adventurer' },
{ label: 'Bender', value: 'bender' },
{ label: 'Chinese', value: 'chinese' },
{ label: 'Comedy', value: 'comedy' },
{ label: 'Cultivation', value: 'cultivation' },
{ label: 'Drama', value: 'drama' },
{ label: 'Eastern', value: 'eastern' },
{ label: 'Ecchi', value: 'ecchi' },
{ label: 'Fan Fiction', value: 'fan-fiction' },
{ label: 'Fanfiction', value: 'fanfiction' },
{ label: 'Fantas', value: 'fantas' },
{ label: 'Fantasy', value: 'fantasy' },
{ label: 'Game', value: 'game' },
{ label: 'Gender', value: 'gender' },
{ label: 'Gender Bender', value: 'gender-bender' },
{ label: 'Harem', value: 'harem' },
{ label: 'HaremAction', value: 'haremaction' },
{ label: 'Haremv', value: 'haremv' },
{ label: 'Historica', value: 'historica' },
{ label: 'Historical', value: 'historical' },
{ label: 'History', value: 'history' },
{ label: 'Horror', value: 'horror' },
{ label: 'Isekai', value: 'isekai' },
{ label: 'Josei', value: 'josei' },
{ label: 'Lolicon', value: 'lolicon' },
{ label: 'Magic', value: 'magic' },
{ label: 'Martial', value: 'martial' },
{ label: 'Martial Arts', value: 'martial-arts' },
{ label: 'Mature', value: 'mature' },
{ label: 'Mecha', value: 'mecha' },
{ label: 'Military', value: 'military' },
{ label: 'Modern Life', value: 'modern-life' },
{ label: 'Mystery', value: 'mystery' },
{ label: 'Mystery Adventure', value: 'mystery-adventure' },
{ label: 'Psychologic', value: 'psychologic' },
{ label: 'Psychological', value: 'psychological' },
{ label: 'Reincarnation', value: 'reincarnation' },
{ label: 'Romance', value: 'romance' },
{ label: 'Romance Adventure', value: 'romance-adventure' },
{ label: 'Romance Harem', value: 'romance-harem' },
{ label: 'Romancem', value: 'romancem' },
{ label: 'School Life', value: 'school-life' },
{ label: 'Sci-fi', value: 'sci-fi' },
{ label: 'Seinen', value: 'seinen' },
{ label: 'Shoujo', value: 'shoujo' },
{ label: 'Shoujo Ai', value: 'shoujo-ai' },
{ label: 'Shounen', value: 'shounen' },
{ label: 'Shounen Ai', value: 'shounen-ai' },
{ label: 'Slice of Life', value: 'slice-of-life' },
{ label: 'Smut', value: 'smut' },
{ label: 'Sports', value: 'sports' },
{ label: 'Superna', value: 'superna' },
{ label: 'Supernatural', value: 'supernatural' },
{ label: 'System', value: 'system' },
{ label: 'Tragedy', value: 'tragedy' },
{ label: 'Urban', value: 'urban' },
{ label: 'Urban Life', value: 'urban-life' },
{ label: 'Wuxia', value: 'wuxia' },
{ label: 'Xianxia', value: 'xianxia' },
{ label: 'Xuanhuan', value: 'xuanhuan' },
{ label: 'Yaoi', value: 'yaoi' },
{ label: 'Yuri', value: 'yuri' },
],
type: FilterTypes.CheckboxGroup,
},
} satisfies Filters;
}
export default new NovelBuddy();
-443
View File
@@ -1,443 +0,0 @@
import { CheerioAPI, load } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { NovelStatus } from '@libs/novelStatus';
import { Filters, FilterTypes } from '@libs/filterInputs';
class NovelFire implements Plugin.PluginBase {
id = 'novelfire';
name = 'Novel Fire';
version = '1.0.4';
icon = 'src/en/novelfire/icon.png';
site = 'https://novelfire.net/';
async getCheerio(url: string, search: boolean): Promise<CheerioAPI> {
const r = await fetchApi(url);
if (!r.ok && search != true)
throw new Error(
'Could not reach site (' + r.status + ') try to open in webview.',
);
const $ = load(await r.text());
return $;
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let url = this.site + 'search-adv';
if (showLatestNovels) {
url += `?ctgcon=and&totalchapter=0&ratcon=min&rating=0&status=-1&sort=date&tagcon=and&page=${pageNo}`;
} else if (filters) {
const params = new URLSearchParams();
for (const language of filters.language.value) {
params.append('country_id[]', language);
}
params.append('ctgcon', filters.genre_operator.value);
for (const genre of filters.genres.value) {
params.append('categories[]', genre);
}
params.append('totalchapter', filters.chapters.value);
params.append('ratcon', filters.rating_operator.value);
params.append('rating', filters.rating.value);
params.append('status', filters.status.value);
params.append('sort', filters.sort.value);
params.append('page', pageNo.toString());
url += `?${params.toString()}`;
} else {
url += `?ctgcon=and&totalchapter=0&ratcon=min&rating=0&status=-1&sort=rank-top&page=${pageNo}`;
}
const loadedCheerio = await this.getCheerio(url, false);
return loadedCheerio('.novel-item')
.map((index, ele) => {
const novelName =
loadedCheerio(ele).find('.novel-title > a').attr('title') ||
'No Title Found';
const novelCover = loadedCheerio(ele)
.find('.novel-cover > img')
.attr('data-src');
const novelPath = loadedCheerio(ele)
.find('.novel-title > a')
.attr('href');
if (!novelPath) return null;
return {
name: novelName,
cover: novelCover,
path: novelPath.replace(this.site, ''),
};
})
.get()
.filter(novel => novel !== null);
}
async parseChapters(
novelPath: string,
pages: number,
): Promise<Plugin.ChapterItem[]> {
const pagesArray = Array.from({ length: pages }, (_, i) => i + 1);
const allChapters: Plugin.ChapterItem[] = [];
// Function to parse a single page
const parsePage = async (page: number) => {
const url = `${this.site}${novelPath}/chapters?page=${page}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = load(body);
if (loadedCheerio.text().includes('You are being rate limited')) {
throw new NovelFireThrottlingError();
}
const chapters = loadedCheerio('.chapter-list li')
.map((index, ele) => {
const chapterName =
loadedCheerio(ele).find('a').attr('title') || 'No Title Found';
const chapterPath = loadedCheerio(ele).find('a').attr('href');
if (!chapterPath) return null;
return {
name: chapterName,
path: chapterPath.replace(this.site, ''),
};
})
.get()
.filter(chapter => chapter !== null) as Plugin.ChapterItem[];
return chapters;
};
// When pages > ~30, we get rate limited. To mitigate, split into chunks and retry chunk on rate limit with delay.
const chunkSize = 5; // 5 pages per chunk was tested to be a good balance between speed and rate limiting.
const retryCount = 10;
const sleepTime = 3.5; // Rate limit seems to be around ~10s, so usually 3 retries should be enough for another ~30 pages.
const chaptersArray: Plugin.ChapterItem[][] = [];
for (let i = 0; i < pagesArray.length; i += chunkSize) {
const pagesArrayChunk = pagesArray.slice(i, i + chunkSize);
const firstPage = pagesArrayChunk[0];
const lastPage = pagesArrayChunk[pagesArrayChunk.length - 1];
let attempt = 0;
while (attempt < retryCount) {
try {
// Parse all pages in chunk in parallel
const chaptersArrayChunk = await Promise.all(
pagesArrayChunk.map(parsePage),
);
chaptersArray.push(...chaptersArrayChunk);
break;
} catch (err) {
if (err instanceof NovelFireThrottlingError) {
attempt += 1;
console.warn(
`[pages=${firstPage}-${lastPage}] Novel Fire is rate limiting requests. Retry attempt ${attempt + 1} in ${sleepTime} seconds...`,
);
if (attempt === retryCount) {
throw err;
}
// Sleep for X second before retrying
await new Promise(resolve => setTimeout(resolve, sleepTime * 1000));
} else {
throw err;
}
}
}
}
// Merge all chapters into a single array
for (const chapters of chaptersArray) {
allChapters.push(...chapters);
}
return allChapters.length === 0 ? [] : allChapters;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const $ = await this.getCheerio(this.site + novelPath, false);
const baseUrl = this.site;
const novel: Partial<Plugin.SourceNovel> = {
path: novelPath,
};
novel.name =
$('.novel-title').text().trim() ??
$('.cover > img').attr('alt') ??
'No Titled Found';
const coverUrl =
$('.cover > img').attr('data-src') ?? $('.cover > img').attr('src');
if (coverUrl) {
novel.cover = new URL(coverUrl, baseUrl).href;
}
novel.genres = $('.categories .property-item')
.map((i, el) => $(el).text())
.toArray()
.join(',');
let summary = $('.summary .content').text().trim();
if (summary) {
summary = summary.replace('Show More', '');
novel.summary = summary;
} else {
novel.summary = 'No Summary Found';
}
novel.author =
$('.author .property-item > span').text() || 'No Author Found';
const rawStatus =
$('.header-stats .ongoing').text() ||
$('.header-stats .completed').text() ||
'Unknown';
const map: Record<string, string> = {
ongoing: NovelStatus.Ongoing,
hiatus: NovelStatus.OnHiatus,
dropped: NovelStatus.Cancelled,
cancelled: NovelStatus.Cancelled,
completed: NovelStatus.Completed,
unknown: NovelStatus.Unknown,
};
novel.status = map[rawStatus.toLowerCase()] ?? NovelStatus.Unknown;
const totalChapters = $('.header-stats .icon-book-open')
.parent()
.text()
.trim();
const pages = Math.ceil(parseInt(totalChapters) / 100);
novel.chapters = await this.parseChapters(novelPath, pages);
return novel as Plugin.SourceNovel;
}
async parseChapter(chapterPath: string): Promise<string> {
const url = this.site + chapterPath;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = load(body);
const bloatElements = [
'.box-ads',
'.box-notification',
/^nf/, // Regular expression to match tags starting with 'nf'
];
bloatElements.forEach(tag => {
if (tag instanceof RegExp) {
loadedCheerio('*')
.filter((_, el) =>
tag.test(loadedCheerio(el).prop('tagName')!.toLowerCase()),
)
.remove();
} else {
loadedCheerio(tag).remove();
}
});
return loadedCheerio('#content').html()!;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}search?keyword=${encodeURIComponent(searchTerm)}&page=${page}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = load(body);
return loadedCheerio('.novel-list.chapters .novel-item')
.map((index, ele) => {
const novelName =
loadedCheerio(ele).find('a').attr('title') || 'No Title Found';
const novelCover = loadedCheerio(ele)
.find('.novel-cover > img')
.attr('src');
const novelPath = loadedCheerio(ele).find('a').attr('href');
if (!novelPath) return null;
return {
name: novelName,
cover: novelCover,
path: novelPath.replace(this.site, ''),
};
})
.get()
.filter(novel => novel !== null);
}
filters = {
sort: {
label: 'Sort Results By',
value: 'rank-top',
options: [
{ label: 'Rank (Top)', value: 'rank-top' },
{ label: 'Rating Score (Top)', value: 'rating-score-top' },
{ label: 'Bookmark Count (Most)', value: 'bookmark' },
{ label: 'Review Count (Most)', value: 'review' },
{ label: 'Title (A>Z)', value: 'abc' },
{ label: 'Title (Z>A)', value: 'cba' },
{ label: 'Last Updated (Newest)', value: 'date' },
{ label: 'Chapter Count (Most)', value: 'chapter-count-most' },
],
type: FilterTypes.Picker,
},
status: {
label: 'Translation Status',
value: '-1',
options: [
{ label: 'All', value: '-1' },
{ label: 'Completed', value: '1' },
{ label: 'Ongoing', value: '0' },
],
type: FilterTypes.Picker,
},
genre_operator: {
label: 'Genres (And/Or)',
value: 'and',
options: [
{ label: 'And', value: 'and' },
{ label: 'Or', value: 'or' },
],
type: FilterTypes.Picker,
},
genres: {
label: 'Genres',
value: [],
options: [
{ label: 'Action', value: '3' },
{ label: 'Adult', value: '28' },
{ label: 'Adventure', value: '4' },
{ label: 'Anime', value: '46' },
{ label: 'Arts', value: '47' },
{ label: 'Comedy', value: '5' },
{ label: 'Drama', value: '24' },
{ label: 'Eastern', value: '44' },
{ label: 'Ecchi', value: '26' },
{ label: 'Fan-fiction', value: '48' },
{ label: 'Fantasy', value: '6' },
{ label: 'Game', value: '19' },
{ label: 'Gender Bender', value: '25' },
{ label: 'Harem', value: '7' },
{ label: 'Historical', value: '12' },
{ label: 'Horror', value: '37' },
{ label: 'Isekai', value: '49' },
{ label: 'Josei', value: '2' },
{ label: 'Lgbt+', value: '45' },
{ label: 'Magic', value: '50' },
{ label: 'Magical Realism', value: '51' },
{ label: 'Manhua', value: '52' },
{ label: 'Martial Arts', value: '15' },
{ label: 'Mature', value: '8' },
{ label: 'Mecha', value: '34' },
{ label: 'Military', value: '53' },
{ label: 'Modern Life', value: '54' },
{ label: 'Movies', value: '55' },
{ label: 'Mystery', value: '16' },
{ label: 'Psychological', value: '9' },
{ label: 'Realistic Fiction', value: '56' },
{ label: 'Reincarnation', value: '43' },
{ label: 'Romance', value: '1' },
{ label: 'School Life', value: '21' },
{ label: 'Sci-fi', value: '20' },
{ label: 'Seinen', value: '10' },
{ label: 'Shoujo', value: '38' },
{ label: 'Shoujo Ai', value: '57' },
{ label: 'Shounen', value: '17' },
{ label: 'Shounen Ai', value: '39' },
{ label: 'Slice of Life', value: '13' },
{ label: 'Smut', value: '29' },
{ label: 'Sports', value: '42' },
{ label: 'Supernatural', value: '18' },
{ label: 'System', value: '58' },
{ label: 'Tragedy', value: '32' },
{ label: 'Urban', value: '63' },
{ label: 'Urban Life', value: '59' },
{ label: 'Video Games', value: '60' },
{ label: 'War', value: '61' },
{ label: 'Wuxia', value: '31' },
{ label: 'Xianxia', value: '23' },
{ label: 'Xuanhuan', value: '22' },
{ label: 'Yaoi', value: '14' },
{ label: 'Yuri', value: '62' },
],
type: FilterTypes.CheckboxGroup,
},
language: {
label: 'Language',
value: [],
options: [
{ label: 'Chinese', value: '1' },
{ label: 'Korean', value: '2' },
{ label: 'Japanese', value: '3' },
{ label: 'English', value: '4' },
],
type: FilterTypes.CheckboxGroup,
},
rating_operator: {
label: 'Rating (Min/Max)',
value: 'min',
options: [
{ label: 'Min', value: 'min' },
{ label: 'Max', value: 'max' },
],
type: FilterTypes.Picker,
},
rating: {
label: 'Rating',
value: '0',
options: [
{ label: 'All', value: '0' },
{ label: '1', value: '1' },
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
{ label: '5', value: '5' },
],
type: FilterTypes.Picker,
},
chapters: {
label: 'Chapters',
value: '0',
options: [
{ label: 'All', value: '0' },
{ label: '<50', value: '1,49' },
{ label: '50-100', value: '50,100' },
{ label: '100-200', value: '100,200' },
{ label: '200-500', value: '200,500' },
{ label: '500-1000', value: '500,1000' },
{ label: '>1000', value: '1001,1000000' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new NovelFire();
// Custom error for when Novel Fire is rate limiting requests
class NovelFireThrottlingError extends Error {
constructor(message = 'Novel Fire is rate limiting requests') {
super(message);
this.name = 'NovelFireError';
}
}
-123
View File
@@ -1,123 +0,0 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { defaultCover } from '@libs/defaultCover';
class NovelHall implements Plugin.PluginBase {
id = 'novelhall';
name = 'Novel Hall';
version = '1.0.2';
icon = 'src/en/novelhall/icon.png';
site = 'https://novelhall.com/';
async popularNovels(page: number): Promise<Plugin.NovelItem[]> {
const url = `${this.site}all2022-${page}.html`;
const body = await fetchApi(url).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('li.btm').each((idx, ele) => {
const novelName = loadedCheerio(ele).text().trim();
const novelUrl = loadedCheerio(ele).find('a').attr('href');
if (!novelUrl) return;
const novel = {
name: novelName,
cover: defaultCover,
path: novelUrl,
};
novels.push(novel);
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const body = await fetchApi(this.site + novelPath).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('.book-info > h1').text() || 'Untitled',
cover: loadedCheerio('meta[property="og:image"]').attr('content'),
summary: loadedCheerio('.intro').text().trim(),
chapters: [],
};
loadedCheerio('.total').find('p').remove();
novel.author = loadedCheerio('.total span:contains("Author")')
.text()
.replace('Author:', '')
.trim();
novel.status = loadedCheerio('.total span:contains("Status")')
.text()
.replace('Status:', '')
.replace('Active', 'Ongoing')
.trim();
novel.genres = loadedCheerio('.total a')
.map((a, ex) => loadedCheerio(ex).text())
.toArray()
.join(',');
const chapter: Plugin.ChapterItem[] = [];
loadedCheerio('#morelist ul > li').each((idx, ele) => {
const chapterName = loadedCheerio(ele).find('a').text().trim();
const chapterUrl = loadedCheerio(ele).find('a').attr('href');
if (!chapterUrl) return;
chapter.push({
name: chapterName,
path: chapterUrl,
});
});
novel.chapters = chapter;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
const loadedCheerio = parseHTML(body);
const chapterText = loadedCheerio('.content').html() || '';
return chapterText;
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const url = `${this.site}index.php?s=so&module=book&keyword=${encodeURIComponent(searchTerm)}`;
const body = await fetchApi(url).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('table tr').each((idx, ele) => {
const novelName = loadedCheerio(ele)
.find('td:nth-child(2)')
.text()
.replace(/\t+/g, '')
.replace(/\n/g, ' ');
const novelUrl = loadedCheerio(ele)
.find('td:nth-child(2) a')
.attr('href');
if (!novelUrl) return;
const novel = {
name: novelName,
cover: defaultCover,
path: novelUrl,
};
novels.push(novel);
});
return novels;
}
}
export default new NovelHall();
-219
View File
@@ -1,219 +0,0 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { defaultCover } from '@libs/defaultCover';
import dayjs from 'dayjs';
import { storage } from '@libs/storage';
import { NovelStatus } from '@libs/novelStatus';
class Novelight implements Plugin.PagePlugin {
id = 'novelight';
name = 'Novelight';
version = '1.1.1';
icon = 'src/en/novelight/icon.png';
site = 'https://novelight.net/';
hideLocked = storage.get('hideLocked');
pluginSettings = {
hideLocked: {
value: '',
label: 'Hide locked chapters',
type: 'Switch',
},
};
async popularNovels(page: number): Promise<Plugin.NovelItem[]> {
const url = `${this.site}catalog/?ordering=popularity&page=${page}`;
const body = await fetchApi(url).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('a.item').each((idx, ele) => {
const novelName = loadedCheerio(ele).find('div.title').text().trim();
const novelUrl = ele.attribs.href;
const bareNovelCover = loadedCheerio(ele).find('img').attr('src');
const novelCover = bareNovelCover
? this.site + bareNovelCover
: defaultCover;
if (!novelUrl) return;
const novel = {
name: novelName,
cover: novelCover ?? defaultCover,
path: novelUrl.replace('/', ''),
};
novels.push(novel);
});
return novels;
}
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const body = await fetchApi(this.site + novelPath).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelPath,
name: loadedCheerio('h1').text() || 'Untitled',
cover: this.site + loadedCheerio('.poster > img').attr('src'),
summary: loadedCheerio('section.text-info.section > p').text(),
totalPages: loadedCheerio('#select-pagination-chapter > option').length,
chapters: [],
};
const info = loadedCheerio('div.mini-info > .item').toArray();
let status = '';
let translation = '';
for (const child of info) {
const type = loadedCheerio(child).find('.sub-header').text().trim();
if (type === 'Status') {
status = loadedCheerio(child).find('div.info').text().trim();
}
if (type === 'Translation') {
translation = loadedCheerio(child).find('div.info').text().trim();
}
if (type === 'Author') {
novel.author = loadedCheerio(child).find('div.info').text().trim();
}
if (type === 'Genres') {
novel.genres = loadedCheerio(child)
.find('div.info > a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(', ');
}
}
if (status === 'cancelled') novel.status = NovelStatus.Cancelled;
else if (status === 'releasing' || translation === 'ongoing')
novel.status = NovelStatus.Ongoing;
else if (status === 'completed' && translation === 'completed')
novel.status = NovelStatus.Completed;
return novel;
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const rawBody = await fetchApi(this.site + novelPath).then(r => r.text());
const csrftoken = rawBody?.match(/window\.CSRF_TOKEN = "([^"]+)"/)?.[1];
const bookId = rawBody?.match(/const OBJECT_BY_COMMENT = ([0-9]+)/)?.[1];
const totalPages = parseInt(
rawBody
?.match(/<option value="([0-9]+)"/g)
?.at(-1)
?.match(/([0-9]+)/)?.[1] ?? '1',
);
const chaptersRaw = await fetchApi(
`${this.site}/book/ajax/chapter-pagination?csrfmiddlewaretoken=${csrftoken}&book_id=${bookId}&page=${totalPages - parseInt(page) + 1}`,
{
headers: {
'Host': this.site.replace('https://', '').replace('/', ''),
'Referer': this.site + novelPath,
'X-Requested-With': 'XMLHttpRequest',
},
},
)
.then(r => r.json())
.then(r => r.html);
const chapter: Plugin.ChapterItem[] = [];
parseHTML('<html>' + chaptersRaw + '</html>')('a').each((idx, ele) => {
const title = parseHTML(ele)('.title').text().trim();
const isLocked = !!parseHTML(ele)('.cost').text().trim();
if (this.hideLocked && isLocked) return;
let date;
try {
date = dayjs(
parseHTML(ele)('.date').text().trim(),
'DD.MM.YYYY',
).toISOString();
} catch (error) {}
const chapterName = isLocked ? '🔒 ' + title : title;
const chapterUrl = ele.attribs.href;
chapter.push({
name: chapterName,
path: chapterUrl,
page: page,
releaseTime: date,
});
});
const chapters = chapter.reverse();
return { chapters };
}
async parseChapter(chapterPath: string): Promise<string> {
const rawBody = await fetchApi(this.site + chapterPath).then(r => {
const res = r.text();
return res;
});
const csrftoken = rawBody?.match(/window\.CSRF_TOKEN = "([^"]+)"/)?.[1];
const chapterId = rawBody?.match(/const CHAPTER_ID = "([0-9]+)/)?.[1];
let className;
const body = await fetchApi(
this.site + 'book/ajax/read-chapter/' + chapterId,
{
method: 'GET',
headers: {
Cookie: 'csrftoken=' + csrftoken,
Referer: this.site + chapterPath,
'X-Requested-With': 'XMLHttpRequest',
},
},
).then(async r => {
const res = await r.json();
className = res.class;
return res.content;
});
const loadedCheerio = parseHTML(body);
const chapterText = loadedCheerio('.' + className).html() || '';
return chapterText.replace(
/class="advertisment"/g,
'style="display:none;"',
);
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const url = `${this.site}catalog/?search=${encodeURIComponent(searchTerm)}`;
const body = await fetchApi(url).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('a.item').each((idx, ele) => {
const novelName = loadedCheerio(ele).find('div.title').text().trim();
const novelUrl = ele.attribs.href;
const bareNovelCover = loadedCheerio(ele).find('img').attr('src');
const novelCover = bareNovelCover
? this.site + bareNovelCover
: defaultCover;
if (!novelUrl) return;
const novel = {
name: novelName,
cover: novelCover ?? defaultCover,
path: novelUrl.replace('/', ''),
};
novels.push(novel);
});
return novels;
}
}
export default new Novelight();
File diff suppressed because it is too large Load Diff
-447
View File
@@ -1,447 +0,0 @@
import { Parser } from 'htmlparser2';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
class PawRead implements Plugin.PluginBase {
id = 'pawread';
name = 'PawRead';
version = '2.1.0';
icon = 'src/en/pawread/icon.png';
site = 'https://m.pawread.com/';
parseNovels(html: string) {
const novels: Plugin.NovelItem[] = [];
let tempNovel: Partial<Plugin.NovelItem> = {};
let state: ParsingState = ParsingState.Idle;
const parser = new Parser({
onopentag(name, attribs) {
if (
attribs.class &&
(attribs.class.includes('list-comic') ||
attribs.class.includes('itemBox'))
) {
state = ParsingState.Novel;
}
if (state !== ParsingState.Novel) return;
switch (name) {
case 'a':
if (attribs.class === 'txtA' || attribs.class === 'title') {
tempNovel.path = attribs.href.split('/').slice(1, 3).join('/');
state = ParsingState.NovelName;
}
break;
case 'img':
tempNovel.cover = attribs.src;
break;
}
},
ontext(text) {
if (state === ParsingState.NovelName) {
tempNovel.name = (tempNovel.name || '') + text;
}
},
onclosetag(name) {
if (name === 'a') {
if (tempNovel.name && tempNovel.cover) {
novels.push(tempNovel as Plugin.NovelItem);
tempNovel = {};
state = ParsingState.Idle;
}
}
},
});
parser.write(html);
parser.end();
return novels;
}
async popularNovels(
page: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let link = `${this.site}list/`;
const filterValues = [
filters.genre.value,
filters.status.value,
filters.lang.value,
].filter(value => value !== '');
if (filterValues.length > 0) {
link += filterValues.join('-') + '/';
}
link += (filters.order.value ? '-' : '') + filters.sort.value;
link += `/?page=${page}`;
const body = await fetchApi(link).then(r => r.text());
return this.parseNovels(body);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const slash = novelPath.endsWith('/') ? '' : '/';
const result = await fetchApi(this.site + novelPath + slash);
const body = await result.text();
const novel: Partial<Plugin.SourceNovel> = {
path: novelPath,
};
let depth = 0;
let state = ParsingState.Idle;
let tempChapter: Partial<Plugin.ChapterItem> = {};
const chapter: Plugin.ChapterItem[] = [];
const summaryParts: string[] = [];
const genreArray: string[] = [];
const parser = new Parser({
onopentag(name, attribs) {
switch (name) {
case 'div':
if (attribs.id === 'Cover') {
state = ParsingState.Cover;
}
if (attribs.class?.includes('item-box')) {
state = ParsingState.Chapter;
const path = attribs.onclick.match(/\d+/)![0];
tempChapter.path = `${novelPath}${slash}${path}.html`;
return;
}
if (state === ParsingState.Chapter) depth++;
break;
case 'img':
if (state === ParsingState.Cover) {
novel.name = attribs.title;
novel.cover = attribs.src;
}
break;
case 'p':
if (attribs.class === 'txtItme') {
if (!novel.status) {
state = ParsingState.Status;
} else if (!novel.author) {
state = ParsingState.Author;
}
} else if (attribs.id === 'full-des') {
state = ParsingState.Summary;
}
break;
case 'br':
summaryParts.push('\n');
break;
case 'a':
if (attribs.class?.includes('btn-default')) {
state = ParsingState.Genres;
}
break;
case 'span':
if (state === ParsingState.Chapter) depth++;
if (depth === 2) {
state = ParsingState.ChapterName;
} else if (depth === 1) {
state = ParsingState.ChapterTime;
}
break;
}
},
ontext(text) {
switch (state) {
case ParsingState.Status:
novel.status = (novel.status || '') + text.trim();
break;
case ParsingState.Author:
novel.author = (novel.author || '') + text.trim();
break;
case ParsingState.Genres:
genreArray.push(text.trim());
break;
case ParsingState.Summary:
summaryParts.push(text);
break;
case ParsingState.ChapterName:
tempChapter.name = (tempChapter.name || '') + text.trim();
break;
case ParsingState.ChapterTime:
if (text?.includes('Advanced')) return;
const releaseDate = text.split('.').map(x => Number(x));
if (releaseDate.length === 3) {
tempChapter.releaseTime = new Date(
releaseDate[0],
releaseDate[1] - 1,
releaseDate[2],
).toISOString();
}
break;
}
},
onclosetag(name) {
switch (name) {
case 'div':
if (state === ParsingState.Cover) {
state = ParsingState.Idle;
}
if (state === ParsingState.Chapter) {
depth--;
if (depth < 0) {
if (
tempChapter.path &&
tempChapter.name &&
tempChapter.releaseTime
) {
chapter.push(tempChapter as Plugin.ChapterItem);
}
tempChapter = {};
depth = 0;
state = ParsingState.Idle;
}
}
break;
case 'p':
if (
state === ParsingState.Status ||
state === ParsingState.Author ||
state === ParsingState.Summary
) {
state = ParsingState.Idle;
}
break;
case 'a':
if (state === ParsingState.Genres) {
state = ParsingState.Idle;
}
break;
case 'span':
if (
state === ParsingState.ChapterName ||
state === ParsingState.ChapterTime
) {
state = ParsingState.Chapter;
depth--;
}
break;
}
},
onend() {
novel.genres = genreArray.join(', ');
novel.summary = summaryParts.join('');
novel.chapters = chapter;
},
});
parser.write(body);
parser.end();
return novel as Plugin.SourceNovel;
}
async parseChapter(chapterPath: string): Promise<string> {
const html = await fetchApi(this.site + chapterPath).then(r => r.text());
let depth = 0;
let state: ParsingState = ParsingState.Idle;
const chapterHtml: string[] = [];
type EscapeChar = '&' | '<' | '>' | '"' | "'" | ' ';
const escapeRegex = /[&<>"' ]/g;
const escapeMap: Record<EscapeChar, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
' ': '&nbsp;',
};
const escapeHtml = (text: string): string =>
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]);
const parser = new Parser({
onopentag(name, attribs) {
switch (state) {
case ParsingState.Idle:
if (name === 'div' && attribs.class === 'main') {
state = ParsingState.Chapter;
depth++;
}
break;
case ParsingState.Chapter:
if (name === 'div') depth++;
break;
default:
return;
}
if (state === ParsingState.Chapter) {
const attr = Object.keys(attribs).map(key => {
const value = attribs[key].replace(/"/g, '&quot;');
return ` ${key}="${value}"`;
});
chapterHtml.push(`<${name}${attr.join('')}>`);
}
},
ontext(data) {
if (state === ParsingState.Chapter) {
const text = escapeHtml(data);
const icontains = ['pawread', 'tinyurl', 'bit.ly'];
if (icontains.some(pattern => text?.includes(pattern))) {
state = ParsingState.Hidden;
chapterHtml.pop();
} else {
chapterHtml.push(text);
}
}
},
onclosetag(name) {
switch (state) {
case ParsingState.Chapter:
if (!parser['isVoidElement'](name)) {
chapterHtml.push(`</${name}>`);
}
if (name === 'div') depth--;
if (depth === 0) {
state = ParsingState.Stopped;
}
break;
case ParsingState.Hidden:
state = ParsingState.Chapter;
break;
}
},
});
parser.write(html);
parser.end();
return chapterHtml.join('');
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const params = new URLSearchParams({
keywords: searchTerm,
page: page.toString(),
});
const result = await fetchApi(`${this.site}search/?${params.toString()}`);
const body = await result.text();
return this.parseNovels(body);
}
filters = {
status: {
value: '',
label: 'Status',
options: [
{ label: 'All', value: '' },
{ label: 'Completed', value: 'wanjie' },
{ label: 'Ongoing', value: 'lianzai' },
{ label: 'Hiatus', value: 'hiatus' },
],
type: FilterTypes.Picker,
},
lang: {
value: '',
label: 'Languages',
options: [
{ label: 'All', value: '' },
{ label: 'Chinese', value: 'chinese' },
{ label: 'Korean', value: 'korean' },
{ label: 'Japanese', value: 'japanese' },
],
type: FilterTypes.Picker,
},
genre: {
value: '',
label: 'Genres',
options: [
{ label: 'All', value: '' },
{ label: 'Fantasy', value: 'Fantasy' },
{ label: 'Action', value: 'Action' },
{ label: 'Xuanhuan', value: 'Xuanhuan' },
{ label: 'Romance', value: 'Romance' },
{ label: 'Comedy', value: 'Comedy' },
{ label: 'Mystery', value: 'Mystery' },
{ label: 'Mature', value: 'Mature' },
{ label: 'Harem', value: 'Harem' },
{ label: 'Wuxia', value: 'Wuxia' },
{ label: 'Xianxia', value: 'Xianxia' },
{ label: 'Tragedy', value: 'Tragedy' },
{ label: 'Sci-fi', value: 'Scifi' },
{ label: 'Historical', value: 'Historical' },
{ label: 'Ecchi', value: 'Ecchi' },
{ label: 'Adventure', value: 'Adventure' },
{ label: 'Adult', value: 'Adult' },
{ label: 'Supernatural', value: 'Supernatural' },
{ label: 'Psychological', value: 'Psychological' },
{ label: 'Drama', value: 'Drama' },
{ label: 'Horror', value: 'Horror' },
{ label: 'Josei', value: 'Josei' },
{ label: 'Mecha', value: 'Mecha' },
{ label: 'Seinen', value: 'Seinen' },
{ label: 'Shoujo', value: 'Shoujo' },
{ label: 'Shounen', value: 'Shounen' },
{ label: 'Smut', value: 'Smut' },
{ label: 'Yaoi', value: 'Yaoi' },
{ label: 'Yuri', value: 'Yuri' },
{ label: 'Martial Arts', value: 'MartialArts' },
{ label: 'School Life', value: 'SchoolLife' },
{ label: 'Shoujo Ai', value: 'ShoujoAi' },
{ label: 'Shounen Ai', value: 'ShounenAi' },
{ label: 'Slice of Life', value: 'SliceofLife' },
{ label: 'Gender Bender', value: 'GenderBender' },
{ label: 'Sports', value: 'Sports' },
{ label: 'Urban', value: 'Urban' },
{ label: 'Adventurer', value: 'Adventurer' },
],
type: FilterTypes.Picker,
},
sort: {
value: 'click',
label: 'Sort By',
options: [
{ label: 'Time Updated', value: 'update' },
{ label: 'Time Posted', value: 'post' },
{ label: 'Clicks', value: 'click' },
],
type: FilterTypes.Picker,
},
order: {
value: false,
label: 'Order ↑ ↓',
type: FilterTypes.Switch,
},
} satisfies Filters;
}
export default new PawRead();
enum ParsingState {
Idle,
Cover,
Genres,
Author,
Status,
Hidden,
Summary,
Stopped,
Chapter,
ChapterName,
ChapterTime,
NovelName,
Novel,
}

Some files were not shown because too many files have changed in this diff Show More