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:
committed by
GitHub
parent
252beff0bf
commit
20c2b75b01
-46
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+90
-71
@@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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'))
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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(/ /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();
|
||||
@@ -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(/ /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();
|
||||
@@ -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 === ' ') {
|
||||
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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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(/ /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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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('%');
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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(/ /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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
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, '"');
|
||||
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();
|
||||
@@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
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,
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
@@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
' ': ' ',
|
||||
};
|
||||
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, '"');
|
||||
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
Reference in New Issue
Block a user