Feat: Vite + React (#991)
* remove test_web * integrate reactjs-vite * update lint-staged * simple cors proxy server which handles cookies and redirections * overrider global fetch with proxy call * simple examples for set cookies and fetch request * app bar * add redux * loadPlugins. error: cant load urlencode module * add ts-node for transforming * plugin search & select * plugin slice * accordion components * path item & popular novel * optional for filters * search novel * parse novel * parseChapter * update cookies * default filters * fix: import path * bundler module resolution * new json_plugins * fix: generate multisrc (missing filters) * fix: host scripts * fix: plugin code path * remove typechecking action
This commit is contained in:
+46
@@ -0,0 +1,46 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import './App.css';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
createTheme,
|
||||
CssBaseline,
|
||||
StyledEngineProvider,
|
||||
ThemeProvider,
|
||||
} from '@mui/material';
|
||||
import Appbar from '@components/Appbar';
|
||||
import { Provider } from 'react-redux';
|
||||
import store from '@redux/store';
|
||||
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';
|
||||
|
||||
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 (
|
||||
<Provider store={store}>
|
||||
<StyledEngineProvider injectFirst>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Appbar />
|
||||
<Container
|
||||
sx={{
|
||||
mt: 6,
|
||||
width: { sm: 500, md: 1024, lg: '90vw' },
|
||||
}}
|
||||
>
|
||||
<HeadersSection />
|
||||
<PopularNovels />
|
||||
<SearchNovels />
|
||||
<ParseNovel />
|
||||
<ParseChapter />
|
||||
</Container>
|
||||
<AppProvider />
|
||||
</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,147 @@
|
||||
import React, { forwardRef, 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,
|
||||
Button,
|
||||
FormControl,
|
||||
InputAdornment,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import { Search, Translate } from '@mui/icons-material';
|
||||
import { searchPlugins } from '@provider/plugins';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { AppState } from '@redux/store';
|
||||
import { selectPlugin } from '@redux/pluginSlice';
|
||||
|
||||
const resovleIcon = (iconPath: string) => {
|
||||
return '/icons/' + iconPath;
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const [searchedPlugins, setSearchPlugins] = useState<Plugin.PluginBase[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [listVisible, setListVisible] = useState(false);
|
||||
const selectedPlugin = useSelector(
|
||||
(state: AppState) => state.plugin.selected,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
const plugins = searchPlugins(keyword);
|
||||
setSearchPlugins(plugins);
|
||||
}, [keyword]);
|
||||
|
||||
function renderPlugin(props: ListChildComponentProps<Plugin.PluginBase[]>) {
|
||||
const { index, style, data } = props;
|
||||
const plugin = data[index];
|
||||
|
||||
return (
|
||||
<ListItem style={style} key={index} component="div" disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar position="fixed" color="inherit" elevation={0}>
|
||||
<Toolbar>
|
||||
<FormControl
|
||||
sx={{
|
||||
display: 'block',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
variant="outlined"
|
||||
placeholder="Seach plugin"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ width: 434 }}
|
||||
onFocus={() => {
|
||||
setListVisible(true);
|
||||
if (searchedPlugins.length === 0)
|
||||
setSearchPlugins(searchPlugins(keyword));
|
||||
}}
|
||||
/>
|
||||
{listVisible ? (
|
||||
<FixedSizeList
|
||||
height={400}
|
||||
width={434}
|
||||
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}
|
||||
</FixedSizeList>
|
||||
) : null}
|
||||
</FormControl>
|
||||
<Backdrop
|
||||
open={listVisible}
|
||||
onClick={() => setListVisible(false)}
|
||||
sx={{ zIndex: 9 }}
|
||||
invisible={true}
|
||||
></Backdrop>
|
||||
<InputAdornment position="end">
|
||||
<Button variant="contained" endIcon={<Translate />}>
|
||||
Language
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
{selectedPlugin ? (
|
||||
<Box sx={{ pl: 2, display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar
|
||||
sx={{ mr: 1 }}
|
||||
src={resovleIcon(selectedPlugin.icon)}
|
||||
variant="square"
|
||||
/>
|
||||
<a href={selectedPlugin.site} target="_blank">
|
||||
{selectedPlugin.name} - v{selectedPlugin.version}
|
||||
</a>
|
||||
</Box>
|
||||
) : null}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Box, Button, Tooltip, withStyles } from '@mui/material';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import CopiableItem from './CopiableItem';
|
||||
|
||||
interface 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
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;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import { Alert, Box, InputAdornment, TextField } from '@mui/material';
|
||||
import useDebounce from '@hooks/useDebounce';
|
||||
|
||||
export default function HeadersSection() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cookies, setCookies] = useState('');
|
||||
const debounceCookies = useDebounce(cookies, 500);
|
||||
const [alertVisible, setAlertVisble] = useState(false);
|
||||
useEffect(() => {
|
||||
if (alertVisible) {
|
||||
setTimeout(() => setAlertVisble(false), 1000);
|
||||
}
|
||||
}, [alertVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch('cookies', {
|
||||
method: 'POST',
|
||||
body: debounceCookies,
|
||||
})
|
||||
.then(() => setAlertVisble(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [debounceCookies]);
|
||||
return (
|
||||
<AccordionContainer title="Headers" loading={loading}>
|
||||
{alertVisible ? (
|
||||
<Alert
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
zIndex: 2,
|
||||
top: 0,
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
Cookies updated
|
||||
</Alert>
|
||||
) : null}
|
||||
<TextField
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">Headers</InputAdornment>
|
||||
),
|
||||
}}
|
||||
disabled
|
||||
value={navigator.userAgent}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
<Box sx={{ height: 10 }} />
|
||||
<TextField
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">Cookies</InputAdornment>
|
||||
),
|
||||
}}
|
||||
value={cookies}
|
||||
onChange={e => {
|
||||
setCookies(e.target.value.trim());
|
||||
}}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
</AccordionContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Box, Button, Stack, TextField } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import usePlugin from '@hooks/usePlugin';
|
||||
import Textarea from '@components/Textarea';
|
||||
export default function ParseChapter() {
|
||||
const plugin = usePlugin();
|
||||
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
|
||||
defaultValue={chapterText}
|
||||
/>
|
||||
) : null}
|
||||
</AccordionContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import PathItem from '@components/PathItem';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} 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, width: '100%', textAlign: 'left' }}>
|
||||
<TableVirtuoso
|
||||
data={chapters}
|
||||
components={VirtuosoTableComponents}
|
||||
fixedHeaderContent={fixedHeaderContent}
|
||||
itemContent={rowContent}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
ListItem,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import CopiableItem from '@components/CopiableItem';
|
||||
import PathItem from '@components/PathItem';
|
||||
import Textarea from '@components/Textarea';
|
||||
|
||||
const FlexRow = ({ children }: { children: ReactNode }) => (
|
||||
<Stack direction="row" gap={1} sx={{ alignItems: 'center' }}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default function SourceNovelCard({
|
||||
sourceNovel,
|
||||
}: {
|
||||
sourceNovel: Plugin.SourceNovel;
|
||||
}) {
|
||||
return (
|
||||
<Card sx={{ display: 'flex', p: 1, width: '100%', textAlign: 'left' }}>
|
||||
<CopiableItem content={sourceNovel.cover}>
|
||||
<Tooltip title={sourceNovel.cover}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
sx={{ width: 200, height: '100%' }}
|
||||
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>
|
||||
<Textarea
|
||||
sx={{ width: '100%' }}
|
||||
maxRows={5}
|
||||
defaultValue={sourceNovel.summary}
|
||||
disabled
|
||||
/>
|
||||
</CardContent>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Pagination,
|
||||
Stack,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import usePlugin from '@hooks/usePlugin';
|
||||
import SourceNovelCard from './SourceNovelCard';
|
||||
import ChapterList from './ChaptertList';
|
||||
|
||||
export default function ParseNovel() {
|
||||
const plugin = usePlugin();
|
||||
const [sourceNovel, setSouceNovel] = useState<
|
||||
Plugin.SourceNovel | 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}>
|
||||
<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 }} />
|
||||
{/* @ts-ignore */}
|
||||
{sourceNovel?.totalPages ? (
|
||||
<Pagination
|
||||
// @ts-ignore
|
||||
count={sourceNovel.totalPages}
|
||||
onChange={(e, page) => fetchPage(page)}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : null}
|
||||
</AccordionContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Box, Button, Stack } 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 { Filters } from '@libs/filterInputs';
|
||||
import usePlugin from '@hooks/usePlugin';
|
||||
|
||||
export default function PopularNovels() {
|
||||
const plugin = usePlugin();
|
||||
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
|
||||
const [filterValues, setFilterValues] = useState<Filters | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fetchNovels = () => {
|
||||
if (plugin) {
|
||||
setLoading(true);
|
||||
plugin
|
||||
.popularNovels(1, { filters: filterValues })
|
||||
.then(res => setNovels(res))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (plugin?.filters) {
|
||||
let filters = {};
|
||||
for (const fKey in plugin.filters) {
|
||||
filters[fKey] = {
|
||||
type: plugin.filters[fKey].type,
|
||||
value: plugin.filters[fKey].value,
|
||||
};
|
||||
}
|
||||
setFilterValues(filters);
|
||||
}
|
||||
}, [plugin]);
|
||||
return (
|
||||
<AccordionContainer title="Popular Novels" loading={loading}>
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<Button
|
||||
disabled={plugin === undefined}
|
||||
onClick={fetchNovels}
|
||||
variant="contained"
|
||||
>
|
||||
Fetch
|
||||
</Button>
|
||||
<Button disabled={plugin === undefined} variant="outlined">
|
||||
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={'novelItem_' + index} item={novel} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</AccordionContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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 { useSelector } from 'react-redux';
|
||||
import { AppState } from '@redux/store';
|
||||
import { getPlugin } from '@provider/plugins';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
export default function SearchNovels() {
|
||||
const selectedPluginItem = useSelector(
|
||||
(state: AppState) => state.plugin.selected,
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [plugin, setPlugin] = useState<Plugin.PluginBase | undefined>();
|
||||
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (selectedPluginItem) {
|
||||
setPlugin(getPlugin(selectedPluginItem.id));
|
||||
}
|
||||
}, [selectedPluginItem]);
|
||||
const fetchNovels = () => {
|
||||
if (plugin) {
|
||||
setLoading(true);
|
||||
plugin
|
||||
.searchNovels(searchTerm, page)
|
||||
.then(res => setNovels(res))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<AccordionContainer title="Search Novels" loading={loading}>
|
||||
<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}
|
||||
variant="contained"
|
||||
>
|
||||
Fetch
|
||||
</Button>
|
||||
<Button disabled={plugin === undefined} variant="outlined">
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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 (
|
||||
<Card 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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function useDebounce(text: string, delay: number) {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = setTimeout(() => {
|
||||
setValue(text);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
}, [text, delay]);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AppState } from '@redux/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { getPlugin } from '@provider/plugins';
|
||||
|
||||
export default function usePlugin() {
|
||||
const selectedPluginItem = useSelector(
|
||||
(state: AppState) => state.plugin.selected,
|
||||
);
|
||||
const [plugin, setPlugin] = useState<Plugin.PluginBase | undefined>();
|
||||
useEffect(() => {
|
||||
if (selectedPluginItem) {
|
||||
setPlugin(getPlugin(selectedPluginItem.id));
|
||||
}
|
||||
}, [selectedPluginItem]);
|
||||
return plugin;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
: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;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.jpg';
|
||||
declare module '*.png';
|
||||
declare module '*.svg';
|
||||
declare module '*.jpeg';
|
||||
@@ -0,0 +1,2 @@
|
||||
export const defaultCover =
|
||||
'https://github.com/LNReader/lnreader-plugins/blob/main/icons/src/coverNotAvailable.jpg?raw=true';
|
||||
@@ -0,0 +1,154 @@
|
||||
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) => {
|
||||
let 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 '';
|
||||
}
|
||||
};
|
||||
|
||||
interface 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,78 @@
|
||||
export interface FilterOption {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
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[];
|
||||
value: { include?: string[]; exclude?: string[] };
|
||||
};
|
||||
|
||||
export type Filters = Record<
|
||||
string,
|
||||
{ label: string } & (
|
||||
| PickerFilter
|
||||
| CheckboxFilter
|
||||
| TextFilter
|
||||
| SwitchFilter
|
||||
| ExcludableCheckboxFilter
|
||||
)
|
||||
>;
|
||||
|
||||
export type FilterToValues<
|
||||
T extends Record<string, { type: FilterTypes }> | undefined,
|
||||
> = T extends undefined
|
||||
? undefined
|
||||
: {
|
||||
[K in keyof T]: Omit<
|
||||
{ type: NonNullable<T>[K]['type'] } & Filters[string],
|
||||
'label' | 'options'
|
||||
>;
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export const NovelStatus = {
|
||||
Unknown: 'Unknown',
|
||||
Ongoing: 'Ongoing',
|
||||
Completed: 'Completed',
|
||||
Licensed: 'Licensed',
|
||||
PublishingFinished: 'Publishing Finished',
|
||||
Cancelled: 'Cancelled',
|
||||
OnHiatus: 'On Hiatus',
|
||||
} as const;
|
||||
@@ -0,0 +1,108 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
class Storage {
|
||||
private db: Record<string, { created: Date; value: any; expires?: number }>;
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the Storage class.
|
||||
*/
|
||||
constructor() {
|
||||
this.db = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a key-value pair in storage.
|
||||
*
|
||||
* @param {string} key - The key to set.
|
||||
* @param {any} value - The value to set.
|
||||
* @param {Date | number} [expires] - Optional expiry date or time in milliseconds.
|
||||
*/
|
||||
set(key: string, value: any, expires?: Date | number): void {
|
||||
this.db[key] = {
|
||||
created: new Date(),
|
||||
value,
|
||||
expires: expires instanceof Date ? expires.getTime() : expires,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the value for a given key from storage.
|
||||
*
|
||||
* @param {string} key - The key to retrieve the value for.
|
||||
* @param {boolean} [raw] - Optional flag to return the raw stored item.
|
||||
* @returns {any} The stored value or undefined if key is not found.
|
||||
*/
|
||||
get(key: string, raw?: boolean): any {
|
||||
const item = this.db[key];
|
||||
if (item?.expires && Date.now() > item.expires) {
|
||||
this.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return raw ? item : item?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all keys set by the `set` method.
|
||||
*
|
||||
* @returns {string[]} An array of keys.
|
||||
*/
|
||||
getAllKeys(): string[] {
|
||||
return Object.keys(this.db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a key from the storage.
|
||||
*
|
||||
* @param pluginID - The ID of the plugin.
|
||||
* @param key - The key to delete.
|
||||
*/
|
||||
delete(key: string): void {
|
||||
delete this.db[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all stored items from storage.
|
||||
*/
|
||||
clearAll(pluginID: string): void {
|
||||
this.db = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance of the Storage class
|
||||
export const storage = new Storage();
|
||||
|
||||
/*
|
||||
These parameters cannot be implemented in `test-web`.
|
||||
They are generated in the browser when js-scripts are executed
|
||||
Read more
|
||||
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents the structure of a storage object with string keys and values.
|
||||
*/
|
||||
interface StorageObject {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a simplified version of the browser's localStorage.
|
||||
*/
|
||||
class LocalStorage {
|
||||
private db: StorageObject;
|
||||
|
||||
constructor() {
|
||||
this.db = {};
|
||||
}
|
||||
|
||||
get(): StorageObject | undefined {
|
||||
return this.db;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instances of LocalStorage and sessionStorage
|
||||
export const localStorage = new LocalStorage();
|
||||
export const sessionStorage = new LocalStorage();
|
||||
@@ -0,0 +1,34 @@
|
||||
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 'qs';
|
||||
import 'protobufjs';
|
||||
|
||||
const { fetch: originalFetch } = window;
|
||||
window.fetch = async (...args) => {
|
||||
let [resource, config] = args;
|
||||
if (resource.toString().includes('localhost')) {
|
||||
const response = await originalFetch(resource, config);
|
||||
return response;
|
||||
}
|
||||
const response = await originalFetch('http://localhost:3001/' + resource, {
|
||||
...config,
|
||||
credentials: 'include',
|
||||
mode: 'cors',
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,221 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchText, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class Shu69 implements Plugin.PluginBase {
|
||||
id = '69shu';
|
||||
name = '69书吧';
|
||||
icon = 'src/cn/69shu/icon.png';
|
||||
site = 'https://www.69shu.xyz';
|
||||
version = '0.2.0';
|
||||
|
||||
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);
|
||||
if (body === '') throw Error('无法获取小说列表,请检查网络');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.book-coverlist').each((i, el) => {
|
||||
let 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,
|
||||
};
|
||||
|
||||
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('无法获取小说内容,请检查网络');
|
||||
|
||||
let 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
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
const allUrl = loadedCheerio('dd.all > a').attr('href');
|
||||
if (allUrl) {
|
||||
const chaptersUrl = this.site + allUrl;
|
||||
const chaptersBody = await fetchText(chaptersUrl);
|
||||
|
||||
const chaptersLoadedCheerio = parseHTML(chaptersBody);
|
||||
|
||||
chaptersLoadedCheerio('dd').each((i, el) => {
|
||||
const chapterUrl = chaptersLoadedCheerio(el).find('a').attr('href');
|
||||
const chapterName = chaptersLoadedCheerio(el).find('a').text().trim();
|
||||
if (chapterUrl) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
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) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchText(this.site + chapterPath);
|
||||
|
||||
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 body = await fetchText(searchUrl, {
|
||||
method: 'post',
|
||||
body: formData,
|
||||
});
|
||||
if (body === '') throw Error('无法获取搜索结果,请检查网络');
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.book-coverlist').each((i, el) => {
|
||||
let 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,
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,208 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchText, fetchFile } 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://www.69shu.pro';
|
||||
version = '0.1.1';
|
||||
|
||||
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('div.newbox > ul > li')
|
||||
: loadedCheerio('li');
|
||||
novelsList.each((i, e) => {
|
||||
const novelUrl = loadedCheerio(e)
|
||||
.find('li > div > h3 > a:nth-child(2)')
|
||||
.attr('href');
|
||||
|
||||
if (novelUrl) {
|
||||
const novelName = loadedCheerio(e)
|
||||
.find('li > div > h3 > a:nth-child(2)')
|
||||
.text();
|
||||
const novelCover = loadedCheerio(e)
|
||||
.find('li > 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('无法获取小说内容,请检查网络');
|
||||
|
||||
let 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
|
||||
let 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('无法获取搜索结果,请检查网络');
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.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();
|
||||
@@ -0,0 +1,547 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchText, fetchFile } 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.2';
|
||||
|
||||
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) => {
|
||||
let 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('无法获取小说内容,请检查网络');
|
||||
|
||||
let 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
|
||||
let 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
|
||||
pageCheerio('#acontentz .adsbygoogle').remove();
|
||||
pageCheerio('#acontentz script').remove();
|
||||
|
||||
// Load lazyloaded images
|
||||
pageCheerio('#acontentz 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('#acontentz').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;
|
||||
let 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) => {
|
||||
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');
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import qs from 'qs';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class LibReadPlugin implements Plugin.PluginBase {
|
||||
id = 'libread';
|
||||
name = 'Lib Read';
|
||||
icon = 'src/en/libread/icon.png';
|
||||
site = 'https://libread.org';
|
||||
version = '1.0.0';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
`Could not reach site (${r.status}: ${r.statusText}) try to open in webview.`,
|
||||
);
|
||||
return parseHTML(await r.text());
|
||||
}
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] {
|
||||
return loadedCheerio('.li-row')
|
||||
.map((index, element) => ({
|
||||
name: loadedCheerio(element).find('.tit').text() || '',
|
||||
cover: loadedCheerio(element).find('img').attr('src'),
|
||||
path: loadedCheerio(element).find('h3 > a').attr('href') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
if (showLatestNovels) url += '/sort/latest-novels/';
|
||||
else {
|
||||
if (
|
||||
filters &&
|
||||
filters.type_genre &&
|
||||
filters.type_genre.value !== 'all' &&
|
||||
filters.type_genre.value !== 'genre'
|
||||
)
|
||||
url += filters.type_genre.value;
|
||||
else {
|
||||
url += '/most-popular/';
|
||||
if (page != 1) return [];
|
||||
page = 0;
|
||||
}
|
||||
}
|
||||
url += page;
|
||||
|
||||
const $ = await this.getCheerio(url);
|
||||
return this.parseNovels($);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const loadedCheerio = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.tit').text(),
|
||||
cover: loadedCheerio('.pic > img').attr('src'),
|
||||
summary: loadedCheerio('.inner').text().trim(),
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('[title=Genre]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.author = loadedCheerio('[title=Author]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.status = loadedCheerio('[title=Status]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.genres = loadedCheerio('[title=Genre]')
|
||||
.next()
|
||||
.text()
|
||||
.trim()
|
||||
.replace(/[\t\n]/g, ',')
|
||||
.replace(/, /g, ',');
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = loadedCheerio('#idData > li > a')
|
||||
.map((chapterIndex, element) => ({
|
||||
name:
|
||||
loadedCheerio(element).attr('title') ||
|
||||
'Chapter ' + (chapterIndex + 1),
|
||||
path:
|
||||
loadedCheerio(element).attr('href') ||
|
||||
novelPath.replace(
|
||||
'.html',
|
||||
'/chapter-' + (chapterIndex + 1) + '.html',
|
||||
),
|
||||
releaseTime: null,
|
||||
chapterNumber: chapterIndex + 1,
|
||||
}))
|
||||
.get();
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const loadedCheerio = await this.getCheerio(this.site + chapterPath);
|
||||
|
||||
// remove freewebnovel signature
|
||||
if (loadedCheerio('style').text().includes('p:nth-last-child(1)'))
|
||||
loadedCheerio('div.txt').find('p:last-child').remove();
|
||||
|
||||
const chapterText = loadedCheerio('div.txt').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const r = await fetchApi(this.site + '/search/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: this.site,
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({ searchkey: searchTerm }),
|
||||
});
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
'Could not reach site (' + r.status + ') try to open in webview.',
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(await r.text());
|
||||
const alertText =
|
||||
loadedCheerio('script')
|
||||
.text()
|
||||
.match(/alert\((.*?)\)/)?.[1] || '';
|
||||
if (alertText) throw new Error(alertText);
|
||||
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
type_genre: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Novel Type/Genre',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'Tous', value: 'all' },
|
||||
{ label: '═══NOVEL TYPES═══', value: '/sort/latest-release/' },
|
||||
{
|
||||
label: 'Chinese Novel',
|
||||
value: '/sort/latest-release/chinese-novel/',
|
||||
},
|
||||
{ label: 'Korean Novel', value: '/sort/latest-release/korean-novel/' },
|
||||
{
|
||||
label: 'Japanese Novel',
|
||||
value: '/sort/latest-release/japanese-novel/',
|
||||
},
|
||||
{
|
||||
label: 'English Novel',
|
||||
value: '/sort/latest-release/english-novel/',
|
||||
},
|
||||
{ label: '═══GENRES═══', value: 'genre' },
|
||||
{ label: 'Action', value: '/genre/Action/' },
|
||||
{ label: 'Adult', value: '/genre/Adult/' },
|
||||
{ label: 'Adventure', value: '/genre/Adventure/' },
|
||||
{ label: 'Comedy', value: '/genre/Comedy/' },
|
||||
{ label: 'Drama', value: '/genre/Drama/' },
|
||||
{ label: 'Eastern', value: '/genre/Eastern/' },
|
||||
{ label: 'Ecchi', value: '/genre/Ecchi/' },
|
||||
{ label: 'Fantasy', value: '/genre/Fantasy/' },
|
||||
{ label: 'Game', value: '/genre/Game/' },
|
||||
{ label: 'Gender Bender', value: '/genre/Gender+Bender/' },
|
||||
{ label: 'Harem', value: '/genre/Harem/' },
|
||||
{ label: 'Historical', value: '/genre/Historical/' },
|
||||
{ label: 'Horror', value: '/genre/Horror/' },
|
||||
{ label: 'Josei', value: '/genre/Josei/' },
|
||||
{ label: 'Martial Arts', value: '/genre/Martial+Arts/' },
|
||||
{ label: 'Mature', value: '/genre/Mature/' },
|
||||
{ label: 'Mecha', value: '/genre/Mecha/' },
|
||||
{ label: 'Mystery', value: '/genre/Mystery/' },
|
||||
{ label: 'Psychological', value: '/genre/Psychological/' },
|
||||
{ label: 'Reincarnation', value: '/genre/Reincarnation/' },
|
||||
{ label: 'Romance', value: '/genre/Romance/' },
|
||||
{ label: 'School Life', value: '/genre/School+Life/' },
|
||||
{ label: 'Sci-fi', value: '/genre/Sci-fi/' },
|
||||
{ label: 'Seinen', value: '/genre/Seinen/' },
|
||||
{ label: 'Shoujo', value: '/genre/Shoujo/' },
|
||||
{ label: 'Shounen Ai', value: '/genre/Shounen+Ai/' },
|
||||
{ label: 'Shounen', value: '/genre/Shounen/' },
|
||||
{ label: 'Slice of Life', value: '/genre/Slice+of+Life/' },
|
||||
{ label: 'Smut', value: '/genre/Smut/' },
|
||||
{ label: 'Sports', value: '/genre/Sports/' },
|
||||
{ label: 'Supernatural', value: '/genre/Supernatural/' },
|
||||
{ label: 'Tragedy', value: '/genre/Tragedy/' },
|
||||
{ label: 'Wuxia', value: '/genre/Wuxia/' },
|
||||
{ label: 'Xianxia', value: '/genre/Xianxia/' },
|
||||
{ label: 'Xuanhuan', value: '/genre/Xuanhuan/' },
|
||||
{ label: 'Yaoi', value: '/genre/Yaoi/' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new LibReadPlugin();
|
||||
@@ -0,0 +1,205 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import qs from 'qs';
|
||||
|
||||
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(),
|
||||
);
|
||||
let $ = 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) => {
|
||||
let detailName = $(el).find('h6').text();
|
||||
let 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,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const result = await fetchApi('https://novelsonline.net/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: qs.stringify({ q: searchTerm }),
|
||||
}).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();
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
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.1';
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
let url = 'https://storyseedling.com/ajax';
|
||||
|
||||
const postUrl = `https://storyseedling.com/browse/`;
|
||||
const body = await fetchApi(postUrl).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
var postValue = loadedCheerio('div[ax-load][x-data]')
|
||||
.attr('x-data')
|
||||
?.replace("browse('", '')
|
||||
.replace("')", '') as string;
|
||||
|
||||
var data = new FormData();
|
||||
data.append('search', '');
|
||||
data.append('orderBy', 'recent');
|
||||
data.append('curpage', pageNo.toString());
|
||||
data.append('post', postValue);
|
||||
data.append('action', 'fetch_browse');
|
||||
|
||||
var response = await (
|
||||
await fetchApi(url, { body: data, method: 'POST' })
|
||||
).json();
|
||||
|
||||
response.data.posts.forEach((element: any) => {
|
||||
const novel = {
|
||||
name: element.title,
|
||||
cover: element.thumbnail,
|
||||
path: element.permalink.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
var site = this.site;
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
};
|
||||
|
||||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
novel.name = loadedCheerio('h1').text().trim();
|
||||
|
||||
//novel.artist = "";
|
||||
//novel.author = "";
|
||||
novel.cover = loadedCheerio(
|
||||
'img[x-ref="art"].w-full.rounded.shadow-md',
|
||||
).attr('src');
|
||||
if (!novel.cover) {
|
||||
novel.cover = defaultCover;
|
||||
}
|
||||
let genres: string[] = [];
|
||||
loadedCheerio(
|
||||
'section[x-data="{ tab: location.hash.substr(1) || \'chapters\' }"].relative > div > div > div.flex.flex-wrap > a',
|
||||
).each(function () {
|
||||
genres.push(loadedCheerio(this).text().trim());
|
||||
});
|
||||
novel.genres = genres.join(', ');
|
||||
// novel.status = NovelStatus.Completed;
|
||||
novel.summary = loadedCheerio('div.mb-4.order-2:not(.lg\\:grid-in-buttons)')
|
||||
.text()
|
||||
.trim()
|
||||
.replace(/(\r\n|\n|\r)/gm, '');
|
||||
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio(
|
||||
'div.grid.w-full.grid-cols-1.gap-4.md\\:grid-cols-2 > a',
|
||||
).each(function () {
|
||||
if (loadedCheerio(this).find('> div').length == 2) {
|
||||
return;
|
||||
}
|
||||
let name = loadedCheerio(this).find('.truncate').text().trim();
|
||||
let url = loadedCheerio(this).attr('href') as string;
|
||||
let releaseTime = loadedCheerio(this)
|
||||
.find('div > div > small')
|
||||
.text()
|
||||
.trim();
|
||||
let chapterNumber = name.split('-')[0].trim().split(' ')[1];
|
||||
const chapter: Plugin.ChapterItem = {
|
||||
name: name,
|
||||
path: url.replace(site, ''),
|
||||
releaseTime: releaseTime,
|
||||
chapterNumber: parseInt(chapterNumber),
|
||||
};
|
||||
chapters.push(chapter);
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
let t = loadedCheerio('div.justify-center > div.mb-4');
|
||||
let chapterText = t.html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
let url = 'https://storyseedling.com/ajax';
|
||||
|
||||
const postUrl = `https://storyseedling.com/browse/`;
|
||||
const body = await fetchApi(postUrl).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
var postValue = loadedCheerio('div[ax-load][x-data]')
|
||||
.attr('x-data')
|
||||
?.replace("browse('", '')
|
||||
.replace("')", '') as string;
|
||||
|
||||
var 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');
|
||||
|
||||
var response = await (
|
||||
await fetchApi(url, { body: data, method: 'POST' })
|
||||
).json();
|
||||
|
||||
response.data.posts.forEach((element: any) => {
|
||||
const novel = {
|
||||
name: element.title,
|
||||
cover: element.thumbnail,
|
||||
path: element.permalink.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new StorySeedlingPlugin();
|
||||
@@ -0,0 +1,210 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
|
||||
|
||||
class AllNovelFullPlugin implements Plugin.PluginBase {
|
||||
id = 'anf.net';
|
||||
name = 'AllNovelFull';
|
||||
icon = 'src/en/allnovelfull/icon.png';
|
||||
site = 'https://allnovelfull.net';
|
||||
version = '1.0.0';
|
||||
|
||||
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');
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novelName = loadedCheerio(el).find('h3.truyen-title > a').text();
|
||||
|
||||
let novelCover = loadedCheerio(el).find('img.cover').attr('src');
|
||||
if (novelCover && !isUrlAbsolute(novelCover)) {
|
||||
novelCover = this.site + novelCover;
|
||||
}
|
||||
|
||||
const novel = {
|
||||
path: novelUrl,
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
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 result = await fetchApi(link);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.book > img').attr('alt') || 'Untitled',
|
||||
cover: this.site + loadedCheerio('.book > img').attr('src'),
|
||||
summary: loadedCheerio('.desc-text').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('.info > div').each((i, el) => {
|
||||
const detailName = loadedCheerio(el).find('h3').text();
|
||||
const detail = loadedCheerio(el)
|
||||
.find('a')
|
||||
.map((a, ex) => loadedCheerio(ex).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Author:':
|
||||
novel.author = detail;
|
||||
break;
|
||||
case 'Status:':
|
||||
novel.status = detail;
|
||||
break;
|
||||
case 'Genre:':
|
||||
novel.genres = detail;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const novelId = loadedCheerio('#rating').attr('data-novel-id');
|
||||
|
||||
const getChapters = async (id: string) => {
|
||||
const chaptersUrl = this.site + '/ajax/chapter-option?novelId=' + id;
|
||||
|
||||
const data = await fetchApi(chaptersUrl);
|
||||
const chapters = await data.text();
|
||||
|
||||
loadedCheerio = parseHTML(chapters);
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('select > option').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).text();
|
||||
const chapterUrl = loadedCheerio(el).attr('value');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
});
|
||||
});
|
||||
|
||||
return chapter;
|
||||
};
|
||||
|
||||
if (novelId) {
|
||||
novel.chapters = await getChapters(novelId);
|
||||
}
|
||||
|
||||
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('#chapter-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}/search?keyword=${searchTerm}&page=${page}`;
|
||||
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: 'Shounen', value: '/genre/Shounen' },
|
||||
{ label: 'Harem', value: '/genre/Harem' },
|
||||
{ label: 'Comedy', value: '/genre/Comedy' },
|
||||
{ label: 'Martial Arts', value: '/genre/Martial+Arts' },
|
||||
{ label: 'School Life', value: '/genre/School+Life' },
|
||||
{ label: 'Mystery', value: '/genre/Mystery' },
|
||||
{ label: 'Shoujo', value: '/genre/Shoujo' },
|
||||
{ label: 'Romance', value: '/genre/Romance' },
|
||||
{ label: 'Sci-fi', value: '/genre/Sci-fi' },
|
||||
{ label: 'Gender Bender', value: '/genre/Gender+Bender' },
|
||||
{ label: 'Mature', value: '/genre/Mature' },
|
||||
{ label: 'Fantasy', value: '/genre/Fantasy' },
|
||||
{ label: 'Horror', value: '/genre/Horror' },
|
||||
{ label: 'Drama', value: '/genre/Drama' },
|
||||
{ label: 'Tragedy', value: '/genre/Tragedy' },
|
||||
{ label: 'Supernatural', value: '/genre/Supernatural' },
|
||||
{ label: 'Ecchi', value: '/genre/Ecchi' },
|
||||
{ label: 'Xuanhuan', value: '/genre/Xuanhuan' },
|
||||
{ label: 'Adventure', value: '/genre/Adventure' },
|
||||
{ label: 'Action', value: '/genre/Action' },
|
||||
{ label: 'Psychological', value: '/genre/Psychological' },
|
||||
{ label: 'Xianxia', value: '/genre/Xianxia' },
|
||||
{ label: 'Wuxia', value: '/genre/Wuxia' },
|
||||
{ label: 'Historical', value: '/genre/Historical' },
|
||||
{ label: 'Slice of Life', value: '/genre/Slice+of+Life' },
|
||||
{ label: 'Seinen', value: '/genre/Seinen' },
|
||||
{ label: 'Lolicon', value: '/genre/Lolicon' },
|
||||
{ label: 'Adult', value: '/genre/Adult' },
|
||||
{ label: 'Josei', value: '/genre/Josei' },
|
||||
{ label: 'Sports', value: '/genre/Sports' },
|
||||
{ label: 'Smut', value: '/genre/Smut' },
|
||||
{ label: 'Mecha', value: '/genre/Mecha' },
|
||||
{ label: 'Yaoi', value: '/genre/Yaoi' },
|
||||
{ label: 'Shounen Ai', value: '/genre/Shounen+Ai' },
|
||||
{ label: 'Magical Realism', value: '/genre/Magical+Realism' },
|
||||
{ label: 'Video Games', value: '/genre/Video+Games' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new AllNovelFullPlugin();
|
||||
@@ -0,0 +1,272 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchFile } 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.0';
|
||||
|
||||
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 fetch(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 fetch(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 fetch(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/${searchTerm}?page=${page}`;
|
||||
|
||||
const result = await fetch(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();
|
||||
@@ -0,0 +1,176 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class ComradeMaoPlugin implements Plugin.PluginBase {
|
||||
id = 'comrademao';
|
||||
name = 'Comrade Mao';
|
||||
site = 'https://comrademao.com/';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/comrademao/icon.png';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.listupd')
|
||||
.find('div.bs')
|
||||
.each(function () {
|
||||
const novelName = loadedCheerio(this).find('.tt').text().trim();
|
||||
const novelCover = loadedCheerio(this).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(this).find('a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
url: novelUrl,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site;
|
||||
|
||||
link += filters.category.value;
|
||||
|
||||
link += 'page/' + pageNo + '/?post_type=novel';
|
||||
|
||||
const body = await fetchApi(link).then(result => result.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovelAndChapters(novelUrl: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(novelUrl).then(result => result.text());
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
url: novelUrl,
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.name = loadedCheerio('.entry-title').text().trim();
|
||||
|
||||
novel.cover = loadedCheerio('div.thumbook > div > img').attr('src');
|
||||
|
||||
loadedCheerio('.infox .wd-full').each((i, el) => {
|
||||
const detailName = loadedCheerio(el).find('b').text();
|
||||
const detail = loadedCheerio(el).find('span').text();
|
||||
|
||||
switch (detailName) {
|
||||
case 'Publisher: ':
|
||||
novel.author = detail.trim();
|
||||
break;
|
||||
case 'Status: ':
|
||||
novel.status = detail.trim();
|
||||
break;
|
||||
case 'Genres:':
|
||||
novel.genres = detail.trim().replace(/ , /g, ',');
|
||||
break;
|
||||
case 'Description: ':
|
||||
novel.summary = detail.trim();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('#chapterlist')
|
||||
.find('li')
|
||||
.each(function () {
|
||||
const chapterUrl = loadedCheerio(this).find('a').attr('href');
|
||||
if (!chapterUrl) return;
|
||||
|
||||
const chapterName = loadedCheerio(this).find('.chapternum').text();
|
||||
let releaseTime = loadedCheerio(this).find('.chapterdate').text();
|
||||
|
||||
if (releaseTime?.includes?.('ago')) {
|
||||
const timeAgo = releaseTime.match(/\d+/)?.[0] || '0';
|
||||
const timeAgoInt = parseInt(timeAgo, 10);
|
||||
|
||||
if (timeAgoInt) {
|
||||
const dayJSDate = dayjs(); // today
|
||||
if (
|
||||
releaseTime.includes('hours ago') ||
|
||||
releaseTime.includes('hour ago')
|
||||
) {
|
||||
dayJSDate.subtract(timeAgoInt, 'hours'); // go back N hours
|
||||
}
|
||||
|
||||
if (
|
||||
releaseTime.includes('days ago') ||
|
||||
releaseTime.includes('day ago')
|
||||
) {
|
||||
dayJSDate.subtract(timeAgoInt, 'days'); // go back N days
|
||||
}
|
||||
|
||||
if (
|
||||
releaseTime.includes('months ago') ||
|
||||
releaseTime.includes('month ago')
|
||||
) {
|
||||
dayJSDate.subtract(timeAgoInt, 'months'); // go back N months
|
||||
}
|
||||
|
||||
releaseTime = dayJSDate.format('LL');
|
||||
}
|
||||
}
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
url: chapterUrl,
|
||||
releaseTime,
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterUrl: string): Promise<string> {
|
||||
const body = await fetchApi(chapterUrl).then(result => result.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('#chaptercontent').html()?.trim() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}page/${pageNo}/?s=${searchTerm}&post_type=novel`;
|
||||
|
||||
const body = await fetchApi(url).then(result => result.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
category: {
|
||||
value: '',
|
||||
label: 'Type',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Chinese', value: '/mtype/chinese/' },
|
||||
{ label: 'Japanese', value: '/mtype/japanese/' },
|
||||
{ label: 'Korean', value: '/mtype/korean/' },
|
||||
{ label: 'Manhua', value: '/mtype/manhua/' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ComradeMaoPlugin();
|
||||
@@ -0,0 +1,133 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class DDLPlugin implements Plugin.PluginBase {
|
||||
id = 'DDL.com';
|
||||
name = 'Divine Dao Library';
|
||||
site = 'https://www.divinedaolibrary.com/';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/divinedaolibrary/icon.png';
|
||||
filters?: undefined;
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI, searchTerm?: string) {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('#main')
|
||||
.find('li')
|
||||
.each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('a').text();
|
||||
const novelCover = defaultCover;
|
||||
const novelUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
if (searchTerm) {
|
||||
novels = novels.filter(novel =>
|
||||
novel.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site + 'novels';
|
||||
|
||||
const body = await fetchApi(link).then(res => res.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('h1.entry-title').text().trim() || 'Untitled',
|
||||
cover:
|
||||
loadedCheerio('.entry-content').find('img').attr('data-ezsrc') ||
|
||||
defaultCover,
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.summary = loadedCheerio('#main > article > div > p:nth-child(6)')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
novel.author = loadedCheerio('#main > article > div > h3:nth-child(2)')
|
||||
.text()
|
||||
.replace(/Author:/g, '')
|
||||
.trim();
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('#main')
|
||||
.find('li > span > a')
|
||||
.each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).text().trim();
|
||||
const chapterUrl = loadedCheerio(el).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 result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterName = loadedCheerio('.entry-title').text().trim();
|
||||
|
||||
let chapterText = loadedCheerio('.entry-content').html();
|
||||
|
||||
if (!chapterText) {
|
||||
chapterText = loadedCheerio('.page-header').html();
|
||||
}
|
||||
|
||||
chapterText = `<p><h1>${chapterName}</h1></p>` + chapterText;
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + 'novels';
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio, searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
export default new DDLPlugin();
|
||||
@@ -0,0 +1,202 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } 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.0';
|
||||
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();
|
||||
|
||||
let 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 () {
|
||||
let detailName = loadedCheerio(this).text();
|
||||
let 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,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search?keyword=${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();
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } 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 = '1.1.1';
|
||||
icon = 'src/en/faqwikius/icon.png';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI, searchTerm?: string) {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('figure.wp-block-image').each((index, element) => {
|
||||
const name = loadedCheerio(element).text();
|
||||
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:')
|
||||
?.slice(this.site.length);
|
||||
if (!path) return;
|
||||
|
||||
novels.push({ name, cover, path });
|
||||
});
|
||||
|
||||
if (searchTerm) {
|
||||
novels = novels.filter(novel =>
|
||||
novel.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ showLatestNovels }: Plugin.PopularNovelsOptions,
|
||||
): 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();
|
||||
|
||||
novel.cover = loadedCheerio('.wp-block-image')
|
||||
.find('img')
|
||||
.attr('data-ezsrc')
|
||||
?.replace(/\?ezimgfmt=.*$/, ''); // Regular expression magic!
|
||||
|
||||
const status = loadedCheerio(
|
||||
"#lcp_instance_0 +:icontains('complete')",
|
||||
).text();
|
||||
novel.status = status ? NovelStatus.Completed : NovelStatus.Ongoing;
|
||||
|
||||
const div = loadedCheerio('.book-review-block__meta-item-value');
|
||||
|
||||
div.html(div.html()?.replace(/(?<=>)([^<]+)(?=<br\s*\/?>)/g, '<p>$1</p>')!);
|
||||
|
||||
loadedCheerio('.book-review-block__meta-item-value strong').each(
|
||||
(i, el) => {
|
||||
const key = loadedCheerio(el)
|
||||
.text()
|
||||
.replace(':', '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const value = loadedCheerio(el).next().text().replace(':', '').trim();
|
||||
|
||||
switch (key) {
|
||||
case 'description':
|
||||
novel.summary = value;
|
||||
break;
|
||||
case 'author(s)':
|
||||
novel.author = value;
|
||||
break;
|
||||
case 'genre':
|
||||
novel.genres = value.split(' ').join(',');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = loadedCheerio(
|
||||
'.lcp_catlist > li > a',
|
||||
)
|
||||
.map((chapterIndex, element) => ({
|
||||
name: loadedCheerio(element)
|
||||
.text()
|
||||
.replace(novel.name + '', '')
|
||||
.replace('Novel' + '', '')
|
||||
.trim(),
|
||||
path: '' + loadedCheerio(element).attr('href')?.slice(this.site.length),
|
||||
chapterNumber: chapterIndex + 1,
|
||||
}))
|
||||
.get();
|
||||
|
||||
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);
|
||||
loadedCheerio('span').remove();
|
||||
|
||||
const chapterParagraphs = loadedCheerio('.entry-content p');
|
||||
|
||||
let chapterContent; // Variable to store the result
|
||||
|
||||
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 chapterContent;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,312 @@
|
||||
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.0';
|
||||
icon = 'src/en/foxteller/icon.png';
|
||||
|
||||
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 fetchApi(url).then(res => res.text());
|
||||
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 fetchApi(this.resolveUrl(novelPath)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
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 fetchApi(this.resolveUrl(chapterPath));
|
||||
const body = await res.text();
|
||||
const novelID = chapterPath.split('/')[0];
|
||||
const chapterID = body.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: res.url,
|
||||
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(escape(decodeBase64(base64)));
|
||||
}
|
||||
|
||||
throw new Error('This chapter is closed');
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(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 }),
|
||||
}).then(res => res.text());
|
||||
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, isNovel?: boolean) =>
|
||||
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) {
|
||||
let output = '';
|
||||
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);
|
||||
output += String.fromCharCode(chr1);
|
||||
|
||||
if (enc3 !== 64) {
|
||||
const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
|
||||
output += String.fromCharCode(chr2);
|
||||
}
|
||||
if (enc4 !== 64) {
|
||||
const chr3 = ((enc3 & 3) << 6) | enc4;
|
||||
output += String.fromCharCode(chr3);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import qs from 'qs';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class FreeWebNovel implements Plugin.PluginBase {
|
||||
id = 'FWN.com';
|
||||
name = 'Free Web Novel';
|
||||
site = 'https://freewebnovel.com';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/freewebnovel/icon.png';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
`Could not reach site (${r.status}: ${r.statusText}) try to open in webview.`,
|
||||
);
|
||||
return parseHTML(await r.text());
|
||||
}
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] {
|
||||
return loadedCheerio('.li-row')
|
||||
.map((index, element) => ({
|
||||
name: loadedCheerio(element).find('.tit').text() || '',
|
||||
cover: loadedCheerio(element).find('img').attr('src'),
|
||||
path: loadedCheerio(element).find('h3 > a').attr('href') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
if (showLatestNovels) url += '/latest-release-novels/';
|
||||
else {
|
||||
if (filters && filters.genres && filters.genres.value !== 'all')
|
||||
url += filters.genres.value;
|
||||
else {
|
||||
url += '/most-popular-novels/';
|
||||
if (page != 1) return [];
|
||||
page = 0;
|
||||
}
|
||||
}
|
||||
url += page;
|
||||
|
||||
const $ = await this.getCheerio(url);
|
||||
return this.parseNovels($);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const loadedCheerio = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.tit').text(),
|
||||
cover: loadedCheerio('.pic > img').attr('src'),
|
||||
summary: loadedCheerio('.inner').text().trim(),
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('[title=Genre]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.author = loadedCheerio('[title=Author]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.status = loadedCheerio('[title=Status]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.genres = loadedCheerio('[title=Genre]')
|
||||
.next()
|
||||
.text()
|
||||
.trim()
|
||||
.replace(/[\t\n]/g, ',')
|
||||
.replace(/, /g, ',');
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = loadedCheerio('#idData > li > a')
|
||||
.map((chapterIndex, element) => ({
|
||||
name:
|
||||
loadedCheerio(element).attr('title') ||
|
||||
'Chapter ' + (chapterIndex + 1),
|
||||
path:
|
||||
loadedCheerio(element).attr('href') ||
|
||||
novelPath.replace(
|
||||
'.html',
|
||||
'/chapter-' + (chapterIndex + 1) + '.html',
|
||||
),
|
||||
releaseTime: null,
|
||||
chapterNumber: chapterIndex + 1,
|
||||
}))
|
||||
.get();
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const loadedCheerio = await this.getCheerio(this.site + chapterPath);
|
||||
|
||||
// remove freewebnovel signature
|
||||
if (loadedCheerio('style').text().includes('p:nth-last-child(1)'))
|
||||
loadedCheerio('div.txt').find('p:last-child').remove();
|
||||
|
||||
const chapterText = loadedCheerio('div.txt').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const r = await fetchApi(this.site + '/search/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: this.site,
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({ searchkey: searchTerm }),
|
||||
});
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
'Could not reach site (' + r.status + ') try to open in webview.',
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(await r.text());
|
||||
const alertText =
|
||||
loadedCheerio('script')
|
||||
.text()
|
||||
.match(/alert\((.*?)\)/)?.[1] || '';
|
||||
if (alertText) throw new Error(alertText);
|
||||
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
genres: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Genre',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'Action', value: '/genres/Action/' },
|
||||
{ label: 'Adult', value: '/genres/Adult/' },
|
||||
{ label: 'Adventure', value: '/genres/Adventure/' },
|
||||
{ label: 'Comedy', value: '/genres/Comedy/' },
|
||||
{ label: 'Drama', value: '/genres/Drama/' },
|
||||
{ label: 'Eastern', value: '/genres-novel/Eastern' },
|
||||
{ label: 'Ecchi', value: '/genres/Ecchi/' },
|
||||
{ label: 'Fantasy', value: '/genres/Fantasy/' },
|
||||
{ label: 'Gender Bender', value: '/genres/Gender+Bender/' },
|
||||
{ label: 'Harem', value: '/genres/Harem/' },
|
||||
{ label: 'Historical', value: '/genres/Historical/' },
|
||||
{ label: 'Horror', value: '/genres/Horror/' },
|
||||
{ label: 'Josei', value: '/genres/Josei/' },
|
||||
{ label: 'Game', value: '/genres/Game/' },
|
||||
{ label: 'Martial Arts', value: '/genres/Martial+Arts/' },
|
||||
{ label: 'Mature', value: '/genres/Mature/' },
|
||||
{ label: 'Mecha', value: '/genres/Mecha/' },
|
||||
{ label: 'Mystery', value: '/genres/Mystery/' },
|
||||
{ label: 'Psychological', value: '/genres/Psychological/' },
|
||||
{ label: 'Reincarnation', value: '/genres-novel/Reincarnation' },
|
||||
{ label: 'Romance', value: '/genres/Romance/' },
|
||||
{ label: 'School Life', value: '/genres/School+Life/' },
|
||||
{ label: 'Sci-fi', value: '/genres/Sci-fi/' },
|
||||
{ label: 'Seinen', value: '/genres/Seinen/' },
|
||||
{ label: 'Shoujo', value: '/genres/Shoujo/' },
|
||||
{ label: 'Shounen Ai', value: '/genres/Shounen+Ai/' },
|
||||
{ label: 'Shounen', value: '/genres/Shounen/' },
|
||||
{ label: 'Slice of Life', value: '/genres/Slice+of+Life/' },
|
||||
{ label: 'Smut', value: '/genres/Smut/' },
|
||||
{ label: 'Sports', value: '/genres/Sports/' },
|
||||
{ label: 'Supernatural', value: '/genres/Supernatural/' },
|
||||
{ label: 'Tragedy', value: '/genres/Tragedy/' },
|
||||
{ label: 'Wuxia', value: '/genres/Wuxia/' },
|
||||
{ label: 'Xianxia', value: '/genres/Xianxia/' },
|
||||
{ label: 'Xuanhuan', value: '/genres/Xuanhuan/' },
|
||||
{ label: 'Yaoi', value: '/genres/Yaoi/' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new FreeWebNovel();
|
||||
@@ -0,0 +1,443 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi, fetchFile } 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.0.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 = {} as Plugin.NovelItem;
|
||||
let isParsingNovel = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (isParsingNovel) {
|
||||
if (name === 'a') {
|
||||
tempNovel.path = attribs['href'].slice(1);
|
||||
tempNovel.name = attribs['title'];
|
||||
}
|
||||
if (name === 'img') {
|
||||
tempNovel.cover = attribs['data-src'] || attribs['src'];
|
||||
}
|
||||
if (tempNovel.path && tempNovel.cover) {
|
||||
novels.push(tempNovel);
|
||||
tempNovel = {} as Plugin.NovelItem;
|
||||
}
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'novel-item') {
|
||||
isParsingNovel = true;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'li') {
|
||||
isParsingNovel = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}browse/`;
|
||||
link += `${filters.genres.value}/`;
|
||||
link += `${filters.order.value}/`;
|
||||
link += `${filters.status.value}/`;
|
||||
link += page;
|
||||
|
||||
const body = await fetchApi(link).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: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
summary: '',
|
||||
totalPages: 1,
|
||||
chapters: [],
|
||||
};
|
||||
let isHeaderStats = false;
|
||||
let isStatus = false;
|
||||
let isTotalChapters = false;
|
||||
let isNovelName = false;
|
||||
let isAuthorName = false;
|
||||
let isSummary = false;
|
||||
let isGenres = false;
|
||||
let isTags = false;
|
||||
let genreArray: string[] = [];
|
||||
let isCover = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'h1' && attribs['class']?.includes('novel-title')) {
|
||||
isNovelName = true;
|
||||
}
|
||||
if (name === 'div' && attribs['class']?.includes('content')) {
|
||||
isSummary = true;
|
||||
}
|
||||
if (name === 'figure' && attribs['class'] === 'cover') {
|
||||
isCover = true;
|
||||
}
|
||||
if (isCover) {
|
||||
if (name === 'img') {
|
||||
novel.cover = attribs['data-src'] || attribs['src'];
|
||||
}
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (isHeaderStats && name === 'strong') {
|
||||
isStatus = true;
|
||||
}
|
||||
if (isSummary && name === 'br') {
|
||||
novel.summary += '\n';
|
||||
}
|
||||
if (isGenres && name === 'a') {
|
||||
isTags = true;
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'header-stats') {
|
||||
isTotalChapters = true;
|
||||
isHeaderStats = true;
|
||||
}
|
||||
if (name === 'itemprop' && value === 'author') {
|
||||
isAuthorName = true;
|
||||
}
|
||||
if (name === 'class' && value === 'categories') {
|
||||
isGenres = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isTotalChapters) {
|
||||
novel.totalPages = Math.ceil(parseInt(data, 10) / 100);
|
||||
}
|
||||
if (isStatus) {
|
||||
novel.status = data.trim();
|
||||
}
|
||||
if (isNovelName) {
|
||||
novel.name += data.trim();
|
||||
}
|
||||
if (isAuthorName) {
|
||||
novel.author = data;
|
||||
}
|
||||
if (isSummary) {
|
||||
novel.summary += data;
|
||||
}
|
||||
if (isTags) {
|
||||
genreArray.push(data);
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'strong') {
|
||||
isTotalChapters = false;
|
||||
isStatus = false;
|
||||
}
|
||||
if (name === 'i') {
|
||||
isStatus = false;
|
||||
}
|
||||
if (name === 'h1') {
|
||||
isNovelName = false;
|
||||
}
|
||||
if (name === 'span') {
|
||||
isAuthorName = false;
|
||||
}
|
||||
if (name === 'div') {
|
||||
isHeaderStats = false;
|
||||
isSummary = false;
|
||||
isGenres = false;
|
||||
}
|
||||
if (name === 'a') {
|
||||
isTags = false;
|
||||
}
|
||||
if (name === 'figure') {
|
||||
isCover = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
novel.genres = genreArray.join(', ');
|
||||
return novel;
|
||||
}
|
||||
|
||||
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 chapter: Plugin.ChapterItem[] = [];
|
||||
let tempChapter = {} as Plugin.ChapterItem;
|
||||
let isChapterItem = false;
|
||||
let isChapterList = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (isChapterList) {
|
||||
if (name === 'li') {
|
||||
isChapterItem = true;
|
||||
tempChapter.chapterNumber = Number(attribs['data-orderno']);
|
||||
}
|
||||
if (isChapterItem) {
|
||||
if (name === 'a') {
|
||||
tempChapter.name = attribs['title'];
|
||||
tempChapter.path = attribs['href'].slice(1);
|
||||
}
|
||||
if (name === 'time') {
|
||||
tempChapter.releaseTime = dayjs(
|
||||
attribs['datetime'],
|
||||
).toISOString();
|
||||
}
|
||||
if (
|
||||
tempChapter.chapterNumber &&
|
||||
tempChapter.path &&
|
||||
tempChapter.releaseTime
|
||||
) {
|
||||
chapter.push(tempChapter);
|
||||
tempChapter = {} as Plugin.ChapterItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'chapter-list') {
|
||||
isChapterList = true;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'a') {
|
||||
isChapterItem = false;
|
||||
}
|
||||
if (name === 'ul') {
|
||||
isChapterList = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
const chapters = chapter;
|
||||
return { chapters };
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
let chapterText = '';
|
||||
let isChapter = false;
|
||||
let isPtag = false;
|
||||
let isStyleText = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (isChapter && name === 'div') {
|
||||
let stylediv = attribs['style'];
|
||||
if (stylediv) {
|
||||
chapterText += `<div style="${stylediv}">`;
|
||||
isStyleText = true;
|
||||
} else {
|
||||
chapterText += `<div>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
let w = attribs['width'];
|
||||
if (w) {
|
||||
chapterText += `<table width="${w}">`;
|
||||
} else {
|
||||
chapterText += `<table>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `<tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `<tr>`;
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
let w1 = attribs['width'];
|
||||
if (w1) {
|
||||
chapterText += `<td width="${w1}">`;
|
||||
} else {
|
||||
chapterText += `<td>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'id' && value === 'chapter-container') {
|
||||
isChapter = true;
|
||||
}
|
||||
if (name === 'class' && value?.includes('chapternav')) {
|
||||
isChapter = false;
|
||||
isPtag = false;
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (isChapter && name === 'p') {
|
||||
chapterText += '<p>';
|
||||
isPtag = true;
|
||||
if (isStyleText) {
|
||||
isStyleText = false;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'br') {
|
||||
chapterText += `<br>`;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isPtag) {
|
||||
chapterText += data;
|
||||
}
|
||||
if (isStyleText) {
|
||||
chapterText += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'p') {
|
||||
isPtag = false;
|
||||
chapterText += '</p>';
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
chapterText += `</td>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `</tr>`;
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `</tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
chapterText += `</table>`;
|
||||
}
|
||||
if (isChapter && name === 'div') {
|
||||
isStyleText = false;
|
||||
chapterText += `</div>`;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): 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();
|
||||
@@ -0,0 +1,383 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi, fetchFile } 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.0.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[]> {
|
||||
let link = this.site + 'novel?';
|
||||
link += `orderBy=${filters.order.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
link += `&filter=${filters.storyStatus.value}`;
|
||||
link += `&page=${page}`;
|
||||
|
||||
const body = await fetchApi(link);
|
||||
const html = await body.text();
|
||||
const baseUrl = this.site;
|
||||
let isParsingNovel = false;
|
||||
let tempNovel = {} as Plugin.NovelItem;
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class']?.includes('media-left')) {
|
||||
isParsingNovel = true;
|
||||
}
|
||||
if (isParsingNovel) {
|
||||
switch (name) {
|
||||
case 'a':
|
||||
tempNovel.path = attribs['href'].slice(baseUrl.length);
|
||||
break;
|
||||
case 'img':
|
||||
tempNovel.name = attribs['alt'];
|
||||
tempNovel.cover = attribs['src'];
|
||||
break;
|
||||
}
|
||||
if (tempNovel.path && tempNovel.name) {
|
||||
novels.push(tempNovel);
|
||||
tempNovel = {} as Plugin.NovelItem;
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'div') {
|
||||
isParsingNovel = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
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: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
totalPages: 1,
|
||||
summary: '',
|
||||
author: '',
|
||||
status: '',
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
let isScript = false;
|
||||
let isDescription = false;
|
||||
let isSource = false;
|
||||
let isPanelKey = false;
|
||||
let isPanelValue = 0;
|
||||
let isAuthorKey = false;
|
||||
let isStatusKey = false;
|
||||
let isList = 0;
|
||||
let isGenres = false;
|
||||
let genreArray: string[] = [];
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'img' && attribs['class']?.includes('img-rounded')) {
|
||||
novel.name = attribs['title'];
|
||||
novel.cover = attribs['src'];
|
||||
}
|
||||
if (name === 'ul' && attribs['class']?.includes('list-inline')) {
|
||||
isList++;
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (name === 'dt') {
|
||||
isPanelKey = true;
|
||||
}
|
||||
if (name === 'dd') {
|
||||
isPanelValue++;
|
||||
}
|
||||
if (isList === 1 && name === 'li') {
|
||||
isGenres = true;
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'description') {
|
||||
isDescription = true;
|
||||
}
|
||||
if (name === 'class' && value === 'source') {
|
||||
isDescription = false;
|
||||
isSource = true;
|
||||
}
|
||||
if (name === 'class' && value === 'progress') {
|
||||
isSource = false;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isScript) {
|
||||
const volume = JSON.parse(
|
||||
data.match(/lnmtl.volumes = (.+])(?=;)/)![1] || '',
|
||||
);
|
||||
novel.totalPages = volume.length;
|
||||
}
|
||||
if (isDescription) {
|
||||
novel.summary += data.trim() + '\n\n';
|
||||
}
|
||||
if (isSource) {
|
||||
novel.summary += data;
|
||||
}
|
||||
if (isPanelKey) {
|
||||
switch (data) {
|
||||
case 'Authors':
|
||||
isAuthorKey = true;
|
||||
break;
|
||||
case 'Current status':
|
||||
isStatusKey = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isAuthorKey && isPanelValue === 1) {
|
||||
novel.author += data.trim();
|
||||
isAuthorKey = false;
|
||||
}
|
||||
if (isStatusKey && isPanelValue === 2) {
|
||||
novel.status += data.trim();
|
||||
isStatusKey = false;
|
||||
}
|
||||
if (isGenres) {
|
||||
genreArray.push(data.trim());
|
||||
novel.genres = genreArray.join(', ');
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'ul') {
|
||||
isGenres = false;
|
||||
}
|
||||
if (name === 'main') {
|
||||
isPanelKey = false;
|
||||
isScript = true;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novel;
|
||||
}
|
||||
|
||||
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 isScript = false;
|
||||
let volume: VolumeEntry = {
|
||||
id: '',
|
||||
title: '',
|
||||
};
|
||||
const parser = new Parser({
|
||||
ontext(data) {
|
||||
if (isScript) {
|
||||
const volumes = JSON.parse(
|
||||
data.match(/lnmtl.volumes = (.+])(?=;)/)![1] || '',
|
||||
);
|
||||
volume = volumes[+page - 1];
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'main') {
|
||||
isScript = true;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
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 isText = false;
|
||||
let chapterText = '';
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'sentence' && attribs['class']?.includes('translated')) {
|
||||
isText = true;
|
||||
chapterText += '<p>';
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (name === 'nav') {
|
||||
isText = false;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isText) {
|
||||
chapterText += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'sentence') {
|
||||
chapterText += '</p>';
|
||||
isText = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return chapterText.replace(/„/g, '“');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const html = await fetchApi(this.site)
|
||||
.then(b => b.text())
|
||||
.then(r => r.replace(/>\s+</g, '><'));
|
||||
let isScript = false;
|
||||
let isFooter = false;
|
||||
let list = '';
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (
|
||||
isFooter &&
|
||||
name === 'script' &&
|
||||
attribs['type']?.includes('application/javascript')
|
||||
) {
|
||||
isScript = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isScript) {
|
||||
list = data.match(/prefetch: '\/(.*json)/)![1];
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'footer') {
|
||||
isFooter = true;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
if (name === 'body') {
|
||||
isFooter = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
const search = await fetch(`${this.site}${list}`);
|
||||
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();
|
||||
|
||||
interface ChapterEntry {
|
||||
number: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface VolumeEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class MTLNovel implements Plugin.PluginBase {
|
||||
id = 'mtlnovel';
|
||||
name = 'MTL Novel';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/mtlnovel/icon.png';
|
||||
site = 'https://www.mtlnovel.com/';
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}novel-list/?`;
|
||||
link += `orderby=${filters.order.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
link += `&status=${filters.storyStatus.value}`;
|
||||
link += `&pg=${page}`;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append('Alt-Used', 'www.mtlnovel.com');
|
||||
const body = await fetch(link, { headers }).then(result => result.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.box.wide').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('a.list-title').text().slice(4);
|
||||
const novelCover = loadedCheerio(el).find('amp-img').attr('src');
|
||||
const novelUrl = loadedCheerio(el).find('a.list-title').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const headers = new Headers();
|
||||
headers.append('Referer', `${this.site}novel-list/`);
|
||||
headers.append('Alt-Used', 'www.mtlnovel.com');
|
||||
|
||||
const body = await fetch(this.site + novelPath, {
|
||||
headers,
|
||||
}).then(result => result.text());
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.entry-title').text() || 'Untitled',
|
||||
cover: loadedCheerio('.nov-head > amp-img').attr('src'),
|
||||
summary: loadedCheerio('div.desc > h2').next().text().trim(),
|
||||
author: loadedCheerio('#author').text(),
|
||||
status: loadedCheerio('#status').text(),
|
||||
genres: loadedCheerio('#genre').text().replace(/\s+/g, ''),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
const chapterListUrl = this.site + novelPath + 'chapter-list/';
|
||||
|
||||
const getChapters = async () => {
|
||||
const listResult = await fetch(chapterListUrl, { headers });
|
||||
const listBody = await listResult.text();
|
||||
|
||||
loadedCheerio = parseHTML(listBody);
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('div.ch-list')
|
||||
.find('a.ch-link')
|
||||
.each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).text().replace('~ ', '');
|
||||
const releaseDate = null;
|
||||
const chapterUrl = loadedCheerio(el).attr('href');
|
||||
if (!chapterUrl) return;
|
||||
chapter.push({
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
});
|
||||
return chapter.reverse();
|
||||
};
|
||||
|
||||
novel.chapters = await getChapters();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const headers = new Headers();
|
||||
headers.append('Alt-Used', 'www.mtlnovel.com');
|
||||
const body = await fetch(this.site + chapterPath, { headers }).then(r =>
|
||||
r.text(),
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('div.par').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const headers = new Headers();
|
||||
headers.append('Alt-Used', 'www.mtlnovel.com');
|
||||
const searchUrl =
|
||||
this.site +
|
||||
'wp-admin/admin-ajax.php?action=autosuggest&q=' +
|
||||
searchTerm +
|
||||
'&__amp_source_origin=https%3A%2F%2Fwww.mtlnovel.com';
|
||||
|
||||
const res = await fetch(searchUrl, { headers });
|
||||
const result = await res.json();
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
interface SearchEntry {
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
permalink: string;
|
||||
}
|
||||
result.items[0].results.map((item: SearchEntry) => {
|
||||
const novelName = item.title.replace(/<\/?strong>/g, '');
|
||||
const novelCover = item.thumbnail;
|
||||
const novelUrl = item.permalink.replace(this.site, '');
|
||||
|
||||
const novel = { name: novelName, cover: novelCover, path: novelUrl };
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'view',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'Date', value: 'date' },
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Rating', value: 'rating' },
|
||||
{ label: 'View', value: 'view' },
|
||||
],
|
||||
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: 'Complete', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
export default new MTLNovel();
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
class MTLReader implements Plugin.PluginBase {
|
||||
id = 'mtlreader';
|
||||
name = 'MTL Reader';
|
||||
version = '1.0.0';
|
||||
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,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): 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,
|
||||
pageNo: number,
|
||||
): 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=${searchTerm}`;
|
||||
const seacrhBody = await fetchApi(searchUrl).then(r => r.text());
|
||||
const loadedCheerio = parseHTML(seacrhBody);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
}
|
||||
export default new MTLReader();
|
||||
@@ -0,0 +1,237 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class NovelBin implements Plugin.PluginBase {
|
||||
id = 'novelbin';
|
||||
name = 'Novel Bin';
|
||||
icon = 'src/en/novelbin/icon.png';
|
||||
site = 'https://binnovel.com/';
|
||||
version = '1.0.1';
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = {
|
||||
headers: {
|
||||
'referrer': this.site,
|
||||
},
|
||||
};
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.col-novel-main .list-novel .row').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('h3.novel-title > a').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('img.cover')
|
||||
.attr('data-src')
|
||||
?.replace('_200_89', '');
|
||||
const novelUrl = loadedCheerio(el)
|
||||
.find('h3.novel-title > a')
|
||||
.attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site;
|
||||
if (filters.genre.value !== '') link += `gen/${filters.genre.value}`;
|
||||
else link += `sort/${filters.sort.value}`;
|
||||
|
||||
if (filters.complete.value) link += `/completed`;
|
||||
|
||||
link += `?page=${page}`;
|
||||
|
||||
const body = await fetchApi(link).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());
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('div.book > img').attr('alt') || 'Untitled',
|
||||
cover: loadedCheerio('div.book > img').attr('data-src'),
|
||||
summary: loadedCheerio('div.desc-text').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('ul.info > li > h3').each(function () {
|
||||
let detailName = loadedCheerio(this).text();
|
||||
let 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;
|
||||
}
|
||||
});
|
||||
|
||||
const novelId = loadedCheerio('#rating').attr('data-novel-id');
|
||||
|
||||
const getChapters = async (id: string) => {
|
||||
const chapterListUrl = this.site + 'ajax/chapter-archive?novelId=' + id;
|
||||
|
||||
const data = await fetchApi(chapterListUrl);
|
||||
const chapterdata = await data.text();
|
||||
|
||||
loadedCheerio = parseHTML(chapterdata);
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('ul.list-chapter > li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').attr('title');
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterName || !chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
return chapter;
|
||||
};
|
||||
|
||||
if (novelId) {
|
||||
novel.chapters = await getChapters(novelId);
|
||||
}
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
loadedCheerio('#chr-content > div,h6,p[style="display: none;"]').remove();
|
||||
const chapterText = loadedCheerio('#chr-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search/?keyword=${searchTerm}`;
|
||||
const body = await fetchApi(url).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
value: 'popular-novels',
|
||||
label: 'Sort by',
|
||||
options: [
|
||||
{ label: 'Latest Release', value: 'update' },
|
||||
{ label: 'Hot Novel', value: 'hot-novels' },
|
||||
{ label: 'Completed Novel', value: 'c' },
|
||||
{ label: 'Most Popular', value: 'popular-novels' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
value: '',
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Anime', value: 'anime' },
|
||||
{ label: 'Arts', value: 'arts' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ 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 bender', value: 'gender-bender' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Hentai', value: 'hentai' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Isekai', value: 'isekai' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ label: 'Lgbt+', value: 'lgbt+' },
|
||||
{ label: 'Litrpg', value: 'litrpg' },
|
||||
{ label: 'Magic', value: 'magic' },
|
||||
{ label: 'Magical realism', value: 'magical-realism' },
|
||||
{ label: 'Manhua', value: 'manhua' },
|
||||
{ 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: 'Movies', value: 'movies' },
|
||||
{ label: 'Mystery', value: 'mystery' },
|
||||
{ label: 'Portal fantasy', value: 'portal-fantasy' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Realistic fiction', value: 'realistic-fiction' },
|
||||
{ label: 'Reincarnation', value: 'reincarnation' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ 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: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Urban life', value: 'urban-life' },
|
||||
{ label: 'Video games', value: 'video-games' },
|
||||
{ label: 'Wuxia', value: 'wuxia' },
|
||||
{ label: 'Xianxia', value: 'xianxia' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
{ label: 'Yaoi', value: 'yaoi' },
|
||||
{ label: 'Yuri', value: 'yuri' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
complete: {
|
||||
label: 'Show Completed Novels Only',
|
||||
value: false,
|
||||
type: FilterTypes.Switch,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelBin();
|
||||
@@ -0,0 +1,174 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
class NovelBuddy implements Plugin.PluginBase {
|
||||
id = 'novelbuddy';
|
||||
name = 'NovelBuddy.io';
|
||||
site = 'https://novelbuddy.io/';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/novelbuddy/icon.png';
|
||||
filters?: undefined; //TODO: Filters, Filters in Filters required
|
||||
|
||||
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,
|
||||
options: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}popular?page=${pageNo}`;
|
||||
|
||||
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').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',
|
||||
].join('|');
|
||||
const rx = new RegExp(`(${months}) (\\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=${searchTerm}&page=${page}`;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
}
|
||||
|
||||
export default new NovelBuddy();
|
||||
@@ -0,0 +1,197 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class NovelFull implements Plugin.PluginBase {
|
||||
id = 'novelfull';
|
||||
name = 'NovelFull';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/novelfull/icon.png';
|
||||
site = 'https://novelfull.com/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.col-truyen-main .list-truyen .row').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('h3.truyen-title > a').text();
|
||||
|
||||
const novelCover =
|
||||
this.site + loadedCheerio(ele).find('img').attr('src')?.slice(1);
|
||||
|
||||
const novelUrl = loadedCheerio(ele)
|
||||
.find('h3.truyen-title > a')
|
||||
.attr('href')
|
||||
?.slice(1);
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site;
|
||||
if (filters.genre.value !== '') link += `genre/${filters.genre.value}`;
|
||||
else link += filters.sort.value;
|
||||
|
||||
link += `?page=${page}`;
|
||||
|
||||
const body = await fetchApi(link).then(r => r.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('div.book > img').attr('alt') || 'Untitled',
|
||||
cover: this.site + loadedCheerio('div.book > img').attr('src'),
|
||||
summary: loadedCheerio('div.desc-text').text().trim(),
|
||||
status: loadedCheerio('h3:contains("Status")').next().text(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.author = loadedCheerio('h3:contains("Author")')
|
||||
.parent()
|
||||
.contents()
|
||||
.text()
|
||||
.replace('Author:', '');
|
||||
|
||||
novel.genres = loadedCheerio('h3:contains("Genre")')
|
||||
.siblings()
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
const novelId = loadedCheerio('#rating').attr('data-novel-id')!;
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
const getChapters = async (id: string) => {
|
||||
const chapterListUrl = this.site + 'ajax/chapter-option?novelId=' + id;
|
||||
|
||||
const data = await fetchApi(chapterListUrl);
|
||||
const chapterlist = await data.text();
|
||||
|
||||
loadedCheerio = parseHTML(chapterlist);
|
||||
|
||||
loadedCheerio('select > option').each(function () {
|
||||
const chapterName = loadedCheerio(this).text();
|
||||
const chapterUrl = loadedCheerio(this).attr('value')?.slice(1);
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
});
|
||||
});
|
||||
return chapter;
|
||||
};
|
||||
|
||||
novel.chapters = await getChapters(novelId);
|
||||
|
||||
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('#chapter-content div.ads').remove();
|
||||
const chapterText = loadedCheerio('#chapter-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = `${this.site}search?keyword=${searchTerm}&page=${page}`;
|
||||
|
||||
const result = await fetchApi(searchUrl);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
value: 'most-popular',
|
||||
label: 'Sort 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,
|
||||
},
|
||||
genre: {
|
||||
value: '',
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Shounen', value: 'Shounen' },
|
||||
{ label: 'Harem', value: 'Harem' },
|
||||
{ label: 'Comedy', value: 'Comedy' },
|
||||
{ label: 'Martial Arts', value: 'Martial+Arts' },
|
||||
{ label: 'School Life', value: 'School+Life' },
|
||||
{ label: 'Mystery', value: 'Mystery' },
|
||||
{ label: 'Shoujo', value: 'Shoujo' },
|
||||
{ label: 'Romance', value: 'Romance' },
|
||||
{ label: 'Sci-fi', value: 'Sci-fi' },
|
||||
{ label: 'Gender Bender', value: 'Gender+Bender' },
|
||||
{ label: 'Mature', value: 'Mature' },
|
||||
{ label: 'Fantasy', value: 'Fantasy' },
|
||||
{ label: 'Horror', value: 'Horror' },
|
||||
{ label: 'Drama', value: 'Drama' },
|
||||
{ label: 'Tragedy', value: 'Tragedy' },
|
||||
{ label: 'Supernatural', value: 'Supernatural' },
|
||||
{ label: 'Ecchi', value: 'Ecchi' },
|
||||
{ label: 'Xuanhuan', value: 'Xuanhuan' },
|
||||
{ label: 'Adventure', value: 'Adventure' },
|
||||
{ label: 'Action', value: 'Action' },
|
||||
{ label: 'Psychological', value: 'Psychological' },
|
||||
{ label: 'Xianxia', value: 'Xianxia' },
|
||||
{ label: 'Wuxia', value: 'Wuxia' },
|
||||
{ label: 'Historical', value: 'Historical' },
|
||||
{ label: 'Slice of Life', value: 'Slice+of+Life' },
|
||||
{ label: 'Seinen', value: 'Seinen' },
|
||||
{ label: 'Lolicon', value: 'Lolicon' },
|
||||
{ label: 'Adult', value: 'Adult' },
|
||||
{ label: 'Josei', value: 'Josei' },
|
||||
{ label: 'Sports', value: 'Sports' },
|
||||
{ label: 'Smut', value: 'Smut' },
|
||||
{ label: 'Mecha', value: 'Mecha' },
|
||||
{ label: 'Yaoi', value: 'Yaoi' },
|
||||
{ label: 'Shounen Ai', value: 'Shounen+Ai' },
|
||||
{ label: 'History', value: 'History' },
|
||||
{ label: 'Martial', value: 'Martial' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelFull();
|
||||
@@ -0,0 +1,131 @@
|
||||
import { load, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class NovelHall implements Plugin.PluginBase {
|
||||
id = 'novelhall';
|
||||
name = 'Novel Hall';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/novelhall/icon.png';
|
||||
filters?: Filters | undefined; //TODO: Filters Requires hideOnSelect
|
||||
site = 'https://novelhall.com/';
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): 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,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}index.php?s=so&module=book&keyword=${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();
|
||||
@@ -0,0 +1,835 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
|
||||
class NovelUpdates implements Plugin.PluginBase {
|
||||
id = 'novelupdates';
|
||||
name = 'Novel Updates';
|
||||
version = '0.5.4';
|
||||
icon = 'src/en/novelupdates/icon.png';
|
||||
site = 'https://www.novelupdates.com/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.search_main_box_nu').each((idx, ele) => {
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
const novelName = loadedCheerio(ele).find('.search_title > a').text();
|
||||
const novelUrl = loadedCheerio(ele)
|
||||
.find('.search_title > a')
|
||||
.attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}`;
|
||||
if (
|
||||
filters.language.value.length ||
|
||||
filters.novelType.value.length ||
|
||||
filters.genres.value.include?.length ||
|
||||
filters.genres.value.exclude?.length ||
|
||||
filters.storyStatus.value !== ''
|
||||
) {
|
||||
link += 'series-finder/?sf=1';
|
||||
} else if (showLatestNovels) {
|
||||
link += 'latest-series/?st=1';
|
||||
} else {
|
||||
link += 'series-ranking/?rank=week';
|
||||
}
|
||||
|
||||
if (filters.language.value.length)
|
||||
link += '&org=' + filters.language.value.join(',');
|
||||
|
||||
if (filters.novelType.value.length)
|
||||
link += '&nt=' + filters.novelType.value.join(',');
|
||||
|
||||
if (filters.genres.value.include?.length)
|
||||
link += '&gi=' + filters.genres.value.include.join(',');
|
||||
|
||||
if (filters.genres.value.exclude?.length)
|
||||
link += '&ge=' + filters.genres.value.exclude.join(',');
|
||||
|
||||
if (
|
||||
filters.genres.value.include?.length ||
|
||||
filters.genres.value.exclude?.length
|
||||
)
|
||||
link += '&mgi=' + filters.genre_operator.value;
|
||||
|
||||
if (filters.storyStatus.value.length)
|
||||
link += '&ss=' + filters.storyStatus.value;
|
||||
|
||||
link += '&sort=' + filters.sort.value;
|
||||
|
||||
link += '&order=' + filters.order.value;
|
||||
|
||||
link += '&pg=' + page;
|
||||
|
||||
const body = await fetchApi(link).then(result => result.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.seriestitlenu').text() || 'Untitled',
|
||||
cover: loadedCheerio('.wpb_wrapper img').attr('src'),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.author = loadedCheerio('#authtag')
|
||||
.map((i, el) => loadedCheerio(el).text().trim())
|
||||
.toArray()
|
||||
.join(', ');
|
||||
|
||||
novel.genres = loadedCheerio('#seriesgenre')
|
||||
.children('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
novel.status = loadedCheerio('#editstatus').text().includes('Ongoing')
|
||||
? 'Ongoing'
|
||||
: 'Completed';
|
||||
|
||||
const type = loadedCheerio('#showtype').text().trim();
|
||||
|
||||
const summary = loadedCheerio('#editdescription').text().trim();
|
||||
|
||||
novel.summary = summary + `\n\nType: ${type}`;
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
const novelId = loadedCheerio('input#mypostid').attr('value')!;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'nd_getchapters');
|
||||
formData.append('mygrr', '0');
|
||||
formData.append('mypostid', novelId);
|
||||
|
||||
const link = `${this.site}wp-admin/admin-ajax.php`;
|
||||
|
||||
const text = await fetchApi(link, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}).then(data => data.text());
|
||||
|
||||
loadedCheerio = parseHTML(text);
|
||||
|
||||
const nameReplacements: { [key: string]: string } = {
|
||||
'v': 'volume ',
|
||||
'c': ' chapter ',
|
||||
'part': 'part ',
|
||||
'ss': 'SS',
|
||||
};
|
||||
|
||||
loadedCheerio('li.sp_li_chp').each((i, el) => {
|
||||
let chapterName = loadedCheerio(el).text();
|
||||
for (let name in nameReplacements) {
|
||||
chapterName = chapterName.replace(name, nameReplacements[name]);
|
||||
}
|
||||
chapterName = chapterName.replace(/\b\w/g, l => l.toUpperCase()).trim();
|
||||
const chapterUrl =
|
||||
'https:' + loadedCheerio(el).find('a').first().next().attr('href');
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
getLocation(href: string) {
|
||||
var match = href.match(
|
||||
/^(https?:)\/\/(([^:/?#]*)(?::([0-9]+))?)([/]{0,1}[^?#]*)(\?[^#]*|)(#.*|)$/,
|
||||
);
|
||||
return match && `${match[1]}//${match[3]}`;
|
||||
}
|
||||
|
||||
async getChapterBody(
|
||||
loadedCheerio: CheerioAPI,
|
||||
domain: string[],
|
||||
url: string,
|
||||
result: Response,
|
||||
) {
|
||||
let bloatClasses = [];
|
||||
let chapterTitle = '';
|
||||
let chapterContent = '';
|
||||
let chapterText = '';
|
||||
|
||||
const unwanted = ['blogspot', 'wordpress', 'www'];
|
||||
const targetDomain = domain.find(d => !unwanted.includes(d));
|
||||
|
||||
switch (targetDomain) {
|
||||
case 'anotivereads':
|
||||
chapterTitle = loadedCheerio('#comic-nav-name').first().text()!;
|
||||
chapterContent = loadedCheerio('#spliced-comic').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'asuratls':
|
||||
let titleElementAsura = loadedCheerio('.post-body div b').first();
|
||||
chapterTitle = titleElementAsura.text()!;
|
||||
titleElementAsura.remove();
|
||||
chapterContent = loadedCheerio('.post-body').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'helscans':
|
||||
chapterTitle = loadedCheerio('.entry-title-main').first().text()!;
|
||||
const chapterStringHelScans =
|
||||
'Chapter ' + chapterTitle.split('Chapter')[1].trim();
|
||||
loadedCheerio('#readerarea.rdminimal')
|
||||
.children()
|
||||
.each((idx, ele) => {
|
||||
let elementText = loadedCheerio(ele).text();
|
||||
if (elementText.includes(chapterStringHelScans)) {
|
||||
chapterTitle = elementText;
|
||||
loadedCheerio(ele).remove();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
chapterContent = loadedCheerio('#readerarea.rdminimal').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'hiraethtranslation':
|
||||
chapterTitle = loadedCheerio('li.active').first().text()!;
|
||||
chapterContent = loadedCheerio('.text-left').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'hostednovel':
|
||||
chapterTitle = loadedCheerio('#chapter-title').first().text()!;
|
||||
chapterContent = loadedCheerio('#chapter-content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'inoveltranslation':
|
||||
const link_inovel = 'https://api.' + url.slice(8);
|
||||
const json_inovel = await fetchApi(link_inovel).then(r => r.json());
|
||||
chapterTitle =
|
||||
'Chapter ' + json_inovel.chapter + ' | ' + json_inovel.title;
|
||||
chapterContent = json_inovel.content.replace(/\n/g, '<br>');
|
||||
if (json_inovel.notes) {
|
||||
chapterContent +=
|
||||
'<br><hr><br>TL Notes:<br>' +
|
||||
json_inovel.notes.replace(/\n/g, '<br>');
|
||||
}
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'ko-fi':
|
||||
chapterText = loadedCheerio('script:contains("shadowDom.innerHTML")')
|
||||
.html()
|
||||
?.match(/shadowDom\.innerHTML \+= '(<div.*?)';/)![1]!;
|
||||
break;
|
||||
case 'mirilu':
|
||||
bloatClasses = ['#jp-post-flair'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
let titleElementMirilu = loadedCheerio(
|
||||
'.entry-content p strong',
|
||||
).first();
|
||||
chapterTitle = titleElementMirilu.text()!;
|
||||
titleElementMirilu.remove();
|
||||
chapterContent = loadedCheerio('.entry-content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'novelplex':
|
||||
bloatClasses = ['.passingthrough_adreminder'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle = loadedCheerio('.halChap--jud').first().text()!;
|
||||
chapterContent = loadedCheerio('.halChap--kontenInner ').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'novelworldtranslations':
|
||||
bloatClasses = ['.separator img'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
loadedCheerio('.entry-content a')
|
||||
.filter((_, el) => {
|
||||
return (
|
||||
loadedCheerio(el)
|
||||
.attr('href')
|
||||
?.includes('https://novelworldtranslations.blogspot.com') ||
|
||||
false
|
||||
);
|
||||
})
|
||||
.each((_, el) => {
|
||||
loadedCheerio(el).parent().remove();
|
||||
});
|
||||
chapterTitle = loadedCheerio('.entry-title').first().text()!;
|
||||
chapterContent = loadedCheerio('.entry-content')
|
||||
.html()!
|
||||
.replace(/ /g, '')
|
||||
.replace(/\n/g, '<br>');
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'raeitranslations':
|
||||
const parts = url.split('/');
|
||||
const link_raei = `${parts[0]}//api.${parts[2]}/api/chapters/?id=${parts[3]}&num=${parts[4]}`;
|
||||
const json = await fetchApi(link_raei).then(r => r.json());
|
||||
chapterTitle = 'Chapter ' + json.currentChapter.num;
|
||||
chapterContent =
|
||||
json.currentChapter.head +
|
||||
`<br><hr><br>` +
|
||||
json.currentChapter.body +
|
||||
`<br><hr><br>Translator's Note:<br>` +
|
||||
json.currentChapter.note;
|
||||
chapterContent = chapterContent.replace(/\n/g, '<br>');
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'rainofsnow':
|
||||
let displayedDivSnow = loadedCheerio('.bb-item').filter(function () {
|
||||
return loadedCheerio(this).css('display') === 'block';
|
||||
});
|
||||
let loadedCheerioSnow = parseHTML(displayedDivSnow.html()!);
|
||||
bloatClasses = [
|
||||
'.responsivevoice-button',
|
||||
'.zoomdesc-cont p img',
|
||||
'.zoomdesc-cont p noscript',
|
||||
];
|
||||
bloatClasses.map(tag => loadedCheerioSnow(tag).remove());
|
||||
chapterContent = loadedCheerioSnow('.zoomdesc-cont').html()!;
|
||||
let titleElementSnow = loadedCheerioSnow('.scroller h2').first();
|
||||
if (titleElementSnow.length) {
|
||||
chapterTitle = titleElementSnow.text()!;
|
||||
titleElementSnow.remove();
|
||||
chapterContent = loadedCheerioSnow('.zoomdesc-cont').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
} else if (chapterContent) {
|
||||
chapterText = chapterContent;
|
||||
}
|
||||
break;
|
||||
case 'sacredtexttranslations':
|
||||
bloatClasses = [
|
||||
'.entry-content blockquote',
|
||||
'.entry-content div',
|
||||
'.reaction-buttons',
|
||||
];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle = loadedCheerio('.entry-title').first().text()!;
|
||||
chapterContent = loadedCheerio('.entry-content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'scribblehub':
|
||||
chapterTitle = loadedCheerio('.chapter-title').first().text()!;
|
||||
chapterContent = loadedCheerio('div.chp_raw').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'skydemonorder':
|
||||
/**
|
||||
* Check for age verification
|
||||
*/
|
||||
const ageVerification = loadedCheerio('main').text().toLowerCase()!;
|
||||
if (ageVerification.includes('age verification required')) {
|
||||
throw new Error('Age verification required, please open in webview.');
|
||||
}
|
||||
chapterTitle = `${loadedCheerio('.pl-4 h1').first().text()!} | ${loadedCheerio('.pl-4 div').first().text()!}`;
|
||||
chapterContent = loadedCheerio('#startContainer + * > *')
|
||||
.first()
|
||||
.html()!;
|
||||
if (!chapterContent) {
|
||||
chapterContent = `${loadedCheerio('#chapter-body').html()!}<hr><br>There could be missing content, please check in webview.`;
|
||||
}
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'stabbingwithasyringe':
|
||||
/**
|
||||
* Get the chapter link from the main page
|
||||
*/
|
||||
const linkSyringe = loadedCheerio('.entry-content a').attr('href')!;
|
||||
const resultSyringe = await fetchApi(linkSyringe);
|
||||
const bodySyringe = await resultSyringe.text();
|
||||
const loadedCheerioSyringe = parseHTML(bodySyringe);
|
||||
bloatClasses = ['.wp-block-buttons', '#jp-post-flair', '.wpcnt'];
|
||||
bloatClasses.map(tag => loadedCheerioSyringe(tag).remove());
|
||||
chapterContent = loadedCheerioSyringe('.entry-content').html()!;
|
||||
let titleElementSyringe =
|
||||
loadedCheerioSyringe('.entry-content h3').first();
|
||||
if (titleElementSyringe.length) {
|
||||
chapterTitle = titleElementSyringe.text();
|
||||
titleElementSyringe.remove();
|
||||
chapterContent = loadedCheerioSyringe('.entry-content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
} else if (chapterContent) {
|
||||
chapterText = chapterContent;
|
||||
}
|
||||
break;
|
||||
case 'tinytranslation':
|
||||
bloatClasses = [
|
||||
'.content noscript',
|
||||
'.google_translate_element',
|
||||
'.navigate',
|
||||
'.post-views',
|
||||
'br',
|
||||
];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle = loadedCheerio('.title-content').first().text()!;
|
||||
loadedCheerio('.title-content').first().remove();
|
||||
chapterContent = loadedCheerio('.content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'tumblr':
|
||||
chapterText = loadedCheerio('.post').html()!;
|
||||
break;
|
||||
case 'wattpad':
|
||||
chapterTitle = loadedCheerio('.h2').first().text()!;
|
||||
chapterContent = loadedCheerio('.part-content pre').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'webnovel':
|
||||
chapterTitle = loadedCheerio('.cha-tit .pr .dib').first().text()!;
|
||||
chapterContent = loadedCheerio('.cha-words').html()!;
|
||||
if (!chapterContent) {
|
||||
chapterContent = loadedCheerio('._content').html()!;
|
||||
}
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'wuxiaworld':
|
||||
bloatClasses = ['.MuiLink-root'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle = loadedCheerio('h4 span').first().text()!;
|
||||
chapterContent = loadedCheerio('.chapter-content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'zetrotranslation':
|
||||
bloatClasses = ['hr', 'p:contains("\u00a0")'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterContent = loadedCheerio('.text-left').html()!;
|
||||
let titleElementZetro = loadedCheerio('.text-left h2').first();
|
||||
if (titleElementZetro.length) {
|
||||
chapterTitle = titleElementZetro.text()!;
|
||||
titleElementZetro.remove();
|
||||
chapterContent = loadedCheerio('.text-left').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
} else if (chapterContent) {
|
||||
chapterTitle = loadedCheerio('.active').first().text()!;
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
let bloatClasses = [];
|
||||
let chapterTitle = '';
|
||||
let chapterContent = '';
|
||||
let chapterText = '';
|
||||
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
const url = result.url.toLowerCase();
|
||||
const domain = url.split('/')[2].split('.');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
/**
|
||||
* Check for Captcha
|
||||
*/
|
||||
const title = loadedCheerio('title').text().toLowerCase().trim();
|
||||
if (
|
||||
title == 'bot verification' ||
|
||||
title == 'just a moment...' ||
|
||||
title == 'redirecting...' ||
|
||||
title == 'un instant...' ||
|
||||
title == 'you are being redirected...'
|
||||
) {
|
||||
throw new Error('Captcha error, please open in webview.');
|
||||
}
|
||||
if (!result.ok) {
|
||||
/**
|
||||
* Check if the chapter url is wrong or the site is genuinely down
|
||||
*/
|
||||
throw new Error(
|
||||
`Could not reach site (${result.status}), try to open in webview.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the site is a Blogspot site
|
||||
*/
|
||||
let isBlogspotStr = loadedCheerio(
|
||||
'meta[name="google-adsense-platform-domain"]',
|
||||
).attr('content');
|
||||
let isBlogspot = false;
|
||||
if (isBlogspotStr) {
|
||||
isBlogspot = isBlogspotStr.toLowerCase().includes('blogspot');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the site is a WordPress site
|
||||
*/
|
||||
let isWordPressStr =
|
||||
loadedCheerio('#dcl_comments-js-extra').html() ||
|
||||
loadedCheerio('meta[name="generator"]').attr('content') ||
|
||||
loadedCheerio('footer').text()!;
|
||||
let isWordPress = false;
|
||||
if (isWordPressStr) {
|
||||
isWordPress =
|
||||
isWordPressStr.toLowerCase().includes('wordpress') ||
|
||||
isWordPressStr.includes('Site Kit by Google') ||
|
||||
loadedCheerio('.powered-by').text().toLowerCase().includes('wordpress');
|
||||
}
|
||||
|
||||
/**
|
||||
* In case sites are not detected correctly
|
||||
*/
|
||||
const manualWordPress = ['genesistls'];
|
||||
if (!isWordPress && domain.find(wp => manualWordPress.includes(wp))) {
|
||||
isWordPress = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sites that are WordPress or Blogspot but have different structure
|
||||
*/
|
||||
const outliers = [
|
||||
'anotivereads',
|
||||
'asuratls',
|
||||
'helscans',
|
||||
'mirilu',
|
||||
'novelworldtranslations',
|
||||
'sacredtexttranslations',
|
||||
'stabbingwithasyringe',
|
||||
'tinytranslation',
|
||||
'zetrotranslation',
|
||||
];
|
||||
if (domain.find(d => outliers.includes(d))) {
|
||||
isWordPress = false;
|
||||
isBlogspot = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blogspot sites:
|
||||
* - ¼-Assed
|
||||
* - AsuraTls (Outlier)
|
||||
* - Novel World Translations (Outlier)
|
||||
* - SacredText TL (Outlier)
|
||||
*
|
||||
* WordPress sites:
|
||||
* - Anomlaously Creative (Outlier)
|
||||
* - Arcane Translations
|
||||
* - Blossom Translation
|
||||
* - Dumahs Translations
|
||||
* - ElloMTL
|
||||
* - Gem Novels
|
||||
* - Genesis Translations (Manually added)
|
||||
* - Goblinslate
|
||||
* - Hel Scans (Outlier)
|
||||
* - ippotranslations
|
||||
* - JATranslations
|
||||
* - Light Novels Translations
|
||||
* - Mirilu - Novel Reader Attempts Translating (Outlier)
|
||||
* - Neosekai Translations
|
||||
* - Shanghai Fantasy
|
||||
* - Soafp
|
||||
* - Stabbing with a Syringe (Outlier)
|
||||
* - StoneScape
|
||||
* - TinyTL (Outlier)
|
||||
* - Wonder Novels
|
||||
* - Yong Library
|
||||
* - Zetro Translation (Outlier)
|
||||
*/
|
||||
if (!isWordPress && !isBlogspot) {
|
||||
chapterText = await this.getChapterBody(
|
||||
loadedCheerio,
|
||||
domain,
|
||||
url,
|
||||
result,
|
||||
);
|
||||
} else if (isBlogspot) {
|
||||
bloatClasses = ['.button-container', '.separator'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle =
|
||||
loadedCheerio('.entry-title').first().text() ||
|
||||
loadedCheerio('.post-title').first().text()!;
|
||||
chapterContent =
|
||||
loadedCheerio('.entry-content').html() ||
|
||||
loadedCheerio('.post-body').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
} else if (isWordPress) {
|
||||
bloatClasses = [
|
||||
'.ad',
|
||||
'.author-avatar',
|
||||
'.chapter-warning',
|
||||
'.entry-meta',
|
||||
'.ezoic-ad',
|
||||
'.genesistls-watermark',
|
||||
'.mb-center',
|
||||
'.modern-footnotes-footnote__note',
|
||||
'.patreon-widget',
|
||||
'.post-cats',
|
||||
'.pre-bar',
|
||||
'.sharedaddy',
|
||||
'.sidebar',
|
||||
'.wp-block-buttons',
|
||||
'.wp-block-image',
|
||||
'.wp-dark-mode-switcher',
|
||||
'.wp-next-post-navi',
|
||||
'#jp-post-flair',
|
||||
'#textbox',
|
||||
];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle =
|
||||
loadedCheerio('.entry-title').first().text() ||
|
||||
loadedCheerio('.entry-title-main').first().text() ||
|
||||
loadedCheerio('.chapter__title').first().text() ||
|
||||
loadedCheerio('.sp-title').first().text() ||
|
||||
loadedCheerio('.title-content').first().text() ||
|
||||
loadedCheerio('.wp-block-post-title').first().text() ||
|
||||
loadedCheerio('.title_story').first().text() ||
|
||||
loadedCheerio('.active').first().text() ||
|
||||
loadedCheerio('head title').first().text()!;
|
||||
let chapterSubtitle = loadedCheerio('.cat-series').first().text() || '';
|
||||
if (chapterSubtitle) {
|
||||
chapterTitle = chapterSubtitle;
|
||||
}
|
||||
chapterContent =
|
||||
loadedCheerio('.rdminimal').html() ||
|
||||
loadedCheerio('.entry-content').html() ||
|
||||
loadedCheerio('.chapter__content').html() ||
|
||||
loadedCheerio('.prevent-select').html() ||
|
||||
loadedCheerio('.text_story').html() ||
|
||||
loadedCheerio('.contenta').html() ||
|
||||
loadedCheerio('.single_post').html() ||
|
||||
loadedCheerio('.post-entry').html() ||
|
||||
loadedCheerio('.main-content').html() ||
|
||||
loadedCheerio('.content').html() ||
|
||||
loadedCheerio('.page-body').html() ||
|
||||
loadedCheerio('.td-page-content').html() ||
|
||||
loadedCheerio('#content').html() ||
|
||||
loadedCheerio('article.post').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!chapterText) {
|
||||
/**
|
||||
* Remove unnecessary tags
|
||||
*/
|
||||
const tags = ['nav', 'header', 'footer', '.hidden'];
|
||||
tags.map(tag => loadedCheerio(tag).remove());
|
||||
chapterText = loadedCheerio('body').html()!;
|
||||
}
|
||||
|
||||
if (chapterText) {
|
||||
/**
|
||||
* Convert relative urls to absolute
|
||||
*/
|
||||
chapterText = chapterText.replace(
|
||||
/href="\//g,
|
||||
`href="${this.getLocation(result.url)}/`,
|
||||
);
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
/**
|
||||
* Split searchTerm by specific special characters and find the longest split
|
||||
*/
|
||||
const splits = searchTerm.split('*');
|
||||
const longestSearchTerm = splits.reduce(
|
||||
(a, b) => (a.length > b.length ? a : b),
|
||||
'',
|
||||
);
|
||||
searchTerm = longestSearchTerm.replace(/[‘’]/g, "'").replace(/\s+/g, '+');
|
||||
|
||||
const url = this.site + '?s=' + searchTerm + '&post_type=seriesplans';
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Sort Results By',
|
||||
value: 'sdate',
|
||||
options: [
|
||||
{ label: 'Last Updated', value: 'sdate' },
|
||||
{ label: 'Rating', value: 'srate' },
|
||||
{ label: 'Rank', value: 'srank' },
|
||||
{ label: 'Reviews', value: 'sreview' },
|
||||
{ label: 'Chapters', value: 'srel' },
|
||||
{ label: 'Title', value: 'abc' },
|
||||
{ label: 'Readers', value: 'sread' },
|
||||
{ label: 'Frequency', value: 'sfrel' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
order: {
|
||||
label: 'Order',
|
||||
value: 'desc',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
label: 'Story Status (Translation)',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Completed', value: '2' },
|
||||
{ label: 'Ongoing', value: '3' },
|
||||
{ label: 'Hiatus', value: '4' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
language: {
|
||||
label: 'Language',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Chinese', value: '495' },
|
||||
{ label: 'Filipino', value: '9181' },
|
||||
{ label: 'Indonesian', value: '9179' },
|
||||
{ label: 'Japanese', value: '496' },
|
||||
{ label: 'Khmer', value: '18657' },
|
||||
{ label: 'Korean', value: '497' },
|
||||
{ label: 'Malaysian', value: '9183' },
|
||||
{ label: 'Thai', value: '9954' },
|
||||
{ label: 'Vietnamese', value: '9177' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
novelType: {
|
||||
label: 'Novel Type',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Light Novel', value: '2443' },
|
||||
{ label: 'Published Novel', value: '26874' },
|
||||
{ label: 'Web Novel', value: '2444' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genres: {
|
||||
label: 'Genres',
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
value: {
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
options: [
|
||||
{ label: 'Action', value: '8' },
|
||||
{ label: 'Adult', value: '280' },
|
||||
{ label: 'Adventure', value: '13' },
|
||||
{ label: 'Comedy', value: '17' },
|
||||
{ label: 'Drama', value: '9' },
|
||||
{ label: 'Ecchi', value: '292' },
|
||||
{ label: 'Fantasy', value: '5' },
|
||||
{ label: 'Gender Bender', value: '168' },
|
||||
{ label: 'Harem', value: '3' },
|
||||
{ label: 'Historical', value: '330' },
|
||||
{ label: 'Horror', value: '343' },
|
||||
{ label: 'Josei', value: '324' },
|
||||
{ label: 'Martial Arts', value: '14' },
|
||||
{ label: 'Mature', value: '4' },
|
||||
{ label: 'Mecha', value: '10' },
|
||||
{ label: 'Mystery', value: '245' },
|
||||
{ label: 'Psychoical', value: '486' },
|
||||
{ label: 'Romance', value: '15' },
|
||||
{ label: 'School Life', value: '6' },
|
||||
{ label: 'Sci-fi', value: '11' },
|
||||
{ label: 'Seinen', value: '18' },
|
||||
{ label: 'Shoujo', value: '157' },
|
||||
{ label: 'Shoujo Ai', value: '851' },
|
||||
{ label: 'Shounen', value: '12' },
|
||||
{ label: 'Shounen Ai', value: '1692' },
|
||||
{ label: 'Slice of Life', value: '7' },
|
||||
{ label: 'Smut', value: '281' },
|
||||
{ label: 'Sports', value: '1357' },
|
||||
{ label: 'Supernatural', value: '16' },
|
||||
{ label: 'Tragedy', value: '132' },
|
||||
{ label: 'Wuxia', value: '479' },
|
||||
{ label: 'Xianxia', value: '480' },
|
||||
{ label: 'Xuanhuan', value: '3954' },
|
||||
{ label: 'Yaoi', value: '560' },
|
||||
{ label: 'Yuri', value: '922' },
|
||||
],
|
||||
},
|
||||
genre_operator: {
|
||||
label: 'Genre (AND/OR)',
|
||||
value: 'and',
|
||||
options: [
|
||||
{ label: 'AND', value: 'and' },
|
||||
{ label: 'OR', value: 'or' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelUpdates();
|
||||
@@ -0,0 +1,226 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } 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.0.0';
|
||||
icon = 'src/en/pawread/icon.png';
|
||||
site = 'https://m.pawread.com/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.list-comic, .itemBox').each((idx, ele) => {
|
||||
loadedCheerio(ele).find('.serialise').remove();
|
||||
const novelName = loadedCheerio(ele).find('a').text().trim();
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(ele).find('a').attr('href')?.slice(1);
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}list/`;
|
||||
|
||||
const filterValues = [
|
||||
filters.status.value,
|
||||
filters.lang.value,
|
||||
filters.genre.value,
|
||||
];
|
||||
|
||||
link += filterValues.filter(value => value !== '').join('-');
|
||||
if (filterValues.some(value => value !== '')) link += '/';
|
||||
|
||||
if (filters.order.value) link += '-';
|
||||
|
||||
link += filters.sort.value;
|
||||
link += `/?page=${page}`;
|
||||
|
||||
const body = await fetchApi(link).then(r => r.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('#Cover img').attr('title') || 'Untitled',
|
||||
cover: loadedCheerio('#Cover img').attr('src'),
|
||||
author: loadedCheerio('.icon01 <').text().trim(),
|
||||
status: loadedCheerio('.txtItme:first').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.summary = loadedCheerio('#full-des')
|
||||
.find('br')
|
||||
.replaceWith('\n')
|
||||
.end()
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
novel.genres = loadedCheerio('a.btn-default')
|
||||
.map((i, el) => loadedCheerio(el).text().trim())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.item-box').each((idx, ele) => {
|
||||
const smallText = loadedCheerio(ele).find('span:last').text().trim();
|
||||
if (smallText === 'Advanced Chapter') return;
|
||||
const releaseDate = smallText.split('.').map(x => Number(x));
|
||||
const chapterName = loadedCheerio(ele).find('span:first').text().trim();
|
||||
const chapterUrl = loadedCheerio(ele).attr('onclick')?.match(/\d+/)![0];
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: `${novelPath}${chapterUrl}.html`,
|
||||
releaseTime: new Date(
|
||||
releaseDate[0],
|
||||
releaseDate[1],
|
||||
releaseDate[2],
|
||||
).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter;
|
||||
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 steal = ['bit.ly', 'tinyurl', 'pawread'];
|
||||
steal.map(tag => loadedCheerio(`p:icontains(${tag})`).remove());
|
||||
const chapterText = loadedCheerio('.main').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = `${this.site}search/?keywords=${searchTerm}&page=${page}`;
|
||||
|
||||
const result = await fetchApi(searchUrl);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,385 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
|
||||
class RanobesPlugin implements Plugin.PagePlugin {
|
||||
id = 'ranobes';
|
||||
name = 'Ranobes';
|
||||
icon = 'src/en/ranobes/icon.png';
|
||||
site = 'https://ranobes.top';
|
||||
filters?: Filters | undefined;
|
||||
version = '2.0.0';
|
||||
|
||||
async sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
parseNovels(html: string) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let tempNovel = {} as Plugin.NovelItem;
|
||||
tempNovel.name = '';
|
||||
const baseUrl = this.site;
|
||||
let isParsingNovel = false;
|
||||
let isTitleTag = false;
|
||||
let isNovelName = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class']?.includes('short-cont')) {
|
||||
isParsingNovel = true;
|
||||
}
|
||||
if (isParsingNovel) {
|
||||
if (name === 'h2' && attribs['class']?.includes('title')) {
|
||||
isTitleTag = true;
|
||||
}
|
||||
if (isTitleTag && name === 'a') {
|
||||
tempNovel.path = attribs['href'].slice(baseUrl.length);
|
||||
isNovelName = true;
|
||||
}
|
||||
if (name === 'figure') {
|
||||
tempNovel.cover = attribs['style'].replace(
|
||||
/.*url\((.*?)\)./g,
|
||||
'$1',
|
||||
);
|
||||
}
|
||||
if (tempNovel.path && tempNovel.cover) {
|
||||
novels.push(tempNovel);
|
||||
tempNovel = {} as Plugin.NovelItem;
|
||||
tempNovel.name = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isNovelName) {
|
||||
tempNovel.name += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'h2') {
|
||||
isNovelName = false;
|
||||
isTitleTag = false;
|
||||
}
|
||||
if (name === 'figure') {
|
||||
isParsingNovel = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseChapters(data: { chapters: ChapterEntry[] }) {
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
data.chapters.map((item: ChapterEntry) => {
|
||||
chapter.push({
|
||||
name: item.title,
|
||||
releaseTime: new Date(item.date).toISOString(),
|
||||
path: item.link.slice(this.site.length),
|
||||
});
|
||||
});
|
||||
return chapter.reverse();
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}/novels/page/${page}/`;
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const url = this.site + novelPath;
|
||||
const result = await fetchApi(url);
|
||||
const html = await result.text();
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
summary: '',
|
||||
chapters: [],
|
||||
totalPages: 1,
|
||||
};
|
||||
let baseUrl = this.site;
|
||||
let isCover = false;
|
||||
let isAuthor = false;
|
||||
let isSummary = false;
|
||||
let isStatus = false;
|
||||
let isStatusText = false;
|
||||
let isGenres = false;
|
||||
let isGenresText = false;
|
||||
let genreArray: string[] = [];
|
||||
let novelId = '';
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class'] === 'poster') {
|
||||
isCover = true;
|
||||
}
|
||||
if (isCover && name === 'img') {
|
||||
novel.name = attribs['alt'];
|
||||
novel.cover = baseUrl + attribs['src'];
|
||||
}
|
||||
if (name === 'div' && attribs['class'] === 'moreless') {
|
||||
isSummary = true;
|
||||
}
|
||||
if (name === 'li' && attribs['title']?.includes('Original status')) {
|
||||
isStatus = true;
|
||||
}
|
||||
if (name === 'input' && attribs['name'] === 'newsid') {
|
||||
novelId = attribs['value'];
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (isSummary && name === 'br') {
|
||||
novel.summary += `/n`;
|
||||
}
|
||||
if (isStatus && name === 'a') {
|
||||
isStatusText = true;
|
||||
}
|
||||
if (isGenres && name === 'a') {
|
||||
isGenresText = true;
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'itemprop' && value === 'creator') {
|
||||
isAuthor = true;
|
||||
}
|
||||
if (name === 'id' && value === 'mc-fs-genre') {
|
||||
isGenres = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isAuthor) {
|
||||
novel.author = data;
|
||||
}
|
||||
if (isSummary) {
|
||||
novel.summary += data;
|
||||
}
|
||||
if (isStatusText) {
|
||||
novel.status = data === 'Ongoing' ? 'Ongoing' : 'Completed';
|
||||
}
|
||||
if (isGenresText) {
|
||||
genreArray.push(data);
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'a') {
|
||||
isCover = false;
|
||||
isAuthor = false;
|
||||
isStatusText = false;
|
||||
isGenresText = false;
|
||||
isStatus = false;
|
||||
}
|
||||
if (name === 'div') {
|
||||
isSummary = false;
|
||||
isGenres = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
novel.genres = genreArray.join(', ');
|
||||
|
||||
const chapterListUrl = this.site + '/chapters/' + novelId + '/';
|
||||
const chaptersHtml = await fetchApi(chapterListUrl).then(r => r.text());
|
||||
let dataJson: {
|
||||
pages_count: string;
|
||||
chapters: ChapterEntry[];
|
||||
} = { pages_count: '', chapters: [] };
|
||||
let isScript = false;
|
||||
const parser2 = new Parser({
|
||||
ontext(data) {
|
||||
if (isScript) {
|
||||
if (data.includes('window.__DATA__ =')) {
|
||||
dataJson = JSON.parse(data.replace('window.__DATA__ =', ''));
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'main') {
|
||||
isScript = true;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser2.write(chaptersHtml);
|
||||
parser2.end();
|
||||
|
||||
novel.totalPages = Number(dataJson.pages_count);
|
||||
novel.chapters = this.parseChapters(dataJson);
|
||||
|
||||
const latestChapterUrl = dataJson.chapters[0].link;
|
||||
const latestChapterName = dataJson.chapters[0].title;
|
||||
const latestChapterDate = dataJson.chapters[0].date;
|
||||
|
||||
novel.latestChapter = latestChapterUrl
|
||||
? {
|
||||
path: latestChapterUrl.replace(this.site, ''),
|
||||
name: latestChapterName,
|
||||
releaseTime: new Date(latestChapterDate).toISOString(),
|
||||
}
|
||||
: undefined;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const pagePath = novelPath.split('-')[0];
|
||||
const firstUrl = this.site + '/chapters' + pagePath.replace('novels/', '');
|
||||
const pageUrl = firstUrl + '/page/' + page;
|
||||
const pageBody = await fetchApi(pageUrl).then(r => r.text());
|
||||
let isScript = false;
|
||||
let dataJson: {
|
||||
pages_count: string;
|
||||
chapters: ChapterEntry[];
|
||||
} = { pages_count: '', chapters: [] };
|
||||
const parser = new Parser({
|
||||
ontext(data) {
|
||||
if (isScript) {
|
||||
if (data.includes('window.__DATA__ =')) {
|
||||
dataJson = JSON.parse(data.replace('window.__DATA__ =', ''));
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'main') {
|
||||
isScript = true;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(pageBody);
|
||||
parser.end();
|
||||
const chapters = this.parseChapters(dataJson);
|
||||
return {
|
||||
chapters,
|
||||
};
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const html = await result.text();
|
||||
let chapterText = '';
|
||||
let isChapter = false;
|
||||
let isPtag = false;
|
||||
let isStyleText = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (isChapter && name === 'div') {
|
||||
let stylediv = attribs['style'];
|
||||
if (stylediv) {
|
||||
chapterText += `<div style="${stylediv}">`;
|
||||
isStyleText = true;
|
||||
} else {
|
||||
chapterText += `<div>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
let w = attribs['width'];
|
||||
if (w) {
|
||||
chapterText += `<table width="${w}">`;
|
||||
} else {
|
||||
chapterText += `<table>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `<tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `<tr>`;
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
let w1 = attribs['width'];
|
||||
if (w1) {
|
||||
chapterText += `<td width="${w1}">`;
|
||||
} else {
|
||||
chapterText += `<td>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'id' && value === 'arrticle') {
|
||||
isChapter = true;
|
||||
}
|
||||
if (name === 'class' && value === 'category grey ellipses') {
|
||||
isChapter = false;
|
||||
isPtag = false;
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (isChapter && name === 'p') {
|
||||
chapterText += '<p>';
|
||||
isPtag = true;
|
||||
if (isStyleText) {
|
||||
isStyleText = false;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'br') {
|
||||
chapterText += `<br>`;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isPtag) {
|
||||
chapterText += data;
|
||||
}
|
||||
if (isStyleText) {
|
||||
chapterText += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'p') {
|
||||
isPtag = false;
|
||||
chapterText += '</p>';
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
chapterText += `</td>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `</tr>`;
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `</tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
chapterText += `</table>`;
|
||||
}
|
||||
if (isChapter && name === 'div') {
|
||||
isStyleText = false;
|
||||
chapterText += `</div>`;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}/search/${searchTerm}/page/${page}`;
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
}
|
||||
export default new RanobesPlugin();
|
||||
|
||||
interface ChapterEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
date: string;
|
||||
link: string;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class ReadLiteNovel implements Plugin.PluginBase {
|
||||
id = 'rln.app';
|
||||
name = 'ReadLiteNovel';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/readlitenovel/icon.png';
|
||||
site = 'https://rln.app';
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const link = `${this.site}/ranking/${filters.order.value}/${page}`;
|
||||
const result = await fetchApi(link);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.category-items li').each((i, el) => {
|
||||
const novelUrl = loadedCheerio(el).find('.category-name a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
const novelName = loadedCheerio(el)
|
||||
.find('.category-name a')
|
||||
.text()
|
||||
.trim();
|
||||
let novelCover = loadedCheerio(el).find('.category-img img').attr('src');
|
||||
|
||||
if (novelCover && !isUrlAbsolute(novelCover)) {
|
||||
novelCover = this.site + novelCover;
|
||||
}
|
||||
|
||||
const novel = {
|
||||
path: novelUrl?.replace(this.site, ''),
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
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('.novel-title').text() || 'Untitled',
|
||||
cover: loadedCheerio('.novels-detail img').attr('src'),
|
||||
summary: loadedCheerio('.empty-box').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('.novels-detail-right li').each((i, el) => {
|
||||
const detailName = loadedCheerio(el).find('div:first').text();
|
||||
const detail = loadedCheerio(el).find('div:last');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Status:':
|
||||
novel.status = detail.text();
|
||||
break;
|
||||
case 'Genres:':
|
||||
novel.genres =
|
||||
detail
|
||||
.find('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(', ') || detail.text();
|
||||
break;
|
||||
case 'Author(s):':
|
||||
novel.author =
|
||||
detail
|
||||
.find('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(', ') || detail.text();
|
||||
break;
|
||||
case 'Translator:':
|
||||
novel.artist = detail.text();
|
||||
break;
|
||||
}
|
||||
});
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
loadedCheerio('.cm-tabs-content li').each((i, el) => {
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
if (!chapterUrl) return;
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
const releaseDate = this.parseAgoDate(
|
||||
loadedCheerio(el).find('svg').attr('data-bs-original-title')!,
|
||||
);
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl?.replace(this.site, ''),
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter;
|
||||
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('#chapterText').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site + `/search/autocomplete?dataType=json&query=${searchTerm}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.json();
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
body.results.forEach(
|
||||
(item: { link: string; original_title: string; image: string }) =>
|
||||
novels.push({
|
||||
path: item.link,
|
||||
name: item.original_title,
|
||||
cover: item.image,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'top-rated',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'MOST VIEWED', value: 'most-viewed' },
|
||||
{ label: 'TOP RATED', value: 'top-rated' },
|
||||
{ label: 'BOOKMARKS', value: 'subscribers' },
|
||||
{ label: 'NEW', value: 'new' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ReadLiteNovel();
|
||||
@@ -0,0 +1,387 @@
|
||||
import { fetchFile, fetchText } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { load as loadCheerio, CheerioAPI } from 'cheerio';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { NovelItem } from '../../test_web/static/js';
|
||||
// import { isUrlAbsolute } from "@libs/isAbsoluteUrl";
|
||||
// import { parseMadaraDate } from "@libs/parseMadaraDate";
|
||||
|
||||
interface FuzzySearchOptions {
|
||||
caseSensitive: boolean;
|
||||
sort: boolean;
|
||||
}
|
||||
|
||||
// Shamelessly stolen from 'https://raw.githubusercontent.com/wouterrutgers/fuzzy-search/master/src/FuzzySearch.mjs'
|
||||
// I did modify the code a fair bit, but the algorithm is still the same under the hood.
|
||||
// The original code is under ISC, which stipluate that it needs to retain the copyright notice, so here it is.
|
||||
//
|
||||
// It only applies to the code in the FuzzySearch Class, even though I did modify it a fair bit
|
||||
|
||||
/*
|
||||
Copyright (c) 2016, Wouter Rutgers
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
class FuzzySearch<Item> {
|
||||
private haystack: Item[];
|
||||
private options: FuzzySearchOptions;
|
||||
private getItems: (item: Item) => string[];
|
||||
|
||||
constructor(
|
||||
getItems: (item: Item) => string[],
|
||||
options: Partial<FuzzySearchOptions>,
|
||||
) {
|
||||
this.haystack = [];
|
||||
this.options = Object.assign(
|
||||
{
|
||||
caseSensitive: false,
|
||||
sort: false,
|
||||
},
|
||||
options,
|
||||
);
|
||||
this.getItems = getItems;
|
||||
}
|
||||
|
||||
getOptions(): FuzzySearchOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
setOptions(options: Partial<FuzzySearchOptions>) {
|
||||
this.options = Object.assign(this.options, options);
|
||||
}
|
||||
|
||||
setHaystack(items: Item[]) {
|
||||
this.haystack = items;
|
||||
}
|
||||
|
||||
getHaystack(): Item[] {
|
||||
return this.haystack;
|
||||
}
|
||||
|
||||
search(query: string): Item[] {
|
||||
if (query.length === 0) {
|
||||
return this.haystack;
|
||||
}
|
||||
const results: { item: Item; score: number }[] = [];
|
||||
|
||||
for (let item of this.haystack) {
|
||||
for (let value of this.getItems(item)) {
|
||||
const score = this.isMatch(value, query);
|
||||
if (score !== undefined) {
|
||||
results.push({ item, score });
|
||||
/// We don't want to have multiple occurence of the same item, so only take one if it is found
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.sort) {
|
||||
results.sort((a, b) => a.score - b.score);
|
||||
}
|
||||
|
||||
return results.map(result => result.item);
|
||||
}
|
||||
|
||||
isMatch(item: string, query: string): number | undefined {
|
||||
if (!this.options.caseSensitive) {
|
||||
item = item.toLocaleLowerCase();
|
||||
query = query.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
const indexes = this.nearestIndexesFor(item, query);
|
||||
|
||||
if (indexes === undefined) {
|
||||
return;
|
||||
}
|
||||
// Exact matches should be first.
|
||||
if (item === query) {
|
||||
return 1;
|
||||
}
|
||||
// If we have more than 2 letters, matches close to each other should be first.
|
||||
if (indexes.length > 1) {
|
||||
return 2 + (indexes[indexes.length - 1] - indexes[0]);
|
||||
}
|
||||
// Matches closest to the start of the string should be first.
|
||||
return 2 + indexes[0];
|
||||
}
|
||||
|
||||
nearestIndexesFor(item: string, query: string): number[] | undefined {
|
||||
const letters = query.split('');
|
||||
let indexes: number[][] = [];
|
||||
|
||||
const idxFL = this.idxFirstLetter(item, query);
|
||||
|
||||
for (let idx of idxFL) {
|
||||
indexes.push([idx]);
|
||||
for (let i = 1; i < letters.length; i++) {
|
||||
const letter = letters[i];
|
||||
let index = item.indexOf(letter, idx + 1);
|
||||
if (index === -1) {
|
||||
indexes.pop();
|
||||
break;
|
||||
}
|
||||
indexes[indexes.length - 1].push(index);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
if (indexes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return indexes.sort((a, b) => {
|
||||
if (a.length === 1) {
|
||||
return a[0] - b[0];
|
||||
}
|
||||
|
||||
let res_a = a[a.length - 1] - a[0];
|
||||
let res_b = b[b.length - 1] - b[0];
|
||||
|
||||
return res_a - res_b;
|
||||
})[0];
|
||||
}
|
||||
|
||||
idxFirstLetter(item: string, query: string): number[] {
|
||||
const match = query[0];
|
||||
|
||||
return item
|
||||
.split('')
|
||||
.map((letter, index) => {
|
||||
if (letter !== match) {
|
||||
return;
|
||||
}
|
||||
return index;
|
||||
})
|
||||
.filter(index => index !== undefined) as number[];
|
||||
}
|
||||
}
|
||||
|
||||
class ReLibraryPlugin implements Plugin.PluginBase {
|
||||
id = 'ReLib';
|
||||
name = 'Re:Library';
|
||||
icon = 'src/en/relibrary/icon.png';
|
||||
site = 'https://re-library.com';
|
||||
version = '1.0.0';
|
||||
filters: Filters | undefined = undefined;
|
||||
|
||||
private searchFunc: FuzzySearch<Plugin.NovelItem> = new FuzzySearch(
|
||||
item => [item.name],
|
||||
{ sort: true, caseSensitive: false },
|
||||
);
|
||||
|
||||
private async popularNovelsInner(url: string): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
loadedCheerio('.entry-content > ol > li').each((_i, el) => {
|
||||
let novel: Partial<NovelItem> = {};
|
||||
novel.name = loadedCheerio(el).find('h3 > a').text();
|
||||
novel.path = loadedCheerio(el)
|
||||
.find('table > tbody > tr > td > a')
|
||||
.attr('href');
|
||||
if (novel.name === undefined || novel.path === undefined) return;
|
||||
novel.cover =
|
||||
loadedCheerio(el)
|
||||
.find('table > tbody > tr > td > a > img')
|
||||
.attr('src') || defaultCover;
|
||||
if (novel.path.startsWith(this.site)) {
|
||||
novel.path = novel.path.slice(this.site.length);
|
||||
}
|
||||
novels.push(novel as Plugin.NovelItem);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
private async lastestNovelsInner(url: string): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
loadedCheerio('article.type-page.page').each((_i, el) => {
|
||||
let novel: Partial<Plugin.NovelItem> = {};
|
||||
novel.name = loadedCheerio(el).find('.entry-title').text();
|
||||
novel.path = loadedCheerio(el).find('.entry-title a').attr('href');
|
||||
if (novel.path === undefined || novel.name === undefined) return;
|
||||
novel.cover =
|
||||
loadedCheerio(el)
|
||||
.find('.entry-content > table > tbody > tr > td > img')
|
||||
.attr('src') || defaultCover;
|
||||
if (novel.path.startsWith(this.site)) {
|
||||
novel.path = novel.path.slice(this.site.length);
|
||||
}
|
||||
novels.push(novel as Plugin.NovelItem);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// The most-popular page only has a single page, so we return an empty array in case you ask for the impossible
|
||||
// the lastest page do have paginated result, so we support that.
|
||||
if (showLatestNovels)
|
||||
return this.lastestNovelsInner(
|
||||
`${this.site}/tag/translations/page/${pageNo}`,
|
||||
);
|
||||
else if (pageNo === 1)
|
||||
return this.popularNovelsInner(`${this.site}/translations/most-popular/`);
|
||||
else return [];
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
let novel: Partial<Plugin.SourceNovel> = {
|
||||
path: novelPath,
|
||||
};
|
||||
// title: .entry-content > header.entry-header > .entry-title
|
||||
// img: .entry-content > table > tbody > tr > td > img
|
||||
// tags: .entry-content > table > tbody > tr > td > p > span > a[]
|
||||
// synopis: .entry-content > div.su-box > div.su-box-content
|
||||
// chapters: .entry-content > div.su-accordion <then> li.page_item[]
|
||||
|
||||
const result = await fetch(`${this.site}/${novelPath}`);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
// If it doesn't find the name I should just throw an error (or early return) since the scraping is broken
|
||||
novel.name = loadedCheerio('header.entry-header > .entry-title')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
if (novel.name === undefined || novel.name === '404 – Page not found')
|
||||
throw new Error(`Invalid novel for url ${novelPath}`);
|
||||
|
||||
// Find the cover
|
||||
novel.cover =
|
||||
loadedCheerio('.entry-content > table > tbody > tr > td > img').attr(
|
||||
'src',
|
||||
) || defaultCover;
|
||||
|
||||
// Genres in comma separated "list"
|
||||
novel.genres = (() => {
|
||||
let genres: string[] = [];
|
||||
loadedCheerio(
|
||||
'.entry-content > table > tbody > tr > td > p > span > a',
|
||||
).each((_i, el) => {
|
||||
genres.push(loadedCheerio(el).text().trim());
|
||||
});
|
||||
return genres.join(', ');
|
||||
})();
|
||||
|
||||
// Handle the novel status
|
||||
// Sadly some novels just state the status inside the summary...
|
||||
// I don't even know if the snippet here works for *most* of the novels preset, or only for a few
|
||||
loadedCheerio('.entry-content > table > tbody > tr > td > p').each(
|
||||
function (_i, el) {
|
||||
if (
|
||||
loadedCheerio(el)
|
||||
.find('strong')
|
||||
.text()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.startsWith('status')
|
||||
) {
|
||||
loadedCheerio(el).find('strong').remove();
|
||||
novel.status = loadedCheerio(el).text();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
novel.summary = loadedCheerio(
|
||||
'.entry-content > div.su-box > div.su-box-content',
|
||||
).text();
|
||||
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
let chapter_idx = 0;
|
||||
loadedCheerio('.entry-content > div.su-accordion').each((_i1, el) => {
|
||||
loadedCheerio(el)
|
||||
.find('li.page_item > a')
|
||||
.each((_i2, chap_el) => {
|
||||
chapter_idx += 1;
|
||||
let chap_path = loadedCheerio(chap_el).attr('href')?.trim();
|
||||
if (
|
||||
loadedCheerio(chap_el).text() === undefined ||
|
||||
chap_path === undefined
|
||||
)
|
||||
return;
|
||||
if (chap_path.startsWith(this.site)) {
|
||||
chap_path = chap_path.slice(this.site.length);
|
||||
}
|
||||
chapters.push({
|
||||
name: loadedCheerio(chap_el).text(),
|
||||
path: chap_path,
|
||||
chapterNumber: chapter_idx,
|
||||
// we KNOW that we can't get the released time (at least without any additional fetches), so set it to null purposfully
|
||||
releaseTime: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel as Plugin.NovelItem;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
// parse chapter text here
|
||||
const result = await fetch(`${this.site}/${chapterPath}`);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
let text: string[] = [];
|
||||
loadedCheerio('.entry-content > p')
|
||||
.slice(1)
|
||||
.each((_i, el) => {
|
||||
loadedCheerio(el).find('span').remove();
|
||||
let t = loadedCheerio(el).html();
|
||||
if (t === undefined) return;
|
||||
text.push(`<p>${t}</p>`);
|
||||
});
|
||||
if (text.length == 0)
|
||||
throw new Error(
|
||||
`Invalid Parsing of chapter, couldn't find any text for url "${this.site}/${chapterPath}"`,
|
||||
);
|
||||
return text.join('');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// We only want to serve a single "page" since we do the search client side.
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
const req = await fetch(`${this.site}/translations/`);
|
||||
const body = await req.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
loadedCheerio('.entry-content table a').each((_i, el) => {
|
||||
let e = loadedCheerio(el);
|
||||
if (e && e.attr('href') && e.text()) {
|
||||
let path = e.attr('href')!;
|
||||
if (path.startsWith(this.site)) {
|
||||
path = path.slice(this.site.length);
|
||||
}
|
||||
novels.push({ name: e.text(), path: path, cover: defaultCover });
|
||||
}
|
||||
});
|
||||
this.searchFunc.setHaystack(novels);
|
||||
return this.searchFunc.search(searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReLibraryPlugin();
|
||||
@@ -0,0 +1,401 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class RoyalRoad implements Plugin.PluginBase {
|
||||
id = 'royalroad';
|
||||
name = 'Royal Road';
|
||||
version = '2.0.0';
|
||||
icon = 'src/en/royalroad/icon.png';
|
||||
site = 'https://www.royalroad.com/';
|
||||
|
||||
parseNovels(html: string) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let tempNovel = {} as Plugin.NovelItem;
|
||||
tempNovel.name = '';
|
||||
let isParsingNovel = false;
|
||||
let isNovelName = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class']?.includes('fiction-list-item')) {
|
||||
isParsingNovel = true;
|
||||
}
|
||||
if (isParsingNovel) {
|
||||
if (name === 'a' && attribs['class']?.includes('bold')) {
|
||||
tempNovel.path = attribs['href'].slice(1);
|
||||
isNovelName = true;
|
||||
}
|
||||
if (name === 'img') {
|
||||
tempNovel.cover = attribs['src'];
|
||||
}
|
||||
if (tempNovel.path && tempNovel.name) {
|
||||
novels.push(tempNovel);
|
||||
tempNovel = {} as Plugin.NovelItem;
|
||||
tempNovel.name = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isNovelName) {
|
||||
tempNovel.name += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'h2') {
|
||||
isNovelName = false;
|
||||
isParsingNovel = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}fictions/`;
|
||||
|
||||
link += filters.order.value;
|
||||
link += `?page=${page}`;
|
||||
|
||||
if (filters.genre.value !== '') link += `&genre=${filters.genre.value}`;
|
||||
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const html = await result.text();
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
summary: '',
|
||||
chapters: [],
|
||||
};
|
||||
let isNovelName = false;
|
||||
let isAuthorName = false;
|
||||
let isDescription = false;
|
||||
let isH4 = false;
|
||||
let isSpan = 0;
|
||||
let isTags = false;
|
||||
let isGenres = false;
|
||||
let genreArray: string[] = [];
|
||||
let isFooter = false;
|
||||
let isScript = false;
|
||||
let chapterJson: ChapterEntry[] = [];
|
||||
let volumeJson: VolumeEntry[] = [];
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'img' && attribs['class']?.includes('thumbnail')) {
|
||||
novel.cover = attribs['src'];
|
||||
}
|
||||
if (name === 'span' && attribs['class']?.includes('label-sm')) {
|
||||
isSpan++;
|
||||
}
|
||||
if (name === 'span' && attribs['class']?.includes('tags')) {
|
||||
isTags = true;
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (name === 'h1') {
|
||||
isNovelName = true;
|
||||
}
|
||||
if (isH4 && name === 'a') {
|
||||
isAuthorName = true;
|
||||
}
|
||||
if (isTags && name === 'a') {
|
||||
isGenres = true;
|
||||
}
|
||||
if (name === 'label') {
|
||||
isDescription = false;
|
||||
isTags = false;
|
||||
}
|
||||
if (isFooter && name === 'script') {
|
||||
isScript = true;
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'description') {
|
||||
isDescription = true;
|
||||
}
|
||||
if (name === 'class' && value === 'page-footer footer') {
|
||||
isFooter = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isNovelName) {
|
||||
novel.name = data;
|
||||
}
|
||||
if (isAuthorName) {
|
||||
novel.author = data;
|
||||
isAuthorName = false;
|
||||
}
|
||||
if (isDescription) {
|
||||
novel.summary += data;
|
||||
}
|
||||
if (isSpan === 2) {
|
||||
novel.status = data.trim();
|
||||
isSpan++;
|
||||
}
|
||||
if (isGenres) {
|
||||
genreArray.push(data);
|
||||
}
|
||||
if (isScript) {
|
||||
if (data.includes('window.chapters =')) {
|
||||
chapterJson = JSON.parse(
|
||||
data.match(/window.chapters = (.+])(?=;)/)![1],
|
||||
);
|
||||
volumeJson = JSON.parse(
|
||||
data.match(/window.volumes = (.+])(?=;)/)![1],
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'h1') {
|
||||
isNovelName = false;
|
||||
isH4 = true;
|
||||
}
|
||||
if (name === 'h4') {
|
||||
isH4 = false;
|
||||
}
|
||||
if (name === 'a') {
|
||||
isGenres = false;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
if (name === 'body') {
|
||||
isFooter = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
novel.summary = novel.summary?.trim();
|
||||
novel.genres = genreArray.join(', ');
|
||||
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;
|
||||
}
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = chapterJson.map(
|
||||
(chapter: ChapterEntry) => {
|
||||
const matchingVolume = volumeJson.find(
|
||||
(volume: VolumeEntry) => volume.id === chapter.volumeId,
|
||||
);
|
||||
return {
|
||||
name: chapter.title,
|
||||
path: chapter.url.slice(1),
|
||||
releaseTime: chapter.date,
|
||||
chapterNumber: chapter?.order,
|
||||
page: matchingVolume?.title,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
novel.chapters = chapter;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const html = await result.text();
|
||||
let chapterText = '';
|
||||
let isChapter = false;
|
||||
let isPtag = false;
|
||||
let isStyle = false;
|
||||
let isStyleText = false;
|
||||
let styles = '';
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (isChapter && name === 'div') {
|
||||
let stylediv = attribs['style'];
|
||||
if (stylediv) {
|
||||
chapterText += `<div style="${stylediv}">`;
|
||||
isStyleText = true;
|
||||
} else {
|
||||
chapterText += `<div>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
let w = attribs['width'];
|
||||
if (w) {
|
||||
chapterText += `<table width="${w}">`;
|
||||
} else {
|
||||
chapterText += `<table>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `<tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `<tr>`;
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
let w1 = attribs['width'];
|
||||
if (w1) {
|
||||
chapterText += `<td width="${w1}">`;
|
||||
} else {
|
||||
chapterText += `<td>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'chapter-inner chapter-content') {
|
||||
isChapter = true;
|
||||
}
|
||||
if (name === 'class' && value === 'portlet light t-center-3') {
|
||||
isChapter = false;
|
||||
isPtag = false;
|
||||
}
|
||||
if (name === 'class' && value === styles) {
|
||||
isPtag = false;
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (isChapter && name === 'p') {
|
||||
chapterText += '<p>';
|
||||
isPtag = true;
|
||||
if (isStyleText) {
|
||||
isStyleText = false;
|
||||
}
|
||||
}
|
||||
if (name === 'style') {
|
||||
isStyle = true;
|
||||
}
|
||||
if (isChapter && name === 'br') {
|
||||
chapterText += `<br>`;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isPtag) {
|
||||
chapterText += data;
|
||||
}
|
||||
if (isStyleText) {
|
||||
chapterText += data;
|
||||
}
|
||||
if (isStyle) {
|
||||
if (data.includes('display: none;')) {
|
||||
styles = data.match(/\.(.*)\{/)![1];
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'p') {
|
||||
isPtag = false;
|
||||
chapterText += '</p>';
|
||||
}
|
||||
if (name === 'style') {
|
||||
isStyle = false;
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
chapterText += `</td>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `</tr>`;
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `</tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
chapterText += `</table>`;
|
||||
}
|
||||
if (isChapter && name === 'div') {
|
||||
isStyleText = false;
|
||||
chapterText += `</div>`;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = `${this.site}fictions/search?page=${page}&title=${searchTerm}`;
|
||||
|
||||
const body = await fetchApi(searchUrl).then(r => r.text());
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'weekly-popular',
|
||||
label: 'Order By',
|
||||
options: [
|
||||
{ label: 'Best Rated', value: 'best-rated' },
|
||||
{ label: 'Trending', value: 'trending' },
|
||||
{ label: 'Ongoing Fictions', value: 'active-popular' },
|
||||
{ label: 'Complete', value: 'complete' },
|
||||
{ label: 'Popular this week', value: 'weekly-popular' },
|
||||
{ label: 'Latest Updates', value: 'latest-updates' },
|
||||
{ label: 'Newest Fictions', value: 'new' },
|
||||
{ label: 'Rising Stars', value: 'rising-stars' },
|
||||
{ label: 'Writathon', value: 'writathon' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
value: '',
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'ALL', value: '' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Contemporary', value: 'contemporary' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Mystery', value: 'mystery' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ label: 'Satire', value: 'satire' },
|
||||
{ label: 'Sci-fi', value: 'sci_fi' },
|
||||
{ label: 'Short Story', value: 'one_shot' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new RoyalRoad();
|
||||
|
||||
interface ChapterEntry {
|
||||
id: number;
|
||||
volumeId: number;
|
||||
title: string;
|
||||
date: string;
|
||||
order: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface VolumeEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
cover: string;
|
||||
order: number;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class ScribbleHubPlugin implements Plugin.PluginBase {
|
||||
id = 'scribblehub';
|
||||
name = 'ScribbleHub';
|
||||
icon = 'src/en/scribblehub/icon.png';
|
||||
site = 'https://www.scribblehub.com/';
|
||||
version = '1.0.0';
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.search_main_box').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('.search_title > a').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('.search_img > img')
|
||||
.attr('src');
|
||||
const novelUrl = loadedCheerio(el).find('.search_title > a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}`;
|
||||
if (showLatestNovels) link += 'latest-series/';
|
||||
// if (filters.genres.value.include?.length ||
|
||||
// filters.genres.value.exclude?.length)
|
||||
else link += 'series-finder/?sf=1';
|
||||
// else
|
||||
// link += "series-ranking/";
|
||||
// TODO, series-ranking filters when hideOnSelect come out
|
||||
|
||||
if (filters.genres.value.include?.length)
|
||||
link += '&gi=' + filters.genres.value.include.join(',');
|
||||
|
||||
if (filters.genres.value.exclude?.length)
|
||||
link += '&ge=' + filters.genres.value.exclude.join(',');
|
||||
|
||||
if (
|
||||
filters.genres.value.include?.length ||
|
||||
filters.genres.value.exclude?.length
|
||||
)
|
||||
link += '&mgi=' + filters.genre_operator.value;
|
||||
|
||||
if (filters.content_warning.value.include?.length)
|
||||
link += '&cti=' + filters.content_warning.value.include.join(',');
|
||||
|
||||
if (filters.content_warning.value.exclude?.length)
|
||||
link += '&cte=' + filters.content_warning.value.exclude.join(',');
|
||||
|
||||
if (
|
||||
filters.content_warning.value.include?.length ||
|
||||
filters.content_warning.value.exclude?.length
|
||||
)
|
||||
link += '&mct' + filters.content_warning_operator.value;
|
||||
|
||||
link += '&sort=' + filters.sort.value;
|
||||
link += '&order=' + filters.order.value;
|
||||
|
||||
const body = await fetchApi(link).then(result => 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('.fic_title').text() || 'Untitled',
|
||||
cover: loadedCheerio('.fic_image > img').attr('src'),
|
||||
summary: loadedCheerio('.wi_fic_desc').text(),
|
||||
author: loadedCheerio('.auth_name_fic').text(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('.fic_genre')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
novel.status = loadedCheerio('.rnd_stats').next().text().includes('Ongoing')
|
||||
? 'Ongoing'
|
||||
: 'Completed';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'wi_getreleases_pagination');
|
||||
formData.append('pagenum', '-1');
|
||||
formData.append('mypostid', novelPath.split('/')[1]);
|
||||
|
||||
const data = await fetchApi(`${this.site}wp-admin/admin-ajax.php`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const text = await data.text();
|
||||
|
||||
loadedCheerio = parseHTML(text);
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
const parseISODate = (date: string) => {
|
||||
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
|
||||
}
|
||||
|
||||
return dayJSDate.toISOString();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
loadedCheerio('.toc_w').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('.toc_a').text();
|
||||
const releaseDate = loadedCheerio(el).find('.fic_date_pub').text();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: parseISODate(releaseDate),
|
||||
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('div.chp_raw').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}?s=${searchTerm}&post_type=fictionposts`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Sort Results By',
|
||||
value: 'ratings',
|
||||
options: [
|
||||
{ label: 'Chapters', value: 'chapters' },
|
||||
{ label: 'Chapters per week', value: 'frequency' },
|
||||
{ label: 'Date Added', value: 'dateadded' },
|
||||
{ label: 'Favorites', value: 'favorites' },
|
||||
{ label: 'Last Updated', value: 'lastchdate' },
|
||||
{ label: 'Number of Ratings', value: 'numofrate' },
|
||||
{ label: 'Pages', value: 'pages' },
|
||||
{ label: 'Pageviews', value: 'pageviews' },
|
||||
{ label: 'Ratings', value: 'ratings' },
|
||||
{ label: 'Readers', value: 'readers' },
|
||||
{ label: 'Reviews', value: 'reviews' },
|
||||
{ label: 'Total Words', value: 'totalwords' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
order: {
|
||||
label: 'Order by',
|
||||
value: 'desc',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
label: 'Story Status (Translation)',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Hiatus', value: 'hiatus' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
label: 'Genres',
|
||||
value: {
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
options: [
|
||||
{ label: 'Action', value: '9' },
|
||||
{ label: 'Adult', value: '902' },
|
||||
{ label: 'Adventure', value: '8' },
|
||||
{ label: 'Boys Love', value: '891' },
|
||||
{ label: 'Comedy', value: '7' },
|
||||
{ label: 'Drama', value: '903' },
|
||||
{ label: 'Ecchi', value: '904' },
|
||||
{ label: 'Fanfiction', value: '38' },
|
||||
{ label: 'Fantasy', value: '19' },
|
||||
{ label: 'Gender Bender', value: '905' },
|
||||
{ label: 'Girls Love', value: '892' },
|
||||
{ label: 'Harem', value: '1015' },
|
||||
{ label: 'Historical', value: '21' },
|
||||
{ label: 'Horror', value: '22' },
|
||||
{ label: 'Isekai', value: '37' },
|
||||
{ label: 'Josei', value: '906' },
|
||||
{ label: 'LitRPG', value: '1180' },
|
||||
{ label: 'Martial Arts', value: '907' },
|
||||
{ label: 'Mature', value: '20' },
|
||||
{ label: 'Mecha', value: '908' },
|
||||
{ label: 'Mystery', value: '909' },
|
||||
{ label: 'Psychological', value: '910' },
|
||||
{ label: 'Romance', value: '6' },
|
||||
{ label: 'School Life', value: '911' },
|
||||
{ label: 'Sci-fi', value: '912' },
|
||||
{ label: 'Seinen', value: '913' },
|
||||
{ label: 'Slice of Life', value: '914' },
|
||||
{ label: 'Smut', value: '915' },
|
||||
{ label: 'Sports', value: '916' },
|
||||
{ label: 'Supernatural', value: '5' },
|
||||
{ label: 'Tragedy', value: '901' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
genre_operator: {
|
||||
value: 'and',
|
||||
label: 'Genre And/Or',
|
||||
options: [
|
||||
{ label: 'OR', value: 'or' },
|
||||
{ label: 'AND', value: 'and' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
content_warning: {
|
||||
value: {
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
label: 'Mature Content',
|
||||
options: [
|
||||
{ label: 'Gore', value: '48' },
|
||||
{ label: 'Sexual Content', value: '50' },
|
||||
{ label: 'Strong Language', value: '49' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
content_warning_operator: {
|
||||
value: 'and',
|
||||
label: 'Mature Content And/Or',
|
||||
options: [
|
||||
{ label: 'OR', value: 'or' },
|
||||
{ label: 'AND', value: 'and' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ScribbleHubPlugin();
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
|
||||
class WTRLAB implements Plugin.PluginBase {
|
||||
id = 'WTRLAB';
|
||||
name = 'WTR-LAB';
|
||||
site = 'https://wtr-lab.com/';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/wtrlab/icon.png';
|
||||
sourceLang = 'en/';
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site + this.sourceLang + 'novel-list?';
|
||||
link += `orderBy=${filters.order.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
link += `&filter=${filters.storyStatus.value}`;
|
||||
link += `&page=${page}`; //TODO Genre & Advance Searching Filter. Ez to implement, too much manual work, too lazy.
|
||||
|
||||
if (showLatestNovels) {
|
||||
const response = await fetch('https://wtr-lab.com/api/home/recent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ page: page }),
|
||||
});
|
||||
|
||||
const recentNovel: JsonNovel = await response.json();
|
||||
|
||||
// Parse novels from JSON
|
||||
const novels: Plugin.NovelItem[] = recentNovel.data.map(
|
||||
(datum: Datum) => ({
|
||||
name: datum.serie.data.title || '',
|
||||
cover: datum.serie.data.image,
|
||||
path:
|
||||
this.sourceLang +
|
||||
'serie-' +
|
||||
datum.serie.raw_id +
|
||||
'/' +
|
||||
datum.serie.slug || '',
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
} else {
|
||||
const body = await fetchApi(link).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novels: Plugin.NovelItem[] = loadedCheerio('.serie-item')
|
||||
.map((index, element) => ({
|
||||
name:
|
||||
loadedCheerio(element)
|
||||
.find('.title-wrap > a')
|
||||
.text()
|
||||
.replace(loadedCheerio(element).find('.rawtitle').text(), '') ||
|
||||
'',
|
||||
cover: loadedCheerio(element).find('img').attr('src'),
|
||||
path: loadedCheerio(element).find('a').attr('href') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.text-uppercase').text(),
|
||||
cover: loadedCheerio('.img-wrap > img').attr('src'),
|
||||
summary: loadedCheerio('.lead').text().trim(),
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('td:contains("Genre")')
|
||||
.next()
|
||||
.find('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
novel.author = loadedCheerio('td:contains("Author")')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.status = loadedCheerio('td:contains("Status")')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
const chapterJson = loadedCheerio('#__NEXT_DATA__').html() + '';
|
||||
const jsonData: NovelJson = JSON.parse(chapterJson);
|
||||
|
||||
const chapters: Plugin.ChapterItem[] =
|
||||
jsonData.props.pageProps.serie.chapters.map(
|
||||
(jsonChapter, chapterIndex) => ({
|
||||
name: jsonChapter.title,
|
||||
path:
|
||||
this.sourceLang +
|
||||
'serie-' +
|
||||
jsonData.props.pageProps.serie.serie_data.raw_id +
|
||||
'/' +
|
||||
jsonData.props.pageProps.serie.serie_data.slug +
|
||||
'/chapter-' +
|
||||
jsonChapter.order, // Assuming 'slug' is the intended path
|
||||
releaseTime: (
|
||||
jsonChapter?.created_at || jsonChapter?.updated_at
|
||||
)?.substring(0, 10),
|
||||
chapterNumber: chapterIndex + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
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 chapterJson = loadedCheerio('#__NEXT_DATA__').html() + '';
|
||||
const jsonData: NovelJson = JSON.parse(chapterJson);
|
||||
|
||||
const chapterContent = JSON.stringify(
|
||||
jsonData.props.pageProps.serie.chapter_data.data.body,
|
||||
);
|
||||
const parsedArray = JSON.parse(chapterContent);
|
||||
let htmlString = '';
|
||||
|
||||
for (const text of parsedArray) {
|
||||
htmlString += `<p>${text}</p>`;
|
||||
}
|
||||
|
||||
return htmlString;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const response = await fetch(this.site + 'api/search', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Referer: this.site + this.sourceLang,
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text: searchTerm }),
|
||||
});
|
||||
|
||||
const recentNovel: JsonNovel = await response.json();
|
||||
|
||||
// Parse novels from JSON
|
||||
const novels: Plugin.NovelItem[] = recentNovel.data.map((datum: Datum) => ({
|
||||
name: datum.data.title || '',
|
||||
cover: datum.data.image,
|
||||
path: this.sourceLang + 'serie-' + datum.raw_id + '/' + datum.slug || '',
|
||||
}));
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'chapter',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'View', value: 'view' },
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Addition Date', value: 'date' },
|
||||
{ label: 'Reader', value: 'reader' },
|
||||
{ label: 'Chapter', value: 'chapter' },
|
||||
],
|
||||
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: 'Completed', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
interface NovelJson {
|
||||
props: Props;
|
||||
page: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pageProps: PageProps;
|
||||
__N_SSP: boolean;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
serie: Serie;
|
||||
server_time: Date;
|
||||
}
|
||||
|
||||
interface Serie {
|
||||
serie_data: SerieData;
|
||||
chapters: Chapter[];
|
||||
recommendation: SerieData[];
|
||||
chapter_data: ChapterData;
|
||||
id: number;
|
||||
raw_id: number;
|
||||
slug: string;
|
||||
data: Data;
|
||||
is_default: boolean;
|
||||
raw_type: string;
|
||||
}
|
||||
|
||||
interface Chapter {
|
||||
serie_id: number;
|
||||
id: number;
|
||||
order: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
interface ChapterData {
|
||||
data: ChapterContent;
|
||||
}
|
||||
interface ChapterContent {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface SerieData {
|
||||
serie_id?: number;
|
||||
recommendation_id?: number;
|
||||
score?: string;
|
||||
id: number;
|
||||
slug: string;
|
||||
search_text: string;
|
||||
status: number;
|
||||
data: Data;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
view: number;
|
||||
in_library: number;
|
||||
rating: number | null;
|
||||
chapter_count: number;
|
||||
power: number;
|
||||
total_rate: number;
|
||||
user_status: number;
|
||||
verified: boolean;
|
||||
from: null;
|
||||
raw_id: number;
|
||||
genres?: number[];
|
||||
}
|
||||
|
||||
interface Data {
|
||||
title: string;
|
||||
author: string;
|
||||
description: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface JsonNovel {
|
||||
success: boolean;
|
||||
data: Datum[];
|
||||
}
|
||||
interface Datum {
|
||||
serie: Serie;
|
||||
chapters: Chapter[];
|
||||
updated_at: Date;
|
||||
raw_id: number;
|
||||
slug: string;
|
||||
data: Data;
|
||||
}
|
||||
|
||||
export default new WTRLAB();
|
||||
@@ -0,0 +1,474 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile, fetchProto } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
interface StringValue {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface BoolValue {
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
interface DecimalValue {
|
||||
units: bigint;
|
||||
nanos: number;
|
||||
}
|
||||
|
||||
interface Timestamp {
|
||||
seconds: number;
|
||||
nanos: number;
|
||||
}
|
||||
|
||||
interface NovelEntry {
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface RelatedChapterUserInfo {
|
||||
isChapterUnlocked?: BoolValue;
|
||||
}
|
||||
|
||||
interface ChapterItem {
|
||||
entityId: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
number?: DecimalValue;
|
||||
content?: StringValue;
|
||||
relatedUserInfo?: RelatedChapterUserInfo;
|
||||
offset: number;
|
||||
publishedAt?: Timestamp;
|
||||
}
|
||||
|
||||
interface GetChapterResponse {
|
||||
item?: ChapterItem;
|
||||
}
|
||||
|
||||
interface ChapterGroupCounts {
|
||||
total: number;
|
||||
advance: number;
|
||||
normal: number;
|
||||
}
|
||||
|
||||
interface ChapterGroupItem {
|
||||
id: number;
|
||||
title: string;
|
||||
order: number;
|
||||
fromChapterNumber?: DecimalValue;
|
||||
toChapterNumber?: DecimalValue;
|
||||
chapterList: ChapterItem[];
|
||||
counts?: ChapterGroupCounts;
|
||||
}
|
||||
|
||||
interface GetChapterListResponse {
|
||||
items: ChapterGroupItem[];
|
||||
}
|
||||
|
||||
enum NovelItem_Status {
|
||||
Finished = 0,
|
||||
Active = 1,
|
||||
Hiatus = 2,
|
||||
All = -1,
|
||||
}
|
||||
|
||||
interface NovelKarmaInfo {
|
||||
isActive: boolean;
|
||||
isFree: boolean;
|
||||
maxFreeChapter?: DecimalValue;
|
||||
canUnlockWithVip: boolean;
|
||||
}
|
||||
|
||||
interface NovelItem {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: NovelItem_Status;
|
||||
visible: boolean;
|
||||
description?: StringValue;
|
||||
synopsis?: StringValue;
|
||||
coverUrl?: StringValue;
|
||||
translatorName?: StringValue;
|
||||
authorName?: StringValue;
|
||||
karmaInfo?: NovelKarmaInfo;
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
interface GetNovelResponse {
|
||||
item?: NovelItem;
|
||||
}
|
||||
|
||||
class WuxiaWorld implements Plugin.PluginBase {
|
||||
id = 'wuxiaworld';
|
||||
name = 'Wuxia World';
|
||||
icon = 'src/en/wuxiaworld/icon.png';
|
||||
site = 'https://www.wuxiaworld.com/';
|
||||
filters?: Filters | undefined;
|
||||
version = '0.5.0';
|
||||
|
||||
parseNovels(data: { items: NovelEntry[] }) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
data.items.map((novel: NovelEntry) => {
|
||||
const name = novel.name;
|
||||
const cover = novel.coverUrl;
|
||||
const path = `novel/${novel.slug}/`;
|
||||
|
||||
novels.push({
|
||||
name,
|
||||
cover,
|
||||
path,
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const link = `${this.site}api/novels`;
|
||||
|
||||
const result = await fetchApi(link);
|
||||
const data = await result.json();
|
||||
|
||||
return this.parseNovels(data);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const data = await fetchProto<GetNovelResponse>(
|
||||
{
|
||||
proto: this.proto,
|
||||
requestType: 'GetNovelRequest',
|
||||
responseType: 'GetNovelResponse',
|
||||
requestData: { slug: novelPath.split('/')[1] },
|
||||
},
|
||||
'https://api2.wuxiaworld.com/wuxiaworld.api.v2.Novels/GetNovel',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/grpc-web+proto',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: data.item?.name || 'Untitled',
|
||||
cover: data.item?.coverUrl?.value,
|
||||
summary: parseHTML(
|
||||
data.item?.description?.value + '\n\n' + data.item?.synopsis?.value,
|
||||
).text(),
|
||||
author: data.item?.authorName?.value,
|
||||
genres: data.item?.genres.join(','),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
const status = data.item?.status;
|
||||
switch (status) {
|
||||
case NovelItem_Status.Active:
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case NovelItem_Status.Hiatus:
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case NovelItem_Status.All:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Completed;
|
||||
}
|
||||
|
||||
// novel.summary = loadedCheerio('.relative > .absolute:first')
|
||||
// .children('span')
|
||||
// .map((i,el) => loadedCheerio(el).text().trim())
|
||||
// .toArray()
|
||||
// .join('\n');
|
||||
|
||||
// novel.genres = loadedCheerio("a.MuiLink-underlineNone")
|
||||
// .map((i,el) => loadedCheerio(el).text())
|
||||
// .toArray()
|
||||
// .join(',');
|
||||
|
||||
// novel.status = loadedCheerio("div.font-set-b10").text();
|
||||
|
||||
const list = await fetchProto<GetChapterListResponse>(
|
||||
{
|
||||
proto: this.proto,
|
||||
requestType: 'GetChapterListRequest',
|
||||
responseType: 'GetChapterListResponse',
|
||||
requestData: { novelId: data.item?.id },
|
||||
},
|
||||
'https://api2.wuxiaworld.com/wuxiaworld.api.v2.Chapters/GetChapterList',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/grpc-web+proto',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const freeChapter =
|
||||
Number(data.item?.karmaInfo?.maxFreeChapter?.units) +
|
||||
(data.item?.karmaInfo?.maxFreeChapter?.nanos || 0) / 1000000000 || 50;
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = list.items.flatMap(
|
||||
(ChapterGroupItem: ChapterGroupItem) =>
|
||||
ChapterGroupItem.chapterList.map((chapterItem: ChapterItem) => ({
|
||||
page: ChapterGroupItem.title,
|
||||
name:
|
||||
chapterItem.name +
|
||||
(chapterItem.relatedUserInfo?.isChapterUnlocked?.value === false ||
|
||||
(!chapterItem.relatedUserInfo &&
|
||||
Number(chapterItem.number?.units) +
|
||||
(chapterItem.number?.nanos || 0) / 1000000000 >
|
||||
freeChapter)
|
||||
? ' 🔒'
|
||||
: ''),
|
||||
path: novelPath + chapterItem.slug,
|
||||
chapterNumber: chapterItem.offset,
|
||||
releaseTime: new Date(
|
||||
(chapterItem.publishedAt?.seconds || 0) * 1000 +
|
||||
(chapterItem.publishedAt?.nanos || 0) / 1000000,
|
||||
).toISOString(),
|
||||
})),
|
||||
);
|
||||
|
||||
novel.chapters = chapter;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const paths = chapterPath.split('/');
|
||||
const data = await fetchProto<GetChapterResponse>(
|
||||
{
|
||||
proto: this.proto,
|
||||
requestType: 'GetChapterRequest',
|
||||
responseType: 'GetChapterResponse',
|
||||
requestData: {
|
||||
chapterProperty: {
|
||||
slugs: {
|
||||
novelSlug: paths[1],
|
||||
chapterSlug: paths[2],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://api2.wuxiaworld.com/wuxiaworld.api.v2.Chapters/GetChapter',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/grpc-web+proto',
|
||||
},
|
||||
},
|
||||
);
|
||||
// loadedCheerio(".chapter-nav").remove();
|
||||
// loadedCheerio("#chapter-content > script").remove();
|
||||
// const chapterText = loadedCheerio("#chapter-content").html() || '';
|
||||
|
||||
const chapterText = data.item?.content?.value || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = 'https://www.wuxiaworld.com/api/novels/search?query=';
|
||||
|
||||
const url = searchUrl + searchTerm;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const data = await result.json();
|
||||
|
||||
return this.parseNovels(data);
|
||||
}
|
||||
|
||||
proto = `
|
||||
syntax = "proto3";
|
||||
option optimize_for = CODE_SIZE;
|
||||
package wuxiaworld.api.v2;
|
||||
|
||||
import public "google/protobuf/wrappers.proto";
|
||||
import public "google/protobuf/timestamp.proto";
|
||||
|
||||
message StringValue {
|
||||
// The string value.
|
||||
string value = 1;
|
||||
}
|
||||
|
||||
message BoolValue {
|
||||
// The bool value.
|
||||
bool value = 1;
|
||||
}
|
||||
|
||||
message Int32Value {
|
||||
// The int32 value.
|
||||
int32 value = 1;
|
||||
}
|
||||
|
||||
message DecimalValue {
|
||||
// Whole units part of the amount
|
||||
int64 units = 1;
|
||||
// Nano units of the amount (10^-9)
|
||||
// Must be the same sign as units
|
||||
sfixed32 nanos = 2;
|
||||
}
|
||||
|
||||
message Timestamp {
|
||||
int64 seconds = 1;
|
||||
int32 nanos = 2;
|
||||
}
|
||||
|
||||
message RelatedChapterUserInfo {
|
||||
optional BoolValue isChapterUnlocked = 1;
|
||||
optional BoolValue isNovelUnlocked = 2;
|
||||
optional BoolValue isChapterFavorite = 3;
|
||||
optional BoolValue isNovelOwned = 4;
|
||||
optional BoolValue isChapterOwned = 5;
|
||||
}
|
||||
|
||||
message ChapterNovelInfo {
|
||||
int32 id = 1;
|
||||
string name = 2;
|
||||
optional StringValue coverUrl = 3;
|
||||
string slug = 4;
|
||||
}
|
||||
|
||||
message ChapterParagraph {
|
||||
string id = 1;
|
||||
int32 chapterId = 2;
|
||||
int32 totalComments = 3;
|
||||
optional StringValue content = 4;
|
||||
}
|
||||
|
||||
message ChapterItem {
|
||||
int32 entityId = 1;
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
optional DecimalValue number = 4;
|
||||
optional StringValue content = 5;
|
||||
int32 novelId = 6;
|
||||
bool visible = 7;
|
||||
bool isTeaser = 8;
|
||||
optional Timestamp whenToPublish = 9;
|
||||
bool spoilerTitle = 10;
|
||||
bool allowComments = 11;
|
||||
optional ChapterNovelInfo novelInfo = 14;
|
||||
optional RelatedChapterUserInfo relatedUserInfo = 16;
|
||||
int32 offset = 17;
|
||||
optional Timestamp publishedAt = 18;
|
||||
optional StringValue translatorThoughts = 19;
|
||||
repeated ChapterParagraph paragraphs = 21;
|
||||
}
|
||||
|
||||
message ChapterGroupCounts {
|
||||
int32 total = 1;
|
||||
int32 advance = 2;
|
||||
int32 normal = 3;
|
||||
}
|
||||
|
||||
message ChapterGroupItem {
|
||||
int32 id = 1;
|
||||
string title = 2;
|
||||
int32 order = 3;
|
||||
optional DecimalValue fromChapterNumber = 4;
|
||||
optional DecimalValue toChapterNumber = 5;
|
||||
repeated ChapterItem chapterList = 6;
|
||||
optional ChapterGroupCounts counts = 7;
|
||||
}
|
||||
|
||||
message GetChapterListRequest {
|
||||
int32 novelId = 1;
|
||||
message BaseChapterInfo {
|
||||
oneof chapterInfo {
|
||||
int32 chapterId = 1;
|
||||
string slug = 2;
|
||||
int32 offset = 3;
|
||||
}
|
||||
}
|
||||
message FilterChapters {
|
||||
optional Int32Value chapterGroupId = 1;
|
||||
optional BoolValue isAdvanceChapter = 2;
|
||||
optional BaseChapterInfo baseChapter = 3;
|
||||
}
|
||||
optional FilterChapters filter = 2;
|
||||
optional Int32Value count = 3;
|
||||
}
|
||||
|
||||
message GetChapterListResponse {
|
||||
repeated ChapterGroupItem items = 1;
|
||||
optional ChapterNovelInfo novelInfo = 2;
|
||||
}
|
||||
|
||||
message GetChapterByProperty {
|
||||
message ByNovelAndChapterSlug {
|
||||
string novelSlug = 1;
|
||||
string chapterSlug = 2;
|
||||
}
|
||||
oneof byProperty {
|
||||
int32 chapterId = 1;
|
||||
ByNovelAndChapterSlug slugs = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GetChapterRequest {
|
||||
optional GetChapterByProperty chapterProperty = 1;
|
||||
}
|
||||
|
||||
message GetChapterResponse {
|
||||
optional ChapterItem item = 1;
|
||||
}
|
||||
|
||||
message NovelKarmaInfo {
|
||||
bool isActive = 1;
|
||||
bool isFree = 2;
|
||||
optional DecimalValue maxFreeChapter = 3;
|
||||
bool canUnlockWithVip = 4;
|
||||
}
|
||||
|
||||
message NovelItem {
|
||||
int32 id = 1;
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
enum Status {
|
||||
Finished = 0;
|
||||
Active = 1;
|
||||
Hiatus = 2;
|
||||
All = -1;
|
||||
}
|
||||
Status status = 4;
|
||||
bool visible = 7;
|
||||
optional StringValue description = 8;
|
||||
optional StringValue synopsis = 9;
|
||||
optional StringValue coverUrl = 10;
|
||||
optional StringValue translatorName = 11;
|
||||
optional StringValue authorName = 13;
|
||||
optional NovelKarmaInfo karmaInfo = 14;
|
||||
repeated string genres = 16;
|
||||
}
|
||||
|
||||
message GetNovelRequest {
|
||||
oneof selector {
|
||||
int32 id = 1;
|
||||
string slug = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GetNovelResponse {
|
||||
optional NovelItem item = 1;
|
||||
}
|
||||
|
||||
service Chapters {
|
||||
rpc GetChapterList(GetChapterListRequest) returns (GetChapterListResponse);
|
||||
rpc GetChapter(GetChapterRequest) returns (GetChapterResponse);
|
||||
}
|
||||
|
||||
service Novels {
|
||||
rpc GetNovel(GetNovelRequest) returns (GetNovelResponse);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default new WuxiaWorld();
|
||||
@@ -0,0 +1,339 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import dayjs from 'dayjs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class ChireadsPlugin implements Plugin.PluginBase {
|
||||
id = 'chireads';
|
||||
name = 'Chireads';
|
||||
icon = 'src/fr/chireads/icon.png';
|
||||
site = 'https://chireads.com';
|
||||
version = '1.0.2';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url, {
|
||||
headers: { 'Accept-Encoding': 'deflate' },
|
||||
});
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters, showLatestNovels }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
let tag: string = 'all';
|
||||
if (showLatestNovels) url += '/category/translatedtales/page/' + pageNo;
|
||||
else {
|
||||
if (
|
||||
filters &&
|
||||
typeof filters.tag.value === 'string' &&
|
||||
filters.tag.value !== 'all'
|
||||
)
|
||||
tag = filters.tag.value;
|
||||
if (tag !== 'all') url += '/tag/' + tag + '/page/' + pageNo;
|
||||
else if (pageNo > 1) return [];
|
||||
}
|
||||
let $ = await this.getCheerio(url);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
|
||||
if (showLatestNovels || tag !== 'all') {
|
||||
let loop = 1;
|
||||
if (showLatestNovels) loop = 2;
|
||||
for (let i = 0; i < loop; i++) {
|
||||
if (i === 1)
|
||||
$ = await this.getCheerio(
|
||||
this.site + '/category/original/page/' + pageNo,
|
||||
);
|
||||
let romans = $('.romans-content li');
|
||||
if (!romans.length) romans = $('#content li');
|
||||
romans.each((i, elem) => {
|
||||
const novelName = $(elem)
|
||||
.contents()
|
||||
.find('div')
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
const novelCover = $(elem)
|
||||
.find('div')
|
||||
.first()
|
||||
.find('img')
|
||||
.attr('src');
|
||||
const novelUrl = $(elem).find('div').first().find('a').attr('href');
|
||||
|
||||
if (novelUrl) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const populaire = $(':contains("Populaire")')
|
||||
.last()
|
||||
.parent()
|
||||
.next()
|
||||
.find('li > div');
|
||||
if (populaire.length === 12) {
|
||||
// pc
|
||||
let novelCover: string | undefined;
|
||||
let novelName: string | undefined;
|
||||
let novelUrl: string | undefined;
|
||||
populaire.each((i, elem) => {
|
||||
if (i % 2 === 0) novelCover = $(elem).find('img').attr('src');
|
||||
else {
|
||||
novelName = $(elem).text().trim();
|
||||
novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover || defaultCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
} // mobile
|
||||
else {
|
||||
const imgs = populaire.find('div.popular-list-img img');
|
||||
const txts = populaire.find('div.popular-list-name');
|
||||
|
||||
txts.each((i, elem) => {
|
||||
const novelName = $(elem).text().trim();
|
||||
const novelCover = $(imgs[i]).attr('src');
|
||||
const novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
if (novelUrl) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = { path: novelPath, name: 'Sans titre' };
|
||||
|
||||
let $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name =
|
||||
$('.inform-product-txt').first().text().trim() ||
|
||||
$('.inform-title').text().trim();
|
||||
novel.cover =
|
||||
$('.inform-product img').attr('src') ||
|
||||
$('.inform-product-img img').attr('src') ||
|
||||
defaultCover;
|
||||
novel.summary =
|
||||
$('.inform-inform-txt').text().trim() ||
|
||||
$('.inform-intr-txt').text().trim();
|
||||
|
||||
let infos =
|
||||
$('div.inform-product-txt > div.inform-intr-col').text().trim() ||
|
||||
$('div.inform-inform-data > h6').text().trim();
|
||||
if (infos.includes('Auteur : '))
|
||||
novel.author = infos
|
||||
.substring(
|
||||
infos.indexOf('Auteur : ') + 9,
|
||||
infos.indexOf('Statut de Parution : '),
|
||||
)
|
||||
.trim();
|
||||
else if (infos.includes('Fantrad : '))
|
||||
novel.author = infos
|
||||
.substring(
|
||||
infos.indexOf('Fantrad : ') + 10,
|
||||
infos.indexOf('Statut de Parution : '),
|
||||
)
|
||||
.trim();
|
||||
else novel.author = 'Inconnu';
|
||||
switch (
|
||||
infos.substring(infos.indexOf('Statut de Parution : ') + 21).toLowerCase()
|
||||
) {
|
||||
case 'en pause':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case 'complet':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
}
|
||||
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
let chapterList = $('.chapitre-table a');
|
||||
if (!chapterList.length) {
|
||||
$('div.inform-annexe-list').first().remove();
|
||||
chapterList = $('.inform-annexe-list').find('a');
|
||||
}
|
||||
chapterList.each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).attr('href');
|
||||
const releaseDate = dayjs(
|
||||
chapterUrl?.substring(chapterUrl.length - 11, chapterUrl.length - 1),
|
||||
).format('DD MMMM YYYY');
|
||||
|
||||
if (chapterUrl) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterUrl: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterUrl);
|
||||
|
||||
const chapterText = $('#content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
let i = 1;
|
||||
let finised = false;
|
||||
while (!finised) {
|
||||
await this.popularNovels(i, {
|
||||
showLatestNovels: true,
|
||||
filters: undefined,
|
||||
}).then(res => {
|
||||
if (res.length === 0) finised = true;
|
||||
novels.push(...res);
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
novels = novels.filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, ''),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
tag: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Tag',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'Tous', value: 'all' },
|
||||
{ label: 'Arts martiaux', value: 'arts-martiaux' },
|
||||
{ label: 'De faible à fort', value: 'de-faible-a-fort' },
|
||||
{ label: 'Adapté en manhua', value: 'adapte-en-manhua' },
|
||||
{ label: 'Cultivation', value: 'cultivation' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Aventure', value: 'aventure' },
|
||||
{ label: 'Monstres', value: 'monstres' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
{ label: 'Fantastique', value: 'fantastique' },
|
||||
{ label: 'Adapté en Animé', value: 'adapte-en-anime' },
|
||||
{ label: 'Alchimie', value: 'alchimie' },
|
||||
{ label: 'Éléments de jeux', value: 'elements-de-jeux' },
|
||||
{ label: 'Calme Protagoniste', value: 'calme-protagoniste' },
|
||||
{
|
||||
label: 'Protagoniste intelligent',
|
||||
value: 'protagoniste-intelligent',
|
||||
},
|
||||
{ label: 'Polygamie', value: 'polygamie' },
|
||||
{ label: 'Belle femelle Lea', value: 'belle-femelle-lea' },
|
||||
{ label: 'Personnages arrogants', value: 'personnages-arrogants' },
|
||||
{ label: 'Système de niveau', value: 'systeme-de-niveau' },
|
||||
{ label: 'Cheat', value: 'cheat' },
|
||||
{ label: 'Protagoniste génie', value: 'protagoniste-genie' },
|
||||
{ label: 'Comédie', value: 'comedie' },
|
||||
{ label: 'Gamer', value: 'gamer' },
|
||||
{ label: 'Mariage', value: 'mariage' },
|
||||
{ label: 'seeking Protag', value: 'seeking-protag' },
|
||||
{ label: 'Romance précoce', value: 'romance-precoce' },
|
||||
{ label: 'Croissance accélérée', value: 'croissance-acceleree' },
|
||||
{ label: 'Artefacts', value: 'artefacts' },
|
||||
{
|
||||
label: 'Intelligence artificielle',
|
||||
value: 'intelligence-artificielle',
|
||||
},
|
||||
{ label: 'Mariage arrangé', value: 'mariage-arrange' },
|
||||
{ label: 'Mature', value: 'mature' },
|
||||
{ label: 'Adulte', value: 'adulte' },
|
||||
{
|
||||
label: 'Administrateur de système',
|
||||
value: 'administrateur-de-systeme',
|
||||
},
|
||||
{ label: 'Beau protagoniste', value: 'beau-protagoniste' },
|
||||
{
|
||||
label: 'Protagoniste charismatique',
|
||||
value: 'protagoniste-charismatique',
|
||||
},
|
||||
{ label: 'Protagoniste masculin', value: 'protagoniste-masculin' },
|
||||
{ label: 'Démons', value: 'demons' },
|
||||
{ label: 'Reincarnation', value: 'reincarnation' },
|
||||
{ label: 'Académie', value: 'academie' },
|
||||
{
|
||||
label: 'Cacher les vraies capacités',
|
||||
value: 'cacher-les-vraies-capacites',
|
||||
},
|
||||
{
|
||||
label: 'Protagoniste surpuissant',
|
||||
value: 'protagoniste-surpuissant',
|
||||
},
|
||||
{ label: 'Joueur', value: 'joueur' },
|
||||
{
|
||||
label: 'Protagoniste fort dès le départ',
|
||||
value: 'protagoniste-fort-des-le-depart',
|
||||
},
|
||||
{ label: 'Immortels', value: 'immortels' },
|
||||
{ label: 'Cultivation rapide', value: 'cultivation-rapide' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Assasins', value: 'assasins' },
|
||||
{ label: 'De pauvre à riche', value: 'de-pauvre-a-riche' },
|
||||
{
|
||||
label: 'Système de classement de jeux',
|
||||
value: 'systeme-de-classement-de-jeux',
|
||||
},
|
||||
{ label: 'Capacités spéciales', value: 'capacites-speciales' },
|
||||
{ label: 'Vengeance', value: 'vengeance' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ChireadsPlugin();
|
||||
@@ -0,0 +1,178 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class HarkenEliwoodPlugin implements Plugin.PluginBase {
|
||||
id = 'harkeneliwood';
|
||||
name = 'HarkenEliwood';
|
||||
icon = 'src/fr/harkeneliwood/icon.png';
|
||||
site = 'https://harkeneliwood.wordpress.com';
|
||||
version = '1.0.0';
|
||||
filters: Filters | undefined = undefined;
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url, {
|
||||
headers: { 'Accept-Encoding': 'deflate' },
|
||||
});
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
let url = this.site;
|
||||
let $ = await this.getCheerio(url + '/projets/');
|
||||
$('#content .entry-content [href]')
|
||||
// We don't collect items for Facebook and Twitter.
|
||||
.not('[rel="nofollow noopener noreferrer"]')
|
||||
.each((i, elem) => {
|
||||
const novelName = $(elem).text().trim();
|
||||
const novelUrl = $(elem).attr('href');
|
||||
if (novelUrl && novelName) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: defaultCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
let $ = await this.getCheerio(this.site + novelPath);
|
||||
novel.name = $('#content h1.entry-title').text().trim();
|
||||
novel.cover =
|
||||
$('#content .entry-content p img').first().attr('src') || defaultCover;
|
||||
novel.summary = this.getSummary($('#content .entry-content').text());
|
||||
novel.author = this.getAuthor($('#content .entry-content').text());
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
$('#content .entry-content p a').each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).attr('href');
|
||||
// Check if the chapter URL exists and contains the site name.
|
||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||
const releaseDate = dayjs(
|
||||
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
||||
).format('DD MMMM YYYY');
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
}
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
getSummary(text: string) {
|
||||
let resume: string = '';
|
||||
const regexResume1: RegExp = /Synopsis :([\s\S]*)Traduction anglaise/i;
|
||||
const regexResume2: RegExp = /Synopsis :([\s\S]*)Raw :/i;
|
||||
const regexResume3: RegExp =
|
||||
/Synopsis 1 :([\s\S]*)Synopsis 2 :([\s\S]*)Raw :/i;
|
||||
const regexResume4: RegExp = /Synopsis :([\s\S]*)Prélude/i;
|
||||
const regexResume5: RegExp = /Synospis :([\s\S]*)Original /i;
|
||||
const regexResume6: RegExp = /([\s\S]*)Raw :/i;
|
||||
|
||||
const match1: RegExpExecArray | null = regexResume1.exec(text);
|
||||
const match2: RegExpExecArray | null = regexResume2.exec(text);
|
||||
const match3: RegExpExecArray | null = regexResume3.exec(text);
|
||||
const match4: RegExpExecArray | null = regexResume4.exec(text);
|
||||
const match5: RegExpExecArray | null = regexResume5.exec(text);
|
||||
|
||||
if (match1 !== null) {
|
||||
resume = match1[1];
|
||||
} else if (match2 !== null) {
|
||||
resume = match2[1];
|
||||
} else if (match3 !== null) {
|
||||
resume = match3[1] + match3[2];
|
||||
} else if (match4 !== null) {
|
||||
resume = match4[1];
|
||||
} else if (match5 !== null) {
|
||||
resume = match5[1];
|
||||
} else {
|
||||
resume = text;
|
||||
}
|
||||
|
||||
if (regexResume6.test(resume)) {
|
||||
const match6: RegExpExecArray | null = regexResume6.exec(resume);
|
||||
if (match6 !== null) {
|
||||
resume = match6[1];
|
||||
}
|
||||
}
|
||||
|
||||
return resume.trim();
|
||||
}
|
||||
|
||||
getAuthor(text: string) {
|
||||
const regexAuteur = /Auteur\s*:\s*(.*?)\s*(?:\r?\n|$)/i;
|
||||
const match = regexAuteur.exec(text);
|
||||
|
||||
if (match !== null && match[1].trim() !== '') {
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
const title = $('h1.entry-title');
|
||||
const chapter = $('div.entry-content');
|
||||
return (title.html() || '') + (chapter.html() || '');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
let popularNovels = this.popularNovels(1, {
|
||||
showLatestNovels: true,
|
||||
filters: undefined,
|
||||
});
|
||||
|
||||
let novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new HarkenEliwoodPlugin();
|
||||
@@ -0,0 +1,251 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class KissWoodPlugin implements Plugin.PluginBase {
|
||||
id = 'kisswood';
|
||||
name = 'KissWood';
|
||||
icon = 'src/fr/kisswood/icon.png';
|
||||
site = 'https://kisswood.eu';
|
||||
version = '1.0.0';
|
||||
filters: Filters | undefined = undefined;
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async getNovelsCovers(
|
||||
novels: Plugin.NovelItem[],
|
||||
listUrlCover: string[],
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
await Promise.all(
|
||||
novels.map(async (novel, index) => {
|
||||
const urlCover = listUrlCover[index];
|
||||
if (urlCover) {
|
||||
novel.cover = this.findCoverImage(await this.getCheerio(urlCover));
|
||||
}
|
||||
}),
|
||||
);
|
||||
return novels;
|
||||
}
|
||||
|
||||
regexAuthors = [/Auteur :([^\n]*)/, /Auteur\u00A0:([^\n]*)/];
|
||||
|
||||
async getNovelInfo(
|
||||
novel: Plugin.SourceNovel,
|
||||
url: string,
|
||||
): Promise<Plugin.SourceNovel> {
|
||||
let $ = await this.getCheerio(url);
|
||||
|
||||
const textArray: string[] = $('.entry-content p')
|
||||
.map((_, element) => $(element).text().trim())
|
||||
.get()
|
||||
.join('\n')
|
||||
.split('\n');
|
||||
|
||||
const index = textArray.findIndex(element =>
|
||||
[
|
||||
'Traducteur Anglais- Français',
|
||||
'Titre en français',
|
||||
'———',
|
||||
'Titre :',
|
||||
'Lien vers le premier chapitre',
|
||||
'____________',
|
||||
'Auteur : ',
|
||||
].some(marker => element.includes(marker)),
|
||||
);
|
||||
|
||||
novel.summary = (index !== -1 ? textArray.slice(0, index) : textArray)
|
||||
.join('\n')
|
||||
.replace('Synopsis :', '');
|
||||
novel.author = this.extractInfo(textArray.join('\n'), this.regexAuthors);
|
||||
novel.cover = this.findCoverImage($);
|
||||
return novel;
|
||||
}
|
||||
|
||||
findCoverImage($: CheerioAPI): string {
|
||||
return (
|
||||
$('div p img').first().attr('src') ||
|
||||
$('figure a img').first().attr('src') ||
|
||||
$('figure img').first().attr('src') ||
|
||||
defaultCover
|
||||
);
|
||||
}
|
||||
|
||||
extractInfo(text: string, regexes: RegExp[]): string {
|
||||
for (const regex of regexes) {
|
||||
const match = regex.exec(text);
|
||||
if (match !== null) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const $ = await this.getCheerio(this.site);
|
||||
var listUrlCover: string[] = [];
|
||||
$('nav div div ul li ul li').each((i, elem) => {
|
||||
if ($(elem).text().trim() === 'Sommaire') {
|
||||
const novelName = $(elem)
|
||||
.closest('ul')
|
||||
.siblings('a')
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
const novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
if (novelUrl && novelName) {
|
||||
const urlCover = $(elem).parent().find('a').attr('href');
|
||||
if (urlCover) {
|
||||
listUrlCover.push(urlCover);
|
||||
} else {
|
||||
listUrlCover.push('');
|
||||
}
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
cover: defaultCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
}
|
||||
});
|
||||
return await this.getNovelsCovers(novels, listUrlCover);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
let novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
status: NovelStatus.Ongoing,
|
||||
};
|
||||
|
||||
let $ = await this.getCheerio(this.site + novelPath);
|
||||
let novelUrl = null;
|
||||
|
||||
$('nav div div ul li ul li').each((i, elem) => {
|
||||
if ($(elem).find('a').attr('href') === this.site + novelPath) {
|
||||
novelUrl = $(elem).parent().find('a').first().attr('href');
|
||||
novel.name = $(elem).closest('ul').siblings('a').first().text().trim();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (novelUrl) {
|
||||
novel = await this.getNovelInfo(novel, novelUrl);
|
||||
}
|
||||
|
||||
const chapterSelectors = [
|
||||
'.entry-content ul li a',
|
||||
'.entry-content ul li ul li a',
|
||||
'.entry-content p a',
|
||||
'.entry-content li a',
|
||||
'.entry-content blockquote a',
|
||||
].join(', ');
|
||||
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
$(chapterSelectors).each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).attr('href')?.replace('http://', 'https://');
|
||||
if (
|
||||
chapterUrl &&
|
||||
chapterName &&
|
||||
chapterUrl.includes(this.site) &&
|
||||
// We remove the unnecessary links to Facebook, X, and the homepage from the chapters.
|
||||
!chapterUrl.includes('share=facebook') &&
|
||||
!chapterUrl.includes('share=x') &&
|
||||
!chapterUrl.includes('/category/traductions/') &&
|
||||
!chapterUrl.includes('/category/tour-des-mondes/') &&
|
||||
// Removal of duplicates
|
||||
!chapters.some(chapter => this.site + chapter.path === chapterUrl)
|
||||
) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
|
||||
const elements: string[] = $('.entry-content')
|
||||
.contents()
|
||||
.map((_, el) => $.html(el))
|
||||
.get();
|
||||
|
||||
let hrIndexes: number[] = elements
|
||||
.map((elem, index) => (elem.includes('<hr>') ? index : -1))
|
||||
.filter(index => index !== -1);
|
||||
|
||||
if (hrIndexes.length === 0) {
|
||||
hrIndexes = [
|
||||
0,
|
||||
elements.findIndex(
|
||||
element =>
|
||||
element.includes('https://fr.tipeee.com/kisswood/') ||
|
||||
element.includes('>Sommaire</a>') ||
|
||||
element.includes('>Chapitre Suivant</a>') ||
|
||||
element.includes('———————————————————————————-') ||
|
||||
element.includes('share=facebook'),
|
||||
),
|
||||
];
|
||||
} else if (hrIndexes.length === 1) {
|
||||
hrIndexes.unshift(0);
|
||||
} else {
|
||||
hrIndexes[0] += 1;
|
||||
}
|
||||
return elements.slice(hrIndexes[0], hrIndexes[1]).join('\n');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
let popularNovels = this.popularNovels(1, {
|
||||
showLatestNovels: true,
|
||||
filters: undefined,
|
||||
});
|
||||
|
||||
let novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new KissWoodPlugin();
|
||||
@@ -0,0 +1,347 @@
|
||||
import { load, CheerioAPI } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class NovelDeGlacePlugin implements Plugin.PluginBase {
|
||||
id = 'noveldeglace';
|
||||
name = 'NovelDeGlace';
|
||||
icon = 'src/fr/noveldeglace/icon.png';
|
||||
site = 'https://noveldeglace.com/';
|
||||
version = '1.0.2';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI | undefined> {
|
||||
const r = await fetchApi(url, {
|
||||
headers: { 'Accept-Encoding': 'deflate' },
|
||||
});
|
||||
if (!r.ok) return undefined;
|
||||
const body = await r.text();
|
||||
const loadedCheerio = load(body);
|
||||
return loadedCheerio;
|
||||
}
|
||||
|
||||
parseDate(date: string): string {
|
||||
const monthMapping: { [key: string]: string } = {
|
||||
janvier: 'January',
|
||||
fevrier: 'February',
|
||||
mars: 'March',
|
||||
avril: 'April',
|
||||
mai: 'May',
|
||||
juin: 'June',
|
||||
juillet: 'July',
|
||||
aout: 'August',
|
||||
septembre: 'September',
|
||||
octobre: 'October',
|
||||
novembre: 'November',
|
||||
decembre: 'December',
|
||||
};
|
||||
const [day, month, year] = date.split(' ');
|
||||
return dayjs(
|
||||
`${day} ${monthMapping[month.normalize('NFD').replace(/[\u0300-\u036f]/g, '')]} ${year}`,
|
||||
'D MMMM YYYY',
|
||||
).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
parseNovels(
|
||||
$: CheerioAPI,
|
||||
showLatestNovels: boolean | undefined,
|
||||
): Plugin.NovelItem[] {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
$('article').each((i, el) => {
|
||||
const cheerio = $(el);
|
||||
const novelName = cheerio.find('h2').text().trim();
|
||||
const novelCover = cheerio.find('img').attr('src');
|
||||
let novelUrl: string | undefined;
|
||||
if (showLatestNovels)
|
||||
novelUrl = cheerio.find('span.Roman > a').attr('href');
|
||||
else novelUrl = cheerio.find('h2 > a').attr('href');
|
||||
|
||||
if (novelUrl) {
|
||||
const novel: Plugin.NovelItem = {
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
cover: novelCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
filters,
|
||||
showLatestNovels,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
if (showLatestNovels) url += 'chapitre';
|
||||
else {
|
||||
let cat_gen: string = 'all';
|
||||
if (filters && typeof filters.categorie_genre.value == 'string')
|
||||
cat_gen = filters.categorie_genre.value;
|
||||
if (
|
||||
cat_gen != 'all' &&
|
||||
cat_gen != 'categorie_roman' &&
|
||||
cat_gen != 'genre'
|
||||
) {
|
||||
if (cat_gen[0] == 'c') url += 'categorie_roman/' + cat_gen.substring(2);
|
||||
else if (cat_gen[0] == 'g') url += 'genre/' + cat_gen.substring(2);
|
||||
} else if (pageNo > 1)
|
||||
return []; // when asking for all novels, there is only 1 page
|
||||
else url += 'roman';
|
||||
}
|
||||
url += '/page/' + pageNo;
|
||||
let $ = await this.getCheerio(url);
|
||||
if (!$) return [];
|
||||
return this.parseNovels($, showLatestNovels);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const $ = await this.getCheerio(this.site + novelPath);
|
||||
if (!$) throw new Error('Failed to load page (open in web view)');
|
||||
|
||||
let novel: Plugin.SourceNovel = { path: novelPath, name: 'Untitled' };
|
||||
|
||||
novel.name =
|
||||
(
|
||||
$('div.entry-content > div > strong')[0].nextSibling as Text | null
|
||||
)?.nodeValue?.trim() || 'Untitled';
|
||||
|
||||
novel.cover = $('.su-row > div > div > img').attr('src') || defaultCover;
|
||||
|
||||
let novelInfos = $('div[data-title=Tomes] >').toArray();
|
||||
novelInfos.pop();
|
||||
novelInfos.shift();
|
||||
novel.summary =
|
||||
$('div[data-title=Synopsis]').text().trim() +
|
||||
'\n\n' +
|
||||
novelInfos
|
||||
.map(el => $(el).text())
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
novel.author = $("strong:contains('Auteur :')")
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Auteur : ', '')
|
||||
.trim();
|
||||
|
||||
novel.artist = $("strong:contains('Illustrateur :')")
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Illustrateur :', '')
|
||||
.trim();
|
||||
|
||||
const categorie = $('.categorie').text().replace('Catégorie :', '').trim();
|
||||
const genres = $('.genre')
|
||||
.text()
|
||||
.replace('Genre :', '')
|
||||
.replace(/, /g, ',')
|
||||
.trim();
|
||||
if (categorie && categorie != 'Autre') novel.genres = categorie;
|
||||
if (genres)
|
||||
novel.genres = novel.genres ? novel.genres + ',' + genres : genres;
|
||||
|
||||
let status = $("strong:contains('Statut :')").parent().attr('class');
|
||||
switch (status) {
|
||||
case 'type etat0':
|
||||
case 'type etat1':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'type etat4':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case 'type etat5':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
case 'type etat6':
|
||||
novel.status = NovelStatus.Cancelled;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
break;
|
||||
}
|
||||
|
||||
let novelChapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
const volumes = $('div[data-title=Tomes] > div').last().contents();
|
||||
const hasMultipleVolumes = volumes.length > 1;
|
||||
|
||||
let chapterName = '';
|
||||
let site = this.site;
|
||||
volumes.each((volumeIndex: number, el) => {
|
||||
if (hasMultipleVolumes) chapterName = 'T.' + (volumeIndex + 1) + ' ';
|
||||
$(el)
|
||||
.find('.chpt')
|
||||
.each((chapterIndex: number, el) => {
|
||||
const cheerio = $(el);
|
||||
const newChapterName =
|
||||
chapterName + cheerio.find('a').first().text().trim() || '';
|
||||
if (!cheerio.find('i.fa').length) {
|
||||
// no parts
|
||||
const dateHtml =
|
||||
cheerio.html()?.substring(cheerio.html()?.indexOf('</a>') || 0) ||
|
||||
'';
|
||||
let releaseDate =
|
||||
dateHtml?.substring(
|
||||
dateHtml.indexOf('(') + 1,
|
||||
dateHtml.indexOf(')'),
|
||||
) || undefined;
|
||||
if (releaseDate)
|
||||
try {
|
||||
releaseDate = this.parseDate(releaseDate);
|
||||
} catch (e) {}
|
||||
const chapterUrl = cheerio.find('a').attr('href');
|
||||
if (chapterUrl) {
|
||||
const chapter: Plugin.ChapterItem = {
|
||||
name: newChapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(site, ''),
|
||||
chapterNumber: chapterIndex,
|
||||
};
|
||||
|
||||
novelChapters.push(chapter);
|
||||
}
|
||||
} // has parts that needs to be added individually
|
||||
else {
|
||||
const items =
|
||||
cheerio.find('i').parent().next().html()?.split('</a>') || [];
|
||||
items?.shift();
|
||||
const dates: string[] = [];
|
||||
items?.forEach(item => {
|
||||
// there is a date on every publish parts
|
||||
dates.push(
|
||||
item.substring(item.indexOf('(') + 1, item.indexOf(')')),
|
||||
);
|
||||
});
|
||||
const hrefs: string[] = [];
|
||||
cheerio
|
||||
.find('i')
|
||||
.parent()
|
||||
.next()
|
||||
.find('a')
|
||||
.each(function () {
|
||||
hrefs.push(this['attribs']['href']);
|
||||
});
|
||||
if (dates.length == hrefs.length)
|
||||
dates.forEach((date, index) => {
|
||||
let releaseDate = date;
|
||||
try {
|
||||
releaseDate = this.parseDate(date);
|
||||
} catch (e) {}
|
||||
const chapter: Plugin.ChapterItem = {
|
||||
name: newChapterName + ' (' + (index + 1) + ')',
|
||||
releaseTime: releaseDate,
|
||||
path: hrefs[index].replace(site, ''),
|
||||
chapterNumber: chapterIndex + (index + 1) / 1000,
|
||||
};
|
||||
novelChapters.push(chapter);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = novelChapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
if (!$) throw new Error('Failed to load page (open in web view)');
|
||||
|
||||
$('.mistape_caption').remove();
|
||||
let chapterText =
|
||||
$('.chapter-content').html() || $('.entry-content').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
num: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (num !== 1) return []; // only 1 page of results
|
||||
let url = this.site + 'roman';
|
||||
const $ = await this.getCheerio(url);
|
||||
if (!$) throw new Error('Failed to load page (open in web view)');
|
||||
|
||||
let novels = this.parseNovels($, false);
|
||||
|
||||
novels = novels.filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
categorie_genre: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Catégorie/Genre',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'Tous', value: 'all' },
|
||||
{ label: '═══CATÉGORIES═══', value: 'categorie_roman' },
|
||||
{ label: 'Seinen', value: 'c_seinen' },
|
||||
{ label: 'Shonen', value: 'c_shonen' },
|
||||
{ label: 'Original', value: 'c_original' },
|
||||
{ label: 'Yuri', value: 'c_yuri' },
|
||||
{ label: 'Autre', value: 'c_autre' },
|
||||
{ label: 'Fille', value: 'c_fille' },
|
||||
{ label: 'Roman pour Adulte', value: 'c_roman-pour-adulte' },
|
||||
{ label: 'Xuanhuan', value: 'c_xuanhuan' },
|
||||
{ label: 'Yaoi', value: 'c_yaoi' },
|
||||
{ label: '═══GENRES═══', value: 'genre' },
|
||||
{ label: 'Action', value: 'g_action' },
|
||||
{ label: 'Aventure', value: 'g_aventure' },
|
||||
{ label: 'Comédie', value: 'g_comedie' },
|
||||
{ label: 'Drame', value: 'g_drame' },
|
||||
{ label: 'Fantastique', value: 'g_fantastique' },
|
||||
{ label: 'Harem', value: 'g_harem' },
|
||||
{ label: 'Psychologique', value: 'g_psychologique' },
|
||||
{ label: 'Romance', value: 'g_romance' },
|
||||
{ label: 'Ecchi', value: 'g_ecchi' },
|
||||
{ label: 'Mature', value: 'g_mature' },
|
||||
{ label: 'Surnaturel', value: 'g_surnaturel' },
|
||||
{ label: 'Vie scolaire', value: 'g_vie-scolaire' },
|
||||
{ label: 'Adulte', value: 'g_adulte' },
|
||||
{ label: 'Tragédie', value: 'g_tragedie' },
|
||||
{ label: 'Arts Martiaux', value: 'g_arts-martiaux' },
|
||||
{ label: 'Pas de harem', value: 'g_pas-de-harem' },
|
||||
{ label: 'Tranche de vie', value: 'g_tranche-de-vie' },
|
||||
{ label: 'Mecha', value: 'g_mecha' },
|
||||
{ label: 'Sci-fi', value: 'g_sci-fi' },
|
||||
{ label: 'Science-Fiction', value: 'g_science-fiction' },
|
||||
{ label: 'Anti-Héros', value: 'g_anti-heros' },
|
||||
{ label: 'Horreur', value: 'g_horreur' },
|
||||
{ label: 'Insectes', value: 'g_insectes' },
|
||||
{ label: 'Mystère', value: 'g_mystere' },
|
||||
{ label: 'Lolicon', value: 'g_lolicon' },
|
||||
{ label: 'Shoujo Ai', value: 'g_shoujo-ai' },
|
||||
{ label: 'Smut', value: 'g_smut' },
|
||||
{ label: 'Xuanhuan', value: 'g_xuanhuan' },
|
||||
{ label: 'Shotacon', value: 'g_shotacon' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelDeGlacePlugin();
|
||||
@@ -0,0 +1,217 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class NovhellPlugin implements Plugin.PluginBase {
|
||||
id = 'novhell';
|
||||
name = 'Novhell';
|
||||
icon = 'src/fr/novhell/icon.png';
|
||||
site = 'https://novhell.org';
|
||||
version = '1.0.1';
|
||||
filters: Filters | undefined = undefined;
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url, {
|
||||
headers: { 'Accept-Encoding': 'deflate' },
|
||||
});
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
let url = this.site;
|
||||
let $ = await this.getCheerio(url);
|
||||
$('article div div div figure').each((i, elem) => {
|
||||
let novelName = $(elem)
|
||||
.find('figcaption span strong')
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
if (!novelName || novelName.trim() === '') {
|
||||
novelName = $(elem).find('figcaption a strong').first().text().trim();
|
||||
}
|
||||
const novelCover = $(elem).find('a img').attr('src');
|
||||
const novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
if (novelUrl && novelName) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
let $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name =
|
||||
$('meta[property="og:title"]')
|
||||
.attr('content')
|
||||
?.replace('- NovHell', '') || '';
|
||||
novel.cover =
|
||||
$('section div div div div div img').first().attr('src') || defaultCover;
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
novel.author = $("strong:contains('Ecrit par ')")
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Ecrit par ', '')
|
||||
.trim();
|
||||
|
||||
if (!novel.author) {
|
||||
novel.author = $("div p:contains('Auteur')")
|
||||
.text()
|
||||
.replace('Auteur', '')
|
||||
.replace(':', '')
|
||||
.trim();
|
||||
}
|
||||
if (!novel.author) {
|
||||
novel.author = $("div p:contains('Ecrit par :')")
|
||||
.text()
|
||||
.replace('Ecrit par :', '')
|
||||
.trim();
|
||||
}
|
||||
novel.genres = $("strong:contains('Genre')")
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Genre', '')
|
||||
.replace(':', '')
|
||||
.trim();
|
||||
if (!novel.genres) {
|
||||
novel.genres = $("div p:contains('Genre')")
|
||||
.text()
|
||||
.replace('Genre', '')
|
||||
.replace(':', '')
|
||||
.trim();
|
||||
}
|
||||
novel.summary = $("strong:contains('Synopsis')")
|
||||
.parent()
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Synopsis', '')
|
||||
.replace('Synopsis', '')
|
||||
.replace(':', '')
|
||||
.trim();
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
$('main div article div div section div div div div div p a').each(
|
||||
(i, elem) => {
|
||||
// Replace non-breaking spaces with a 'normal' space.
|
||||
const chapterName = $(elem)
|
||||
.text()
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.trim();
|
||||
const chapterUrl = $(elem).attr('href');
|
||||
// Check if the chapter URL exists and contains the site name.
|
||||
if (chapterUrl && chapterUrl.includes(this.site)) {
|
||||
const regex = /Chapitre (\d+)/g;
|
||||
let chapterNumber = 0;
|
||||
let match;
|
||||
while ((match = regex.exec(chapterName)) !== null) {
|
||||
const number = parseInt(match[1]);
|
||||
chapterNumber += number;
|
||||
}
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
chapterNumber: chapterNumber,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Sort the chapters array based on the chapter numbers.
|
||||
// We retrieve the chapters in the order 1-6-11-16-21-......
|
||||
novel.chapters = chapters.sort((chapterA, chapterB) => {
|
||||
if (
|
||||
chapterA.chapterNumber !== undefined &&
|
||||
chapterB.chapterNumber !== undefined
|
||||
) {
|
||||
return chapterA.chapterNumber - chapterB.chapterNumber;
|
||||
}
|
||||
if (chapterA.chapterNumber === undefined) {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
});
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
const sections = $('main article div div section');
|
||||
if (sections) {
|
||||
const numberOfSection = sections.length;
|
||||
let title;
|
||||
let positionChapter = 2;
|
||||
|
||||
for (let i = 3; i <= 5; i++) {
|
||||
title = sections.eq(numberOfSection - i);
|
||||
if (title.find('h4').length !== 0) {
|
||||
positionChapter = i - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const chapter = sections.eq(numberOfSection - positionChapter);
|
||||
|
||||
if (title && chapter) {
|
||||
return (title.html() || '') + (chapter.html() || '');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
let popularNovels = this.popularNovels(1, {
|
||||
showLatestNovels: true,
|
||||
filters: undefined,
|
||||
});
|
||||
|
||||
let novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new NovhellPlugin();
|
||||
@@ -0,0 +1,274 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class PhenixScansTradPlugin implements Plugin.PluginBase {
|
||||
id = 'phenixscans';
|
||||
name = 'PhenixScans';
|
||||
icon = 'src/fr/phenixscans/icon.png';
|
||||
site = 'https://phenixscans.fr';
|
||||
version = '1.0.0';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
|
||||
let filter = '';
|
||||
for (const key in filters) {
|
||||
if (typeof filters[key].value === 'object') {
|
||||
for (const value of filters[key].value as string[]) {
|
||||
filter += `&genre%5B%5D=${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const order = showLatestNovels ? 'update' : 'popular';
|
||||
const url = `${this.site}/manga/?page=${pageNo}${filter}&status=&type=novel&order=${order}`;
|
||||
|
||||
let $ = await this.getCheerio(url);
|
||||
$('div div div div a').each((i, elem) => {
|
||||
const novelName = $(elem).attr('title')?.trim();
|
||||
const novelUrl = $(elem).attr('href');
|
||||
const novelCover = $(elem).find('div img').attr('src') || defaultCover;
|
||||
|
||||
if (novelUrl && novelName) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
let $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name = $('h1[itemprop=name]').text().replace('– Novel', '').trim();
|
||||
novel.cover =
|
||||
$('div[itemprop=image] img').first().attr('src') || defaultCover;
|
||||
novel.author = $('.fmed b:contains(Auteur)+span').text().trim();
|
||||
novel.genres = $('.mgen a')
|
||||
.map(function () {
|
||||
return $(this).text();
|
||||
})
|
||||
.get()
|
||||
.join(', ');
|
||||
novel.summary = $('.entry-content[itemprop=description]').text().trim();
|
||||
novel.status = this.getStatus(
|
||||
$('.tsinfo .imptdt:contains(Statut)').text().replace('Statut', '').trim(),
|
||||
);
|
||||
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
$('ul li:has(div.chbox):has(div.eph-num)').each((i, elem) => {
|
||||
const chapterName = $(elem).find('a .chapternum').text().trim();
|
||||
const chapterUrl = $(elem).find('a').attr('href');
|
||||
const releaseDate = this.parseDate($(elem).find('a .chapterdate').text());
|
||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
}
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
parseDate(date: string): string {
|
||||
const monthMapping: { [key: string]: string } = {
|
||||
janvier: 'January',
|
||||
fevrier: 'February',
|
||||
mars: 'March',
|
||||
avril: 'April',
|
||||
mai: 'May',
|
||||
juin: 'June',
|
||||
juillet: 'July',
|
||||
aout: 'August',
|
||||
septembre: 'September',
|
||||
octobre: 'October',
|
||||
novembre: 'November',
|
||||
decembre: 'December',
|
||||
};
|
||||
const [month, day, year] = date.split(' ');
|
||||
return dayjs(
|
||||
`${day} ${monthMapping[month.normalize('NFD').replace(/[\u0300-\u036f]/g, '')]} ${year}`,
|
||||
'D MMMM YYYY',
|
||||
).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
getStatus(status: string) {
|
||||
const lowerCaseStatus = status.toLowerCase();
|
||||
const ongoing = ['en cours', 'en cours de publication'];
|
||||
const onhiatus = ['en pause', 'en attente'];
|
||||
const completed = ['complété', 'fini', 'achevé', 'terminé'];
|
||||
const cancelled = ['abandonné'];
|
||||
|
||||
if (ongoing.includes(lowerCaseStatus)) {
|
||||
return NovelStatus.Ongoing;
|
||||
} else if (onhiatus.includes(lowerCaseStatus)) {
|
||||
return NovelStatus.OnHiatus;
|
||||
} else if (completed.includes(lowerCaseStatus)) {
|
||||
return NovelStatus.Completed;
|
||||
} else if (cancelled.includes(lowerCaseStatus)) {
|
||||
return NovelStatus.Cancelled;
|
||||
}
|
||||
return NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
return $('#readerarea').html() || '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
let popularNovels = this.popularNovels(1, {
|
||||
showLatestNovels: true,
|
||||
filters: undefined,
|
||||
});
|
||||
|
||||
let novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
genre: {
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
label: 'Genre',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Action', value: 'action' },
|
||||
{
|
||||
label: 'Action Adventure Fantaisie Psychologique',
|
||||
value: 'action-adventure-fantaisie-psychologique',
|
||||
},
|
||||
{
|
||||
label: 'Action Arts martiaux Aventure Fantastique Surnaturel',
|
||||
value: 'action-arts-martiaux-aventure-fantastique-surnaturel',
|
||||
},
|
||||
{
|
||||
label: 'Action Aventure Fantastique',
|
||||
value: 'action-aventure-fantastique',
|
||||
},
|
||||
{ label: 'Action Drame Shônen', value: 'action-drame-shonen' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'amitié', value: 'amitie' },
|
||||
{ label: 'amour', value: 'amour' },
|
||||
{ label: 'Art-martiaux', value: 'art-martiaux' },
|
||||
{ label: 'Arts-martiaux', value: 'arts-martiaux' },
|
||||
{ label: 'Aventure', value: 'aventure' },
|
||||
{ label: 'Combat', value: 'combat' },
|
||||
{ label: 'Comedie', value: 'comedie' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Demons', value: 'demons' },
|
||||
{ label: 'Dragon', value: 'dragon' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Drame', value: 'drame' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'Fantaisie', value: 'fantaisie' },
|
||||
{ label: 'fantastique', value: 'fantastique' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Ghosts', value: 'ghosts' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Historique', value: 'historique' },
|
||||
{ label: 'Horreur', value: 'horreur' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Isekai', value: 'isekai' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ label: 'longstrip art', value: 'longstrip-art' },
|
||||
{ label: 'Magic', value: 'magic' },
|
||||
{ label: 'Magie', value: 'magie' },
|
||||
{ label: 'Male Protagonist', value: 'male-protagonist' },
|
||||
{ label: 'manga', value: 'manga' },
|
||||
{ label: 'Manhwa', value: 'manhwa' },
|
||||
{ label: 'Manhwa Player', value: 'manhwa-player' },
|
||||
{ label: 'Manwha', value: 'manwha' },
|
||||
{ label: 'Martial Arts', value: 'martial-arts' },
|
||||
{ label: 'Mature', value: 'mature' },
|
||||
{ label: 'Monstre', value: 'monstre' },
|
||||
{ label: 'Murim', value: 'murim' },
|
||||
{ label: 'mystère', value: 'mystere' },
|
||||
{ label: 'Mystery', value: 'mystery' },
|
||||
{ label: 'Necromancien', value: 'necromancien' },
|
||||
{ label: 'necromencer', value: 'necromencer' },
|
||||
{ label: 'Novel', value: 'novel' },
|
||||
{ label: 'One Shot', value: 'one-shot' },
|
||||
{ label: 'Over Power MC', value: 'over-power-mc' },
|
||||
{ label: 'Partenaire', value: 'partenaire' },
|
||||
{ label: 'Player', value: 'player' },
|
||||
{ label: 'Player Manhwa', value: 'player-manhwa' },
|
||||
{ label: 'Portail', value: 'portail' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Psychologique', value: 'psychologique' },
|
||||
{ label: 'regresseur', value: 'regresseur' },
|
||||
{ label: 'régression', value: 'regression' },
|
||||
{ label: 'Réincarnation', value: 'reincarnation' },
|
||||
{ label: 'Returner', value: 'returner' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ label: 'School Life', value: 'school-life' },
|
||||
{ label: 'Sci-fi', value: 'sci-fi' },
|
||||
{ label: 'Seinen', value: 'seinen' },
|
||||
{ label: 'Shôjo', value: 'shojo' },
|
||||
{ label: 'Shônen', value: 'shonen' },
|
||||
{ label: 'Shotacon', value: 'shotacon' },
|
||||
{ label: 'Shoujo', value: 'shoujo' },
|
||||
{ label: 'Shounen', value: 'shounen' },
|
||||
{ label: 'Slice of Life', value: 'slice-of-life' },
|
||||
{ label: 'Slide of Life', value: 'slide-of-life' },
|
||||
{ label: 'Smut', value: 'smut' },
|
||||
{ label: 'Sports', value: 'sports' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'Surnaturel', value: 'surnaturel' },
|
||||
{ label: 'Système', value: 'systeme' },
|
||||
{ label: 'Tragédie', value: 'tragedie' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Webtoons', value: 'webtoons' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new PhenixScansTradPlugin();
|
||||
@@ -0,0 +1,198 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class WarriorLegendTradPlugin implements Plugin.PluginBase {
|
||||
id = 'warriorlegendtrad';
|
||||
name = 'Warrior Legend Trad';
|
||||
icon = 'src/fr/warriorlegendtrad/icon.png';
|
||||
site = 'https://warriorlegendtrad.fr';
|
||||
version = '1.0.0';
|
||||
filters: Filters | undefined = undefined;
|
||||
|
||||
regexAuthors = [/Auteur\u00A0:([^\n]*)/];
|
||||
|
||||
regexGenres = [/Genre :([^\n]*)/];
|
||||
|
||||
regexSummary = [/Synopsis\u00A0:([\s\S]*)index chapitre :/i];
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 2) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
let url;
|
||||
// light novel
|
||||
if (pageNo === 1) {
|
||||
url = this.site + '/light-novel';
|
||||
}
|
||||
// Original creation
|
||||
else {
|
||||
url = this.site + '/crea';
|
||||
}
|
||||
|
||||
let $ = await this.getCheerio(url);
|
||||
$('div div div article').each((i, elem) => {
|
||||
const novelName = $(elem).find('.entry-wrapper h2').first().text().trim();
|
||||
const novelUrl = $(elem).find('.entry-wrapper h2 a').attr('href');
|
||||
const novelCover =
|
||||
$(elem).find('figure a img').attr('src') || defaultCover;
|
||||
|
||||
if (novelUrl && novelName) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
let $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name = $('.site-main article header h1').text().trim();
|
||||
novel.cover =
|
||||
$('.site-main article figure img').first().attr('src') || defaultCover;
|
||||
const entryContentText = $('.entry-content').text();
|
||||
novel.author = this.extractInfo(entryContentText, this.regexAuthors);
|
||||
novel.genres = this.extractInfo(entryContentText, this.regexGenres);
|
||||
novel.summary = this.extractInfo(entryContentText, this.regexSummary);
|
||||
novel.status = this.getStatus(entryContentText);
|
||||
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
$('div div article div')
|
||||
.find('h2 a, h3 a')
|
||||
.each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).attr('href');
|
||||
const releaseDate = dayjs(
|
||||
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
||||
).format('DD MMMM YYYY');
|
||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//We sort by date because the elements are not in a fixed order,
|
||||
//and then by name because there are chapters with the same dates.
|
||||
novel.chapters = chapters.sort((a, b) => {
|
||||
if (!a.releaseTime) return 1;
|
||||
if (!b.releaseTime) return -1;
|
||||
const dateA = new Date(a.releaseTime).getTime();
|
||||
const dateB = new Date(b.releaseTime).getTime();
|
||||
const dateComparison = dateA - dateB;
|
||||
if (dateComparison === 0) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return dateComparison;
|
||||
});
|
||||
return novel;
|
||||
}
|
||||
|
||||
extractInfo(text: string, regexes: RegExp[]): string {
|
||||
for (const regex of regexes) {
|
||||
const match = regex.exec(text);
|
||||
if (match !== null) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
getStatus(text: string) {
|
||||
const regexSummary = [
|
||||
/État sur le site :([^\n]*)/i,
|
||||
/état sur le site:([^\n]*)/i,
|
||||
];
|
||||
|
||||
const status = this.extractInfo(text, regexSummary);
|
||||
if (status.includes('en cours')) {
|
||||
return NovelStatus.Ongoing;
|
||||
} else if (status.includes('en pause')) {
|
||||
return NovelStatus.OnHiatus;
|
||||
} else if (status.includes('terminé')) {
|
||||
return NovelStatus.Completed;
|
||||
} else if (status.includes('abandonné')) {
|
||||
return NovelStatus.Cancelled;
|
||||
}
|
||||
|
||||
return NovelStatus.Ongoing;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
var contenuHtml = '';
|
||||
$('.entry-content')
|
||||
.contents()
|
||||
.each(function () {
|
||||
if (
|
||||
!$.html(this)?.startsWith('<div') &&
|
||||
!$.html(this)?.startsWith('<hr') &&
|
||||
!$.html(this)?.includes('<script')
|
||||
) {
|
||||
contenuHtml += $.html(this);
|
||||
}
|
||||
});
|
||||
return contenuHtml;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
let popularNovels = this.popularNovels(1, {
|
||||
showLatestNovels: true,
|
||||
filters: undefined,
|
||||
});
|
||||
|
||||
let novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new WarriorLegendTradPlugin();
|
||||
@@ -0,0 +1,216 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class WuxialnscantradPlugin implements Plugin.PluginBase {
|
||||
id = 'wuxialnscantrad';
|
||||
name = 'WuxiaLnScantrad';
|
||||
icon = 'src/fr/wuxialnscantrad/icon.png';
|
||||
site = 'https://wuxialnscantrad.wordpress.com';
|
||||
version = '1.0.0';
|
||||
filters: Filters | undefined = undefined;
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url, {
|
||||
headers: { 'Accept-Encoding': 'deflate' },
|
||||
});
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
let url = this.site;
|
||||
let $ = await this.getCheerio(url);
|
||||
$('#menu-item-2210 ul li').each((i, elem) => {
|
||||
const novelName = $(elem).first().text().trim();
|
||||
const novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
if (novelUrl && novelName) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: defaultCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
let $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name = $('.entry-title').text().trim();
|
||||
novel.cover =
|
||||
$('.entry-content p strong img').first().attr('src') ||
|
||||
$('.entry-content p img').first().attr('src');
|
||||
|
||||
const entryContentText = $('.entry-content').text();
|
||||
novel.author = this.getAuthor(entryContentText);
|
||||
novel.genres = this.getGenres(entryContentText);
|
||||
novel.artist = this.getArtist(entryContentText);
|
||||
novel.summary = this.getSummary(entryContentText);
|
||||
novel.status = this.getStatus(entryContentText);
|
||||
|
||||
var pathChapter = $('.entry-content ul').first().children('li');
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
pathChapter.each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).find('a').attr('href');
|
||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||
const pathchapter = chapterUrl.replace(this.site, '');
|
||||
// we do not take the paths already present
|
||||
if (!chapters.some(chap => chap.path === pathchapter)) {
|
||||
const releaseDate = dayjs(
|
||||
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
||||
).format('DD MMMM YYYY');
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: pathchapter,
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
getAuthor(text: string) {
|
||||
const regex = /Auteur\(s\):\s*(.*)/;
|
||||
const match = regex.exec(text);
|
||||
let author = '';
|
||||
if (match !== null) {
|
||||
author = match[1].trim();
|
||||
}
|
||||
return author;
|
||||
}
|
||||
|
||||
getGenres(text: string) {
|
||||
const regex = /Genres:\s*(.*)/;
|
||||
const match = regex.exec(text);
|
||||
let genre = '';
|
||||
if (match !== null) {
|
||||
genre = match[1].trim();
|
||||
}
|
||||
return genre;
|
||||
}
|
||||
|
||||
getArtist(text: string) {
|
||||
const regex = /Artiste\(s\):\s*(.*)Genres/;
|
||||
const match = regex.exec(text);
|
||||
let artist = '';
|
||||
if (match !== null) {
|
||||
artist = match[1].trim();
|
||||
}
|
||||
return artist;
|
||||
}
|
||||
|
||||
getSummary(text: string) {
|
||||
const regexAuthors = [
|
||||
/Synopsis :([\s\S]*)Chapitres disponibles/,
|
||||
/Sypnopsis([\s\S]*)Sypnopsis officiel/,
|
||||
/Synopsis([\s\S]*)Chapitres disponibles/,
|
||||
];
|
||||
|
||||
for (const regex of regexAuthors) {
|
||||
const match = regex.exec(text);
|
||||
if (match !== null) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
getStatus(text: string) {
|
||||
const regex = /Statut:\s*(.*)/;
|
||||
const match = regex.exec(text);
|
||||
let status = '';
|
||||
if (match !== null) {
|
||||
status = match[1].trim();
|
||||
}
|
||||
switch (status) {
|
||||
case 'En cours':
|
||||
return NovelStatus.Ongoing;
|
||||
case 'Arrêté':
|
||||
return NovelStatus.Cancelled;
|
||||
case 'Terminé':
|
||||
return NovelStatus.Completed;
|
||||
default:
|
||||
return NovelStatus.Ongoing;
|
||||
}
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
|
||||
var contenuHtml = '';
|
||||
$('.entry-content')
|
||||
.contents()
|
||||
.each(function () {
|
||||
if ($(this).html()?.includes('<script')) {
|
||||
return false;
|
||||
}
|
||||
//Removing tags linked to navigation and unnecessary paragraphs.
|
||||
if (
|
||||
!$(this).html()?.includes('data-attachment-id="480') &&
|
||||
!$.html(this)?.includes('<hr>') &&
|
||||
!$.html(this)?.includes('<p> </p>')
|
||||
) {
|
||||
contenuHtml += $.html(this);
|
||||
}
|
||||
});
|
||||
return contenuHtml;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
let popularNovels = this.popularNovels(1, {
|
||||
showLatestNovels: true,
|
||||
filters: undefined,
|
||||
});
|
||||
|
||||
let novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new WuxialnscantradPlugin();
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Cheerio, CheerioAPI, load, Element } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class XiaowazPlugin implements Plugin.PluginBase {
|
||||
id = 'xiaowaz';
|
||||
name = 'Xiaowaz';
|
||||
icon = 'src/fr/xiaowaz/icon.png';
|
||||
site = 'https://xiaowaz.fr';
|
||||
version = '1.0.1';
|
||||
filters: Filters | undefined = undefined;
|
||||
static novels: Plugin.NovelItem[] | undefined;
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
let retries = 5; // when fetching for images the sites sometimes terminates the connection
|
||||
let returnError: any;
|
||||
while (retries > 0) {
|
||||
try {
|
||||
const r = await fetchApi(url);
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
returnError = error;
|
||||
retries--;
|
||||
// wait 1 second before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
throw new Error(returnError ? returnError : 'Error fetching the page');
|
||||
}
|
||||
|
||||
async getAllNovels(): Promise<Plugin.NovelItem[]> {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
let $ = await this.getCheerio(this.site);
|
||||
|
||||
const categories: Cheerio<Element>[] = [
|
||||
$('li.page_item').find('a:contains("Séries")').parent(),
|
||||
$('li.page_item').find('a:contains("Créations")').parent(),
|
||||
$('li.page_item').find('a:contains("†")').parent(),
|
||||
];
|
||||
|
||||
categories.forEach(cheerio => {
|
||||
cheerio.find('ul.children li').each((i, elem) => {
|
||||
const novelName = $(elem).first().text().trim().replace('✔', '');
|
||||
const novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
// Douluo Dalu is no good
|
||||
if (novelUrl && novelName && novelName !== 'Douluo Dalu') {
|
||||
novel = {
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async getNovelsCovers(
|
||||
novels: Plugin.NovelItem[],
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
await Promise.all(
|
||||
novels.map(async novel => {
|
||||
const $novel = await this.getCheerio(this.site + novel.path);
|
||||
novel.cover =
|
||||
$novel('img[fetchpriority = "high"]').first().attr('src') ||
|
||||
$novel('img.aligncenter').first().attr('src') ||
|
||||
defaultCover;
|
||||
}),
|
||||
);
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(pageNo: number, {}: any): Promise<Plugin.NovelItem[]> {
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
if (!XiaowazPlugin.novels) XiaowazPlugin.novels = await this.getAllNovels();
|
||||
let novels: Plugin.NovelItem[] = XiaowazPlugin.novels;
|
||||
|
||||
const totalPages = Math.ceil(novels.length / PAGE_SIZE);
|
||||
if (pageNo > totalPages) return [];
|
||||
|
||||
// splitting novel list to make fewer requests for getting images
|
||||
novels = novels.slice(PAGE_SIZE * (pageNo - 1), PAGE_SIZE * pageNo);
|
||||
|
||||
return await this.getNovelsCovers(novels);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
let $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name = $('.card_title').text().trim();
|
||||
novel.cover =
|
||||
$('img[fetchpriority = "high"]').first().attr('src') ||
|
||||
$('img.aligncenter').first().attr('src') ||
|
||||
defaultCover;
|
||||
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
if (novel.name.charAt(novel.name.length - 1) === '✔') {
|
||||
novel.status = NovelStatus.Completed;
|
||||
novel.name = novel.name.slice(0, -1);
|
||||
} else if (novelPath.startsWith('/series-abandonnees')) {
|
||||
novel.status = NovelStatus.Cancelled;
|
||||
}
|
||||
|
||||
const entryContentText = $('.entry-content').text();
|
||||
novel.author = this.getAuthor(entryContentText);
|
||||
novel.genres = this.getGenres(entryContentText);
|
||||
if (novelPath.startsWith('/oeuvres-originales')) {
|
||||
novel.genres += novel.genres ? ', Oeuvre originale' : 'Oeuvre originale';
|
||||
}
|
||||
|
||||
const listeParagraphe: string[] = [];
|
||||
|
||||
const PARAGRAPH_EXCLUDE_LIST = [
|
||||
'Écrit par',
|
||||
'Ecrit par',
|
||||
'Sorties régulières',
|
||||
'Auteur\u00A0:',
|
||||
'Statut VO\u00A0:',
|
||||
'Nom utilisé\u00A0:',
|
||||
'Auteur original\u00A0:',
|
||||
'Auteur original de l’oeuvre',
|
||||
'851 chapitres en',
|
||||
'Index\u00A0:',
|
||||
];
|
||||
|
||||
$('.entry-content > p').each((index, element) => {
|
||||
const balise = $(element);
|
||||
|
||||
// remove chapter links
|
||||
if (balise.find('a[href*="xiaowaz.fr/articles"]').length !== 0)
|
||||
return false;
|
||||
|
||||
const textbalise = balise.text();
|
||||
if (PARAGRAPH_EXCLUDE_LIST.some(keyword => textbalise.includes(keyword)))
|
||||
return false;
|
||||
|
||||
if (
|
||||
!textbalise.includes('Genre') &&
|
||||
!textbalise.includes('Synopsis') &&
|
||||
//Managing the novel 'Rebirth of The Thief Who Roamed The World' to remove these three fields.
|
||||
!textbalise.includes('重生之賊行天下') &&
|
||||
!textbalise.includes('Rebirth of The Thief Who Roamed The World') &&
|
||||
!textbalise.includes(
|
||||
'Romance, Comédie, Action, VRMMO, Réincarnation, Futuriste',
|
||||
)
|
||||
)
|
||||
listeParagraphe.push(textbalise);
|
||||
});
|
||||
novel.summary = listeParagraphe.join('\n').trim();
|
||||
|
||||
//Search for chapter links within ul/li tags; otherwise, within p/a tags,
|
||||
//but not both at the same time because otherwise, on the TDG page, it redirects to a PDF download link.
|
||||
var pathChapter = $('.entry-content ul li a');
|
||||
if (pathChapter.length === 0) {
|
||||
pathChapter = $('.entry-content p a');
|
||||
}
|
||||
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
pathChapter.each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).attr('href');
|
||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
getAuthor(text: string) {
|
||||
const regexAuthors = [
|
||||
/Écrit par([^\n]*). Traduction/i,
|
||||
/Écrit par([^\n]*)./i,
|
||||
/Auteur original de l’œuvre\u00A0:([^\n]*)VO/i,
|
||||
/Auteur\u00A0:([^\n]*)sur/,
|
||||
/Auteur\u00A0:([^\n]*)/,
|
||||
/Auteure\u00A0:\u00A0([^\n]*)/,
|
||||
/Auteur original de l’oeuvre\u00A0:([^\n]*)/i,
|
||||
/Auteur original\u00A0:([^\n]*)/i,
|
||||
];
|
||||
|
||||
for (const regex of regexAuthors) {
|
||||
const match = regex.exec(text);
|
||||
if (match !== null) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
getGenres(text: string) {
|
||||
// We handle several cases where there are multiple spellings for the word 'genre'. Genre, Genre :, Genres, Genres:
|
||||
const regex = /Genre((?:.*?\n)*?)\s*Synopsis\s*/;
|
||||
const match = regex.exec(text);
|
||||
let genre = '';
|
||||
if (match !== null) {
|
||||
genre = match[1]
|
||||
.replace('\u00A0', ' ')
|
||||
.replace('s :', '')
|
||||
.replace(':', '')
|
||||
.trim();
|
||||
if (genre.startsWith('s\n')) {
|
||||
genre = genre.replace(/^./, '').trim();
|
||||
}
|
||||
}
|
||||
return genre;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
|
||||
const startTag = $('.wp-post-navigation');
|
||||
const endTag = $('.abh_box.abh_box_down.abh_box_business');
|
||||
|
||||
const elementsBetweenTags: string[] = [];
|
||||
let footnotesElement: string | null = null;
|
||||
|
||||
if (startTag.length > 0 && endTag.length > 0) {
|
||||
let currentElement = startTag.next();
|
||||
while (currentElement.length > 0 && !currentElement.is(endTag)) {
|
||||
if (
|
||||
currentElement.find('p > a[href="https://ko-fi.com/wazouille"]')
|
||||
.length > 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (currentElement.hasClass('footnote_container_prepare')) {
|
||||
footnotesElement = $.html(currentElement);
|
||||
} else {
|
||||
let htmlCurrentElement = $.html(currentElement);
|
||||
htmlCurrentElement =
|
||||
htmlCurrentElement === '<p> </p>'
|
||||
? '<p/>'
|
||||
: htmlCurrentElement;
|
||||
elementsBetweenTags.push(htmlCurrentElement);
|
||||
}
|
||||
currentElement = currentElement.next();
|
||||
}
|
||||
}
|
||||
|
||||
// Place footnotes at the end of the chapter, not at the beginning
|
||||
if (footnotesElement) {
|
||||
elementsBetweenTags.push(footnotesElement);
|
||||
}
|
||||
|
||||
return elementsBetweenTags.join('\n').trim();
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
if (!XiaowazPlugin.novels) XiaowazPlugin.novels = await this.getAllNovels();
|
||||
let popularNovels = XiaowazPlugin.novels;
|
||||
|
||||
// Normalize the text to remove accents and other special characters
|
||||
// This ensures that the search term and novel names are compared accurately
|
||||
let novels = popularNovels.filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return await this.getNovelsCovers(novels);
|
||||
}
|
||||
}
|
||||
|
||||
export default new XiaowazPlugin();
|
||||
@@ -0,0 +1,219 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class IndoWebNovel implements Plugin.PluginBase {
|
||||
id = 'IDWN.id';
|
||||
name = 'IndoWebNovel';
|
||||
icon = 'src/id/indowebnovel/icon.png';
|
||||
site = 'https://indowebnovel.id/id/';
|
||||
version = '1.2.2';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.flexbox2-item').each((i, el) => {
|
||||
const novelName = loadedCheerio(el)
|
||||
.find('.flexbox2-title span')
|
||||
.first()
|
||||
.text();
|
||||
const novelCover = loadedCheerio(el).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(el)
|
||||
.find('.flexbox2-content > a')
|
||||
.attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
novels.push({
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.slice(this.site.length),
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number = 1,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
/*
|
||||
let link = `${this.site}advanced-search/page/${page}/?title=&author=&yearx=`;
|
||||
link += `&status=${filters.status.value}`;
|
||||
link += `&type=${filters.type.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
|
||||
if (filters.lang.value.length)
|
||||
link += filters.lang.value.map(i => `&country[]=${i}`).join('');
|
||||
if (filters.genre.value.length)
|
||||
link += filters.genre.value.map(i => `&genre[]=${i}`).join('');
|
||||
*/
|
||||
let link = this.site + `page/${page}/?s`;
|
||||
const result = await fetchApi(link);
|
||||
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);
|
||||
loadedCheerio('.series-synops div').remove();
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.series-title h2').text().trim() || 'Untitled',
|
||||
cover: loadedCheerio('.series-thumb img').attr('src'),
|
||||
author: loadedCheerio(".series-infolist li:contains('Author') span")
|
||||
.text()
|
||||
.trim(),
|
||||
// status: loadedCheerio('.status').text().trim(),
|
||||
summary: loadedCheerio('.series-synops').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('.series-genres a')
|
||||
.map((i, el) => loadedCheerio(el).text().trim())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.series-chapterlist li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.slice(this.site.length),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapters.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('.readersss .text-left ').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number = 1,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
/*
|
||||
let link = `${this.site}advanced-search/page/${page}/?title=${searchTerm}&author=&yearx=`;
|
||||
link += `&status=${this.filters.status.value}`;
|
||||
link += `&type=${this.filters.type.value}`;
|
||||
link += `&order=${this.filters.sort.value}`;
|
||||
link += this.filters.lang.value.map(i => `&country[]=${i}`).join('');
|
||||
*/
|
||||
let link = this.site + `page/${page}/?s=${searchTerm}`;
|
||||
const result = await fetchApi(link);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
value: '',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
type: {
|
||||
value: '',
|
||||
label: 'Type',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Web Novel', value: 'Web+Novel' },
|
||||
{ label: 'Light Novel', value: 'Light+Novel' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'rating',
|
||||
label: 'Order By',
|
||||
options: [
|
||||
{ label: 'A-Z', value: 'title' },
|
||||
{ label: 'Z-A', value: 'titlereverse' },
|
||||
{ label: 'Latest Update', value: 'update' },
|
||||
{ label: 'Latest Added', value: 'latest' },
|
||||
{ label: 'Popular', value: 'popular' },
|
||||
{ label: 'Rating', value: 'rating' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
lang: {
|
||||
value: ['china', 'jepang', 'korea', 'unknown'],
|
||||
label: 'Country',
|
||||
options: [
|
||||
{ label: 'China', value: 'china' },
|
||||
{ label: 'Jepang', value: 'jepang' },
|
||||
{ label: 'Korea', value: 'korea' },
|
||||
{ label: 'Unknown', value: 'unknown' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genre: {
|
||||
value: [],
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ 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: 'Horror', value: 'horror' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ 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: 'Shoujo', value: 'shoujo' },
|
||||
{ label: 'Shounen', value: 'shounen' },
|
||||
{ label: 'Slice of Life', value: 'slice-of-life' },
|
||||
{ label: 'Smut', value: 'smut' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Wuxia', value: 'wuxia' },
|
||||
{ label: 'Xianxia', value: 'xianxia' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new IndoWebNovel();
|
||||
@@ -0,0 +1,256 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class NovelRingan implements Plugin.PluginBase {
|
||||
id = 'novelringan.com';
|
||||
name = 'NovelRingan';
|
||||
icon = 'src/id/novelringan/icon.png';
|
||||
site = 'https://novelringan.com/';
|
||||
version = '1.0.0';
|
||||
coverUriPrefix = 'https://i0.wp.com/novelringan.com/wp-content/uploads/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('article.post').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('.entry-title').text()?.trim();
|
||||
const novelCover =
|
||||
this.coverUriPrefix + loadedCheerio(ele).find('img').attr('data-sxrx');
|
||||
const novelUrl = loadedCheerio(ele).find('h2 > a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}advanced-search/page/${page}/?title`;
|
||||
link += `&status=${filters.status.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
|
||||
if (filters.type.value.length)
|
||||
link += filters.type.value.map(i => `&type[]=${i}`).join('');
|
||||
if (filters.genre.value.length)
|
||||
link += filters.genre.value.map(i => `&genre[]=${i}`).join('');
|
||||
|
||||
const result = await fetchApi(link);
|
||||
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 styletag = Array.from(
|
||||
loadedCheerio('meta[name=msapplication-TileImage] + style')
|
||||
.html()
|
||||
?.matchAll(/"(.*?)"/g) || [],
|
||||
m => m[1],
|
||||
);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: styletag[0] || loadedCheerio('.entry-title').text() || 'Untitled',
|
||||
author: styletag[1],
|
||||
summary: loadedCheerio('.maininfo span p').text(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.cover =
|
||||
this.coverUriPrefix +
|
||||
loadedCheerio('img.ts-post-image').attr('data-sxrx');
|
||||
|
||||
loadedCheerio('.maininfo li').each(function () {
|
||||
let detailName = loadedCheerio(this).find('b').text().trim();
|
||||
let detail = loadedCheerio(this).find('b').remove().end().text().trim();
|
||||
|
||||
switch (detailName) {
|
||||
case 'Status:':
|
||||
novel.status = detail;
|
||||
break;
|
||||
case 'Genre:':
|
||||
novel.genres = detail;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.bxcl > ul > li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').text();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
loadedCheerio('.entry-content div[style="display:none"]').remove();
|
||||
|
||||
const chapterText = loadedCheerio('.entry-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = this.site + 'page/' + page + '/?s=' + searchTerm;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
value: '',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Ongoing', value: 'Ongoing' },
|
||||
{ label: 'Completed', value: 'Completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'popular',
|
||||
label: 'Urutkan',
|
||||
options: [
|
||||
{ label: 'A-Z', value: 'title' },
|
||||
{ label: 'Z-A', value: 'titlereverse' },
|
||||
{ label: 'Terbarui', value: 'update' },
|
||||
{ label: 'Ditambahkan', value: 'latest' },
|
||||
{ label: 'Terpopuler', value: 'popular' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
type: {
|
||||
value: [],
|
||||
label: 'Tipe',
|
||||
options: [
|
||||
{ label: 'Chinese Novel', value: 'chinese-novel' },
|
||||
{ label: 'Chinese Web Novel', value: 'chinese-web-novel' },
|
||||
{ label: 'Filipino Novel', value: 'filipino-novel' },
|
||||
{ label: 'Indonesia Novel', value: 'indonesia-novel' },
|
||||
{ label: 'Korean Novel', value: 'korean-novel' },
|
||||
{ label: 'Light Novel', value: 'light-novel' },
|
||||
{ label: 'Light Novel (CN)', value: 'light-novel-cn' },
|
||||
{ label: 'Light Novel (JP)', value: 'light-novel-jp' },
|
||||
{ label: 'Light Novel (KR)', value: 'light-novel-kr' },
|
||||
{ label: 'Malaysian Novel', value: 'malaysian-novel' },
|
||||
{ label: 'Published Novel (CN)', value: 'published-novel-cn' },
|
||||
{ label: 'Published Novel (JP)', value: 'published-novel-jp' },
|
||||
{ label: 'Published Novel (KR)', value: 'published-novel-kr' },
|
||||
{ label: 'Published Novel (TH)', value: 'published-novel-th' },
|
||||
{ label: 'Thai Novel', value: 'thai-novel' },
|
||||
{ label: 'Vietnamese Novel', value: 'vietnamese-novel' },
|
||||
{ label: 'Web Novel', value: 'web-novel' },
|
||||
{ label: 'Webnovel', value: 'webnovel' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genre: {
|
||||
value: [],
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Celebrity', value: 'celebrity' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'ction', value: 'ction' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Eastern', value: 'eastern' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Game', value: 'game' },
|
||||
{ label: 'Games', value: 'games' },
|
||||
{ label: 'Gender Bender', value: 'gender-bender' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Isekai', value: 'isekai' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ label: 'Life', value: 'life' },
|
||||
{ label: 'LitRPG', value: 'litrpg' },
|
||||
{ label: 'Magical Realism', value: 'magical-realism' },
|
||||
{ label: 'Martial Arts', value: 'martial-arts' },
|
||||
{ label: 'Mature', value: 'mature' },
|
||||
{ label: 'Mecha', value: 'mecha' },
|
||||
{ label: 'Mystery', value: 'mystery' },
|
||||
{ label: 'Psychologic', value: 'psychologic' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Recarnation', value: 'recarnation' },
|
||||
{ label: 'Reincarnation', value: 'reincarnation' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ label: 'School', value: 'school' },
|
||||
{ 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: 'Smut', value: 'smut' },
|
||||
{ label: 'Sports', value: 'sports' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Urban', value: 'urban' },
|
||||
{ label: 'Urban Life', value: 'urban-life' },
|
||||
{
|
||||
label: 've names:N/A Genre:Romance',
|
||||
value: 've-namesn-a-genreromance',
|
||||
},
|
||||
{ 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 NovelRingan();
|
||||
@@ -0,0 +1,247 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class SakuraNovel implements Plugin.PluginBase {
|
||||
id = 'sakura.id';
|
||||
name = 'SakuraNovel';
|
||||
icon = 'src/id/sakuranovel/icon.png';
|
||||
site = 'https://sakuranovel.id/';
|
||||
version = '1.0.1';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.flexbox2-item').each((i, el) => {
|
||||
const novelName = loadedCheerio(el)
|
||||
.find('.flexbox2-title span')
|
||||
.first()
|
||||
.text();
|
||||
const novelCover = loadedCheerio(el).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(el)
|
||||
.find('.flexbox2-content > a')
|
||||
.attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
novels.push({
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}advanced-search/page/${page}/?title&author&yearx`;
|
||||
link += `&status=${filters.status.value}`;
|
||||
link += `&type=${filters.type.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
|
||||
if (filters.lang.value.length)
|
||||
link += filters.lang.value.map(i => `&country[]=${i}`).join('');
|
||||
if (filters.genre.value.length)
|
||||
link += filters.genre.value.map(i => `&genre[]=${i}`).join('');
|
||||
|
||||
const result = await fetchApi(link);
|
||||
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);
|
||||
loadedCheerio('.series-synops div').remove();
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.series-title h2').text().trim() || 'Untitled',
|
||||
cover: loadedCheerio('.series-thumb img').attr('src'),
|
||||
author: loadedCheerio(".series-infolist > li b:contains('Author') +")
|
||||
.text()
|
||||
.trim(),
|
||||
status: loadedCheerio('.status').text().trim(),
|
||||
summary: loadedCheerio('.series-synops').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('.series-genres')
|
||||
.children('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
const imageTitle = novel.cover
|
||||
?.split('/')
|
||||
.pop()
|
||||
?.split('-')
|
||||
.join(' ')
|
||||
.split('.')[0];
|
||||
const chapterTitle = novel.name
|
||||
.replace(/\(LN\)|\(WN\)/, '')
|
||||
.split(',')[0]
|
||||
.trim();
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.series-flexright li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el)
|
||||
.find('a span')
|
||||
.first()
|
||||
.text()
|
||||
.replace(chapterTitle, '')
|
||||
.replace(imageTitle!, '')
|
||||
.replace(/Bahasa Indonesia/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const releaseDate = loadedCheerio(el)
|
||||
.find('.date')
|
||||
.text()
|
||||
.trim()
|
||||
.split('/')
|
||||
.map(x => Number(x));
|
||||
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: new Date(
|
||||
releaseDate[2],
|
||||
releaseDate[1],
|
||||
releaseDate[0],
|
||||
).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 divi = loadedCheerio("div:contains('Daftar Isi') +")
|
||||
.find('div:first')
|
||||
.attr('class');
|
||||
loadedCheerio(`.${divi}`).remove();
|
||||
const chapterText =
|
||||
loadedCheerio("div:contains('Daftar Isi') +").html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}page/${page}/?s=${searchTerm}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
value: '',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
type: {
|
||||
value: '',
|
||||
label: 'Type',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Web Novel', value: 'Web+Novel' },
|
||||
{ label: 'Light Novel', value: 'Light+Novel' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'rating',
|
||||
label: 'Order By',
|
||||
options: [
|
||||
{ label: 'A-Z', value: 'title' },
|
||||
{ label: 'Z-A', value: 'titlereverse' },
|
||||
{ label: 'Latest Update', value: 'update' },
|
||||
{ label: 'Latest Added', value: 'latest' },
|
||||
{ label: 'Popular', value: 'popular' },
|
||||
{ label: 'Rating', value: 'rating' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
lang: {
|
||||
value: ['china', 'jepang', 'korea', 'unknown'],
|
||||
label: 'Country',
|
||||
options: [
|
||||
{ label: 'China', value: 'china' },
|
||||
{ label: 'Jepang', value: 'jepang' },
|
||||
{ label: 'Korea', value: 'korea' },
|
||||
{ label: 'Unknown', value: 'unknown' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genre: {
|
||||
value: [],
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ 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: 'Horror', value: 'horror' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ 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: 'Shoujo', value: 'shoujo' },
|
||||
{ label: 'Shounen', value: 'shounen' },
|
||||
{ label: 'Slice of Life', value: 'slice-of-life' },
|
||||
{ label: 'Smut', value: 'smut' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Wuxia', value: 'wuxia' },
|
||||
{ label: 'Xianxia', value: 'xianxia' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new SakuraNovel();
|
||||
@@ -0,0 +1,282 @@
|
||||
import { load, load as loadCheerio } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { NovelItem } from '../../test_web/static/js';
|
||||
// const novelStatus = require('@libs/novelStatus');
|
||||
// const isUrlAbsolute = require('@libs/isAbsoluteUrl');
|
||||
// const parseDate = require('@libs/parseDate');
|
||||
|
||||
class Syosetu implements Plugin.PluginBase {
|
||||
id = 'yomou.syosetu';
|
||||
name = 'Syosetu';
|
||||
icon = 'src/jp/syosetu/icon.png';
|
||||
site = 'https://yomou.syosetu.com/';
|
||||
novelPrefix = 'https://ncode.syosetu.com';
|
||||
version = '1.1.0';
|
||||
headers = {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
};
|
||||
searchUrl = (pagenum?: number, order?: string) => {
|
||||
return `https://yomou.syosetu.com/search.php?order=${order || 'hyoka'}${
|
||||
pagenum !== undefined
|
||||
? `&p=${pagenum <= 1 || pagenum > 100 ? '1' : pagenum}` // check if pagenum is between 1 and 100
|
||||
: '' // if isn't don't set ?p
|
||||
}`;
|
||||
};
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const getNovelsFromPage = async (pagenumber: number) => {
|
||||
// load page
|
||||
let url = this.site;
|
||||
|
||||
if (!filters.genre.value) {
|
||||
url += `rank/list/type/${filters.ranking.value}_${filters.modifier.value}/?p=${pagenumber}`;
|
||||
} else {
|
||||
url += `rank/${
|
||||
filters.genre.value.length === 1 ? 'isekailist' : 'genrelist'
|
||||
}/type/${filters.ranking.value}_${filters.genre.value}${
|
||||
filters.modifier.value === 'total' ? '' : `_${filters.modifier.value}`
|
||||
}/?p=${pagenumber}`;
|
||||
}
|
||||
const html = await (await fetchApi(url)).text();
|
||||
|
||||
const loadedCheerio = loadCheerio(html, {
|
||||
decodeEntities: false,
|
||||
});
|
||||
|
||||
if (parseInt(loadedCheerio('.is-current').html() || '1') !== pagenumber)
|
||||
return [];
|
||||
|
||||
const novels: NovelItem[] = [];
|
||||
loadedCheerio('.c-card').each((_, e) => {
|
||||
const anchor = loadedCheerio(e).find('.p-ranklist-item__title a');
|
||||
const url = anchor.attr('href');
|
||||
if (!url) return;
|
||||
const name = anchor.text();
|
||||
const novel: NovelItem = {
|
||||
path: url.replace(this.novelPrefix, ''),
|
||||
name,
|
||||
cover: defaultCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
};
|
||||
const novels = await getNovelsFromPage(pageNo);
|
||||
return novels;
|
||||
}
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
const result = await fetch(this.novelPrefix + novelPath, {
|
||||
headers: this.headers,
|
||||
});
|
||||
const body = await result.text();
|
||||
const loadedCheerio = loadCheerio(body, { decodeEntities: false });
|
||||
|
||||
// create novel object
|
||||
let novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.novel_title').text(),
|
||||
author: loadedCheerio('.novel_writername').text().replace('作者:', ''),
|
||||
cover: defaultCover,
|
||||
};
|
||||
|
||||
// Get all the chapters
|
||||
const cqGetChapters = loadedCheerio('.novel_sublist2');
|
||||
if (cqGetChapters.length !== 0) {
|
||||
// has more than 1 chapter
|
||||
novel.summary = loadedCheerio('#novel_ex')
|
||||
.text()
|
||||
.replace(/<\s*br.*?>/g, '\n');
|
||||
cqGetChapters.each(function (i, e) {
|
||||
const chapterA = loadedCheerio(this).find('a');
|
||||
const [chapterName, releaseDate, chapterUrl] = [
|
||||
// set the variables
|
||||
chapterA.text(),
|
||||
loadedCheerio(this)
|
||||
.find('dt') // get title
|
||||
.text() // get text
|
||||
.replace(/(.)/g, '') // remove "(edited)" mark
|
||||
.trim(), // trim spaces
|
||||
chapterA.attr('href'),
|
||||
];
|
||||
if (chapterUrl) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
/**
|
||||
* Because there are oneshots on the site, they have to be treated with special care
|
||||
* that's what pisses me off in Shosetsu app. They have this extension,
|
||||
* but every oneshot is set as "there are no chapters" and all contents are thrown into the description!!
|
||||
*/
|
||||
// get summary for oneshot chapter
|
||||
|
||||
const nameResult = await fetch(this.searchUrl() + `&word=${novel.name}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
const nameBody = await nameResult.text();
|
||||
const summaryQuery = loadCheerio(nameBody, {
|
||||
decodeEntities: false,
|
||||
});
|
||||
const foundText = summaryQuery('.searchkekka_box')
|
||||
.first()
|
||||
.find('.ex')
|
||||
.text()
|
||||
.replace(/\s{2,}/g, '\n');
|
||||
novel.summary = foundText;
|
||||
|
||||
// add single chapter
|
||||
chapters.push({
|
||||
name: 'Oneshot',
|
||||
releaseTime: loadedCheerio('head')
|
||||
.find("meta[name='WWWC']")
|
||||
.attr('content'), // get date from metadata
|
||||
path: novelPath, // set chapterUrl to oneshot so that chapterScraper knows it's a one-shot,
|
||||
});
|
||||
}
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetch(this.novelPrefix + chapterPath, {
|
||||
headers: this.headers,
|
||||
});
|
||||
const body = await result.text();
|
||||
|
||||
// create cheerioQuery
|
||||
const cheerioQuery = loadCheerio(body, {
|
||||
decodeEntities: false,
|
||||
});
|
||||
|
||||
let chapterText =
|
||||
cheerioQuery('#novel_honbun') // get chapter text
|
||||
.html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let novels = [];
|
||||
|
||||
// returns list of novels from given page
|
||||
const getNovelsFromPage = async (pagenumber: number) => {
|
||||
// load page
|
||||
const url = this.searchUrl(pagenumber) + `&word=${searchTerm}`;
|
||||
const result = await fetch(url, { headers: this.headers });
|
||||
const body = await result.text();
|
||||
// Cheerio it!
|
||||
const cheerioQuery = loadCheerio(body, { decodeEntities: false });
|
||||
|
||||
let pageNovels: Plugin.NovelItem[] = [];
|
||||
// find class=searchkekka_box
|
||||
cheerioQuery('.searchkekka_box').each((i, e) => {
|
||||
// get div with link and name
|
||||
const novelDIV = cheerioQuery(e).find('.novel_h');
|
||||
// get link element
|
||||
const novelA = novelDIV.children()[0];
|
||||
// add new novel to array
|
||||
const novelPath = novelA.attribs.href.replace(this.novelPrefix, '');
|
||||
if (novelPath) {
|
||||
pageNovels.push({
|
||||
name: novelDIV.text(), // get the name
|
||||
path: novelPath, // get last part of the link
|
||||
cover: defaultCover,
|
||||
});
|
||||
}
|
||||
});
|
||||
// return all novels from this page
|
||||
return pageNovels;
|
||||
};
|
||||
|
||||
// counter of loaded pages
|
||||
// let pagesLoaded = 0;
|
||||
// do {
|
||||
// // always load first one
|
||||
// novels.push(...(await getNovelsFromPage(pagesLoaded + 1)));
|
||||
// pagesLoaded++;
|
||||
// } while (pagesLoaded < maxPageLoad && isNext); // check if we should load more
|
||||
|
||||
novels = await getNovelsFromPage(pageNo);
|
||||
|
||||
/** Use
|
||||
* novels.push(...(await getNovelsFromPage(pageNumber)))
|
||||
* if you want to load more
|
||||
*/
|
||||
|
||||
// respond with novels!
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl(path: string, isNovel?: boolean | undefined): string {
|
||||
return this.novelPrefix + path;
|
||||
}
|
||||
filters = {
|
||||
ranking: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Ranked by',
|
||||
options: [
|
||||
{ label: '日間', value: 'daily' },
|
||||
{ label: '週間', value: 'weekly' },
|
||||
{ label: '月間', value: 'monthly' },
|
||||
{ label: '四半期', value: 'quarter' },
|
||||
{ label: '年間', value: 'yearly' },
|
||||
{ label: '累計', value: 'total' },
|
||||
],
|
||||
value: 'total',
|
||||
},
|
||||
genre: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Ranking Genre',
|
||||
options: [
|
||||
{ label: '総ジャンル', value: '' },
|
||||
{ label: '異世界転生/転移〔恋愛〕〕', value: '1' },
|
||||
{ label: '異世界転生/転移〔ファンタジー〕', value: '2' },
|
||||
{ label: '異世界転生/転移〔文芸・SF・その他〕', value: 'o' },
|
||||
{ label: '異世界〔恋愛〕', value: '101' },
|
||||
{ label: '現実世界〔恋愛〕', value: '102' },
|
||||
{ label: 'ハイファンタジー〔ファンタジー〕', value: '201' },
|
||||
{ label: 'ローファンタジー〔ファンタジー〕', value: '202' },
|
||||
{ label: '純文学〔文芸〕', value: '301' },
|
||||
{ label: 'ヒューマンドラマ〔文芸〕', value: '302' },
|
||||
{ label: '歴史〔文芸〕', value: '303' },
|
||||
{ label: '推理〔文芸〕', value: '304' },
|
||||
{ label: 'ホラー〔文芸〕', value: '305' },
|
||||
{ label: 'アクション〔文芸〕', value: '306' },
|
||||
{ label: 'コメディー〔文芸〕', value: '307' },
|
||||
{ label: 'VRゲーム〔SF〕', value: '401' },
|
||||
{ label: '宇宙〔SF〕', value: '402' },
|
||||
{ label: '空想科学〔SF〕', value: '403' },
|
||||
{ label: 'パニック〔SF〕', value: '404' },
|
||||
{ label: '童話〔その他〕', value: '9901' },
|
||||
{ label: '詩〔その他〕', value: '9902' },
|
||||
{ label: 'エッセイ〔その他〕', value: '9903' },
|
||||
{ label: 'その他〔その他〕', value: '9999' },
|
||||
],
|
||||
value: '',
|
||||
},
|
||||
modifier: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Modifier',
|
||||
options: [
|
||||
{ label: 'すべて', value: 'total' },
|
||||
{ label: '連載中', value: 'r' },
|
||||
{ label: '完結済', value: 'er' },
|
||||
{ label: '短編', value: 't' },
|
||||
],
|
||||
value: 'total',
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Syosetu();
|
||||
@@ -0,0 +1,191 @@
|
||||
import { fetchFile, fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import qs from 'qs';
|
||||
|
||||
class Agitoon implements Plugin.PluginBase {
|
||||
id = 'agit.xyz';
|
||||
name = 'Agitoon';
|
||||
icon = 'src/kr/agitoon/icon.png';
|
||||
site = 'https://agit659.xyz';
|
||||
version = '3.0.2';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters, showLatestNovels }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const res = await fetchApi(this.site + '/novel/index.update.php', {
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({
|
||||
mode: 'get_data_novel_list_p',
|
||||
novel_menu: showLatestNovels ? '1' : '3',
|
||||
np_day: new Date().getDay(),
|
||||
np_rank: '1',
|
||||
np_distributor: '0',
|
||||
np_genre: '00',
|
||||
np_order: '1',
|
||||
np_genre_ex_1: '00',
|
||||
np_genre_ex_2: '00',
|
||||
list_limit: 20 * (pageNo - 1),
|
||||
is_query_first: pageNo == 1,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to get popular novels: (${res.status}: ${res.statusText}) (try to open in webview)`,
|
||||
);
|
||||
}
|
||||
const resJson = (await res.json()) as response;
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
resJson?.list?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.wr_subject,
|
||||
cover: this.site + novel.np_dir + '/thumbnail/' + novel.np_thumbnail,
|
||||
path: novel.wr_id,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.resolveUrl(novelPath, true)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(result, { decodeEntities: false });
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h5.pt-2').text(),
|
||||
cover: this.site + loadedCheerio('div.col-5.pr-0.pl-0 img').attr('src'),
|
||||
summary: loadedCheerio('.pt-1.mt-1.pb-1.mb-1').text(),
|
||||
};
|
||||
|
||||
novel.author = loadedCheerio('.post-item-list-cate-v')
|
||||
.first()
|
||||
.text()
|
||||
.split(' : ')[1];
|
||||
|
||||
const genres = loadedCheerio('.col-7 > .post-item-list-cate > span')
|
||||
.map((index, element) => loadedCheerio(element).text().trim())
|
||||
.get();
|
||||
|
||||
if (genres.length) {
|
||||
novel.genres = genres.join(', ');
|
||||
}
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
const res = await fetchApi(this.site + '/novel/list.update.php', {
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({
|
||||
mode: 'get_data_novel_list_c',
|
||||
wr_id_p: novelPath,
|
||||
page_no: '1',
|
||||
cnt_list: '10000',
|
||||
order_type: 'Asc',
|
||||
}),
|
||||
});
|
||||
|
||||
const resJson = (await res.json()) as responseBook;
|
||||
resJson?.list?.forEach(chapter =>
|
||||
chapters.push({
|
||||
name: chapter.wr_subject,
|
||||
path: chapter.wr_id + '/2',
|
||||
releaseTime: chapter.wr_datetime,
|
||||
}),
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.resolveUrl(chapterPath)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(result);
|
||||
let content = loadedCheerio('#id_wr_content').html() || '';
|
||||
|
||||
// gets rid of the popup thingy
|
||||
content = content
|
||||
.replace('팝업메뉴는 빈공간을 더치하거나 스크룰시 사라집니다', '')
|
||||
.trim();
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const rawResults = await fetchApi(this.site + '/novel/search.php', {
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({
|
||||
mode: 'get_data_novel_list_p_sch',
|
||||
search_novel: searchTerm,
|
||||
list_limit: 0,
|
||||
}),
|
||||
});
|
||||
const resJson = (await rawResults.json()) as response;
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
resJson?.list?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.wr_subject,
|
||||
cover:
|
||||
this.site + '/' + novel.np_dir + '/thumbnail/' + novel.np_thumbnail,
|
||||
path: novel.wr_id,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/novel/list/' : '/novel/view/') + path;
|
||||
}
|
||||
|
||||
export default new Agitoon();
|
||||
|
||||
interface response {
|
||||
list_limit: number;
|
||||
list?: ListEntity[] | null;
|
||||
list_count: number;
|
||||
}
|
||||
interface ListEntity {
|
||||
wr_id: string;
|
||||
wr_subject: string;
|
||||
np_dir: string;
|
||||
np_type_02: string;
|
||||
np_thumbnail: string;
|
||||
np_author: string;
|
||||
wr_subject2: string;
|
||||
wr_datetime: string;
|
||||
np_distributor: string;
|
||||
np_genre: string;
|
||||
np_country: string;
|
||||
np_age: string;
|
||||
is_scrap: number;
|
||||
}
|
||||
|
||||
interface responseBook {
|
||||
list?: ListEntity2[] | null;
|
||||
download_time: string;
|
||||
}
|
||||
interface ListEntity2 {
|
||||
wr_id: string;
|
||||
wr_subject: string;
|
||||
wr_datetime: string;
|
||||
url_view1: string;
|
||||
novel_c_style1: string;
|
||||
novel_c_str1: string;
|
||||
data_novel_c_view: string;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class NovelkiPL implements Plugin.PluginBase {
|
||||
id = 'novelki.pl';
|
||||
name = 'Novelki';
|
||||
icon = 'src/pl/novelki/icon.png';
|
||||
site = 'https://novelki.pl';
|
||||
version = '1.0.3';
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
//So far is working, If the author of the site changes it, it will have to be changed.
|
||||
let link = this.site + '/projekty?filter=t';
|
||||
link += '&genre=' + filters?.genres.value;
|
||||
link += '&status=' + filters?.status.value;
|
||||
link += '&type=' + filters?.type.value;
|
||||
link += '&page=' + page;
|
||||
|
||||
const body = await fetchApi(link).then(res => {
|
||||
if (res.url == `${this.site}/guest`)
|
||||
throw new Error('Failed to load novels (open in web view and login)');
|
||||
return res.text();
|
||||
});
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const load = loadedCheerio('#projects > div');
|
||||
|
||||
//if(load.length == 0) throw new Error("Failed to load page (open in web view and login)");
|
||||
|
||||
const novels: Plugin.NovelItem[] = load
|
||||
.map((index, element) => ({
|
||||
name: loadedCheerio(element)
|
||||
.find('.card-title')
|
||||
.attr('title') as string,
|
||||
cover:
|
||||
this.site + loadedCheerio(element).find('.card-img-top').attr('src'),
|
||||
path: loadedCheerio(element).find('a').attr('href') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
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('p.h5').each((i, e) => {
|
||||
const text = loadedCheerio(e).text().trim();
|
||||
if (text.includes('Autor:')) novel.author = text.split(':')[1].trim();
|
||||
if (text.includes('Status projektu:')) {
|
||||
switch (`${text.split(':')[1].trim()}`.toLowerCase()) {
|
||||
case 'wstrzymany':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case 'zakończony':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
case 'aktywny':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'porzucony':
|
||||
novel.status = NovelStatus.Cancelled;
|
||||
break;
|
||||
case 'zlicencjonowany':
|
||||
novel.status = NovelStatus.Licensed;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
novel.name = loadedCheerio('div.col-sm-12.col-md-6.col-lg-8.mb-5')
|
||||
.find('h3')
|
||||
.text()
|
||||
.trim();
|
||||
novel.cover = this.site + loadedCheerio('.img-fluid').attr('src'); // TODO: return not only undefined
|
||||
|
||||
novel.genres = loadedCheerio('span.badge')
|
||||
.map((i, e) => loadedCheerio(e).text())
|
||||
.get()
|
||||
.join(', ');
|
||||
|
||||
novel.summary = loadedCheerio('.h5:contains("Opis:")')
|
||||
.next('p')
|
||||
.next('p')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
//TODO: Dodać tłumacza danej serii :D
|
||||
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
let chaptersList = loadedCheerio('.chapters > .col-md-3 > div').get();
|
||||
chaptersList.forEach((e, i) => {
|
||||
let urlChapters = loadedCheerio(e).find('a').attr('href') || '';
|
||||
|
||||
const chapter: Plugin.ChapterItem = {
|
||||
name: loadedCheerio(e).find('a')?.text().trim(),
|
||||
path: urlChapters,
|
||||
releaseTime: loadedCheerio(e)
|
||||
.find('.card-footer > span')
|
||||
.text()
|
||||
.trim()
|
||||
.split('-')
|
||||
.reverse()
|
||||
.join('-'),
|
||||
chapterNumber: chaptersList.length - i,
|
||||
};
|
||||
chapters.push(chapter);
|
||||
});
|
||||
|
||||
novel.chapters = chapters.reverse();
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
var pattern = /\/projekty\/([^\/]+)\/([^\/]+)/;
|
||||
let codeChapter = pattern.exec(chapterPath) || '';
|
||||
const body = await fetchApi(
|
||||
`${this.site}/api/reader/chapters/${codeChapter[2]}`,
|
||||
).then(res => {
|
||||
if (res.url == `${this.site}/guest`)
|
||||
throw new Error('Failed to load chapter (open in web view and login)');
|
||||
return res.json();
|
||||
});
|
||||
|
||||
const chapterText = body.data.content;
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(
|
||||
`${this.site}/projekty?filter=t&title=${searchTerm}+&page=${page}`,
|
||||
).then(res => {
|
||||
if (res.url == `${this.site}/guest`)
|
||||
throw new Error('Failed to search novels (open in web view and login)');
|
||||
return res.text();
|
||||
});
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = loadedCheerio('#projects > div')
|
||||
.map((index, element) => ({
|
||||
name: loadedCheerio(element)
|
||||
.find('.card-title')
|
||||
.attr('title') as string,
|
||||
cover:
|
||||
this.site + loadedCheerio(element).find('.card-img-top').attr('src'),
|
||||
path: loadedCheerio(element).find('a').attr('href') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
genres: {
|
||||
value: '',
|
||||
label: 'Genre',
|
||||
options: [
|
||||
{ label: 'Wybierz gatunek', value: '' },
|
||||
{ label: 'Adaptacja do anime', value: '35' },
|
||||
{ label: 'Adaptacja do dramy', value: '40' },
|
||||
{ label: 'Adaptacja do mangi', value: '36' },
|
||||
{ label: 'Akcja', value: '1' },
|
||||
{ label: 'Boys Love', value: '22' },
|
||||
{ label: 'Bromans', value: '33' },
|
||||
{ label: 'Dojrzały', value: '11' },
|
||||
{ label: 'Dramat', value: '10' },
|
||||
{ label: 'Ecchi', value: '29' },
|
||||
{ label: 'Fantasy', value: '2' },
|
||||
{ label: 'Gender Bender', value: '28' },
|
||||
{ label: 'Girls Love', value: '23' },
|
||||
{ label: 'Harem', value: '8' },
|
||||
{ label: 'Hentai', value: '43' },
|
||||
{ label: 'Historyczny', value: '30' },
|
||||
{ label: 'Horror', value: '15' },
|
||||
{ label: 'Isekai', value: '44' },
|
||||
{ label: 'Josei', value: '27' },
|
||||
{ label: 'Komedia', value: '3' },
|
||||
{ label: 'Mecha', value: '16' },
|
||||
{ label: 'Nadprzyrodzone', value: '9' },
|
||||
{ label: 'Okruchy życia', value: '18' },
|
||||
{ label: 'Oneshot', value: '41' },
|
||||
{ label: 'Parodia', value: '19' },
|
||||
{ label: 'Przygodowy', value: '4' },
|
||||
{ label: 'Psychologiczny', value: '12' },
|
||||
{ label: 'Reinkarnacja', value: '42' },
|
||||
{ label: 'Romans', value: '7' },
|
||||
{ label: 'RPG', value: '17' },
|
||||
{ label: 'Sci-fi', value: '21' },
|
||||
{ label: 'Seinen', value: '26' },
|
||||
{ label: 'Shoujo', value: '25' },
|
||||
{ label: 'Shounen', value: '24' },
|
||||
{ label: 'Smut', value: '31' },
|
||||
{ label: 'Sport', value: '32' },
|
||||
{ label: 'Świat gry', value: '5' },
|
||||
{ label: 'Szkolne życie', value: '20' },
|
||||
{ label: 'Sztuki walki', value: '6' },
|
||||
{ label: 'Tajemnica', value: '14' },
|
||||
{ label: 'Tragedia', value: '13' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
status: {
|
||||
label: 'Status',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Wybierz status', value: '' },
|
||||
{ label: 'Zakończony', value: '0' },
|
||||
{ label: 'Aktywny', value: '1' },
|
||||
{ label: 'Porzucony', value: '2' },
|
||||
{ label: 'Wstrzymany', value: '3' },
|
||||
{ label: 'Zlicencjonowany', value: '4' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
type: {
|
||||
label: 'Type',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Wybierz typ', value: '' },
|
||||
{ label: 'Nie zdefiniowano', value: '0' },
|
||||
{ label: 'Light Novel', value: '1' },
|
||||
{ label: 'Wuxia', value: '2' },
|
||||
{ label: 'Xianxia', value: '3' },
|
||||
{ label: 'Web Novel', value: '4' },
|
||||
{ label: 'Autorska', value: '5' },
|
||||
{ label: 'Inne', value: '6' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelkiPL();
|
||||
@@ -0,0 +1,283 @@
|
||||
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 dayjs from 'dayjs';
|
||||
|
||||
class freedlit implements Plugin.PluginBase {
|
||||
id = 'freedlit.space';
|
||||
name = 'LitSpace';
|
||||
site = 'https://freedlit.space';
|
||||
version = '1.0.0';
|
||||
icon = 'src/ru/freedlit/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + '/get-books/all/list/' + page + '?sort=';
|
||||
url += showLatestNovels ? 'recent' : filters?.sort?.value || 'popular';
|
||||
url += '&status=' + (filters?.status?.value || 'all');
|
||||
url += '&access=' + (filters?.access?.value || 'all');
|
||||
url += '&adult=' + (filters?.adult?.value || 'hide');
|
||||
|
||||
if (filters?.genre?.value?.include?.length) {
|
||||
url += filters.genre.value.include
|
||||
.map(id => '&genres_included[]=' + id)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (filters?.genre?.value?.exclude?.length) {
|
||||
url += filters.genre.value.exclude
|
||||
.map(id => '&genres_excluded[]=' + id)
|
||||
.join('');
|
||||
}
|
||||
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('#bookListBlock > div > div').each((index, element) => {
|
||||
const name = loadedCheerio(element).find('div > h4 > a').text()?.trim();
|
||||
const cover = loadedCheerio(element)
|
||||
.find('div > a > img')
|
||||
.attr('src')
|
||||
?.trim();
|
||||
const url = loadedCheerio(element)
|
||||
.find('div > h4 > a')
|
||||
.attr('href')
|
||||
?.trim();
|
||||
if (!name || !url) return;
|
||||
|
||||
novels.push({ name, cover, path: url.replace(this.site, '') });
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.book-info h4').text(),
|
||||
cover: loadedCheerio('.book-cover > div > img').attr('src')?.trim(),
|
||||
summary: loadedCheerio('#nav-home').text()?.trim(),
|
||||
author: loadedCheerio('.book-info h5 > a').text(),
|
||||
genres: loadedCheerio('.genre-list > a')
|
||||
.map((index, element) => loadedCheerio(element).text())
|
||||
.get()
|
||||
.join(','),
|
||||
};
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('a.chapter-line').each((chapterIndex, element) => {
|
||||
const name = loadedCheerio(element).find('h6').text();
|
||||
const url = loadedCheerio(element).attr('href');
|
||||
if (!name || !url) return;
|
||||
|
||||
const releaseDate = loadedCheerio(element)
|
||||
.find('span[class="date"]')
|
||||
.text();
|
||||
chapters.push({
|
||||
name,
|
||||
path: url.replace(this.site, ''),
|
||||
releaseTime: this.parseDate(releaseDate),
|
||||
chapterNumber: chapterIndex + 1,
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
loadedCheerio('div.mobile-block').remove();
|
||||
loadedCheerio('div.standart-block').remove();
|
||||
|
||||
const chapterText = loadedCheerio('div[class="chapter"]').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = this.site + '/search?query=' + searchTerm + '&type=all';
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('#bookGridBlock > div').each((index, element) => {
|
||||
const name = loadedCheerio(element).find('h4 > a').text()?.trim();
|
||||
const cover = loadedCheerio(element).find('a > img').attr('src')?.trim();
|
||||
const url = loadedCheerio(element).find('h4 > a').attr('href')?.trim();
|
||||
if (!name || !url) return;
|
||||
|
||||
novels.push({ name, cover, path: url.replace(this.site, '') });
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseDate = (dateString: string | undefined = '') => {
|
||||
const months: { [key: string]: number } = {
|
||||
января: 1,
|
||||
февраля: 2,
|
||||
марта: 3,
|
||||
апреля: 4,
|
||||
мая: 5,
|
||||
июня: 6,
|
||||
июля: 7,
|
||||
августа: 8,
|
||||
сентября: 9,
|
||||
октября: 10,
|
||||
ноября: 11,
|
||||
декабря: 12,
|
||||
};
|
||||
|
||||
const [day, month, year] = dateString.split(' ');
|
||||
if (day && months[month] && year) {
|
||||
return dayjs(year + '-' + months[month] + '-' + day).format('LL');
|
||||
}
|
||||
return dateString || null;
|
||||
};
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка:',
|
||||
value: 'popular',
|
||||
options: [
|
||||
{ label: 'По популярности', value: 'popular' },
|
||||
{ label: 'По количеству комментариев', value: 'comments' },
|
||||
{ label: 'По количеству лайков', value: 'likes' },
|
||||
{ label: 'По новизне', value: 'recent' },
|
||||
{ label: 'По просмотрам', value: 'views' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
label: 'Жанры:',
|
||||
value: { include: [], exclude: [] },
|
||||
options: [
|
||||
{ label: 'Любой жанр', value: 'all' },
|
||||
{ label: 'Альтернативная история', value: 'alternative-history' },
|
||||
{ label: 'Антиутопия', value: 'dystopia' },
|
||||
{ label: 'Бизнес-литература', value: 'business-literature' },
|
||||
{ label: 'Боевая фантастика', value: 'combat-fiction' },
|
||||
{ label: 'Боевик', value: 'action' },
|
||||
{ label: 'Боевое фэнтези', value: 'combat-fantasy' },
|
||||
{ label: 'Бояръ-Аниме', value: 'boyar-anime' },
|
||||
{ label: 'Героическая фантастика', value: 'heroic-fiction' },
|
||||
{ label: 'Героическое фэнтези', value: 'heroic-fantasy' },
|
||||
{ label: 'Городское фэнтези', value: 'urban-fantasy' },
|
||||
{ label: 'Гримдарк', value: 'grimdark' },
|
||||
{ label: 'Детектив', value: 'mystery' },
|
||||
{ label: 'Детская литература', value: 'kids-literature' },
|
||||
{ label: 'Документальная проза', value: 'biography' },
|
||||
{ label: 'Историческая проза', value: 'historical-fiction' },
|
||||
{ label: 'Исторический детектив', value: 'historical-mystery' },
|
||||
{
|
||||
label: 'Исторический любовный роман',
|
||||
value: 'historical-romantic-novel',
|
||||
},
|
||||
{ label: 'Историческое фэнтези', value: 'historical-fantasy' },
|
||||
{ label: 'Киберпанк', value: 'cyberpunk' },
|
||||
{ label: 'Космическая фантастика', value: 'cosmic-fiction' },
|
||||
{ label: 'ЛитРПГ', value: 'litrpg' },
|
||||
{ label: 'Лоу / Низкое фэнтези', value: 'low-fantasy' },
|
||||
{ label: 'Любовное фэнтези', value: 'romantic-fantasy' },
|
||||
{ label: 'Любовный роман', value: 'romantic-novel' },
|
||||
{ label: 'Мистика', value: 'mystic' },
|
||||
{ label: 'Мистический детектив', value: 'mystic-detective' },
|
||||
{ label: 'Научная фантастика', value: 'science-fiction' },
|
||||
{ label: 'Подростковая проза', value: 'young-adult' },
|
||||
{ label: 'Политический роман', value: 'political-romance' },
|
||||
{ label: 'Попаданцы', value: 'accidental-travel' },
|
||||
{ label: 'Попаданцы в магические миры', value: 'magic-worlds-travel' },
|
||||
{ label: 'Попаданцы во времени', value: 'time-travel' },
|
||||
{ label: 'Порнотика', value: 'pornotica' },
|
||||
{ label: 'Постапокалипсис', value: 'post-apocalypse' },
|
||||
{ label: 'Поэзия', value: 'poetry' },
|
||||
{ label: 'Приключения', value: 'adventure' },
|
||||
{ label: 'Публицистика', value: 'journalism' },
|
||||
{ label: 'Пьеса', value: 'play' },
|
||||
{ label: 'Развитие личности', value: 'how-to-book' },
|
||||
{ label: 'Разное', value: 'other' },
|
||||
{ label: 'Реализм', value: 'Realism' },
|
||||
{ label: 'РеалРПГ', value: 'realrpg' },
|
||||
{ label: 'Репликация', value: 'replication' },
|
||||
{ label: 'Романтическая эротика', value: 'romantic-erotic-fiction' },
|
||||
{ label: 'Сказка', value: 'fairy-tale' },
|
||||
{ label: 'Слэш', value: 'slash' },
|
||||
{ label: 'Современная проза', value: 'modern-prose' },
|
||||
{ label: 'Современный любовный роман', value: 'modern-romantic-novel' },
|
||||
{ label: 'Социальная фантастика', value: 'social-fiction' },
|
||||
{ label: 'Стимпанк', value: 'steampunk' },
|
||||
{ label: 'Сценарий', value: 'scenario' },
|
||||
{ label: 'Сюаньхуань', value: 'xuanhuan' },
|
||||
{ label: 'Сянься', value: 'xianxia' },
|
||||
{ label: 'Тёмное фэнтези', value: 'dark-fantasy' },
|
||||
{ label: 'Триллер', value: 'thriller' },
|
||||
{ label: 'Уся', value: 'wuxia' },
|
||||
{ label: 'Фантастика', value: 'fiction' },
|
||||
{ label: 'Фантастический детектив', value: 'mystery-fiction' },
|
||||
{ label: 'Фанфик', value: 'fan-fiction' },
|
||||
{ label: 'Фемслэш', value: 'femslash' },
|
||||
{ label: 'Фэнтези', value: 'fantasy' },
|
||||
{ label: 'Хоррор', value: 'horror' },
|
||||
{ label: 'Шпионский детектив', value: 'spy-crime' },
|
||||
{ label: 'Эпическое фэнтези', value: 'epic-fantasy' },
|
||||
{ label: 'Эротика', value: 'erotic-fiction' },
|
||||
{ label: 'Эротическая фантастика', value: 'erotic-fiction' },
|
||||
{ label: 'Эротический фанфик', value: 'erotic-fan-fiction' },
|
||||
{ label: 'Эротическое фэнтези', value: 'erotic-fantasy' },
|
||||
{ label: 'Этническое фэнтези', value: 'ethnic-fantasy' },
|
||||
{ label: 'Юмор', value: 'humor' },
|
||||
{ label: 'Юмористическая фантастика', value: 'humor-fiction' },
|
||||
{ label: 'Юмористическое фэнтези', value: 'humor-fantasy' },
|
||||
{ label: 'RPS', value: 'rps' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
status: {
|
||||
label: 'Статус:',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'Любой статус', value: 'all' },
|
||||
{ label: 'В процессе', value: 'in-process' },
|
||||
{ label: 'Завершено', value: 'finished' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
access: {
|
||||
label: 'Доступ:',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'Любой доступ', value: 'all' },
|
||||
{ label: 'Бесплатные', value: 'free' },
|
||||
{ label: 'Платные', value: 'paid' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
adult: {
|
||||
label: 'Возрастные ограничения:',
|
||||
value: 'hide',
|
||||
options: [
|
||||
{ label: 'Скрыть 18+', value: 'hide' },
|
||||
{ label: 'Показать +18', value: 'show' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new freedlit();
|
||||
@@ -0,0 +1,615 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { storage } from '@libs/storage';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class AuthorToday implements Plugin.PluginBase {
|
||||
id = 'AT';
|
||||
name = 'Автор Тудей';
|
||||
icon = 'src/ru/authortoday/icon.png';
|
||||
site = 'https://author.today';
|
||||
apiUrl = 'https://api.author.today/v1/';
|
||||
version = '1.1.0';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.apiUrl + 'catalog/search?page=' + pageNo;
|
||||
if (filters?.genre?.value) {
|
||||
url += '&genre=' + filters.genre.value;
|
||||
}
|
||||
|
||||
url +=
|
||||
'&sorting=' +
|
||||
(showLatestNovels ? 'recent' : filters?.sort?.value || 'popular');
|
||||
|
||||
url += '&form=' + (filters?.form?.value || 'any');
|
||||
url += '&state=' + (filters?.state?.value || 'any');
|
||||
url += '&series=' + (filters?.series?.value || 'any');
|
||||
url += '&access=' + (filters?.access?.value || 'any');
|
||||
url += '&promo=' + (filters?.promo?.value || 'hide');
|
||||
|
||||
const result: response = await fetchApi(url, {
|
||||
headers: {
|
||||
Authorization: 'Bearer guest',
|
||||
},
|
||||
}).then(res => res.json());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
if (result.code === 'NotFound') {
|
||||
return novels;
|
||||
}
|
||||
|
||||
result?.searchResults?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel.coverUrl
|
||||
? 'https://cm.author.today/content/' + novel.coverUrl
|
||||
: defaultCover,
|
||||
path: novel.id.toString(),
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(workID: string): Promise<Plugin.SourceNovel> {
|
||||
if (!this.user) this.user = await this.getUser();
|
||||
const book: responseBook = await fetchApi(
|
||||
this.apiUrl + 'work/' + workID + '/details',
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.user?.token || 'guest',
|
||||
},
|
||||
},
|
||||
).then(res => res.json());
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: workID,
|
||||
name: book.title,
|
||||
cover: book.coverUrl ? book.coverUrl.split('?')[0] : defaultCover,
|
||||
genres: book.tags?.join(', '),
|
||||
summary: '',
|
||||
author:
|
||||
book.originalAuthor ||
|
||||
book.authorFIO ||
|
||||
book.coAuthorFIO ||
|
||||
book.secondCoAuthorFIO ||
|
||||
book.translator ||
|
||||
'',
|
||||
status: book.isFinished ? NovelStatus.Completed : NovelStatus.Ongoing,
|
||||
};
|
||||
|
||||
if (book.annotation) {
|
||||
novel.summary += book.annotation + '\n';
|
||||
}
|
||||
if (book.authorNotes) {
|
||||
novel.summary += 'Примечания автора:\n' + book.authorNotes;
|
||||
}
|
||||
|
||||
const chaptersJSON: ChaptersEntity[] = await fetchApi(
|
||||
this.apiUrl + 'work/' + workID + '/content',
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.user?.token || 'guest',
|
||||
},
|
||||
},
|
||||
).then(res => res.json());
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
chaptersJSON.forEach((chapter, chapterIndex) => {
|
||||
if (chapter.isAvailable && !chapter.isDraft) {
|
||||
chapters.push({
|
||||
name: chapter.title || 'Глава ' + (chapterIndex + 1),
|
||||
path: workID + '/' + chapter.id,
|
||||
releaseTime: dayjs(
|
||||
chapter.publishTime || chapter.lastModificationTime,
|
||||
).format('LLL'),
|
||||
chapterNumber: (chapter.sortOrder || chapterIndex) + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const [workID, chapterID] = chapterPath.split('/');
|
||||
if (!this.user) this.user = await this.getUser();
|
||||
const result: encryptedСhapter = await fetchApi(
|
||||
this.apiUrl + `work/${workID}/chapter/${chapterID}/text`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.user?.token || 'guest',
|
||||
},
|
||||
},
|
||||
).then(res => res.json());
|
||||
|
||||
if (result.code) {
|
||||
return result.code + '\n' + result?.message;
|
||||
}
|
||||
|
||||
const chapterText = decrypt(result.text, result.key, this.user?.userId);
|
||||
if (chapterText.includes('<img')) {
|
||||
return chapterText.replace(/src="(.*?)"/g, (match, url) => {
|
||||
if (!url.startsWith('http')) {
|
||||
return `src="${this.site}${url}"`;
|
||||
}
|
||||
return `src="${url}"`;
|
||||
});
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number | undefined = 1,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site + '/search?category=works&q=' + searchTerm + '&page=' + pageNo;
|
||||
const result = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(result);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('a.work-row').each((index, element) => {
|
||||
const name = loadedCheerio(element)
|
||||
.find('h4[class="work-title"]')
|
||||
.text()
|
||||
.trim();
|
||||
let cover = loadedCheerio(element).find('img').attr('data-src');
|
||||
const path = loadedCheerio(element).attr('href');
|
||||
|
||||
cover = cover ? cover.split('?')[0] : defaultCover;
|
||||
|
||||
if (!path) return;
|
||||
novels.push({ name, cover, path: path.replace('/work/', '') });
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
isNovel ? this.site + '/work/' + path : this.site + '/reader/' + path;
|
||||
|
||||
user: authorization | undefined;
|
||||
getUser = async () => {
|
||||
let user = storage.get('user') || { userId: '', token: 'guest' };
|
||||
if (user && user.userId && user.token) {
|
||||
const currentUser: currentUser = await fetchApi(
|
||||
this.apiUrl + 'account/current-user',
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + user.token || 'guest',
|
||||
},
|
||||
},
|
||||
).then(res => res.json());
|
||||
if (currentUser?.id && !currentUser.isDisabled) return user;
|
||||
|
||||
storage.delete('user');
|
||||
user = { userId: '', token: 'guest' };
|
||||
}
|
||||
|
||||
const result = await fetchApi(this.site + '/account/bearer-token');
|
||||
if (result.url.includes('Login?ReturnUrl=')) {
|
||||
return user; //It looks like the user has lost the session
|
||||
}
|
||||
|
||||
const loginUser: authorization = await result.json();
|
||||
user = { userId: loginUser.userId, token: loginUser.token };
|
||||
|
||||
storage.set(
|
||||
'user',
|
||||
user, //for some reason they're ending an hour early.
|
||||
new Date(loginUser.expires).getTime() - 1 * 60 * 60 * 1000,
|
||||
);
|
||||
return user; //user authorized successfully
|
||||
};
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: 'popular',
|
||||
options: [
|
||||
{ label: 'По популярности', value: 'popular' },
|
||||
{ label: 'По количеству лайков', value: 'likes' },
|
||||
{ label: 'По комментариям', value: 'comments' },
|
||||
{ label: 'По новизне', value: 'recent' },
|
||||
{ label: 'По просмотрам', value: 'views' },
|
||||
{ label: 'Набирающие популярность', value: 'trending' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
label: 'Жанры',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Все', value: '' },
|
||||
{ label: 'Альтернативная история', value: 'sf-history' },
|
||||
{ label: 'Антиутопия', value: 'dystopia' },
|
||||
{ label: 'Бизнес-литература', value: 'biznes-literatura' },
|
||||
{ label: 'Боевая фантастика', value: 'sf-action' },
|
||||
{ label: 'Боевик', value: 'action' },
|
||||
{ label: 'Боевое фэнтези', value: 'fantasy-action' },
|
||||
{ label: 'Бояръ-Аниме', value: 'boyar-anime' },
|
||||
{ label: 'Героическая фантастика', value: 'sf-heroic' },
|
||||
{ label: 'Героическое фэнтези', value: 'heroic-fantasy' },
|
||||
{ label: 'Городское фэнтези', value: 'urban-fantasy' },
|
||||
{ label: 'Детектив', value: 'detective' },
|
||||
{ label: 'Детская литература', value: 'detskaya-literatura' },
|
||||
{ label: 'Документальная проза', value: 'non-fiction' },
|
||||
{ label: 'Историческая проза', value: 'historical-fiction' },
|
||||
{ label: 'Исторические приключения', value: 'historical-adventure' },
|
||||
{ label: 'Исторический детектив', value: 'historical-mystery' },
|
||||
{ label: 'Исторический любовный роман', value: 'historical-romance' },
|
||||
{ label: 'Историческое фэнтези', value: 'historical-fantasy' },
|
||||
{ label: 'Киберпанк', value: 'cyberpunk' },
|
||||
{ label: 'Короткий любовный роман', value: 'short-romance' },
|
||||
{ label: 'Космическая фантастика', value: 'sf-space' },
|
||||
{ label: 'ЛитРПГ', value: 'litrpg' },
|
||||
{ label: 'Любовное фэнтези', value: 'love-fantasy' },
|
||||
{ label: 'Любовные романы', value: 'romance' },
|
||||
{ label: 'Мистика', value: 'paranormal' },
|
||||
{ label: 'Назад в СССР', value: 'back-to-ussr' },
|
||||
{ label: 'Научная фантастика', value: 'science-fiction' },
|
||||
{ label: 'Подростковая проза', value: 'teen-prose' },
|
||||
{ label: 'Политический роман', value: 'political-fiction' },
|
||||
{ label: 'Попаданцы', value: 'popadantsy' },
|
||||
{ label: 'Попаданцы в космос', value: 'popadantsy-v-kosmos' },
|
||||
{
|
||||
label: 'Попаданцы в магические миры',
|
||||
value: 'popadantsy-v-magicheskie-miry',
|
||||
},
|
||||
{ label: 'Попаданцы во времени', value: 'popadantsy-vo-vremeni' },
|
||||
{ label: 'Постапокалипсис', value: 'postapocalyptic' },
|
||||
{ label: 'Поэзия', value: 'poetry' },
|
||||
{ label: 'Приключения', value: 'adventure' },
|
||||
{ label: 'Публицистика', value: 'publicism' },
|
||||
{ label: 'Развитие личности', value: 'razvitie-lichnosti' },
|
||||
{ label: 'Разное', value: 'other' },
|
||||
{ label: 'РеалРПГ', value: 'realrpg' },
|
||||
{ label: 'Романтическая эротика', value: 'romantic-erotika' },
|
||||
{ label: 'Сказка', value: 'fairy-tale' },
|
||||
{ label: 'Современная проза', value: 'modern-prose' },
|
||||
{ label: 'Современный любовный роман', value: 'contemporary-romance' },
|
||||
{ label: 'Социальная фантастика', value: 'sf-social' },
|
||||
{ label: 'Стимпанк', value: 'steampunk' },
|
||||
{ label: 'Темное фэнтези', value: 'dark-fantasy' },
|
||||
{ label: 'Триллер', value: 'thriller' },
|
||||
{ label: 'Ужасы', value: 'horror' },
|
||||
{ label: 'Фантастика', value: 'sci-fi' },
|
||||
{
|
||||
label: 'Фантастический детектив',
|
||||
value: 'detective-science-fiction',
|
||||
},
|
||||
{ label: 'Фанфик', value: 'fanfiction' },
|
||||
{ label: 'Фэнтези', value: 'fantasy' },
|
||||
{ label: 'Шпионский детектив', value: 'spy-mystery' },
|
||||
{ label: 'Эпическое фэнтези', value: 'epic-fantasy' },
|
||||
{ label: 'Эротика', value: 'erotica' },
|
||||
{ label: 'Эротическая фантастика', value: 'sf-erotika' },
|
||||
{ label: 'Эротический фанфик', value: 'fanfiction-erotika' },
|
||||
{ label: 'Эротическое фэнтези', value: 'fantasy-erotika' },
|
||||
{ label: 'Юмор', value: 'humor' },
|
||||
{ label: 'Юмористическая фантастика', value: 'sf-humor' },
|
||||
{ label: 'Юмористическое фэнтези', value: 'ironical-fantasy' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
form: {
|
||||
label: 'Форма произведения',
|
||||
value: 'any',
|
||||
options: [
|
||||
{ label: 'Любой', value: 'any' },
|
||||
{ label: 'Перевод', value: 'translation' },
|
||||
{ label: 'Повесть', value: 'tale' },
|
||||
{ label: 'Рассказ', value: 'story' },
|
||||
{ label: 'Роман', value: 'novel' },
|
||||
{ label: 'Сборник поэзии', value: 'poetry' },
|
||||
{ label: 'Сборник рассказов', value: 'story-book' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
state: {
|
||||
label: 'Статус произведения',
|
||||
value: 'any',
|
||||
options: [
|
||||
{ label: 'Любой статус', value: 'any' },
|
||||
{ label: 'В процессе', value: 'in-progress' },
|
||||
{ label: 'Завершено', value: 'finished' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
series: {
|
||||
label: 'Статус цикла',
|
||||
value: 'any',
|
||||
options: [
|
||||
{ label: 'Не важно', value: 'any' },
|
||||
{ label: 'Вне цикла', value: 'out' },
|
||||
{ label: 'Цикл завершен', value: 'finished' },
|
||||
{ label: 'Цикл не завершен', value: 'unfinished' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
access: {
|
||||
label: 'Тип доступа',
|
||||
value: 'any',
|
||||
options: [
|
||||
{ label: 'Любой', value: 'any' },
|
||||
{ label: 'Платный', value: 'paid' },
|
||||
{ label: 'Бесплатный', value: 'free' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
promo: {
|
||||
label: 'Промо-фрагмент',
|
||||
value: 'hide',
|
||||
options: [
|
||||
{ label: 'Скрывать', value: 'hide' },
|
||||
{ label: 'Показывать', value: 'show' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new AuthorToday();
|
||||
|
||||
function decrypt(
|
||||
encrypt: string,
|
||||
encryptedKey: string,
|
||||
userId: number | string = '',
|
||||
) {
|
||||
const key = encryptedKey.split('').reverse().join('') + '@_@' + userId;
|
||||
let text = '';
|
||||
|
||||
for (let i = 0; i < encrypt.length; i++) {
|
||||
text += String.fromCharCode(
|
||||
encrypt.charCodeAt(i) ^ key.charCodeAt(i % key.length),
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
interface authorization {
|
||||
userId: number;
|
||||
token: string;
|
||||
issued: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
interface currentUser {
|
||||
id: number;
|
||||
userName: string;
|
||||
fio: string;
|
||||
email: string;
|
||||
avatarUrl?: null;
|
||||
backgroundUrl?: null;
|
||||
status?: null;
|
||||
hideDislike: boolean;
|
||||
hideFinished: boolean;
|
||||
isBanned: boolean;
|
||||
banReason?: null;
|
||||
banEnd?: null;
|
||||
emailConfirmed: boolean;
|
||||
isDeleted: boolean;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
interface response {
|
||||
searchResults?: SearchResultsEntity[] | null;
|
||||
realTotalCount: number;
|
||||
code?: string;
|
||||
errorMessage?: null;
|
||||
isLastPage: boolean;
|
||||
format: Format;
|
||||
duration: Duration;
|
||||
isWorkMarkEnabled: boolean;
|
||||
}
|
||||
interface SearchResultsEntity {
|
||||
id: number;
|
||||
title: string;
|
||||
annotation: string;
|
||||
authorId: number;
|
||||
authorFIO: string;
|
||||
authorUserName: string;
|
||||
originalAuthor?: null;
|
||||
coAuthorId?: number | null;
|
||||
coAuthorFIO?: string | null;
|
||||
coAuthorUserName?: string | null;
|
||||
coAuthorConfirmed: boolean;
|
||||
seriesId: number;
|
||||
seriesTitle?: string | null;
|
||||
publishTime: string;
|
||||
lastModificationTime: string;
|
||||
finishTime?: string | null;
|
||||
isDraft: boolean;
|
||||
formEnum: string;
|
||||
genreId: number;
|
||||
firstSubGenreId: number;
|
||||
secondSubGenreId?: number | null;
|
||||
adultOnly: boolean;
|
||||
tags?: (string | null)[] | null;
|
||||
coverUrl: string;
|
||||
viewCount: number;
|
||||
commentCount: number;
|
||||
reviewCount: number;
|
||||
textLength: number;
|
||||
likeCount: number;
|
||||
showAuthor: boolean;
|
||||
price?: number | null;
|
||||
discount?: null;
|
||||
discountStartDate?: null;
|
||||
discountEndDate?: null;
|
||||
status: string;
|
||||
promoFragment: boolean;
|
||||
isExclusive: boolean;
|
||||
format: string;
|
||||
marks?: null[] | null;
|
||||
isLiked: boolean;
|
||||
isWorkMarkEnabled: boolean;
|
||||
workInLibraryState: string;
|
||||
}
|
||||
interface Format {
|
||||
enumValue: string;
|
||||
value: string;
|
||||
title: string;
|
||||
mobileTitle: string;
|
||||
}
|
||||
interface Duration {
|
||||
value: string;
|
||||
title: string;
|
||||
mobileTitle: string;
|
||||
}
|
||||
|
||||
interface responseBook {
|
||||
message?: string;
|
||||
chapters?: ChaptersEntity[] | null;
|
||||
allowDownloads: boolean;
|
||||
downloadErrorCode: string;
|
||||
downloadErrorMessage: string;
|
||||
privacyDownloads: string;
|
||||
collectionCount: number;
|
||||
annotation: string;
|
||||
authorNotes: string;
|
||||
atRecommendation: boolean;
|
||||
seriesWorkIds?: number[] | null;
|
||||
seriesWorkNumber: number;
|
||||
reviewCount: number;
|
||||
tags?: string[] | null;
|
||||
orderId?: null;
|
||||
orderStatus: string;
|
||||
orderStatusMessage?: null;
|
||||
contests?: null[] | null;
|
||||
galleryImages?: GalleryImagesEntity[] | null;
|
||||
booktrailerVideoUrl?: null;
|
||||
isExclusive: boolean;
|
||||
freeChapterCount: number;
|
||||
promoFragment: boolean;
|
||||
recommendations?: RecommendationsEntity[] | null;
|
||||
linkedWork?: null;
|
||||
id: number;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
lastModificationTime: string;
|
||||
lastUpdateTime: string;
|
||||
finishTime?: null;
|
||||
isFinished: boolean;
|
||||
textLength: number;
|
||||
textLengthLastRead: number;
|
||||
price: number;
|
||||
discount?: null;
|
||||
workForm: string;
|
||||
status: string;
|
||||
authorId: number;
|
||||
authorFIO: string;
|
||||
authorUserName: string;
|
||||
originalAuthor?: null;
|
||||
translator?: null;
|
||||
reciter?: null;
|
||||
coAuthorId?: null;
|
||||
coAuthorFIO?: null;
|
||||
coAuthorUserName?: null;
|
||||
coAuthorConfirmed: boolean;
|
||||
secondCoAuthorId?: null;
|
||||
secondCoAuthorFIO?: null;
|
||||
secondCoAuthorUserName?: null;
|
||||
secondCoAuthorConfirmed: boolean;
|
||||
isPurchased: boolean;
|
||||
userLikeId?: null;
|
||||
lastReadTime?: null;
|
||||
lastChapterId?: null;
|
||||
lastChapterProgress: number;
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
rewardCount: number;
|
||||
rewardsEnabled: boolean;
|
||||
inLibraryState: string;
|
||||
addedToLibraryTime: string;
|
||||
updateInLibraryTime: string;
|
||||
privacyDisplay: string;
|
||||
state: string;
|
||||
isDraft: boolean;
|
||||
enableRedLine: boolean;
|
||||
enableTTS: boolean;
|
||||
adultOnly: boolean;
|
||||
seriesId: number;
|
||||
seriesOrder: number;
|
||||
seriesTitle: string;
|
||||
afterword: string;
|
||||
seriesNextWorkId?: null;
|
||||
genreId: number;
|
||||
firstSubGenreId: number;
|
||||
secondSubGenreId: number;
|
||||
format: string;
|
||||
marks?: null;
|
||||
purchaseTime?: null;
|
||||
likeTime?: null;
|
||||
markTime?: null;
|
||||
}
|
||||
interface ChaptersEntity {
|
||||
id: number;
|
||||
workId: number;
|
||||
title: string;
|
||||
isDraft: boolean;
|
||||
sortOrder: number;
|
||||
publishTime?: string;
|
||||
lastModificationTime: string;
|
||||
textLength: number;
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
interface GalleryImagesEntity {
|
||||
id: string;
|
||||
caption?: null;
|
||||
url: string;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
interface RecommendationsEntity {
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
authorId: number;
|
||||
authorFIO: string;
|
||||
authorUserName: string;
|
||||
originalAuthor?: null;
|
||||
coAuthorId?: null;
|
||||
coAuthorFIO?: null;
|
||||
coAuthorUserName?: null;
|
||||
coAuthorConfirmed: boolean;
|
||||
secondCoAuthorId?: null;
|
||||
secondCoAuthorFIO?: null;
|
||||
secondCoAuthorUserName?: null;
|
||||
secondCoAuthorConfirmed: boolean;
|
||||
discount?: null;
|
||||
price?: number | null;
|
||||
lastModificationTime: string;
|
||||
finishTime: string;
|
||||
finished: boolean;
|
||||
isExclusive: boolean;
|
||||
format: string;
|
||||
status: string;
|
||||
state: string;
|
||||
removalReason?: null;
|
||||
removalReasonComment?: null;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface encryptedСhapter {
|
||||
id: number;
|
||||
title: string;
|
||||
isDraft: boolean;
|
||||
publishTime: string;
|
||||
lastModificationTime: string;
|
||||
text: string;
|
||||
key: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const regex =
|
||||
/<script id="__NEXT_DATA__" type="application\/json">(\{.*?\})<\/script>/;
|
||||
|
||||
class Bookriver implements Plugin.PluginBase {
|
||||
id = 'bookriver';
|
||||
name = 'Bookriver';
|
||||
site = 'https://bookriver.ru';
|
||||
version = '1.0.1';
|
||||
icon = 'src/ru/bookriver/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + '/genre?page=' + pageNo + '&perPage=24&sortingType=';
|
||||
url += showLatestNovels
|
||||
? 'last-update'
|
||||
: filters?.sort?.value || 'bestseller';
|
||||
|
||||
if (filters?.genres?.value?.length) {
|
||||
url += '&g=' + filters.genres.value.join(',');
|
||||
}
|
||||
|
||||
const result = await fetchApi(url).then(res => res.text());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const jsonRaw = result.match(regex);
|
||||
if (jsonRaw instanceof Array && jsonRaw[1]) {
|
||||
const json: response = JSON.parse(jsonRaw[1]);
|
||||
|
||||
json.props.pageProps.state.pagesFilter?.genre?.books?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name,
|
||||
cover: novel.coverImages[0].url,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.resolveUrl(novelPath, true)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
};
|
||||
|
||||
const jsonRaw = result.match(regex);
|
||||
if (jsonRaw instanceof Array && jsonRaw[1]) {
|
||||
const book: BooksEntity = JSON.parse(jsonRaw[1]).props.pageProps.state
|
||||
.book.bookPage;
|
||||
|
||||
novel.name = book.name || '';
|
||||
novel.cover = book.coverImages[0].url;
|
||||
novel.summary = book.annotation;
|
||||
novel.author = book.author?.name;
|
||||
|
||||
novel.status =
|
||||
book?.statusComplete === 'writing'
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed;
|
||||
|
||||
if (book.tags?.length)
|
||||
novel.genres = book.tags?.map(tag => tag.name).join(',');
|
||||
|
||||
if (book?.ebook?.chapters?.length) {
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
book.ebook.chapters.forEach((chapter, chapterIndex) => {
|
||||
if (chapter.available) {
|
||||
chapters.push({
|
||||
name: chapter.name,
|
||||
path: novelPath + '/' + chapter.chapterId,
|
||||
releaseTime: dayjs(
|
||||
chapter.firstPublishedAt || chapter.createdAt || undefined,
|
||||
).format('LLL'),
|
||||
chapterNumber: chapterIndex + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
}
|
||||
}
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = 'https://api.bookriver.ru/api/v1/books/chapter/text/';
|
||||
const { data }: responseChapter = await fetchApi(
|
||||
url + chapterPath.split('/').pop(),
|
||||
).then(res => res.json());
|
||||
|
||||
let chapterText = data.content || 'Конец произведения';
|
||||
if (data?.audio?.available) {
|
||||
chapterText += '\n' + data.audio.url;
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number | undefined = 1,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
'https://api.bookriver.ru/api/v1/search/autocomplete?keyword=' +
|
||||
searchTerm +
|
||||
'&page=' +
|
||||
pageNo +
|
||||
'&perPage=10';
|
||||
const { data }: responseSearch = await fetchApi(url).then(res =>
|
||||
res.json(),
|
||||
);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
data?.books?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name,
|
||||
cover: novel.coverImages[0].url,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/book/' : '/reader/') + path;
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: 'bestseller',
|
||||
options: [
|
||||
{ label: 'Бестселлеры', value: 'bestseller' },
|
||||
{ label: 'Дате добавления', value: 'newest' },
|
||||
{ label: 'Дате обновления', value: 'last-update' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
label: 'жанры',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Альтернативная история', value: 'alternativnaya-istoriya' },
|
||||
{ label: 'Боевая фантастика', value: 'boevaya-fantastika' },
|
||||
{ label: 'Боевое фэнтези', value: 'boevoe-fentezi' },
|
||||
{ label: 'Бытовое фэнтези', value: 'bytovoe-fentezi' },
|
||||
{ label: 'Героическая фантастика', value: 'geroicheskaya-fantastika' },
|
||||
{ label: 'Героическое фэнтези', value: 'geroicheskoe-fentezi' },
|
||||
{ label: 'Городское фэнтези', value: 'gorodskoe-fentezi' },
|
||||
{ label: 'Детектив', value: 'detektiv' },
|
||||
{ label: 'Детективная фантастика', value: 'detektivnaya-fantastika' },
|
||||
{ label: 'Жёсткая эротика', value: 'zhyostkaya-erotika' },
|
||||
{ label: 'Исторический детектив', value: 'istoricheskii-detektiv' },
|
||||
{
|
||||
label: 'Исторический любовный роман',
|
||||
value: 'istoricheskii-lyubovnyi-roman',
|
||||
},
|
||||
{ label: 'Историческое фэнтези', value: 'istoricheskoe-fentezi' },
|
||||
{ label: 'Киберпанк', value: 'kiberpank' },
|
||||
{ label: 'Классический детектив', value: 'klassicheskii-detektiv' },
|
||||
{ label: 'Короткий любовный роман', value: 'korotkii-lyubovnyi-roman' },
|
||||
{ label: 'Космическая фантастика', value: 'kosmicheskaya-fantastika' },
|
||||
{ label: 'Криминальный детектив', value: 'kriminalnyi-detektiv' },
|
||||
{ label: 'ЛитРПГ', value: 'litrpg' },
|
||||
{ label: 'Любовная фантастика', value: 'lyubovnaya-fantastika' },
|
||||
{ label: 'Любовное фэнтези', value: 'lyubovnoe-fentezi' },
|
||||
{ label: 'Любовный роман', value: 'lyubovnyi-roman' },
|
||||
{ label: 'Мистика', value: 'mistika' },
|
||||
{ label: 'Молодежная проза', value: 'molodezhnaya-proza' },
|
||||
{ label: 'Научная фантастика', value: 'nauchnaya-fantastika' },
|
||||
{
|
||||
label: 'Остросюжетный любовный роман',
|
||||
value: 'ostrosyuzhetnyi-lyubovnyi-roman',
|
||||
},
|
||||
{ label: 'Политический детектив', value: 'politicheskii-detektiv' },
|
||||
{ label: 'Попаданцы', value: 'popadantsy' },
|
||||
{ label: 'Постапокалипсис', value: 'postapokalipsis' },
|
||||
{
|
||||
label: 'Приключенческое фэнтези',
|
||||
value: 'priklyuchencheskoe-fentezi',
|
||||
},
|
||||
{ label: 'Романтическая эротика', value: 'romanticheskaya-erotika' },
|
||||
{ label: 'С элементами эротики', value: 's-elementami-erotiki' },
|
||||
{ label: 'Славянское фэнтези', value: 'slavyanskoe-fentezi' },
|
||||
{
|
||||
label: 'Современный любовный роман',
|
||||
value: 'sovremennyi-lyubovnyi-roman',
|
||||
},
|
||||
{ label: 'Социальная фантастика', value: 'sotsialnaya-fantastika' },
|
||||
{ label: 'Тёмное фэнтези', value: 'temnoe-fentezi' },
|
||||
{ label: 'Фантастика', value: 'fantastika' },
|
||||
{ label: 'Фэнтези', value: 'fentezi' },
|
||||
{ label: 'Шпионский детектив', value: 'shpionskii-detektiv' },
|
||||
{ label: 'Эпическое фэнтези', value: 'epicheskoe-fentezi' },
|
||||
{ label: 'Эротика', value: 'erotika' },
|
||||
{ label: 'Эротическая фантастика', value: 'eroticheskaya-fantastika' },
|
||||
{ label: 'Эротический фанфик', value: 'eroticheskii-fanfik' },
|
||||
{ label: 'Эротическое фэнтези', value: 'eroticheskoe-fentezi' },
|
||||
{
|
||||
label: 'Юмористический детектив',
|
||||
value: 'yumoristicheskii-detektiv',
|
||||
},
|
||||
{ label: 'Юмористическое фэнтези', value: 'yumoristicheskoe-fentezi' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
export default new Bookriver();
|
||||
|
||||
interface response {
|
||||
props: {
|
||||
pageProps: {
|
||||
state: {
|
||||
book?: {
|
||||
bookPage: BooksEntity;
|
||||
};
|
||||
pagesFilter?: {
|
||||
genre: {
|
||||
books: BooksEntity[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface BooksEntity {
|
||||
name: string;
|
||||
coverImages: { url: string }[];
|
||||
slug: string;
|
||||
annotation?: string;
|
||||
author?: {
|
||||
name: string;
|
||||
};
|
||||
tags?: { name: string }[];
|
||||
ebook?: {
|
||||
chapters: Chapter[];
|
||||
};
|
||||
statusComplete: string;
|
||||
}
|
||||
|
||||
interface Chapter {
|
||||
name: string;
|
||||
available: boolean;
|
||||
firstPublishedAt?: Date | null;
|
||||
createdAt?: Date | null;
|
||||
chapterId: number | string;
|
||||
}
|
||||
|
||||
interface responseChapter {
|
||||
data: {
|
||||
id: number;
|
||||
bookId: number;
|
||||
name: string;
|
||||
content: string;
|
||||
symbols: number;
|
||||
number: number;
|
||||
authorPages: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
firstPublishedAt: string;
|
||||
status: string;
|
||||
protected: boolean;
|
||||
free: boolean;
|
||||
audio?: {
|
||||
available: boolean;
|
||||
url: string;
|
||||
duration: number;
|
||||
};
|
||||
publicationScheduledFor?: null;
|
||||
};
|
||||
}
|
||||
|
||||
interface responseSearch {
|
||||
data: Data;
|
||||
total: number;
|
||||
}
|
||||
interface Data {
|
||||
books?: BooksEntity[] | null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
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 dayjs from 'dayjs';
|
||||
|
||||
class Jaomix implements Plugin.PluginBase {
|
||||
id = 'jaomix.ru';
|
||||
name = 'Jaomix';
|
||||
site = 'https://jaomix.ru';
|
||||
version = '1.0.0';
|
||||
icon = 'src/ru/jaomix/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + '/?searchrn';
|
||||
|
||||
if (filters?.lang?.value?.length) {
|
||||
url += filters.lang.value
|
||||
.map((lang, idx) => `&lang[${idx}]=${lang}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (filters?.genre?.value?.include?.length) {
|
||||
url += filters.genre.value.include
|
||||
.map((genre, idx) => `&genre[${idx}]=${genre}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (filters?.genre?.value?.exclude?.length) {
|
||||
url += filters.genre.value.exclude
|
||||
.map((genre, idx) => `&delgenre[${idx}]=del ${genre}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
url += '&sortcountchapt=' + (filters?.sortcountchapt?.value || '1');
|
||||
url += '&sortdaycreate=' + (filters?.sortdaycreate?.value || '1');
|
||||
url +=
|
||||
'&sortby=' +
|
||||
(showLatestNovels ? 'upd' : filters?.sortby?.value || 'topweek');
|
||||
url += '&gpage=' + pageNo;
|
||||
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('div[class="block-home"] > div[class="one"]').each(
|
||||
(index, element) => {
|
||||
const name = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a')
|
||||
.attr('title');
|
||||
const cover = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a > img')
|
||||
.attr('src')
|
||||
?.replace('-150x150', '');
|
||||
const url = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a')
|
||||
.attr('href');
|
||||
|
||||
if (!name || !url) return;
|
||||
|
||||
novels.push({ name, cover, path: url.replace(this.site, '') });
|
||||
},
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('div[class="desc-book"] > h1').text().trim(),
|
||||
cover: loadedCheerio('div[class="img-book"] > img').attr('src'),
|
||||
summary: loadedCheerio('div[id="desc-tab"]').text().trim(),
|
||||
};
|
||||
|
||||
loadedCheerio('#info-book > p').each(function () {
|
||||
let text = loadedCheerio(this).text().replace(/,/g, '').split(' ');
|
||||
if (text[0] === 'Автор:') {
|
||||
novel.author = text.splice(1).join(' ');
|
||||
} else if (text[0] === 'Жанры:') {
|
||||
novel.genres = text.splice(1).join(',');
|
||||
} else if (text[0] === 'Статус:') {
|
||||
novel.status = text.includes('продолжается')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed;
|
||||
}
|
||||
});
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
const totalChapters = loadedCheerio('.download-chapter div.title').length;
|
||||
|
||||
loadedCheerio('.download-chapter div.title').each(
|
||||
(chapterIndex, element) => {
|
||||
const name = loadedCheerio(element).find('a').attr('title');
|
||||
const url = loadedCheerio(element).find('a').attr('href');
|
||||
if (!name || !url) return;
|
||||
|
||||
const releaseDate = loadedCheerio(element).find('time').text();
|
||||
chapters.push({
|
||||
name,
|
||||
path: url.replace(this.site, ''),
|
||||
releaseTime: this.parseDate(releaseDate),
|
||||
chapterNumber: totalChapters - chapterIndex,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
novel.chapters = chapters.reverse();
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
loadedCheerio('div[class="adblock-service"]').remove();
|
||||
const chapterText = loadedCheerio('div[class="entry-content"]').html();
|
||||
|
||||
return chapterText || '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number | undefined = 1,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site +
|
||||
'/?searchrn=' +
|
||||
searchTerm +
|
||||
'&but=Поиск по названию&sortby=upd&gpage=' +
|
||||
pageNo;
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('div[class="block-home"] > div[class="one"]').each(
|
||||
(index, element) => {
|
||||
const name = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a')
|
||||
.attr('title');
|
||||
const cover = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a > img')
|
||||
.attr('src')
|
||||
?.replace('-150x150', '');
|
||||
const url = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a')
|
||||
.attr('href');
|
||||
|
||||
if (!name || !url) return;
|
||||
|
||||
novels.push({ name, cover, path: url.replace(this.site, '') });
|
||||
},
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseDate = (dateString: string | undefined = '') => {
|
||||
const months: { [key: string]: number } = {
|
||||
Янв: 1,
|
||||
Фев: 2,
|
||||
Мар: 3,
|
||||
Апр: 4,
|
||||
Май: 5,
|
||||
Июн: 6,
|
||||
Июл: 7,
|
||||
Авг: 8,
|
||||
Сен: 9,
|
||||
Окт: 10,
|
||||
Ноя: 11,
|
||||
Дек: 12,
|
||||
};
|
||||
|
||||
const [time, day, month, year] = dateString.split(' ');
|
||||
if (time && day && months[month] && year) {
|
||||
return dayjs(year + '-' + months[month] + '-' + day + ' ' + time).format(
|
||||
'LLL',
|
||||
);
|
||||
}
|
||||
|
||||
return dateString || null;
|
||||
};
|
||||
|
||||
filters = {
|
||||
sortby: {
|
||||
label: 'Сортировка:',
|
||||
value: 'topweek',
|
||||
options: [
|
||||
{ label: 'Топ недели', value: 'topweek' },
|
||||
{ label: 'По алфавиту', value: 'alphabet' },
|
||||
{ label: 'По дате обновления', value: 'upd' },
|
||||
{ label: 'По дате создания', value: 'new' },
|
||||
{ label: 'По просмотрам', value: 'count' },
|
||||
{ label: 'Топ года', value: 'topyear' },
|
||||
{ label: 'Топ дня', value: 'topday' },
|
||||
{ label: 'Топ за все время', value: 'alltime' },
|
||||
{ label: 'Топ месяца', value: 'topmonth' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sortdaycreate: {
|
||||
label: 'Дата добавления:',
|
||||
value: '1',
|
||||
options: [
|
||||
{ label: 'Любое', value: '1' },
|
||||
{ label: 'От 120 до 180 дней', value: '1218' },
|
||||
{ label: 'От 180 до 365 дней', value: '1836' },
|
||||
{ label: 'От 30 до 60 дней', value: '3060' },
|
||||
{ label: 'От 365 дней', value: '365' },
|
||||
{ label: 'От 60 до 90 дней', value: '6090' },
|
||||
{ label: 'От 90 до 120 дней', value: '9012' },
|
||||
{ label: 'Послед. 30 дней', value: '30' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sortcountchapt: {
|
||||
label: 'Количество глав:',
|
||||
value: '1',
|
||||
options: [
|
||||
{ label: 'Любое кол-во глав', value: '1' },
|
||||
{ label: 'До 500', value: '500' },
|
||||
{ label: 'От 1000 до 2000', value: '1020' },
|
||||
{ label: 'От 2000 до 3000', value: '2030' },
|
||||
{ label: 'От 3000 до 4000', value: '3040' },
|
||||
{ label: 'От 4000', value: '400' },
|
||||
{ label: 'От 500 до 1000', value: '510' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
label: 'Жанры:',
|
||||
value: { include: [], exclude: [] },
|
||||
options: [
|
||||
{ label: 'Боевые Искусства', value: 'Боевые Искусства' },
|
||||
{ label: 'Виртуальный Мир', value: 'Виртуальный Мир' },
|
||||
{ label: 'Гарем', value: 'Гарем' },
|
||||
{ label: 'Детектив', value: 'Детектив' },
|
||||
{ label: 'Драма', value: 'Драма' },
|
||||
{ label: 'Игра', value: 'Игра' },
|
||||
{ label: 'Истории из жизни', value: 'Истории из жизни' },
|
||||
{ label: 'Исторический', value: 'Исторический' },
|
||||
{ label: 'История', value: 'История' },
|
||||
{ label: 'Исэкай', value: 'Исэкай' },
|
||||
{ label: 'Комедия', value: 'Комедия' },
|
||||
{ label: 'Меха', value: 'Меха' },
|
||||
{ label: 'Мистика', value: 'Мистика' },
|
||||
{ label: 'Научная Фантастика', value: 'Научная Фантастика' },
|
||||
{ label: 'Повседневность', value: 'Повседневность' },
|
||||
{ label: 'Постапокалипсис', value: 'Постапокалипсис' },
|
||||
{ label: 'Приключения', value: 'Приключения' },
|
||||
{ label: 'Психология', value: 'Психология' },
|
||||
{ label: 'Романтика', value: 'Романтика' },
|
||||
{ label: 'Сверхъестественное', value: 'Сверхъестественное' },
|
||||
{ label: 'Сёнэн', value: 'Сёнэн' },
|
||||
{ label: 'Сёнэн-ай', value: 'Сёнэн-ай' },
|
||||
{ label: 'Спорт', value: 'Спорт' },
|
||||
{ label: 'Сэйнэн', value: 'Сэйнэн' },
|
||||
{ label: 'Сюаньхуа', value: 'Сюаньхуа' },
|
||||
{ label: 'Трагедия', value: 'Трагедия' },
|
||||
{ label: 'Триллер', value: 'Триллер' },
|
||||
{ label: 'Фантастика', value: 'Фантастика' },
|
||||
{ label: 'Фэнтези', value: 'Фэнтези' },
|
||||
{ label: 'Хоррор', value: 'Хоррор' },
|
||||
{ label: 'Школьная жизнь', value: 'Школьная жизнь' },
|
||||
{ label: 'Шоунен', value: 'Шоунен' },
|
||||
{ label: 'Экшн', value: 'Экшн' },
|
||||
{ label: 'Этти', value: 'Этти' },
|
||||
{ label: 'Юри', value: 'Юри' },
|
||||
{ label: 'Adult', value: 'Adult' },
|
||||
{ label: 'Ecchi', value: 'Ecchi' },
|
||||
{ label: 'Josei', value: 'Josei' },
|
||||
{ label: 'Lolicon', value: 'Lolicon' },
|
||||
{ label: 'Mature', value: 'Mature' },
|
||||
{ label: 'Shoujo', value: 'Shoujo' },
|
||||
{ label: 'Wuxia', value: 'Wuxia' },
|
||||
{ label: 'Xianxia', value: 'Xianxia' },
|
||||
{ label: 'Xuanhuan', value: 'Xuanhuan' },
|
||||
{ label: 'Yaoi', value: 'Yaoi' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
lang: {
|
||||
label: 'Выбрать языки:',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Английский', value: 'Английский' },
|
||||
{ label: 'Китайский', value: 'Китайский' },
|
||||
{ label: 'Корейский', value: 'Корейский' },
|
||||
{ label: 'Японский', value: 'Японский' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Jaomix();
|
||||
@@ -0,0 +1,454 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
const statusKey: { [key: string]: string } = {
|
||||
'0': NovelStatus.Unknown,
|
||||
'1': NovelStatus.Ongoing,
|
||||
'2': NovelStatus.Completed,
|
||||
'3': NovelStatus.OnHiatus,
|
||||
'4': NovelStatus.Cancelled,
|
||||
};
|
||||
|
||||
class Neobook implements Plugin.PluginBase {
|
||||
id = 'neobook';
|
||||
name = 'Neobook';
|
||||
site = 'https://neobook.org';
|
||||
version = '1.0.1';
|
||||
icon = 'src/ru/neobook/icon.png';
|
||||
|
||||
async fetchNovels(
|
||||
page: number,
|
||||
{
|
||||
filters,
|
||||
showLatestNovels,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
searchTerm?: string,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const formData = new FormData();
|
||||
formData.append('version', '4.0');
|
||||
formData.append('uid', '0');
|
||||
formData.append('utoken', '');
|
||||
formData.append('resource', 'general');
|
||||
formData.append('action', 'get_bundle');
|
||||
formData.append('bundle', 'bundle_books');
|
||||
formData.append('target', 'feed');
|
||||
formData.append('page', page.toString());
|
||||
formData.append('filter_category_id', filters?.category?.value || '0');
|
||||
formData.append('filter_completed', '-1');
|
||||
formData.append('filter_search', searchTerm || '');
|
||||
formData.append('filter_tags', filters?.tags?.value || '');
|
||||
formData.append(
|
||||
'filter_sort',
|
||||
showLatestNovels ? 'new' : filters?.sort?.value || 'popular',
|
||||
);
|
||||
formData.append('filter_timeread', filters?.timeread?.value || '0-999999');
|
||||
|
||||
const { bundle_books }: { bundle_books: BundleBooks } = await fetchApi(
|
||||
'https://api.neobook.org/',
|
||||
{
|
||||
method: 'post',
|
||||
body: formData,
|
||||
},
|
||||
).then(res => res.json());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
if (bundle_books.feed?.length) {
|
||||
bundle_books.feed?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel?.attachment?.image?.m || defaultCover,
|
||||
path: novel.token + '/',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
popularNovels = this.fetchNovels;
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const defaultOptions: any = {
|
||||
filters: undefined,
|
||||
showLatestNovels: false,
|
||||
};
|
||||
return this.fetchNovels(page, defaultOptions, searchTerm);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.resolveUrl(novelPath, true)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
cover: defaultCover,
|
||||
};
|
||||
|
||||
const bookRaw = body.match(/var postData = ({.*?});/);
|
||||
if (bookRaw instanceof Array && bookRaw[1]) {
|
||||
const book: Novels = JSON.parse(bookRaw[1]);
|
||||
|
||||
novel.name = book.title;
|
||||
novel.summary = book.text?.replace?.(/<br>/g, '\n') || book.text_fix;
|
||||
novel.author =
|
||||
book.user && book.user.firstname && book.user.lastname
|
||||
? book.user.firstname + ' ' + book.user.lastname
|
||||
: book.user?.initials || '';
|
||||
novel.status = statusKey[book.status || '0'] || NovelStatus.Unknown;
|
||||
|
||||
if (book.attachment?.image?.m) novel.cover = book.attachment.image.m;
|
||||
if (book.tags?.length) novel.genres = book.tags.join(',');
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
book.chapters?.forEach((chapter, chapterIndex) => {
|
||||
if (chapter.access == '1' && chapter.status == '1') {
|
||||
chapters.push({
|
||||
name: chapter.title || 'Глава ' + (chapterIndex + 1),
|
||||
path: `?book=${book.token}&chapter=${chapter.token}`,
|
||||
releaseTime: null,
|
||||
chapterNumber: Number(chapter.sort) || chapterIndex + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
}
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.resolveUrl(chapterPath)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const bookRaw = body.match(/var data = ({.*?});/);
|
||||
let chapterText = '';
|
||||
|
||||
if (bookRaw instanceof Array && bookRaw[1]) {
|
||||
const token = chapterPath.split('=')[2];
|
||||
const book: Novels = JSON.parse(bookRaw[1]);
|
||||
const chapter = book.chapters?.find?.(chapter => chapter.token == token);
|
||||
chapterText = (chapter?.data?.html || '').replace(/<br>/g, '');
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/book/' : '/reader/') + path;
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка:',
|
||||
value: 'popular',
|
||||
options: [
|
||||
{ label: 'Сначала популярные', value: 'popular' },
|
||||
{ label: 'Сначала новые', value: 'new' },
|
||||
{ label: 'В случайном порядке', value: 'rand' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
timeread: {
|
||||
label: 'Время прочтения:',
|
||||
value: '0-999999',
|
||||
options: [
|
||||
{ label: 'Все', value: '0-999999' },
|
||||
{ label: 'Менее 15 минут', value: '0-900' },
|
||||
{ label: '15-30 минут', value: '900-1800' },
|
||||
{ label: '30-60 минут', value: '1800-3600' },
|
||||
{ label: '1-2 часа', value: '3600-7200' },
|
||||
{ label: 'Более 2 часов', value: '7200-999999' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
category: {
|
||||
label: 'Жанр:',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Все', value: '' },
|
||||
{ label: 'Антиутопия', value: '10' },
|
||||
{ label: 'Детектив', value: '13' },
|
||||
{ label: 'Детские книги', value: '14' },
|
||||
{ label: 'Драма', value: '15' },
|
||||
{ label: 'Другое', value: '16' },
|
||||
{ label: 'История', value: '18' },
|
||||
{ label: 'Мелодрама', value: '21' },
|
||||
{ label: 'Мистика', value: '22' },
|
||||
{ label: 'Научная фантастика', value: '23' },
|
||||
{ label: 'Нон-фикшн', value: '35' },
|
||||
{ label: 'Подростки и молодежь', value: '26' },
|
||||
{ label: 'Постапокалипсис', value: '27' },
|
||||
{ label: 'Поэзия', value: '28' },
|
||||
{ label: 'Приключения', value: '29' },
|
||||
{ label: 'Рассказ', value: '34' },
|
||||
{ label: 'Роман', value: '36' },
|
||||
{ label: 'Творчество', value: '40' },
|
||||
{ label: 'Триллер', value: '91' },
|
||||
{ label: 'Ужасы', value: '90' },
|
||||
{ label: 'Фантастика', value: '41' },
|
||||
{ label: 'Фанфик', value: '42' },
|
||||
{ label: 'Фэнтези', value: '44' },
|
||||
{ label: 'Эротика', value: '46' },
|
||||
{ label: 'Юмор', value: '47' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
tags: {
|
||||
label: 'Тэги:',
|
||||
value: '',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Neobook();
|
||||
|
||||
interface BundleBooks {
|
||||
categories: any[];
|
||||
topSection: any[];
|
||||
feed: Novels[];
|
||||
}
|
||||
|
||||
interface BottomSection {
|
||||
id: string;
|
||||
boost: string;
|
||||
preferenceid: string;
|
||||
type_id: string;
|
||||
contentTypeid: string;
|
||||
name: string;
|
||||
title: string;
|
||||
more: string;
|
||||
part: string;
|
||||
items: Novels[];
|
||||
}
|
||||
|
||||
interface Novels {
|
||||
status?: string;
|
||||
error?: string;
|
||||
login?: Login;
|
||||
requestid?: string;
|
||||
bundleBooks?: BundleBooks;
|
||||
available?: string;
|
||||
boosted?: string;
|
||||
id?: string;
|
||||
token: string;
|
||||
type?: string;
|
||||
typeid?: string;
|
||||
verified?: string;
|
||||
audio?: string;
|
||||
labelid?: string;
|
||||
labelTitle?: string;
|
||||
languageid?: string;
|
||||
languageTitle?: string;
|
||||
categoryid?: string;
|
||||
categoryTitle?: string;
|
||||
price?: Price;
|
||||
priceSpecial?: Price;
|
||||
priceDiscount?: string;
|
||||
title: string;
|
||||
text: string;
|
||||
text_fix: string;
|
||||
copyright?: string;
|
||||
align?: string;
|
||||
completed?: string;
|
||||
timereadS?: string;
|
||||
views?: string;
|
||||
likes?: string;
|
||||
comments?: string;
|
||||
bookmarks?: string;
|
||||
isLike?: string;
|
||||
isBookmark?: string;
|
||||
isPurchase?: string;
|
||||
isSelf?: string;
|
||||
dateEdit?: string;
|
||||
datePublish?: string;
|
||||
link_path?: string;
|
||||
user?: NovelsUser;
|
||||
attachment?: Attachment;
|
||||
adult?: string;
|
||||
adultPlaceholder?: string;
|
||||
nft?: string;
|
||||
feed?: Feed;
|
||||
counters?: NovelsCounters;
|
||||
rating?: Rating;
|
||||
readTime?: ReadTime;
|
||||
readProgress?: ReadProgress;
|
||||
tags?: string[];
|
||||
chapters?: Chapter[];
|
||||
lastComments?: any[];
|
||||
bottomSection?: BottomSection;
|
||||
carouselItems?: Novels[];
|
||||
book?: Novels;
|
||||
activeChapterIndex?: number;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
has: string;
|
||||
id: string;
|
||||
typeid: string;
|
||||
userid: string;
|
||||
width: string;
|
||||
height: string;
|
||||
duration: string;
|
||||
image: Image;
|
||||
video: string;
|
||||
origin: string;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
interface Image {
|
||||
s: string;
|
||||
m: string;
|
||||
l: string;
|
||||
has?: string;
|
||||
}
|
||||
|
||||
interface Chapter {
|
||||
id: string;
|
||||
token: string;
|
||||
status: string;
|
||||
sort: string;
|
||||
free: string;
|
||||
title: string;
|
||||
words: string;
|
||||
access: string;
|
||||
loaded: string;
|
||||
data?: Data;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
available: string;
|
||||
access: string;
|
||||
id: string;
|
||||
token: string;
|
||||
postid: string;
|
||||
free: string;
|
||||
title: string;
|
||||
html: string;
|
||||
dateEdit: string;
|
||||
datePublish: string;
|
||||
attachment: Attachment;
|
||||
lastComments: any[];
|
||||
}
|
||||
|
||||
interface NovelsCounters {
|
||||
impressions: Bookmarks;
|
||||
views: Bookmarks;
|
||||
likes: Bookmarks;
|
||||
comments: Bookmarks;
|
||||
bookmarks: Bookmarks;
|
||||
}
|
||||
|
||||
interface Bookmarks {
|
||||
value: string;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
interface Feed {
|
||||
name: string;
|
||||
canRemoved: string;
|
||||
}
|
||||
|
||||
interface Login {
|
||||
uid: string;
|
||||
utoken: string;
|
||||
has: string;
|
||||
banned: string;
|
||||
verified: string;
|
||||
acc: string;
|
||||
arm: string;
|
||||
grm: string;
|
||||
counters: LoginCounters;
|
||||
user: LoginUser;
|
||||
pro: any[];
|
||||
earn: any[];
|
||||
boost: any[];
|
||||
deposit: any[];
|
||||
affiliate: any[];
|
||||
preferences: any[];
|
||||
}
|
||||
|
||||
interface LoginCounters {
|
||||
notifications: string;
|
||||
messages: string;
|
||||
}
|
||||
|
||||
interface LoginUser {
|
||||
id: string;
|
||||
username: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
photo: string;
|
||||
status: string;
|
||||
balance: Balance;
|
||||
connectedService: string;
|
||||
}
|
||||
|
||||
interface Balance {
|
||||
neo: Price;
|
||||
}
|
||||
|
||||
interface Price {
|
||||
has: string;
|
||||
value: string;
|
||||
currency: Currency;
|
||||
formatted: string;
|
||||
deadline: string;
|
||||
}
|
||||
|
||||
type Currency = 'NEO';
|
||||
|
||||
interface Rating {
|
||||
value: string;
|
||||
count: string;
|
||||
my: string;
|
||||
}
|
||||
|
||||
interface ReadProgress {
|
||||
chapter: string;
|
||||
progress: string;
|
||||
}
|
||||
|
||||
interface ReadTime {
|
||||
hours: string;
|
||||
minutes: string;
|
||||
}
|
||||
|
||||
interface NovelsUser {
|
||||
available: string;
|
||||
boosted: string;
|
||||
id: string;
|
||||
status: string;
|
||||
verified: string;
|
||||
metrica: string;
|
||||
cover: Image;
|
||||
photo: Image;
|
||||
username: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
initials: string;
|
||||
link: string;
|
||||
about: string;
|
||||
subscriptions: string;
|
||||
subscribers: string;
|
||||
publications: string;
|
||||
books: string;
|
||||
poems: string;
|
||||
posts: string;
|
||||
drafts: string;
|
||||
isPro: string;
|
||||
isAdult: string;
|
||||
isBanned: string;
|
||||
isSubscription: string;
|
||||
isBlock: string;
|
||||
gr: string;
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class novelOvh implements Plugin.PluginBase {
|
||||
id = 'novelovh';
|
||||
name = 'НовелОВХ';
|
||||
site = 'https://novel.ovh';
|
||||
version = '1.0.2';
|
||||
icon = 'src/ru/novelovh/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + '/content?page=' + (pageNo - 1);
|
||||
url +=
|
||||
'&sort=' +
|
||||
(showLatestNovels
|
||||
? 'updatedAt'
|
||||
: filters?.sort?.value || 'averageRating') +
|
||||
',desc';
|
||||
|
||||
const { books }: { books: BooksEntity[] } = await fetchApi(
|
||||
url + '&_data=routes/reader/book/index',
|
||||
).then(res => res.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
books.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name.ru,
|
||||
cover: novel.poster,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const { book, chapters }: responseNovel = await fetchApi(
|
||||
this.site +
|
||||
'/content/' +
|
||||
novelPath +
|
||||
'?_data=routes/reader/book/$slug/index',
|
||||
).then(res => res.json());
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: book.name.ru,
|
||||
cover: book.poster,
|
||||
genres: book.labels?.map?.(label => label.name).join(','),
|
||||
summary: book.description,
|
||||
status:
|
||||
book.status == 'ONGOING' ? NovelStatus.Ongoing : NovelStatus.Completed,
|
||||
};
|
||||
|
||||
book.relations?.forEach(person => {
|
||||
switch (person.type) {
|
||||
case 'AUTHOR':
|
||||
novel.author = person.publisher.name;
|
||||
case 'ARTIST':
|
||||
novel.artist = person.publisher.name;
|
||||
}
|
||||
});
|
||||
|
||||
const chaptersRes: Plugin.ChapterItem[] = [];
|
||||
|
||||
chapters.forEach((chapter, chapterIndex) =>
|
||||
chaptersRes.push({
|
||||
name:
|
||||
chapter.title ||
|
||||
'Том ' +
|
||||
(chapter.volume || 0) +
|
||||
' ' +
|
||||
(chapter.name ||
|
||||
'Глава ' + (chapter.number || chapters.length - chapterIndex)),
|
||||
path: novelPath + '/' + chapter.id + '/0',
|
||||
releaseTime: dayjs(chapter.createdAt).format('LLL'),
|
||||
chapterNumber: chapters.length - chapterIndex,
|
||||
}),
|
||||
);
|
||||
|
||||
novel.chapters = chaptersRes.reverse();
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const book: responseChapter = await fetchApi(
|
||||
'https://api.novel.ovh/v2/chapters/' + chapterPath.split('/')[1],
|
||||
).then(res => res.json());
|
||||
|
||||
const image = Object.fromEntries(
|
||||
book?.pages?.map(({ id, image }) => [id, image]) || [],
|
||||
);
|
||||
|
||||
const chapterText = this.jsonToHtml(book.content.content || [], image);
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `https://api.novel.ovh/v2/books?type=NOVEL&search=${searchTerm}`;
|
||||
const books: BooksEntity[] = await fetchApi(url).then(res => res.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
books.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name.ru,
|
||||
cover: novel.poster,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
jsonToHtml = (
|
||||
json: ContentEntity[],
|
||||
image: { [key: string]: string },
|
||||
html: string = '',
|
||||
) => {
|
||||
json.forEach(element => {
|
||||
switch (element.type) {
|
||||
case 'image':
|
||||
if (element.attrs?.pages?.[0]) {
|
||||
html += `<img src="${image[element.attrs.pages[0]]}"/>`;
|
||||
}
|
||||
break;
|
||||
case 'hardBreak':
|
||||
html += '<br>';
|
||||
break;
|
||||
case 'horizontalRule':
|
||||
case 'delimiter':
|
||||
html += '<h2 style="text-align: center">***</h2>';
|
||||
break;
|
||||
case 'paragraph':
|
||||
html +=
|
||||
'<p' +
|
||||
(element?.attrs?.textAlign
|
||||
? ` style="text-align: ${element.attrs.textAlign}"`
|
||||
: '') +
|
||||
'>' +
|
||||
(element.content
|
||||
? this.jsonToHtml(element.content, image)
|
||||
: '<br>') +
|
||||
'</p>';
|
||||
break;
|
||||
case 'text':
|
||||
html += element.text;
|
||||
break;
|
||||
default:
|
||||
html += JSON.stringify(element, null, '\t'); //maybe I missed something.
|
||||
break;
|
||||
}
|
||||
});
|
||||
return html;
|
||||
};
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + '/novel/' + path;
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: 'averageRating',
|
||||
options: [
|
||||
{ label: 'Кол-во просмотров', value: 'viewsCount' },
|
||||
{ label: 'Кол-во лайков', value: 'likesCount' },
|
||||
{ label: 'Кол-во глав', value: 'chaptersCount' },
|
||||
{ label: 'Кол-во закладок', value: 'bookmarksCount' },
|
||||
{ label: 'Рейтингу', value: 'averageRating' },
|
||||
{ label: 'Дате создания', value: 'createdAt' },
|
||||
{ label: 'Дате обновления', value: 'updatedAt' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new novelOvh();
|
||||
|
||||
interface BooksEntity {
|
||||
id: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
serviceName: string;
|
||||
poster: string;
|
||||
background?: string | null;
|
||||
featuredCharacter?: null;
|
||||
featuredCharacterBackground?: null;
|
||||
featuredCharacterAnimation?: null;
|
||||
featuredCharacterAnimationWithMask?: null;
|
||||
featuredCharacterAnimationFirstFrame?: null;
|
||||
status: string;
|
||||
contentStatus: string;
|
||||
name: Name;
|
||||
altNames?: AltNamesEntity[] | null;
|
||||
country: string;
|
||||
year: number;
|
||||
formats?: null[] | null;
|
||||
featured: boolean;
|
||||
viewsCount: number;
|
||||
likesCount: number;
|
||||
bookmarksCount: number;
|
||||
ratingVotesCount: number;
|
||||
averageRating: number;
|
||||
chaptersCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
interface Name {
|
||||
ru: string;
|
||||
original: string;
|
||||
en: string;
|
||||
}
|
||||
interface AltNamesEntity {
|
||||
language: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface responseNovel {
|
||||
book: Book;
|
||||
branches: BranchesEntity[];
|
||||
chapters: ChaptersEntity[];
|
||||
related?: RelatedEntityOrBook[] | null;
|
||||
associated?: AssociatedEntity[] | null;
|
||||
chapterLikes?: null[] | null;
|
||||
covers?: CoversEntity[] | null;
|
||||
promotionVideos?: null[] | null;
|
||||
folderItems?: null[] | null;
|
||||
contentNotices?: null[] | null;
|
||||
}
|
||||
interface Book {
|
||||
id: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
serviceName: string;
|
||||
poster: string;
|
||||
background?: null;
|
||||
featuredCharacter?: null;
|
||||
featuredCharacterBackground?: null;
|
||||
featuredCharacterAnimation?: null;
|
||||
featuredCharacterAnimationWithMask?: null;
|
||||
featuredCharacterAnimationFirstFrame?: null;
|
||||
contentBlockedCountries?: null[] | null;
|
||||
status: string;
|
||||
contentStatus: string;
|
||||
moderationStatus: string;
|
||||
name: Name;
|
||||
altNames?: null[] | null;
|
||||
externalLinks?: string[] | null;
|
||||
description: string;
|
||||
country: string;
|
||||
year: number;
|
||||
formats?: null[] | null;
|
||||
featured: boolean;
|
||||
viewsCount: number;
|
||||
viewsDayCount: number;
|
||||
viewsWeekCount: number;
|
||||
viewsMonthCount: number;
|
||||
likesCount: number;
|
||||
bookmarksCount: number;
|
||||
ratingVotesCount: number;
|
||||
averageRating: number;
|
||||
chaptersCount: number;
|
||||
labels?: LabelsEntity[] | null;
|
||||
relations?: RelationsEntity[] | null;
|
||||
createdById: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
rating?: null;
|
||||
}
|
||||
interface LabelsEntity {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
interface RelationsEntity {
|
||||
type: string;
|
||||
publisher: Publisher;
|
||||
}
|
||||
interface Publisher {
|
||||
id: string;
|
||||
kind: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
altNames?: (AltNamesEntity | null)[] | null;
|
||||
poster: string;
|
||||
background?: null;
|
||||
trusted: boolean;
|
||||
donationsEnabled: boolean;
|
||||
vkGroupId?: null;
|
||||
vkGroupName?: null;
|
||||
tonAddress?: null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
interface BranchesEntity {
|
||||
id: string;
|
||||
licensed: boolean;
|
||||
editorsChoice: boolean;
|
||||
chaptersCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishers?: PublishersEntity[] | null;
|
||||
subscription?: null;
|
||||
bookmark?: null;
|
||||
}
|
||||
interface PublishersEntity {
|
||||
id: string;
|
||||
kind: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
altNames?: AltNamesEntity[] | null;
|
||||
poster: string;
|
||||
background?: null;
|
||||
trusted: boolean;
|
||||
donationsEnabled: boolean;
|
||||
vk?: null;
|
||||
tonAddress?: null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
interface ChaptersEntity {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
title?: string | null;
|
||||
number: number;
|
||||
volume: number;
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
donut: boolean;
|
||||
corrupted: boolean;
|
||||
createdById: string;
|
||||
branchId: string;
|
||||
bookId: string;
|
||||
createdAt: string;
|
||||
expiredAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
interface RelatedEntityOrBook {
|
||||
id: string;
|
||||
type: string;
|
||||
slug: string;
|
||||
poster: string;
|
||||
motion?: null;
|
||||
status: string;
|
||||
contentStatus: string;
|
||||
name: Name;
|
||||
country: string;
|
||||
year: number;
|
||||
formats?: null[] | null;
|
||||
viewsCount: number;
|
||||
likesCount: number;
|
||||
bookmarksCount: number;
|
||||
ratingVotesCount: number;
|
||||
averageRating: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
interface AssociatedEntity {
|
||||
id: string;
|
||||
kind: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
book: RelatedEntityOrBook;
|
||||
}
|
||||
interface CoversEntity {
|
||||
id: string;
|
||||
volume: number;
|
||||
image: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface responseChapter {
|
||||
id: string;
|
||||
name?: string;
|
||||
title: string;
|
||||
number: number;
|
||||
volume: number;
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
moderationStatus: string;
|
||||
content: Content;
|
||||
pages?: PagesEntity[];
|
||||
donut: boolean;
|
||||
corrupted: boolean;
|
||||
createdById: string;
|
||||
branchId: string;
|
||||
bookId: string;
|
||||
publishers?: PublishersEntity[] | null;
|
||||
createdAt: string;
|
||||
expiredAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
interface Content {
|
||||
type: string;
|
||||
content?: ContentEntity[] | null;
|
||||
}
|
||||
interface ContentEntity {
|
||||
text?: string | null;
|
||||
type: string;
|
||||
attrs?: Attrs | null;
|
||||
content?: Content[] | null;
|
||||
}
|
||||
interface Attrs {
|
||||
textAlign?: string;
|
||||
pages?: [string];
|
||||
}
|
||||
interface PagesEntity {
|
||||
id: string;
|
||||
image: string;
|
||||
index: number;
|
||||
commentsCount: number;
|
||||
height: number;
|
||||
width: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,950 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const statusKey: { [key: string]: string } = {
|
||||
'active': NovelStatus.Ongoing,
|
||||
'completed': NovelStatus.Completed,
|
||||
'freezed': NovelStatus.OnHiatus,
|
||||
'unknown': NovelStatus.Unknown,
|
||||
};
|
||||
|
||||
class TL implements Plugin.PluginBase {
|
||||
id = 'TL';
|
||||
name = 'NovelTL';
|
||||
site = 'https://novel.tl';
|
||||
version = '1.0.1';
|
||||
icon = 'src/ru/noveltl/icon.png';
|
||||
|
||||
async fetchNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
searchTerm?: string,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const { data }: response = await fetchApi(
|
||||
this.site + '/api/site/v2/graphql',
|
||||
{
|
||||
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',
|
||||
},
|
||||
Referer: this.site,
|
||||
body: JSON.stringify({
|
||||
query:
|
||||
'query Projects($hostname:String! $filter:SearchFilter $page:Int $limit:Int){projects(section:{fullUrl:$hostname}filter:$filter page:{pageSize:$limit,pageNumber:$page}){content{title fullUrl covers{url}}}}',
|
||||
variables: {
|
||||
filter: {
|
||||
query: searchTerm || undefined,
|
||||
tags: filters.tags?.value?.length
|
||||
? filters.tags.value
|
||||
: undefined,
|
||||
genres: filters.genres?.value?.length
|
||||
? filters.genres.value
|
||||
: undefined,
|
||||
},
|
||||
hostname: 'novel.tl',
|
||||
limit: 40,
|
||||
page,
|
||||
},
|
||||
}),
|
||||
},
|
||||
).then(res => res.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
data?.projects?.content?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
path: novel.fullUrl,
|
||||
cover: novel?.covers?.[0]?.url
|
||||
? this.site + novel.covers[0].url
|
||||
: defaultCover,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
popularNovels = this.fetchNovels;
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const defaultOptions: any = {
|
||||
filters: {},
|
||||
};
|
||||
return this.fetchNovels(page, defaultOptions, searchTerm);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const { data }: response = await fetchApi(
|
||||
this.site + '/api/site/v2/graphql',
|
||||
{
|
||||
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',
|
||||
},
|
||||
Referer: this.site,
|
||||
body: JSON.stringify({
|
||||
query:
|
||||
'query Book($url:String){project(project:{fullUrl:$url}){title translationStatus fullUrl covers{url}persons(langs:["ru","en","*"],roles:["author","illustrator"]){role name{firstName lastName}}genres{nameRu nameEng}tags{nameRu nameEng}annotation{text}subprojects{content{title volumes{content{shortName chapters{title publishDate fullUrl published}}}}}}}',
|
||||
variables: {
|
||||
url: novelPath,
|
||||
},
|
||||
}),
|
||||
},
|
||||
).then(res => res.json());
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: data.project?.title || '',
|
||||
cover: data.project?.covers?.[0]?.url
|
||||
? this.site + data.project.covers[0].url
|
||||
: defaultCover,
|
||||
summary: data.project?.annotation?.text,
|
||||
status:
|
||||
statusKey[data.project?.translationStatus || 'unknown'] ||
|
||||
NovelStatus.Unknown,
|
||||
};
|
||||
|
||||
const genres = [data.project?.tags, data.project?.genres]
|
||||
.flat()
|
||||
.map(item => item?.nameRu || item?.nameEng)
|
||||
.filter(item => item);
|
||||
|
||||
if (genres?.length) {
|
||||
novel.genres = genres.join(', ');
|
||||
}
|
||||
|
||||
data.project?.persons?.forEach(person => {
|
||||
if (person.role == 'author' && person.name.firstName) {
|
||||
novel.author =
|
||||
person.name.firstName + ' ' + (person.name?.lastName || '');
|
||||
}
|
||||
if (person.role == 'illustrator' && person.name.firstName) {
|
||||
novel.artist =
|
||||
person.name.firstName + ' ' + (person.name?.lastName || '');
|
||||
}
|
||||
});
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
data.project?.subprojects?.content?.forEach(work =>
|
||||
work.volumes.content.forEach((volume, volumeIndex) =>
|
||||
volume.chapters.forEach((chapter, chapterIndex) => {
|
||||
if (chapter.published) {
|
||||
chapters.push({
|
||||
name:
|
||||
(volume.shortName || 'Том ' + (volumeIndex + 1)) +
|
||||
' ' +
|
||||
(chapter.title || 'Глава ' + (chapterIndex + 1)),
|
||||
path: chapter.fullUrl,
|
||||
releaseTime: dayjs(chapter.publishDate).format('LLL'),
|
||||
chapterNumber: chapters.length + 1,
|
||||
page: work.title || 'Основная серия',
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const { data } = await fetchApi(this.site + '/api/site/v2/graphql', {
|
||||
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',
|
||||
},
|
||||
Referer: this.site,
|
||||
body: JSON.stringify({
|
||||
query:
|
||||
'query($url:String){chapter(chapter:{fullUrl:$url}){text{text}}}',
|
||||
variables: {
|
||||
url: decodeURI(chapterPath),
|
||||
},
|
||||
}),
|
||||
}).then(res => res.json());
|
||||
|
||||
const chapterText = data.chapter?.text?.text || '';
|
||||
if (chapterText.includes('<img')) {
|
||||
return chapterText.replace(
|
||||
/src="(.*?)"|href="(.*?)"/g,
|
||||
(match: string, src: string, href: string) => {
|
||||
if (src && !src.startsWith('http')) {
|
||||
return `src="${this.site + src}"`;
|
||||
} else if (href && !href.startsWith('http')) {
|
||||
return `href="${this.site + href}"`;
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) => 'https://' + path;
|
||||
|
||||
filters = {
|
||||
tags: {
|
||||
label: 'Тэги',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Автоматы', value: '64' },
|
||||
{ label: 'Агрессивные персонажи', value: '26' },
|
||||
{ label: 'Ад', value: '338' },
|
||||
{ label: 'Адаптация манги', value: '8' },
|
||||
{ label: 'Академия', value: '5' },
|
||||
{ label: 'Актёрское искусство', value: '7' },
|
||||
{ label: 'Актеры озвучки', value: '746' },
|
||||
{ label: 'Алхимия', value: '27' },
|
||||
{ label: 'Альтернативная реальность', value: '30' },
|
||||
{ label: 'Амнезия', value: '31' },
|
||||
{ label: 'Анабиоз', value: '170' },
|
||||
{ label: 'Анал', value: '33' },
|
||||
{ label: 'Ангелы', value: '38' },
|
||||
{ label: 'Андроиды', value: '37' },
|
||||
{ label: 'Анти-рост персонажа', value: '25' },
|
||||
{ label: 'Антигерой', value: '43' },
|
||||
{ label: 'Антикварный магазин', value: '44' },
|
||||
{ label: 'Антимагия', value: '41' },
|
||||
{ label: 'Антиутопия', value: '225' },
|
||||
{ label: 'Апатичный главный герой', value: '46' },
|
||||
{ label: 'Апокалипсис', value: '47' },
|
||||
{ label: 'Аристократия', value: '51' },
|
||||
{ label: 'Армия', value: '53' },
|
||||
{ label: 'Артефакты', value: '58' },
|
||||
{ label: 'Асоциальный главный герой', value: '42' },
|
||||
{ label: 'Ассасины', value: '61' },
|
||||
{ label: 'Атмосфера Европы', value: '245' },
|
||||
{ label: 'Банды', value: '302' },
|
||||
{ label: 'БДСМ', value: '77' },
|
||||
{ label: 'Бедный главный герой', value: '541' },
|
||||
{ label: 'Бездушный главный герой', value: '143' },
|
||||
{ label: 'Беззаботный герой', value: '115' },
|
||||
{ label: 'Безответная любовь', value: '213' },
|
||||
{ label: 'Безответная любовь', value: '740' },
|
||||
{ label: 'Безработные', value: '377' },
|
||||
{ label: 'Беременность', value: '551' },
|
||||
{ label: 'Бескорыстная любовь', value: '733' },
|
||||
{ label: 'Бессмертные', value: '359' },
|
||||
{ label: 'Бесстрашный главный герой', value: '274' },
|
||||
{ label: 'Библиотека', value: '393' },
|
||||
{ label: 'Биография', value: '86' },
|
||||
{ label: 'Близнецы', value: '729' },
|
||||
{ label: 'Богачи', value: '753' },
|
||||
{ label: 'Боги', value: '318' },
|
||||
{ label: 'Богини', value: '316' },
|
||||
{ label: 'Богоподобный протагонист', value: '314' },
|
||||
{ label: 'Боевая академия', value: '75' },
|
||||
{ label: 'Боевая парочка', value: '547' },
|
||||
{ label: 'Божественная защита', value: '208' },
|
||||
{ label: 'Божественная сила', value: '317' },
|
||||
{ label: 'Болеющие персонажи', value: '648' },
|
||||
{ label: 'Борьба за власть', value: '548' },
|
||||
{ label: 'Брак по расчету', value: '55' },
|
||||
{ label: 'Братство', value: '106' },
|
||||
{ label: 'Братья или сёстры', value: '646' },
|
||||
{ label: 'Брошенные дети', value: '1' },
|
||||
{ label: 'Бывший герой', value: '290' },
|
||||
{ label: 'Вампиры', value: '742' },
|
||||
{ label: 'Ваншот', value: '496' },
|
||||
{ label: 'Вдали от родителей', value: '3' },
|
||||
{ label: 'Ведьмы', value: '756' },
|
||||
{ label: 'Везучий главный герой', value: '411' },
|
||||
{ label: 'Верные подданные', value: '410' },
|
||||
{ label: 'Вероломные герои/плетение интриг', value: '424' },
|
||||
{ label: 'Взросление персонажа', value: '24' },
|
||||
{ label: 'Видит то, чего остальной люд не в силах', value: '617' },
|
||||
{ label: 'Виртуальная реальность', value: '744' },
|
||||
{ label: 'Владелец магазина', value: '677' },
|
||||
{ label: 'Владелец уникального оружия', value: '736' },
|
||||
{ label: 'Властные персонажи', value: '545' },
|
||||
{ label: 'Влияние прошлого', value: '517' },
|
||||
{ label: 'Внезапное обогащение', value: '691' },
|
||||
{ label: 'Внезапное обретение силы', value: '690' },
|
||||
{ label: 'Военные хроники', value: '749' },
|
||||
{ label: 'Возвращение в родной мир', value: '587' },
|
||||
{ label: 'Война', value: '750' },
|
||||
{ label: 'Вокалоид', value: '745' },
|
||||
{ label: 'Волшебные твари', value: '413' },
|
||||
{ label: 'Воскрешение', value: '586' },
|
||||
{ label: 'Воспитанный главный герой', value: '537' },
|
||||
{ label: 'Воспоминания прошлого', value: '283' },
|
||||
{ label: 'Враги становятся союзниками', value: '238' },
|
||||
{ label: 'Врата в иной мир', value: '303' },
|
||||
{ label: 'Временной парадокс', value: '711' },
|
||||
{ label: 'Вторжение на Землю', value: '228' },
|
||||
{ label: 'Второй шанс', value: '608' },
|
||||
{ label: 'Выживание', value: '695' },
|
||||
{ label: 'Вынужденные условия проживания', value: '287' },
|
||||
{ label: 'Высокомерный герой', value: '56' },
|
||||
{ label: 'Гадание', value: '207' },
|
||||
{ label: 'Галлюцинации', value: '189' },
|
||||
{ label: 'Гарем рабов', value: '656' },
|
||||
{ label: 'Геймеры', value: '301' },
|
||||
{ label: 'Генетические модификации', value: '306' },
|
||||
{ label: 'Герои', value: '341' },
|
||||
{ label: 'Герои непонятного пола', value: '36' },
|
||||
{ label: 'Героиня девушка-сорванец', value: '715' },
|
||||
{ label: 'Героиня красавица', value: '81' },
|
||||
{ label: 'Герой влюбляется первым', value: '561' },
|
||||
{ label: 'Герой использует щит', value: '636' },
|
||||
{ label: 'Герой красавец', value: '329' },
|
||||
{ label: 'Герой со множеством тел', value: '564' },
|
||||
{ label: 'Герой-бесстыдник', value: '632' },
|
||||
{ label: 'Герой-извращенец', value: '521' },
|
||||
{ label: 'Герой-трудяга', value: '330' },
|
||||
{ label: 'Герой-яндере', value: '421' },
|
||||
{ label: 'Гетерохромия', value: '342' },
|
||||
{ label: 'Гильдии', value: '324' },
|
||||
{ label: 'Гиперопека', value: '509' },
|
||||
{ label: 'Главная героиня — женщина', value: '277' },
|
||||
{ label: 'Главный герой - гений', value: '308' },
|
||||
{ label: 'Главный герой - Моб', value: '446' },
|
||||
{ label: 'Главный герой — знаменитость', value: '263' },
|
||||
{ label: 'Главный герой — мужчина', value: '419' },
|
||||
{ label: 'Главный герой в поисках гарема', value: '331' },
|
||||
{ label: 'Главный герой заранее готовится', value: '557' },
|
||||
{ label: 'Главный герой не человек', value: '488' },
|
||||
{ label: 'Главный герой приёмный', value: '20' },
|
||||
{ label: 'Главный герой сразу силён', value: '563' },
|
||||
{ label: 'Гладиаторы', value: '310' },
|
||||
{ label: 'Глухой к любви герой', value: '194' },
|
||||
{ label: 'Гоблины', value: '313' },
|
||||
{ label: 'Големы', value: '319' },
|
||||
{ label: 'Гомункул', value: '347' },
|
||||
{ label: 'Горничные', value: '418' },
|
||||
{ label: 'Государственные интриги', value: '212' },
|
||||
{ label: 'Гринд', value: '322' },
|
||||
{ label: 'Давняя травма', value: '518' },
|
||||
{ label: 'Двойник', value: '97' },
|
||||
{ label: 'Дворецкие', value: '111' },
|
||||
{ label: 'Дворфы', value: '224' },
|
||||
{ label: 'Дворяне', value: '487' },
|
||||
{ label: 'Двоюродные братья или сёстры', value: '162' },
|
||||
{ label: 'Девочки-волшебницы', value: '415' },
|
||||
{ label: 'Демоны', value: '193' },
|
||||
{ label: 'Депрессия', value: '196' },
|
||||
{ label: 'Детектив', value: '198' },
|
||||
{ label: 'Детективное расследование', value: '473' },
|
||||
{ label: 'Детское насилие', value: '125' },
|
||||
{ label: 'Детское обещание', value: '130' },
|
||||
{ label: 'Дискриминация', value: '203' },
|
||||
{ label: 'Длительный разрыв отношений', value: '400' },
|
||||
{ label: 'Домашняя жизнь', value: '45' },
|
||||
{ label: 'Драконы', value: '218' },
|
||||
{ label: 'Древний Китай', value: '34' },
|
||||
{ label: 'Дружба', value: '293' },
|
||||
{ label: 'Друзья детства', value: '128' },
|
||||
{ label: 'Друзья становятся врагами', value: '292' },
|
||||
{ label: 'Духи', value: '673' },
|
||||
{ label: 'Духовная сила', value: '665' },
|
||||
{ label: 'Духовный наставник', value: '671' },
|
||||
{ label: 'Душещипательная история', value: '335' },
|
||||
{ label: 'Души', value: '666' },
|
||||
{ label: 'Дьявольский путь культивации', value: '192' },
|
||||
{ label: 'Ёкай', value: '763' },
|
||||
{ label: 'Есть аниме-адаптация', value: '10' },
|
||||
{ label: 'Есть видеоигра', value: '13' },
|
||||
{ label: 'Есть дорама-адаптация', value: '11' },
|
||||
{ label: 'Есть манга-адаптация', value: '14' },
|
||||
{ label: 'Есть манхва-адаптация', value: '16' },
|
||||
{ label: 'Есть маньхуа-адаптация', value: '15' },
|
||||
{ label: 'Есть фильм', value: '17' },
|
||||
{ label: 'Есть CD дорама-адаптация', value: '12' },
|
||||
{ label: 'Жёсткий главный герой', value: '597' },
|
||||
{ label: 'Жестокие персонажи', value: '4' },
|
||||
{ label: 'Жёстокие персонажи', value: '169' },
|
||||
{ label: 'Животное-спутник', value: '78' },
|
||||
{ label: 'Жизнь в одиночку', value: '396' },
|
||||
{ label: 'Жрецы', value: '555' },
|
||||
{ label: 'Жрицы', value: '554' },
|
||||
{ label: 'Забота о детях', value: '127' },
|
||||
{ label: 'Заботливый главный герой', value: '116' },
|
||||
{ label: 'Загадочное заболевание', value: '471' },
|
||||
{ label: 'Загадочное прошлое родителей', value: '470' },
|
||||
{ label: 'Заговоры', value: '154' },
|
||||
{ label: 'Закалка тела', value: '96' },
|
||||
{ label: 'Закон джунглей', value: '682' },
|
||||
{ label: 'Закулисная борба', value: '603' },
|
||||
{ label: 'Замкнутый главный герой', value: '371' },
|
||||
{ label: 'Запечатанная сила', value: '607' },
|
||||
{ label: 'Затворник', value: '346' },
|
||||
{ label: 'Звери', value: '80' },
|
||||
{ label: 'Зверолюди', value: '39' },
|
||||
{ label: 'Звероподобные', value: '79' },
|
||||
{ label: 'Земледелие', value: '268' },
|
||||
{ label: 'Злобные благородные девы', value: '743' },
|
||||
{ label: 'Зловещие организации', value: '247' },
|
||||
{ label: 'Злой главный герой', value: '248' },
|
||||
{ label: 'Злые боги', value: '246' },
|
||||
{ label: 'Злые религии', value: '249' },
|
||||
{ label: 'Знаменитости', value: '118' },
|
||||
{ label: 'Знания из прошлой жизни', value: '553' },
|
||||
{ label: 'Зомби', value: '767' },
|
||||
{ label: 'Игра на выживание', value: '696' },
|
||||
{ label: 'Игровая рейтинговая система', value: '300' },
|
||||
{ label: 'Из грязи в князи', value: '542' },
|
||||
{ label: 'Известные родители', value: '262' },
|
||||
{ label: 'Изменение внешнего вида', value: '48' },
|
||||
{ label: 'Изнасилование', value: '574' },
|
||||
{ label: 'Инвалидность', value: '202' },
|
||||
{ label: 'Индустриализация', value: '364' },
|
||||
{ label: 'Инженер', value: '241' },
|
||||
{ label: 'Инцест', value: '361' },
|
||||
{ label: 'Искажённая личность', value: '730' },
|
||||
{ label: 'Искусственный интеллект', value: '59' },
|
||||
{ label: 'Исполнение желаний', value: '755' },
|
||||
{ label: 'Каннибализм', value: '113' },
|
||||
{ label: 'Карточные игры', value: '114' },
|
||||
{ label: 'Кладбищенский смотритель', value: '321' },
|
||||
{ label: 'Классика', value: '134' },
|
||||
{ label: 'Книги', value: '99' },
|
||||
{ label: 'Книги навыков', value: '654' },
|
||||
{ label: 'Книжный червь', value: '100' },
|
||||
{ label: 'Коллеги', value: '140' },
|
||||
{ label: 'Колледж/Университет', value: '145' },
|
||||
{ label: 'Командная работа', value: '701' },
|
||||
{ label: 'Комедийный подтекст', value: '147' },
|
||||
{ label: 'Комплекс брата', value: '105' },
|
||||
{ label: 'Комплекс неполноценности', value: '365' },
|
||||
{ label: 'Контракты', value: '155' },
|
||||
{ label: 'Контроль разума', value: '441' },
|
||||
{ label: 'Конфликт в семье', value: '261' },
|
||||
{ label: 'Конфликт верности', value: '153' },
|
||||
{ label: 'Копейщик', value: '668' },
|
||||
{ label: 'Королевская особа', value: '596' },
|
||||
{ label: 'Королевства', value: '382' },
|
||||
{ label: 'Короткий рассказ', value: '638' },
|
||||
{ label: 'Коррупция', value: '157' },
|
||||
{ label: 'Космические Войны', value: '158' },
|
||||
{ label: 'Космос', value: '507' },
|
||||
{ label: 'Косплей', value: '159' },
|
||||
{ label: 'Кража способностей', value: '2' },
|
||||
{ label: 'Крафтинг', value: '164' },
|
||||
{ label: 'Кризис личности', value: '357' },
|
||||
{ label: 'Кровь', value: '320' },
|
||||
{ label: 'Кружки, клубы', value: '138' },
|
||||
{ label: 'Кудэрэ', value: '384' },
|
||||
{ label: 'Кузнец', value: '90' },
|
||||
{ label: 'Кукловоды', value: '567' },
|
||||
{ label: 'Куклы/Марионетки', value: '211' },
|
||||
{ label: 'Кулинария', value: '156' },
|
||||
{ label: 'Культивация', value: '171' },
|
||||
{ label: 'Куннилингус', value: '172' },
|
||||
{ label: 'Легенды', value: '391' },
|
||||
{ label: 'Легкая жизнь', value: '229' },
|
||||
{ label: 'Лекари', value: '334' },
|
||||
{ label: 'Ленивый главный герой', value: '389' },
|
||||
{ label: 'Лидерство', value: '390' },
|
||||
{ label: 'Любит персонажа младше себя', value: '765' },
|
||||
{ label: 'Любит персонажа старше себя', value: '494' },
|
||||
{ label: 'Любовное соперничество', value: '406' },
|
||||
{ label: 'Любовные треугольники', value: '407' },
|
||||
{ label: 'Любовь детства', value: '129' },
|
||||
{ label: 'Любовь с первого взгляда', value: '404' },
|
||||
{ label: 'Любящие родители', value: '215' },
|
||||
{ label: 'Маги', value: '757' },
|
||||
{ label: 'Магические технологии', value: '417' },
|
||||
{ label: 'Магия', value: '412' },
|
||||
{ label: 'Магия призыва', value: '694' },
|
||||
{ label: 'Маленькие девочки', value: '397' },
|
||||
{ label: 'Мангака', value: '423' },
|
||||
{ label: 'Маниакальная любовь', value: '492' },
|
||||
{ label: 'Марти Сью', value: '508' },
|
||||
{ label: 'Мастер подземелий', value: '222' },
|
||||
{ label: 'Мастурбация', value: '432' },
|
||||
{ label: 'Матриархат', value: '433' },
|
||||
{ label: 'Межпространственные путешествия', value: '370' },
|
||||
{ label: 'Менеджмент', value: '422' },
|
||||
{ label: 'Месть', value: '588' },
|
||||
{ label: 'Мечи и магия', value: '697' },
|
||||
{ label: 'Мечники', value: '698' },
|
||||
{ label: 'Миленькие дети', value: '176' },
|
||||
{ label: 'Милитаризм', value: '439' },
|
||||
{ label: 'Миловидная история', value: '178' },
|
||||
{ label: 'Милый главный герой', value: '177' },
|
||||
{ label: 'Минет', value: '275' },
|
||||
{ label: 'Мировое древо', value: '760' },
|
||||
{ label: 'Миролюбивый главный герой', value: '510' },
|
||||
{ label: 'Мифология', value: '475' },
|
||||
{ label: 'Младшие сёстры', value: '766' },
|
||||
{ label: 'ММОРПГ', value: '445' },
|
||||
{ label: 'Множество временных линий', value: '463' },
|
||||
{ label: 'Модели', value: '447' },
|
||||
{ label: 'Молод не по годам', value: '49' },
|
||||
{ label: 'Монстры', value: '454' },
|
||||
{ label: 'Мрачность', value: '183' },
|
||||
{ label: 'Музыка', value: '466' },
|
||||
{ label: 'Музыкальные группы', value: '68' },
|
||||
{ label: 'Мутации', value: '468' },
|
||||
{ label: 'На волосок от смерти', value: '479' },
|
||||
{ label: 'На все руки мастер', value: '374' },
|
||||
{ label: 'Навыки имеют стоимость', value: '150' },
|
||||
{ label: 'Нагота', value: '490' },
|
||||
{ label: 'Наездники на драконах', value: '216' },
|
||||
{ label: 'Наёмники', value: '437' },
|
||||
{ label: 'Наивный главный герой', value: '476' },
|
||||
{ label: 'Наследие', value: '366' },
|
||||
{ label: 'Настоящая любовь', value: '200' },
|
||||
{ label: 'Настоящий гарем', value: '540' },
|
||||
{ label: 'Небесное испытание', value: '337' },
|
||||
{ label: 'Невезучий главный герой', value: '738' },
|
||||
{ label: 'Недоверчивый протагонист', value: '206' },
|
||||
{ label: 'Недооцененный главный герой', value: '734' },
|
||||
{ label: 'Недопонимание', value: '444' },
|
||||
{ label: 'Некромант', value: '480' },
|
||||
{ label: 'Нелинейное повествование', value: '489' },
|
||||
{ label: 'Неловкий главный герой', value: '67' },
|
||||
{ label: 'Нелюбимый протагонист', value: '333' },
|
||||
{ label: 'Немой персонаж', value: '469' },
|
||||
{ label: 'Ненадежный рассказчик', value: '739' },
|
||||
{ label: 'Непримечательный герой', value: '65' },
|
||||
{ label: 'Нерешительный протагонист', value: '363' },
|
||||
{ label: 'Несгибаемый главный герой', value: '676' },
|
||||
{ label: 'Несколько главных героев', value: '460' },
|
||||
{ label: 'Несколько миров', value: '461' },
|
||||
{ label: 'Несколько перерожденцев', value: '462' },
|
||||
{ label: 'Несколько попаданцев', value: '464' },
|
||||
{ label: 'Несравненная скорость культивации', value: '269' },
|
||||
{ label: 'Нехватка/отсутствие здравого смысла', value: '385' },
|
||||
{ label: 'Нечестный протагонист', value: '205' },
|
||||
{ label: 'НИИТ', value: '481' },
|
||||
{ label: 'Ниндзя', value: '486' },
|
||||
{ label: 'Обмен телами', value: '95' },
|
||||
{ label: 'Оборотни', value: '754' },
|
||||
{ label: 'Объект любви влюбился первым', value: '405' },
|
||||
{ label: 'Объект любви популярен', value: '543' },
|
||||
{ label: 'Огнестрельное оружие', value: '280' },
|
||||
{ label: 'Ограничение на время жизни', value: '394' },
|
||||
{ label: 'Одержимость', value: '544' },
|
||||
{ label: 'Один родитель', value: '651' },
|
||||
{ label: 'Одинокий главный герой', value: '399' },
|
||||
{ label: 'Одиночество', value: '398' },
|
||||
{ label: 'Одно тело на двоих', value: '634' },
|
||||
{ label: 'Око силы', value: '253' },
|
||||
{ label: 'Оммёдзи', value: '498' },
|
||||
{ label: 'Организованная преступность', value: '500' },
|
||||
{ label: 'Оргия', value: '501' },
|
||||
{ label: 'Орки', value: '499' },
|
||||
{ label: 'Основано на аниме', value: '74' },
|
||||
{ label: 'Основано на видеоигре', value: '72' },
|
||||
{ label: 'Основано на визуальной новелле', value: '73' },
|
||||
{ label: 'Основано на песне', value: '70' },
|
||||
{ label: 'Основано на фильме', value: '69' },
|
||||
{ label: 'Осторожный главный герой', value: '117' },
|
||||
{ label: 'От ненависти до любви один шаг', value: '239' },
|
||||
{ label: 'От сильного с сильнейшему', value: '684' },
|
||||
{ label: 'От слабого к сильному', value: '752' },
|
||||
{ label: 'Отаку', value: '503' },
|
||||
{ label: 'Отзывчивая любовь', value: '380' },
|
||||
{ label: 'Отзывчивый главный герой', value: '339' },
|
||||
{ label: 'Отношение учитель-ученик', value: '430' },
|
||||
{ label: 'Отношения бог-человек', value: '315' },
|
||||
{ label: 'Отношения между учителем и учеником', value: '687' },
|
||||
{ label: 'Отношения начальник-подчинённый', value: '101' },
|
||||
{ label: 'Отношения против воли', value: '286' },
|
||||
{ label: 'Отношения с нечеловеком', value: '353' },
|
||||
{ label: 'Отношения сэмпай-кохай', value: '621' },
|
||||
{ label: 'Отношения хозяин-слуга', value: '431' },
|
||||
{ label: 'Отомэ-игры', value: '504' },
|
||||
{ label: 'ОТП', value: '562' },
|
||||
{ label: 'Отшельники', value: '505' },
|
||||
{ label: 'Офисный работник', value: '600' },
|
||||
{ label: 'Официанты', value: '748' },
|
||||
{ label: 'Охотники', value: '355' },
|
||||
{ label: 'Падшие ангелы', value: '255' },
|
||||
{ label: 'Падшие дворяне', value: '256' },
|
||||
{ label: 'Пайзури', value: '511' },
|
||||
{ label: 'Паразиты', value: '513' },
|
||||
{ label: 'Параллельные миры', value: '512' },
|
||||
{ label: 'Парк развлечений', value: '32' },
|
||||
{ label: 'Пародия', value: '515' },
|
||||
{ label: 'Перемещение между мирами', value: '758' },
|
||||
{ label: 'Переодевание', value: '167' },
|
||||
{ label: 'Переплавка пилюль', value: '529' },
|
||||
{ label: 'Перерождение в девушку', value: '420' },
|
||||
{ label: 'Перерождение в другом мире', value: '580' },
|
||||
{ label: 'Перерождение в другом мире', value: '720' },
|
||||
{ label: 'Перерождение в игровом мире', value: '579' },
|
||||
{ label: 'Перерождение в монстра', value: '577' },
|
||||
{ label: 'Персонажи с мазохистскими наклонностями', value: '429' },
|
||||
{ label: 'Персонажи с садистскими наклонностями', value: '598' },
|
||||
{ label: 'Петля времени', value: '709' },
|
||||
{ label: 'Петтинг', value: '328' },
|
||||
{ label: 'Писатели', value: '761' },
|
||||
{ label: 'Питомцы', value: '522' },
|
||||
{ label: 'Племенное общество', value: '726' },
|
||||
{ label: 'Повелитель демонов', value: '191' },
|
||||
{ label: 'Повествование от нескольких лиц', value: '459' },
|
||||
{ label: 'Поглощение навыков', value: '653' },
|
||||
{ label: 'Подземелья', value: '223' },
|
||||
{ label: 'Поздно начинающаяся романтическая линия', value: '387' },
|
||||
{ label: 'Политика', value: '538' },
|
||||
{ label: 'Полулюди', value: '190' },
|
||||
{ label: 'Помолвка', value: '240' },
|
||||
{ label: 'Попаданец в другой мир', value: '723' },
|
||||
{ label: 'Попаданец в игровой мир', value: '722' },
|
||||
{ label: 'Постапокалипсис', value: '546' },
|
||||
{ label: 'Постижение Дао', value: '181' },
|
||||
{ label: 'Потеря девственности', value: '282' },
|
||||
{ label: 'Потустороннее пространство', value: '416' },
|
||||
{ label: 'Похищения', value: '379' },
|
||||
{ label: 'Прагматичный главный герой', value: '549' },
|
||||
{ label: 'Предательство', value: '83' },
|
||||
{ label: 'Предвидение', value: '550' },
|
||||
{ label: 'Преследователи', value: '674' },
|
||||
{ label: 'Преступление', value: '165' },
|
||||
{ label: 'Преступность', value: '166' },
|
||||
{ label: 'Приёмные дети', value: '19' },
|
||||
{ label: 'Призванный герой', value: '693' },
|
||||
{ label: 'Призраки', value: '309' },
|
||||
{ label: 'Приключения', value: '22' },
|
||||
{ label: 'Принуждение', value: '108' },
|
||||
{ label: 'Приручатель', value: '453' },
|
||||
{ label: 'Прислуга', value: '625' },
|
||||
{ label: 'Приставучий любовник', value: '136' },
|
||||
{ label: 'Притворная любовь', value: '552' },
|
||||
{ label: 'Пришельцы', value: '28' },
|
||||
{ label: 'Программист', value: '558' },
|
||||
{ label: 'Произведение - обладатель наград', value: '66' },
|
||||
{ label: 'Проклятия', value: '175' },
|
||||
{ label: 'Промывка мозгов', value: '102' },
|
||||
{ label: 'Пропуск времени', value: '712' },
|
||||
{ label: 'Пророчества', value: '559' },
|
||||
{ label: 'Протагонист в очках', value: '312' },
|
||||
{ label: 'Протагонист раб', value: '657' },
|
||||
{ label: 'Психокинез', value: '565' },
|
||||
{ label: 'Психопаты', value: '566' },
|
||||
{ label: 'Путешествия', value: '759' },
|
||||
{ label: 'Путешествия во времени', value: '713' },
|
||||
{ label: 'Пытки', value: '716' },
|
||||
{ label: 'Рабы', value: '658' },
|
||||
{ label: 'Развитие отношений', value: '160' },
|
||||
{ label: 'Развитие персонажа', value: '119' },
|
||||
{ label: 'Раздвоение личности', value: '458' },
|
||||
{ label: 'Размеренная романтика', value: '661' },
|
||||
{ label: 'Разница в статусе', value: '201' },
|
||||
{ label: 'Разорванная помолвка', value: '104' },
|
||||
{ label: 'Разумные предметы', value: '622' },
|
||||
{ label: 'Рай', value: '336' },
|
||||
{ label: 'Раняя романтика', value: '227' },
|
||||
{ label: 'Расизм', value: '573' },
|
||||
{ label: 'Расследования', value: '372' },
|
||||
{ label: 'Ребенок — главный герой', value: '126' },
|
||||
{ label: 'Реверс-гарем', value: '589' },
|
||||
{ label: 'Реверс-изнасилование', value: '590' },
|
||||
{ label: 'Резкая смена характера', value: '520' },
|
||||
{ label: 'Реинкарнация', value: '581' },
|
||||
{ label: 'Религии', value: '582' },
|
||||
{ label: 'Ресторан', value: '585' },
|
||||
{ label: 'Решительный главный герой', value: '199' },
|
||||
{ label: 'Робкий протагонист', value: '714' },
|
||||
{ label: 'Родительсткий комплекс', value: '514' },
|
||||
{ label: 'Родные не связанные кровью', value: '647' },
|
||||
{ label: 'Родословные', value: '94' },
|
||||
{ label: 'РПГ-система', value: '392' },
|
||||
{ label: 'Рыцари', value: '383' },
|
||||
{ label: 'Самовлюбленный главный герой', value: '477' },
|
||||
{ label: 'Самоубийства', value: '692' },
|
||||
{ label: 'Самураи', value: '601' },
|
||||
{ label: 'Сборник коротких историй', value: '144' },
|
||||
{ label: 'Свадьба', value: '426' },
|
||||
{ label: 'Связанные сюжетные линии', value: '369' },
|
||||
{ label: 'Святые', value: '599' },
|
||||
{ label: 'Секреты', value: '614' },
|
||||
{ label: 'Секс втроём', value: '707' },
|
||||
{ label: 'Секс-рабыня', value: '629' },
|
||||
{ label: 'Семейная любовь', value: '257' },
|
||||
{ label: 'Семь добродетелей', value: '627' },
|
||||
{ label: 'Семь смертных грехов', value: '626' },
|
||||
{ label: 'Серийные убийцы', value: '624' },
|
||||
{ label: 'Сикигами', value: '637' },
|
||||
{ label: 'Сильная любовь от старших', value: '214' },
|
||||
{ label: 'Синдром восьмиклассника', value: '132' },
|
||||
{ label: 'Синдром сестры', value: '652' },
|
||||
{ label: 'Сироты', value: '502' },
|
||||
{ label: 'Системный администратор', value: '699' },
|
||||
{ label: 'Скромный главный герой', value: '409' },
|
||||
{ label: 'Скрытые романтические отношения', value: '23' },
|
||||
{ label: 'Скрытые способности', value: '343' },
|
||||
{ label: 'Скряга', value: '450' },
|
||||
{ label: 'Скульпторы', value: '606' },
|
||||
{ label: 'Слабая романтика', value: '688' },
|
||||
{ label: 'Слабый главный герой', value: '751' },
|
||||
{ label: 'Сложные семейные отношения', value: '149' },
|
||||
{ label: 'Смена расы', value: '572' },
|
||||
{ label: 'Смертельное заболевание', value: '704' },
|
||||
{ label: 'Смерть', value: '185' },
|
||||
{ label: 'Смерть любимых', value: '186' },
|
||||
{ label: 'Современность', value: '448' },
|
||||
{ label: 'Современные знания в слаборазвитых мирах', value: '449' },
|
||||
{ label: 'Сожительство', value: '141' },
|
||||
{ label: 'Создание артефактов', value: '57' },
|
||||
{ label: 'Создание клана', value: '133' },
|
||||
{ label: 'Создание королевства', value: '381' },
|
||||
{ label: 'Создание навыков', value: '655' },
|
||||
{ label: 'Сокрытие истинных способностей', value: '344' },
|
||||
{ label: 'Сокрытие личности', value: '345' },
|
||||
{ label: 'Солдаты', value: '664' },
|
||||
{ label: 'Сон', value: '659' },
|
||||
{ label: 'Соседи по комнате', value: '595' },
|
||||
{ label: 'Социальные изгои', value: '663' },
|
||||
{ label: 'Спасение мира', value: '602' },
|
||||
{ label: 'Спорящая пара', value: '84' },
|
||||
{ label: 'Способность перевоплощения', value: '719' },
|
||||
{ label: 'Средневековье', value: '436' },
|
||||
{ label: 'Стеснительные персонажи', value: '643' },
|
||||
{ label: 'Стокгольмский синдром', value: '575' },
|
||||
{ label: 'Стокгольмский синдром', value: '675' },
|
||||
{ label: 'Стратег', value: '681' },
|
||||
{ label: 'Стратегические битвы', value: '680' },
|
||||
{ label: 'Стрелки', value: '325' },
|
||||
{ label: 'Стрельба из лука', value: '50' },
|
||||
{ label: 'Студенческий совет', value: '686' },
|
||||
{ label: 'Судьба', value: '197' },
|
||||
{ label: 'Суккубы', value: '689' },
|
||||
{ label: 'Суперспособности', value: '669' },
|
||||
{ label: 'Суровая тренировка', value: '332' },
|
||||
{ label: 'Сцены насилия', value: '195' },
|
||||
{ label: 'Таинственное прошлое', value: '472' },
|
||||
{ label: 'Тайная личность', value: '610' },
|
||||
{ label: 'Тайные организации', value: '611' },
|
||||
{ label: 'Телохранители', value: '98' },
|
||||
{ label: 'Тентакли', value: '703' },
|
||||
{ label: 'Террористы', value: '705' },
|
||||
{ label: 'Технологический разрыв', value: '702' },
|
||||
{ label: 'Тихие герои', value: '568' },
|
||||
{ label: 'Толстый протагонист', value: '271' },
|
||||
{ label: 'Торговцы', value: '438' },
|
||||
{ label: 'Травник', value: '340' },
|
||||
{ label: 'Трагичное прошлое', value: '718' },
|
||||
{ label: 'Трап', value: '725' },
|
||||
{ label: 'Триллер', value: '708' },
|
||||
{ label: 'Турнир', value: '76' },
|
||||
{ label: 'Тюрьма', value: '556' },
|
||||
{ label: 'Убийства', value: '465' },
|
||||
{ label: 'Уверенный в себе главный герой', value: '151' },
|
||||
{ label: 'Умная пара', value: '662' },
|
||||
{ label: 'Умный главный герой', value: '135' },
|
||||
{ label: 'Уникальная техника культивации', value: '735' },
|
||||
{ label: 'Уникальные оружия', value: '737' },
|
||||
{ label: 'Управление бизнесом', value: '109' },
|
||||
{ label: 'Управление временем', value: '710' },
|
||||
{ label: 'Управление пространством', value: '667' },
|
||||
{ label: 'Ускоренный рост', value: '6' },
|
||||
{ label: 'Учёные', value: '605' },
|
||||
{ label: 'Учителя', value: '700' },
|
||||
{ label: 'Фамильяры', value: '258' },
|
||||
{ label: 'Фанатизм', value: '264' },
|
||||
{ label: 'Фармацевт', value: '523' },
|
||||
{ label: 'Феи', value: '254' },
|
||||
{ label: 'Фениксы', value: '526' },
|
||||
{ label: 'Фетиш на грудь', value: '103' },
|
||||
{ label: 'Фетиш на очки', value: '311' },
|
||||
{ label: 'Философия', value: '524' },
|
||||
{ label: 'Фобии/Страхи', value: '525' },
|
||||
{ label: 'Фольклор', value: '285' },
|
||||
{ label: 'Формирование армии', value: '54' },
|
||||
{ label: 'Футанари', value: '295' },
|
||||
{ label: 'Футуристический мир', value: '296' },
|
||||
{ label: 'Фэнтези', value: '267' },
|
||||
{ label: 'Фэнтезийные существа', value: '266' },
|
||||
{ label: 'Хакеры', value: '326' },
|
||||
{ label: 'Харизматичный протагонист', value: '120' },
|
||||
{ label: 'Хитрый главный герой', value: '173' },
|
||||
{ label: 'Хладнокровный главный герой', value: '112' },
|
||||
{ label: 'Художники', value: '60' },
|
||||
{ label: 'Хулиганы', value: '188' },
|
||||
{ label: 'Цундере', value: '728' },
|
||||
{ label: 'Чат', value: '122' },
|
||||
{ label: 'Человек-оружие', value: '352' },
|
||||
{ label: 'Человекоподобный главный герой', value: '354' },
|
||||
{ label: 'Честный главный герой', value: '348' },
|
||||
{ label: 'Четкие любовные убеждения', value: '683' },
|
||||
{ label: 'Читы', value: '123' },
|
||||
{ label: 'Чудаковатые герои', value: '569' },
|
||||
{ label: 'Чужие воспоминания', value: '721' },
|
||||
{ label: 'Шантаж', value: '89' },
|
||||
{ label: 'Шеф-повар', value: '124' },
|
||||
{ label: 'Школа для девочек', value: '29' },
|
||||
{ label: 'Шоу-бизнес', value: '642' },
|
||||
{ label: 'Шпионы', value: '670' },
|
||||
{ label: 'Эволюция', value: '250' },
|
||||
{ label: 'Эгоистичный главный герой', value: '618' },
|
||||
{ label: 'Экзорцизм', value: '252' },
|
||||
{ label: 'Экономика', value: '230' },
|
||||
{ label: 'Эксгибиционизм', value: '251' },
|
||||
{ label: 'Эксперименты на людях', value: '351' },
|
||||
{ label: 'Элементальная магия', value: '234' },
|
||||
{ label: 'Элементы игрового мира', value: '299' },
|
||||
{ label: 'Элементы романтики', value: '594' },
|
||||
{ label: 'Элементы юри', value: '640' },
|
||||
{ label: 'Элементы яоя', value: '641' },
|
||||
{ label: 'Эльфы', value: '235' },
|
||||
{ label: 'Эпизодичность', value: '243' },
|
||||
{ label: 'Язвительные персонажи', value: '635' },
|
||||
{ label: 'Языковой барьер', value: '386' },
|
||||
{ label: 'Яндере', value: '762' },
|
||||
{ label: 'Яойщица', value: '294' },
|
||||
{ label: 'Японские силы самообороны', value: '378' },
|
||||
{ label: 'R-15', value: '570' },
|
||||
{ label: 'R-18', value: '571' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genres: {
|
||||
label: 'Жанры',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: '16+', value: '1' },
|
||||
{ label: '18+', value: '2' },
|
||||
{ label: 'Боевик', value: '3' },
|
||||
{ label: 'Боевые искусства', value: '4' },
|
||||
{ label: 'Вампиры', value: '47' },
|
||||
{ label: 'Военное', value: '44' },
|
||||
{ label: 'Гарем', value: '5' },
|
||||
{ label: 'Демоны', value: '48' },
|
||||
{ label: 'Детектив', value: '6' },
|
||||
{ label: 'Дзёсей', value: '50' },
|
||||
{ label: 'Драма', value: '7' },
|
||||
{ label: 'Игры', value: '45' },
|
||||
{ label: 'Историческое', value: '8' },
|
||||
{ label: 'Киберпанк', value: '9' },
|
||||
{ label: 'Комедия', value: '10' },
|
||||
{ label: 'Космос', value: '46' },
|
||||
{ label: 'Магия', value: '42' },
|
||||
{ label: 'Махо-сёдзё', value: '12' },
|
||||
{ label: 'Меха', value: '13' },
|
||||
{ label: 'Мистика', value: '14' },
|
||||
{ label: 'Музыка', value: '49' },
|
||||
{ label: 'Научная фантастика', value: '15' },
|
||||
{ label: 'Пародия', value: '16' },
|
||||
{ label: 'Повседневность', value: '17' },
|
||||
{ label: 'Приключения', value: '18' },
|
||||
{ label: 'Психологическое', value: '19' },
|
||||
{ label: 'Романтика', value: '20' },
|
||||
{ label: 'Сверхъестественное', value: '21' },
|
||||
{ label: 'Сёдзё', value: '22' },
|
||||
{ label: 'Сёдзё-ай', value: '23' },
|
||||
{ label: 'Сёнэн', value: '24' },
|
||||
{ label: 'Сёнэн-ай', value: '25' },
|
||||
{ label: 'Смена пола', value: '26' },
|
||||
{ label: 'Современность', value: '27' },
|
||||
{ label: 'Спорт', value: '28' },
|
||||
{ label: 'Супер сила', value: '43' },
|
||||
{ label: 'Сэйнэн', value: '29' },
|
||||
{ label: 'Трагедия', value: '32' },
|
||||
{ label: 'Триллер', value: '33' },
|
||||
{ label: 'Ужасы', value: '34' },
|
||||
{ label: 'Уся', value: '35' },
|
||||
{ label: 'Фэнтези', value: '36' },
|
||||
{ label: 'Школьная жизнь', value: '37' },
|
||||
{ label: 'Этти', value: '38' },
|
||||
{ label: 'Юри', value: '39' },
|
||||
{ label: 'Яой', value: '40' },
|
||||
{ label: 'LitRPG', value: '41' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new TL();
|
||||
|
||||
interface response {
|
||||
data: Data;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
projects?: Projects;
|
||||
project?: Project;
|
||||
chapter?: DataChapter;
|
||||
}
|
||||
|
||||
interface DataChapter {
|
||||
text: Text;
|
||||
}
|
||||
|
||||
interface Text {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
title: string;
|
||||
translationStatus: string;
|
||||
fullUrl: string;
|
||||
covers: Cover[];
|
||||
persons: Person[];
|
||||
genres: Genre[];
|
||||
tags: Genre[];
|
||||
annotation: Text;
|
||||
subprojects: Subprojects;
|
||||
}
|
||||
|
||||
interface Cover {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
nameRu: string;
|
||||
nameEng: string;
|
||||
}
|
||||
|
||||
interface Person {
|
||||
role: string;
|
||||
name: Name;
|
||||
}
|
||||
|
||||
interface Name {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
interface Subprojects {
|
||||
content: SubprojectsContent[];
|
||||
}
|
||||
|
||||
interface SubprojectsContent {
|
||||
title: null | string;
|
||||
volumes: Volumes;
|
||||
}
|
||||
|
||||
interface Volumes {
|
||||
content: VolumesContent[];
|
||||
}
|
||||
|
||||
interface VolumesContent {
|
||||
shortName: null | string;
|
||||
chapters: ChapterElement[];
|
||||
}
|
||||
|
||||
interface ChapterElement {
|
||||
title: null | string;
|
||||
publishDate: Date;
|
||||
fullUrl: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
interface Projects {
|
||||
content: ProjectsContent[];
|
||||
}
|
||||
|
||||
interface ProjectsContent {
|
||||
title: string;
|
||||
fullUrl: string;
|
||||
covers: Cover[];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,726 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { storage, localStorage } from '@libs/storage';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const statusKey: { [key: number]: string } = {
|
||||
1: NovelStatus.Ongoing,
|
||||
2: NovelStatus.Completed,
|
||||
3: NovelStatus.OnHiatus,
|
||||
4: NovelStatus.Cancelled,
|
||||
};
|
||||
|
||||
class RLIB implements Plugin.PluginBase {
|
||||
id = 'RLIB';
|
||||
name = 'RanobeLib';
|
||||
site = 'https://ranobelib.me';
|
||||
apiSite = 'https://api.lib.social/api/manga/';
|
||||
version = '2.0.1';
|
||||
icon = 'src/ru/ranobelib/icon.png';
|
||||
webStorageUtilized = true;
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.apiSite + '?site_id[0]=3&page=' + pageNo;
|
||||
url +=
|
||||
'&sort_by=' +
|
||||
(showLatestNovels
|
||||
? 'last_chapter_at'
|
||||
: filters?.sort_by?.value || 'rating_score');
|
||||
url += '&sort_type=' + (filters?.sort_type?.value || 'desc');
|
||||
|
||||
if (filters?.require_chapters?.value) {
|
||||
url += '&chapters[min]=1';
|
||||
}
|
||||
if (filters?.types?.value?.length) {
|
||||
url += '&types[]=' + filters.types.value.join('&types[]=');
|
||||
}
|
||||
if (filters?.scanlateStatus?.value?.length) {
|
||||
url +=
|
||||
'&scanlateStatus[]=' +
|
||||
filters.scanlateStatus.value.join('&scanlateStatus[]=');
|
||||
}
|
||||
if (filters?.manga_status?.value?.length) {
|
||||
url +=
|
||||
'&manga_status[]=' +
|
||||
filters.manga_status.value.join('&manga_status[]=');
|
||||
}
|
||||
|
||||
if (filters?.genres) {
|
||||
if (filters.genres.value?.include?.length) {
|
||||
url += '&genres[]=' + filters.genres.value.include.join('&genres[]=');
|
||||
}
|
||||
if (filters.genres.value?.exclude?.length) {
|
||||
url +=
|
||||
'&genres_exclude[]=' +
|
||||
filters.genres.value.exclude.join('&genres_exclude[]=');
|
||||
}
|
||||
}
|
||||
if (filters?.tags) {
|
||||
if (filters.tags.value?.include?.length) {
|
||||
url += '&tags[]=' + filters.tags.value.include.join('&tags[]=');
|
||||
}
|
||||
if (filters.tags.value?.exclude?.length) {
|
||||
url +=
|
||||
'&tags_exclude[]=' +
|
||||
filters.tags.value.exclude.join('&tags_exclude[]=');
|
||||
}
|
||||
}
|
||||
|
||||
const result: TopLevel = await fetchApi(url, {
|
||||
headers: this.user?.token,
|
||||
}).then(res => res.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
if (result.data instanceof Array) {
|
||||
result.data.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.rus_name || novel.eng_name || novel.name,
|
||||
cover: novel.cover?.default || defaultCover,
|
||||
path: novel.slug_url || novel.id + '--' + novel.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const { data }: { data: DataClass } = await fetchApi(
|
||||
this.apiSite +
|
||||
novelPath +
|
||||
'?fields[]=summary&fields[]=genres&fields[]=tags&fields[]=teams&fields[]=authors&fields[]=status_id&fields[]=artists',
|
||||
{ headers: this.user?.token },
|
||||
).then(res => res.json());
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: data.rus_name || data.name,
|
||||
cover: data.cover?.default || defaultCover,
|
||||
summary: data.summary,
|
||||
};
|
||||
|
||||
if (data.status?.id) {
|
||||
novel.status = statusKey[data.status.id] || NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
if (data.authors?.length) {
|
||||
novel.author = data.authors[0].name;
|
||||
}
|
||||
if (data.artists?.length) {
|
||||
novel.artist = data.artists[0].name;
|
||||
}
|
||||
|
||||
const genres = [data.genres || [], data.tags || []]
|
||||
.flat()
|
||||
.map(genres => genres?.name)
|
||||
.filter(genres => genres);
|
||||
if (genres.length) {
|
||||
novel.genres = genres.join(', ');
|
||||
}
|
||||
|
||||
const branch_id: { [key: number]: string } = {};
|
||||
if (data.teams.length) {
|
||||
data.teams.forEach(
|
||||
({ name, details }) => (branch_id[details?.branch_id || '0'] = name),
|
||||
);
|
||||
}
|
||||
|
||||
const chaptersJSON: { data: DataChapter[] } = await fetchApi(
|
||||
this.apiSite + novelPath + '/chapters',
|
||||
{
|
||||
headers: this.user?.token,
|
||||
},
|
||||
).then(res => res.json());
|
||||
|
||||
if (chaptersJSON.data.length) {
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
chaptersJSON.data.forEach(chapter =>
|
||||
chapters.push({
|
||||
name:
|
||||
'Том ' +
|
||||
chapter.volume +
|
||||
' Глава ' +
|
||||
chapter.number +
|
||||
(chapter.name ? ' ' + chapter.name : ''),
|
||||
path:
|
||||
novelPath +
|
||||
'/' +
|
||||
chapter.volume +
|
||||
'/' +
|
||||
chapter.number +
|
||||
'/' +
|
||||
(chapter.branches[0]?.branch_id || ''),
|
||||
releaseTime: dayjs(chapter.branches[0].created_at).format('LLL'),
|
||||
chapterNumber: chapter.index,
|
||||
page: branch_id[chapter.branches[0].branch_id || '0'],
|
||||
}),
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
}
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const [slug, volume, number, branch_id] = chapterPath.split('/');
|
||||
let chapterText = '';
|
||||
|
||||
if (slug && volume && number) {
|
||||
const result: { data: DataClass } = await fetchApi(
|
||||
this.apiSite +
|
||||
slug +
|
||||
'/chapter?' +
|
||||
(branch_id ? 'branch_id=' + branch_id + '&' : '') +
|
||||
'number=' +
|
||||
number +
|
||||
'&volume=' +
|
||||
volume,
|
||||
{ headers: this.user?.token },
|
||||
).then(res => res.json());
|
||||
chapterText =
|
||||
result?.data?.content?.type == 'doc'
|
||||
? jsonToHtml(result.data.content.content)
|
||||
: result?.data?.content;
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = this.apiSite + '?site_id[0]=3&q=' + searchTerm;
|
||||
const result: TopLevel = await fetchApi(url, {
|
||||
headers: this.user?.token,
|
||||
}).then(res => res.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
if (result.data instanceof Array) {
|
||||
result.data.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.rus_name || novel.eng_name || novel.name,
|
||||
cover: novel.cover?.default || defaultCover,
|
||||
path: novel.slug_url || novel.id + '--' + novel.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) => {
|
||||
const ui = this.user?.ui ? 'ui=' + this.user.ui : '';
|
||||
|
||||
if (isNovel) return this.site + '/ru/book/' + path + (ui ? '?' + ui : '');
|
||||
|
||||
const [slug, volume, number, branch_id] = path.split('/');
|
||||
const chapterPath =
|
||||
slug +
|
||||
'/read/v' +
|
||||
volume +
|
||||
'/c' +
|
||||
number +
|
||||
(branch_id ? '?bid=' + branch_id : '');
|
||||
|
||||
return (
|
||||
this.site +
|
||||
'/ru/' +
|
||||
chapterPath +
|
||||
(ui ? (branch_id ? '&' : '?') + ui : '')
|
||||
);
|
||||
};
|
||||
|
||||
getUser = () => {
|
||||
const user = storage.get('user');
|
||||
if (user) {
|
||||
return { token: { Authorization: 'Bearer ' + user.token }, ui: user.id };
|
||||
}
|
||||
const dataRaw = localStorage.get()?.auth;
|
||||
if (!dataRaw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = JSON.parse(dataRaw) as authorization;
|
||||
if (!data?.token?.access_token) return;
|
||||
storage.set(
|
||||
'user',
|
||||
{
|
||||
id: data.auth.id,
|
||||
token: data.token.access_token,
|
||||
},
|
||||
data.token.timestamp + data.token.expires_in, //the token is valid for about 7 days
|
||||
);
|
||||
return {
|
||||
token: { Authorization: 'Bearer ' + data.token.access_token },
|
||||
ui: data.auth.id,
|
||||
};
|
||||
};
|
||||
user = this.getUser(); //To change the account, you need to restart the application
|
||||
|
||||
filters = {
|
||||
sort_by: {
|
||||
label: 'Сортировка',
|
||||
value: 'rating_score',
|
||||
options: [
|
||||
{ label: 'По рейтингу', value: 'rate_avg' },
|
||||
{ label: 'По популярности', value: 'rating_score' },
|
||||
{ label: 'По просмотрам', value: 'views' },
|
||||
{ label: 'Количеству глав', value: 'chap_count' },
|
||||
{ label: 'Дате обновления', value: 'last_chapter_at' },
|
||||
{ label: 'Дате добавления', value: 'created_at' },
|
||||
{ label: 'По названию (A-Z)', value: 'name' },
|
||||
{ label: 'По названию (А-Я)', value: 'rus_name' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort_type: {
|
||||
label: 'Порядок',
|
||||
value: 'desc',
|
||||
options: [
|
||||
{ label: 'По убыванию', value: 'desc' },
|
||||
{ label: 'По возрастанию', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
types: {
|
||||
label: 'Тип',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Япония', value: '10' },
|
||||
{ label: 'Корея', value: '11' },
|
||||
{ label: 'Китай', value: '12' },
|
||||
{ label: 'Английский', value: '13' },
|
||||
{ label: 'Авторский', value: '14' },
|
||||
{ label: 'Фанфик', value: '15' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
scanlateStatus: {
|
||||
label: 'Статус перевода',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Продолжается', value: '1' },
|
||||
{ label: 'Завершен', value: '2' },
|
||||
{ label: 'Заморожен', value: '3' },
|
||||
{ label: 'Заброшен', value: '4' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
manga_status: {
|
||||
label: 'Статус тайтла',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Онгоинг', value: '1' },
|
||||
{ label: 'Завершён', value: '2' },
|
||||
{ label: 'Анонс', value: '3' },
|
||||
{ label: 'Приостановлен', value: '4' },
|
||||
{ label: 'Выпуск прекращён', value: '5' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genres: {
|
||||
label: 'Жанры',
|
||||
value: { include: [], exclude: [] },
|
||||
options: [
|
||||
{ label: 'Арт', value: '32' },
|
||||
{ label: 'Безумие', value: '91' },
|
||||
{ label: 'Боевик', value: '34' },
|
||||
{ label: 'Боевые искусства', value: '35' },
|
||||
{ label: 'Вампиры', value: '36' },
|
||||
{ label: 'Военное', value: '89' },
|
||||
{ label: 'Гарем', value: '37' },
|
||||
{ label: 'Гендерная интрига', value: '38' },
|
||||
{ label: 'Героическое фэнтези', value: '39' },
|
||||
{ label: 'Демоны', value: '81' },
|
||||
{ label: 'Детектив', value: '40' },
|
||||
{ label: 'Детское', value: '88' },
|
||||
{ label: 'Дзёсэй', value: '41' },
|
||||
{ label: 'Драма', value: '43' },
|
||||
{ label: 'Игра', value: '44' },
|
||||
{ label: 'Исекай', value: '79' },
|
||||
{ label: 'История', value: '45' },
|
||||
{ label: 'Киберпанк', value: '46' },
|
||||
{ label: 'Кодомо', value: '76' },
|
||||
{ label: 'Комедия', value: '47' },
|
||||
{ label: 'Космос', value: '83' },
|
||||
{ label: 'Магия', value: '85' },
|
||||
{ label: 'Махо-сёдзё', value: '48' },
|
||||
{ label: 'Машины', value: '90' },
|
||||
{ label: 'Меха', value: '49' },
|
||||
{ label: 'Мистика', value: '50' },
|
||||
{ label: 'Музыка', value: '80' },
|
||||
{ label: 'Научная фантастика', value: '51' },
|
||||
{ label: 'Омегаверс', value: '77' },
|
||||
{ label: 'Пародия', value: '86' },
|
||||
{ label: 'Повседневность', value: '52' },
|
||||
{ label: 'Полиция', value: '82' },
|
||||
{ label: 'Постапокалиптика', value: '53' },
|
||||
{ label: 'Приключения', value: '54' },
|
||||
{ label: 'Психология', value: '55' },
|
||||
{ label: 'Романтика', value: '56' },
|
||||
{ label: 'Самурайский боевик', value: '57' },
|
||||
{ label: 'Сверхъестественное', value: '58' },
|
||||
{ label: 'Сёдзё', value: '59' },
|
||||
{ label: 'Сёдзё-ай', value: '60' },
|
||||
{ label: 'Сёнэн', value: '61' },
|
||||
{ label: 'Сёнэн-ай', value: '62' },
|
||||
{ label: 'Спорт', value: '63' },
|
||||
{ label: 'Супер сила', value: '87' },
|
||||
{ label: 'Сэйнэн', value: '64' },
|
||||
{ label: 'Трагедия', value: '65' },
|
||||
{ label: 'Триллер', value: '66' },
|
||||
{ label: 'Ужасы', value: '67' },
|
||||
{ label: 'Фантастика', value: '68' },
|
||||
{ label: 'Фэнтези', value: '69' },
|
||||
{ label: 'Хентай', value: '84' },
|
||||
{ label: 'Школа', value: '70' },
|
||||
{ label: 'Эротика', value: '71' },
|
||||
{ label: 'Этти', value: '72' },
|
||||
{ label: 'Юри', value: '73' },
|
||||
{ label: 'Яой', value: '74' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
tags: {
|
||||
label: 'Теги',
|
||||
value: { include: [], exclude: [] },
|
||||
options: [
|
||||
{ label: 'Авантюристы', value: '328' },
|
||||
{ label: 'Антигерой', value: '175' },
|
||||
{ label: 'Бессмертные', value: '333' },
|
||||
{ label: 'Боги', value: '218' },
|
||||
{ label: 'Борьба за власть', value: '309' },
|
||||
{ label: 'Брат и сестра', value: '360' },
|
||||
{ label: 'Ведьма', value: '339' },
|
||||
{ label: 'Видеоигры', value: '204' },
|
||||
{ label: 'Виртуальная реальность', value: '214' },
|
||||
{ label: 'Владыка демонов', value: '349' },
|
||||
{ label: 'Военные', value: '198' },
|
||||
{ label: 'Воспоминания из другого мира', value: '310' },
|
||||
{ label: 'Выживание', value: '212' },
|
||||
{ label: 'ГГ женщина', value: '294' },
|
||||
{ label: 'ГГ имба', value: '292' },
|
||||
{ label: 'ГГ мужчина', value: '295' },
|
||||
{ label: 'ГГ не ояш', value: '325' },
|
||||
{ label: 'ГГ не человек', value: '331' },
|
||||
{ label: 'ГГ ояш', value: '326' },
|
||||
{ label: 'Главный герой бог', value: '324' },
|
||||
{ label: 'Глупый ГГ', value: '298' },
|
||||
{ label: 'Горничные', value: '171' },
|
||||
{ label: 'Гуро', value: '306' },
|
||||
{ label: 'Гяру', value: '197' },
|
||||
{ label: 'Демоны', value: '157' },
|
||||
{ label: 'Драконы', value: '313' },
|
||||
{ label: 'Древний мир', value: '317' },
|
||||
{ label: 'Зверолюди', value: '163' },
|
||||
{ label: 'Зомби', value: '155' },
|
||||
{ label: 'Исторические фигуры', value: '323' },
|
||||
{ label: 'Кулинария', value: '158' },
|
||||
{ label: 'Культивация', value: '161' },
|
||||
{ label: 'ЛГБТ', value: '344' },
|
||||
{ label: 'ЛитРПГ', value: '319' },
|
||||
{ label: 'Лоли', value: '206' },
|
||||
{ label: 'Магия', value: '170' },
|
||||
{ label: 'Машинный перевод', value: '345' },
|
||||
{ label: 'Медицина', value: '159' },
|
||||
{ label: 'Межгалактическая война', value: '330' },
|
||||
{ label: 'Монстр Девушки', value: '207' },
|
||||
{ label: 'Монстры', value: '208' },
|
||||
{ label: 'Мрачный мир', value: '316' },
|
||||
{ label: 'Музыка', value: '358' },
|
||||
{ label: 'Музыка', value: '209' },
|
||||
{ label: 'Ниндзя', value: '199' },
|
||||
{ label: 'Обратный Гарем', value: '210' },
|
||||
{ label: 'Офисные Работники', value: '200' },
|
||||
{ label: 'Пираты', value: '341' },
|
||||
{ label: 'Подземелья', value: '314' },
|
||||
{ label: 'Политика', value: '311' },
|
||||
{ label: 'Полиция', value: '201' },
|
||||
{ label: 'Преступники / Криминал', value: '205' },
|
||||
{ label: 'Призраки / Духи', value: '196' },
|
||||
{ label: 'Призыватели', value: '329' },
|
||||
{ label: 'Прыжки между мирами', value: '321' },
|
||||
{ label: 'Путешествие в другой мир', value: '318' },
|
||||
{ label: 'Путешествие во времени', value: '213' },
|
||||
{ label: 'Рабы', value: '355' },
|
||||
{ label: 'Ранги силы', value: '312' },
|
||||
{ label: 'Реинкарнация', value: '154' },
|
||||
{ label: 'Самураи', value: '202' },
|
||||
{ label: 'Скрытие личности', value: '315' },
|
||||
{ label: 'Средневековье', value: '174' },
|
||||
{ label: 'Традиционные игры', value: '203' },
|
||||
{ label: 'Умный ГГ', value: '303' },
|
||||
{ label: 'Характерный рост', value: '332' },
|
||||
{ label: 'Хикикомори', value: '167' },
|
||||
{ label: 'Эволюция', value: '322' },
|
||||
{ label: 'Элементы РПГ', value: '327' },
|
||||
{ label: 'Эльфы', value: '217' },
|
||||
{ label: 'Якудза', value: '165' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
require_chapters: {
|
||||
label: 'Только проекты с главами',
|
||||
value: true,
|
||||
type: FilterTypes.Switch,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new RLIB();
|
||||
|
||||
function jsonToHtml(json: HTML[], html: string = '') {
|
||||
json.forEach(element => {
|
||||
switch (element.type) {
|
||||
case 'hardBreak':
|
||||
html += '<br>';
|
||||
break;
|
||||
case 'horizontalRule':
|
||||
html += '<hr>';
|
||||
break;
|
||||
case 'image':
|
||||
if (element.attrs) {
|
||||
const attrs = Object.entries(element.attrs)
|
||||
.filter(attr => attr?.[1])
|
||||
.map(attr => `${attr[0]}="${attr[1]}"`);
|
||||
html += '<img ' + attrs.join('; ') + '>';
|
||||
}
|
||||
break;
|
||||
case 'paragraph':
|
||||
html +=
|
||||
'<p>' +
|
||||
(element.content ? jsonToHtml(element.content) : '<br>') +
|
||||
'</p>';
|
||||
break;
|
||||
case 'heading':
|
||||
html +=
|
||||
'<h2>' +
|
||||
(element.content ? jsonToHtml(element.content) : '<br>') +
|
||||
'</h2>';
|
||||
break;
|
||||
case 'text':
|
||||
html += element.text;
|
||||
break;
|
||||
default:
|
||||
html += JSON.stringify(element, null, '\t'); //maybe I missed something.
|
||||
break;
|
||||
}
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
interface HTML {
|
||||
type: string;
|
||||
content?: HTML[];
|
||||
attrs?: Attrs;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface Attrs {
|
||||
src: string;
|
||||
alt: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
interface authorization {
|
||||
token: Token;
|
||||
auth: Auth;
|
||||
timestamp: number;
|
||||
}
|
||||
interface Token {
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
timestamp: number;
|
||||
}
|
||||
interface Auth {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar: Cover;
|
||||
last_online_at: string;
|
||||
metadata: Metadata;
|
||||
}
|
||||
interface Metadata {
|
||||
auth_domains: string;
|
||||
}
|
||||
|
||||
interface TopLevel {
|
||||
data: DataClass | DataClass[];
|
||||
links?: Links;
|
||||
meta?: Meta;
|
||||
}
|
||||
|
||||
interface AgeRestriction {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Branch {
|
||||
id: number;
|
||||
branch_id: null;
|
||||
created_at: string;
|
||||
teams: BranchTeam[];
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface BranchTeam {
|
||||
id: number;
|
||||
slug: string;
|
||||
slug_url: string;
|
||||
model: string;
|
||||
name: string;
|
||||
cover: Cover;
|
||||
}
|
||||
|
||||
interface Cover {
|
||||
filename: null | string;
|
||||
thumbnail: string;
|
||||
default: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface Rating {
|
||||
average: string;
|
||||
votes: number;
|
||||
votesFormated: string;
|
||||
}
|
||||
|
||||
interface DataClass {
|
||||
id: number;
|
||||
name: string;
|
||||
rus_name?: string;
|
||||
eng_name?: string;
|
||||
slug: string;
|
||||
slug_url?: string;
|
||||
cover?: Cover;
|
||||
ageRestriction?: AgeRestriction;
|
||||
site?: number;
|
||||
type: string;
|
||||
summary?: string;
|
||||
is_licensed?: boolean;
|
||||
teams: DataTeam[];
|
||||
genres?: Genre[];
|
||||
tags?: Genre[];
|
||||
authors?: Artist[];
|
||||
model?: string;
|
||||
status?: AgeRestriction;
|
||||
scanlateStatus?: AgeRestriction;
|
||||
artists?: Artist[];
|
||||
releaseDateString?: string;
|
||||
volume?: string;
|
||||
number?: string;
|
||||
number_secondary?: string;
|
||||
branch_id?: null;
|
||||
manga_id?: number;
|
||||
created_at?: string;
|
||||
moderated?: AgeRestriction;
|
||||
likes_count?: number;
|
||||
content?: any;
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
id: number;
|
||||
slug: string;
|
||||
slug_url: string;
|
||||
model: string;
|
||||
name: string;
|
||||
rus_name: null;
|
||||
alt_name: null;
|
||||
cover: Cover;
|
||||
subscription: Subscription;
|
||||
confirmed: null;
|
||||
user_id: number;
|
||||
titles_count_details: null;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
is_subscribed: boolean;
|
||||
source_type: string;
|
||||
source_id: number;
|
||||
relation: null;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
id: null;
|
||||
filename: string;
|
||||
name: string;
|
||||
extension: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DataTeam {
|
||||
id: number;
|
||||
slug: string;
|
||||
slug_url: string;
|
||||
model: string;
|
||||
name: string;
|
||||
cover: Cover;
|
||||
details?: Details;
|
||||
vk?: string;
|
||||
discord?: null;
|
||||
}
|
||||
|
||||
interface Details {
|
||||
branch_id: null;
|
||||
is_active: boolean;
|
||||
subscriptions_count: null;
|
||||
}
|
||||
|
||||
interface Links {
|
||||
first: string;
|
||||
last: null;
|
||||
prev: null;
|
||||
next: string;
|
||||
}
|
||||
|
||||
interface Meta {
|
||||
current_page?: number;
|
||||
from?: number;
|
||||
path?: string;
|
||||
per_page?: number;
|
||||
to?: number;
|
||||
page?: number;
|
||||
has_next_page?: boolean;
|
||||
seed?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
interface DataChapter {
|
||||
id: number;
|
||||
index: number;
|
||||
item_number: number;
|
||||
volume: string;
|
||||
number: string;
|
||||
number_secondary: string;
|
||||
name: string;
|
||||
branches_count: number;
|
||||
branches: Branch[];
|
||||
}
|
||||
interface BranchesEntity {
|
||||
id: number;
|
||||
branch_id?: number;
|
||||
created_at: string;
|
||||
teams?: BranchTeam[];
|
||||
user: User;
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const regex =
|
||||
/<script id="__NEXT_DATA__" type="application\/json">(\{.*?\})<\/script>/;
|
||||
|
||||
class RNRF implements Plugin.PluginBase {
|
||||
id = 'RNRF';
|
||||
name = 'РанобэРФ';
|
||||
site = 'https://ранобэ.рф';
|
||||
version = '1.0.1';
|
||||
icon = 'src/ru/ranoberf/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + '/books?order=';
|
||||
url += showLatestNovels
|
||||
? 'lastPublishedChapter'
|
||||
: filters?.sort?.value || 'popular';
|
||||
url += '&page=' + pageNo;
|
||||
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const jsonRaw = body.match(regex);
|
||||
if (jsonRaw instanceof Array && jsonRaw[1]) {
|
||||
const json: response = JSON.parse(jsonRaw[1]);
|
||||
|
||||
json.props.pageProps?.totalData?.items?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel?.verticalImage?.url
|
||||
? this.site + novel.verticalImage.url
|
||||
: defaultCover,
|
||||
path: '/' + novel.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(res => res.text());
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
};
|
||||
|
||||
const jsonRaw = body.match(regex);
|
||||
if (jsonRaw instanceof Array && jsonRaw[1]) {
|
||||
const book: PagePropsBook = JSON.parse(jsonRaw[1]).props.pageProps.book;
|
||||
|
||||
novel.name = book.title;
|
||||
novel.summary = book.description;
|
||||
|
||||
novel.cover = book.verticalImage?.url
|
||||
? this.site + book.verticalImage.url
|
||||
: defaultCover;
|
||||
novel.status = book?.additionalInfo.includes('Активен')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed;
|
||||
|
||||
if (book.author) novel.author = book.author;
|
||||
if (book.genres?.length)
|
||||
novel.genres = book?.genres.map(item => item.title).join(',');
|
||||
|
||||
if (book.chapters?.length) {
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
book.chapters.forEach((chapter, chapterIndex) => {
|
||||
if (!chapter.isDonate || chapter.isUserPaid) {
|
||||
chapters.push({
|
||||
name: chapter.title,
|
||||
path: chapter.url,
|
||||
releaseTime: dayjs(chapter.publishedAt).format('LLL'),
|
||||
chapterNumber: book.chapters.length - chapterIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters.reverse();
|
||||
}
|
||||
}
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const jsonRaw = body.match(regex);
|
||||
if (jsonRaw instanceof Array && jsonRaw[1]) {
|
||||
let chapter: string =
|
||||
JSON.parse(jsonRaw[1])?.props?.pageProps?.chapter?.content?.text || '';
|
||||
|
||||
if (chapter.includes('<img')) {
|
||||
return chapter.replace(/src="(.*?)"/g, (match, url) => {
|
||||
if (!url.startsWith('http')) {
|
||||
return `src="${this.site}${url}"`;
|
||||
}
|
||||
return `src="${url}"`;
|
||||
});
|
||||
}
|
||||
return chapter;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}/v3/books?filter[or][0][title][like]=${searchTerm}&filter[or][1][titleEn][like]=${searchTerm}&filter[or][2][fullTitle][like]=${searchTerm}&filter[status][]=active&filter[status][]=abandoned&filter[status][]=completed&expand=verticalImage`;
|
||||
const { items }: { items: Item[] } = await fetchApi(url).then(res =>
|
||||
res.json(),
|
||||
);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
items.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel?.verticalImage?.url
|
||||
? this.site + novel.verticalImage.url
|
||||
: defaultCover,
|
||||
path: '/' + novel.slug,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: 'popular',
|
||||
options: [
|
||||
{ label: 'Рейтинг', value: 'popular' },
|
||||
{ label: 'Дате добавления', value: 'new' },
|
||||
{ label: 'Дате обновления', value: 'lastPublishedChapter' },
|
||||
{ label: 'Законченные', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new RNRF();
|
||||
|
||||
interface response {
|
||||
props: Props;
|
||||
page: string;
|
||||
query: Query;
|
||||
buildId: string;
|
||||
isFallback: boolean;
|
||||
gssp: boolean;
|
||||
appGip: boolean;
|
||||
dynamicIds?: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pageProps: PageProps;
|
||||
isMobile: boolean;
|
||||
theme: string;
|
||||
__N_SSP: boolean;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
initialReduxState: InitialReduxState;
|
||||
book?: PagePropsBook;
|
||||
totalData?: TotalData;
|
||||
layoutViewCookies?: string;
|
||||
displayBanner?: boolean;
|
||||
chapter?: PagePropsChapter;
|
||||
}
|
||||
|
||||
interface PagePropsBook {
|
||||
id: number;
|
||||
countryId: number;
|
||||
creatorId: number;
|
||||
lastChapterId: number;
|
||||
sourceId: number;
|
||||
imgVerticalId: number;
|
||||
imgHorizontalId: number;
|
||||
imgSquareId: number;
|
||||
imageVerticalId: number;
|
||||
imageHorizontalId: number;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
fullTitle: string;
|
||||
fullTitleEn: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
author: string;
|
||||
additionalInfo: string;
|
||||
importantInfo: string;
|
||||
status: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
intervalSec: number;
|
||||
intervalForNextFreeChapterSec: number;
|
||||
chapterCost: number;
|
||||
chapterPrice: number;
|
||||
chapterPriceCollected: number;
|
||||
maxSubscriptionChapters: number;
|
||||
maxDonateChapters: number;
|
||||
isDisplayOnMainPage: boolean;
|
||||
withoutSchedule: boolean;
|
||||
nextFreeChapterPublishedAt: null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userRating: null;
|
||||
userBookmark: null;
|
||||
country: Country;
|
||||
genres: Genre[];
|
||||
timer: Timer;
|
||||
chapters: ChapterElement[];
|
||||
verticalImage: Image;
|
||||
horizontalImage: Image;
|
||||
squareImage: Image;
|
||||
}
|
||||
|
||||
interface ChapterElement {
|
||||
id: number;
|
||||
bookId: number;
|
||||
title: string;
|
||||
url: string;
|
||||
numberChapter: string;
|
||||
tom: number | null;
|
||||
chapterShortNumber: number | null;
|
||||
isDonate: boolean;
|
||||
isSponsored: boolean;
|
||||
isSubscription: boolean;
|
||||
isEdited: boolean;
|
||||
publishedAt: Date;
|
||||
price: number;
|
||||
views: number;
|
||||
isUserPaid?: boolean;
|
||||
}
|
||||
|
||||
interface Country {
|
||||
id: number;
|
||||
title: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface Image {
|
||||
id: number;
|
||||
alt: string;
|
||||
path: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Timer {
|
||||
value: null;
|
||||
for: null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface PagePropsChapter {
|
||||
id: number;
|
||||
bookId: number;
|
||||
editorId: null;
|
||||
translatorId: number;
|
||||
parserChapterId: null;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
tom: null;
|
||||
views: number;
|
||||
isSponsored: boolean;
|
||||
isSubscription: boolean;
|
||||
isDonate: boolean;
|
||||
isIndexing: boolean;
|
||||
publishedAt: Date;
|
||||
changeAvailabilityAt: null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
numberChapter: string;
|
||||
price: number;
|
||||
content: Content;
|
||||
book: ChapterBook;
|
||||
userBookmark: null;
|
||||
isUserPaid: boolean;
|
||||
nextChapter: null;
|
||||
previousChapter: PreviousChapter;
|
||||
corrections: null;
|
||||
}
|
||||
|
||||
interface ChapterBook {
|
||||
id: number;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Content {
|
||||
id: number;
|
||||
text: string;
|
||||
symbolsCount: number;
|
||||
}
|
||||
|
||||
interface PreviousChapter {
|
||||
id: number;
|
||||
title: string;
|
||||
views: number;
|
||||
publishedAt: Date;
|
||||
url: string;
|
||||
numberChapter: string;
|
||||
}
|
||||
|
||||
interface InitialReduxState {
|
||||
data?: Data;
|
||||
auth: Auth;
|
||||
banners?: Banners;
|
||||
corrections?: Corrections;
|
||||
readerSettings?: ReaderSettings;
|
||||
}
|
||||
|
||||
interface Auth {
|
||||
isAuth: boolean;
|
||||
user: null;
|
||||
}
|
||||
|
||||
interface Banners {
|
||||
displaySale: boolean;
|
||||
}
|
||||
|
||||
interface Corrections {
|
||||
items: null;
|
||||
data: null;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
bookPage: BookPage | null;
|
||||
chapterPage: ChapterPage | null;
|
||||
}
|
||||
|
||||
interface BookPage {
|
||||
bookId: number;
|
||||
bookmark: null;
|
||||
}
|
||||
|
||||
interface ChapterPage {
|
||||
chapterId: number;
|
||||
bookId: number;
|
||||
bookmark: null;
|
||||
}
|
||||
|
||||
interface ReaderSettings {
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
textIndent: number;
|
||||
sidebarHide: number;
|
||||
textAlign: string;
|
||||
}
|
||||
|
||||
interface TotalData {
|
||||
items: Item[];
|
||||
_links: Links;
|
||||
pagesData: PagesData;
|
||||
}
|
||||
|
||||
interface Links {
|
||||
self: First;
|
||||
first: First;
|
||||
last: First;
|
||||
next: First;
|
||||
}
|
||||
|
||||
interface First {
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
status: string;
|
||||
dislikes: number;
|
||||
likes: number;
|
||||
userRating: null;
|
||||
lastChapter: ChapterElement;
|
||||
verticalImage: Image;
|
||||
horizontalImage: Image;
|
||||
squareImage: Image;
|
||||
}
|
||||
|
||||
interface PagesData {
|
||||
totalCount: number;
|
||||
pageCount: number;
|
||||
currentPage: number;
|
||||
perPage: number;
|
||||
}
|
||||
|
||||
interface Query {
|
||||
book?: string;
|
||||
chapter?: string;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
||||
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 dayjs from 'dayjs';
|
||||
import qs from 'qs';
|
||||
|
||||
class Zelluloza implements Plugin.PluginBase {
|
||||
id = 'zelluloza';
|
||||
name = 'Целлюлоза';
|
||||
site = 'https://zelluloza.ru';
|
||||
version = '1.0.1';
|
||||
icon = 'src/ru/zelluloza/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
filters,
|
||||
showLatestNovels,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const sort = showLatestNovels ? '0.0' : filters?.sort?.value || '6.0';
|
||||
const genres = filters?.genres?.value || '';
|
||||
|
||||
const body = await fetchApi(this.site + '/ajaxcall/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: this.site + '/top/freebooks/',
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({
|
||||
op: 'morebooks',
|
||||
par1: '',
|
||||
par2: `125:0:${genres}:0.${sort}.0.0.0.0.0.0.0.0.0.1.s.1..:${pageNo}`,
|
||||
par4: '',
|
||||
}),
|
||||
}).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div[style="display: flex;"]').each((index, element) => {
|
||||
novels.push({
|
||||
name: loadedCheerio(element).find('span[itemprop="name"]').text() || '',
|
||||
cover:
|
||||
this.site +
|
||||
loadedCheerio(element).find('img[class="shadow"]').attr('src'),
|
||||
path: (
|
||||
loadedCheerio(element).find('a[class="txt"]').attr('href') || ''
|
||||
).replace(/\D/g, ''),
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.resolveUrl(novelPath, true)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h2[class="bookname"]').text().trim(),
|
||||
cover: this.site + loadedCheerio('img[class="shadow"]').attr('src'),
|
||||
genres: loadedCheerio('.gnres').text()?.split?.(': ')?.[1],
|
||||
summary:
|
||||
loadedCheerio('#bann_full').text() ||
|
||||
loadedCheerio('#bann_short').text(),
|
||||
author: loadedCheerio('.author_link').text(),
|
||||
status: loadedCheerio('.tech_decription').text().includes('Пишется')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed,
|
||||
};
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
loadedCheerio('ul[class="g0"] div[class="w800_m"]').each(
|
||||
(chapterIndex, element) => {
|
||||
const isFree = loadedCheerio(element).find(
|
||||
'div[class="chaptfree"]',
|
||||
).length;
|
||||
if (isFree) {
|
||||
const chapter = loadedCheerio(element).find('a[class="chptitle"]');
|
||||
const releaseDate = loadedCheerio(element)
|
||||
.find('div[class="stat"]')
|
||||
.text();
|
||||
chapters.push({
|
||||
name: chapter.text().trim(),
|
||||
path: (chapter.attr('href') || '').split('/').slice(2, 4).join('/'),
|
||||
releaseTime: this.parseDate(releaseDate),
|
||||
chapterNumber: chapterIndex + 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const [bookID, chapterID] = chapterPath.split('/');
|
||||
const body = await fetchApi(this.site + '/ajaxcall/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: this.resolveUrl(chapterPath),
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({
|
||||
op: 'getbook',
|
||||
par1: bookID,
|
||||
par2: chapterID,
|
||||
}),
|
||||
}).then(res => res.text());
|
||||
|
||||
const encrypted = body.split('<END>')[0].split('\n');
|
||||
const decrypted = encrypted
|
||||
.map(str => decrypt(str))
|
||||
.join('')
|
||||
.replace(/\r/g, '')
|
||||
.trim();
|
||||
|
||||
const chapterText = decrypted //cosmetic filters
|
||||
.replace(/\[\*]([\s\S]*?)\[\/]/g, '<b>$1</b>')
|
||||
.replace(/\[_]([\s\S]*?)\[\/]/g, '<u>$1</u>')
|
||||
.replace(/\[-]([\s\S]*?)\[\/]/g, '<s>$1</s>')
|
||||
.replace(/\[~]([\s\S]*?)\[\/]/g, '<i>$1</i>'); /*
|
||||
.replace(/\[!]([\s\S]*?)\[\/]/g, '<span style="font-family: Times New Roman">$1</span>')
|
||||
.replace(/\[ctr]([\s\S]*?)\[\/]/g, "<center>$1</center>")
|
||||
.replace(/\[rht]([\s\S]*?)\[\/]/g, '<span style="text-align: right">$1</span>')
|
||||
.replace(/\[grn]([\s\S]*?)\[\/]/g, '<span style="color: #019208">$1</span>')
|
||||
.replace(/\[red]([\s\S]*?)\[\/]/g, '<span style="color: #E84040">$1</span>')
|
||||
.replace(/\[blu]([\s\S]*?)\[\/]/g, '<span style="color: #354FA3">$1</span>')
|
||||
.replace(/\[gry]([\s\S]*?)\[\/]/g, '<span style="color: #909090">$1</span>')
|
||||
.replace(/\[geo]([\s\S]*?)\[\/]/g, '<span style="font-family: Georgia">$1</span>')
|
||||
.replace(/\[gld]([\s\S]*?)\[\/]/g, '<span style="color: #988c07">$1</span>');*/
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(this.site + '/ajaxcall/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: this.site + '/search/done/#result',
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({
|
||||
op: 'morebooks',
|
||||
par1: searchTerm,
|
||||
par2: '206:0:0:0.0.0.0.0.0.0.10.0.0.0.0.0..0..:' + pageNo,
|
||||
par4: '',
|
||||
}),
|
||||
}).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div[style="display: flex;"]').each((index, element) => {
|
||||
novels.push({
|
||||
name: loadedCheerio(element).find('span[itemprop="name"]').text() || '',
|
||||
cover:
|
||||
this.site +
|
||||
loadedCheerio(element).find('img[class="shadow"]').attr('src'),
|
||||
path: (
|
||||
loadedCheerio(element).find('a[class="txt"]').attr('href') || ''
|
||||
).replace(/\D/g, ''),
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseDate = (dateString: string | undefined = '') => {
|
||||
const [, day, month, year, hour, minute] =
|
||||
dateString.match(/(\d{2})\.(\d{2})\.(\d{4})г (\d{2}):(\d{2})/) || [];
|
||||
|
||||
if (day && month && year && hour && minute) {
|
||||
return dayjs(
|
||||
year + '-' + month + '-' + day + ' ' + hour + ':' + minute,
|
||||
).format('LLL');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + '/books/' + path;
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: '6.0',
|
||||
options: [
|
||||
{ label: 'Топ-недели', value: '6.0' },
|
||||
{ label: 'Бестселеры', value: '5.0' },
|
||||
{ label: 'Популярные у читателей', value: '2.0' },
|
||||
{ label: 'Книжные новинки', value: '0.0' },
|
||||
{ label: 'Законченные книги', value: '0.1' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
label: 'Жанры',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Все', value: '' },
|
||||
{ label: '16+', value: '204' },
|
||||
{ label: '18+', value: '100' },
|
||||
{ label: 'Авантюра', value: '347' },
|
||||
{ label: 'Альтернативная история', value: '55' },
|
||||
{ label: 'Ангст', value: '463' },
|
||||
{ label: 'Аниме', value: '405' },
|
||||
{ label: 'Антиутопия', value: '21' },
|
||||
{ label: 'Апокалипсис', value: '368' },
|
||||
{ label: 'Аудиокнига', value: '391' },
|
||||
{ label: 'Байки', value: '435' },
|
||||
{ label: 'Бизнес-книги', value: '522' },
|
||||
{ label: 'Биография', value: '52' },
|
||||
{ label: 'Биопанк', value: '379' },
|
||||
{ label: 'Боевая фантастика', value: '512' },
|
||||
{ label: 'Боевик', value: '61' },
|
||||
{ label: 'Боевые искусства', value: '142' },
|
||||
{ label: 'Бояръ-аниме', value: '433' },
|
||||
{ label: 'Вбоквелл', value: '372' },
|
||||
{ label: 'Вестерн', value: '96' },
|
||||
{ label: 'Война миров', value: '137' },
|
||||
{ label: 'Восточное фэнтези', value: '505' },
|
||||
{ label: 'Городское фентези', value: '63' },
|
||||
{ label: 'Городское фэнтези', value: '520' },
|
||||
{ label: 'Готика', value: '83' },
|
||||
{ label: 'Детективы', value: '4' },
|
||||
{ label: 'Детские книги', value: '8' },
|
||||
{ label: 'Документальные книги', value: '351' },
|
||||
{ label: 'Дом и дача', value: '534' },
|
||||
{ label: 'Дорожные истории', value: '41' },
|
||||
{ label: 'Драма', value: '47' },
|
||||
{ label: 'Древняя литература', value: '529' },
|
||||
{ label: 'Женский психологический роман', value: '186' },
|
||||
{ label: 'Женский роман', value: '141' },
|
||||
{ label: 'Зарубежная классика', value: '524' },
|
||||
{ label: 'Здоровье и красота', value: '533' },
|
||||
{ label: 'Изучение языков', value: '537' },
|
||||
{ label: 'Иронический детектив', value: '85' },
|
||||
{ label: 'Исследования', value: '156' },
|
||||
{ label: 'Историческая литература', value: '3' },
|
||||
{ label: 'Исторический любовный роман', value: '518' },
|
||||
{ label: 'Историческое фэнтези', value: '90' },
|
||||
{ label: 'Киберпанк', value: '7' },
|
||||
{ label: 'Классическая литература', value: '9' },
|
||||
{ label: 'Книга-игра', value: '371' },
|
||||
{ label: 'Книги для родителей', value: '535' },
|
||||
{ label: 'Комедия', value: '80' },
|
||||
{ label: 'Конкурсные сборники', value: '349' },
|
||||
{ label: 'Короткий любовный роман', value: '517' },
|
||||
{ label: 'Космическая фантастика', value: '513' },
|
||||
{ label: 'Космоопера', value: '58' },
|
||||
{ label: 'Криминальная проза', value: '35' },
|
||||
{ label: 'Криптоистория', value: '400' },
|
||||
{ label: 'Ксенофантастика', value: '102' },
|
||||
{ label: 'Культура и искусство', value: '536' },
|
||||
{ label: 'Легенды', value: '161' },
|
||||
{ label: 'Лирика', value: '49' },
|
||||
{ label: 'ЛитРПГ', value: '62' },
|
||||
{ label: 'Любовная фантастика', value: '516' },
|
||||
{ label: 'Любовное фэнтези', value: '515' },
|
||||
{ label: 'Любовный роман', value: '46' },
|
||||
{ label: 'Магический реализм', value: '42' },
|
||||
{ label: 'Матриархат', value: '385' },
|
||||
{ label: 'Медицина', value: '57' },
|
||||
{ label: 'Мелодрама', value: '45' },
|
||||
{ label: 'Мемуары', value: '532' },
|
||||
{ label: 'Метафизика', value: '430' },
|
||||
{ label: 'Милитари', value: '126' },
|
||||
{ label: 'Мистика', value: '22' },
|
||||
{ label: 'Морские приключения', value: '211' },
|
||||
{ label: 'Мотивация', value: '135' },
|
||||
{ label: 'Научная фантастика', value: '122' },
|
||||
{ label: 'Научно-популярная', value: '43' },
|
||||
{ label: 'Ненаучная фантастика', value: '387' },
|
||||
{ label: 'Новелла', value: '498' },
|
||||
{ label: 'Нон-фикшн', value: '507' },
|
||||
{ label: 'Нуар', value: '121' },
|
||||
{ label: 'О войне', value: '136' },
|
||||
{ label: 'Оккультизм', value: '424' },
|
||||
{ label: 'Параллельные миры', value: '438' },
|
||||
{ label: 'Пародия', value: '108' },
|
||||
{ label: 'Подростковая фантастика', value: '27' },
|
||||
{ label: 'Подростковый роман', value: '388' },
|
||||
{ label: 'Подростковый ужастик', value: '110' },
|
||||
{ label: 'Постапокалипсис', value: '82' },
|
||||
{ label: 'Постапокалиптика', value: '67' },
|
||||
{ label: 'Поэзия', value: '44' },
|
||||
{ label: 'Превращение', value: '170' },
|
||||
{ label: 'Приключение', value: '497' },
|
||||
{ label: 'Приключения', value: '5' },
|
||||
{ label: 'Природа и животные', value: '538' },
|
||||
{ label: 'Проза', value: '95' },
|
||||
{ label: 'Производственный роман', value: '148' },
|
||||
{ label: 'Психология', value: '31' },
|
||||
{ label: 'Путешествия', value: '383' },
|
||||
{ label: 'Рассказы', value: '101' },
|
||||
{ label: 'Реализм', value: '422' },
|
||||
{ label: 'РеалРПГ', value: '442' },
|
||||
{ label: 'Религия', value: '188' },
|
||||
{ label: 'Робинзонада', value: '434' },
|
||||
{ label: 'Романтика', value: '130' },
|
||||
{ label: 'Романтическая фантастика', value: '501' },
|
||||
{ label: 'Русская классика', value: '523' },
|
||||
{ label: 'Рыцарский роман', value: '112' },
|
||||
{ label: 'Символизм', value: '439' },
|
||||
{ label: 'Сказки', value: '93' },
|
||||
{ label: 'Славянское фэнтези', value: '508' },
|
||||
{ label: 'Сновидения', value: '428' },
|
||||
{ label: 'Современный любовный роман', value: '519' },
|
||||
{ label: 'Социально-психологическая фантастика', value: '98' },
|
||||
{ label: 'Спорт', value: '147' },
|
||||
{ label: 'Справочники', value: '528' },
|
||||
{ label: 'Стимпанк', value: '56' },
|
||||
{ label: 'Субкультура', value: '209' },
|
||||
{ label: 'Сюрреализм', value: '26' },
|
||||
{ label: 'Тёмное фэнтези', value: '437' },
|
||||
{ label: 'Техномагия', value: '159' },
|
||||
{ label: 'Триллер', value: '6' },
|
||||
{ label: 'Трэш', value: '162' },
|
||||
{ label: 'Ужасы', value: '531' },
|
||||
{ label: 'Утопия', value: '191' },
|
||||
{ label: 'Учебник', value: '411' },
|
||||
{ label: 'Фантастика', value: '1' },
|
||||
{ label: 'Фантастический боевик', value: '38' },
|
||||
{ label: 'Фанфик', value: '111' },
|
||||
{ label: 'Философия', value: '461' },
|
||||
{ label: 'Фурри', value: '394' },
|
||||
{ label: 'Фэнтези', value: '2' },
|
||||
{ label: 'Хентай', value: '441' },
|
||||
{ label: 'Хобби и досуг', value: '530' },
|
||||
{ label: 'Хоррор', value: '86' },
|
||||
{ label: 'Чёрный юмор', value: '73' },
|
||||
{ label: 'Эзотерика', value: '30' },
|
||||
{ label: 'Экшн', value: '94' },
|
||||
{ label: 'Эпическое фэнтези', value: '499' },
|
||||
{ label: 'Эротика', value: '19' },
|
||||
{ label: 'Эссе', value: '353' },
|
||||
{ label: 'ЭТТИ', value: '502' },
|
||||
{ label: 'Юмористическая литература', value: '20' },
|
||||
{ label: 'Юмористическая фантастика', value: '514' },
|
||||
{ label: 'Юмористическое фэнтези', value: '521' },
|
||||
{ label: 'EVE - Миры Содружества', value: '99' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
const alphabet: { [key: string]: string } = {
|
||||
'~': '0',
|
||||
'H': '1',
|
||||
'^': '2',
|
||||
'@': '3',
|
||||
'f': '4',
|
||||
'0': '5',
|
||||
'5': '6',
|
||||
'n': '7',
|
||||
'r': '8',
|
||||
'=': '9',
|
||||
'W': 'a',
|
||||
'L': 'b',
|
||||
'7': 'c',
|
||||
' ': 'd',
|
||||
'u': 'e',
|
||||
'c': 'f',
|
||||
};
|
||||
|
||||
function decrypt(encrypt: string) {
|
||||
if (!encrypt) return '';
|
||||
const hexArray = [];
|
||||
|
||||
for (let j = 0; j < encrypt.length; j += 2) {
|
||||
const firstChar = encrypt.substring(j, j + 1);
|
||||
const secondChar = encrypt.substring(j + 1, j + 2);
|
||||
hexArray.push(alphabet[firstChar] + alphabet[secondChar]);
|
||||
}
|
||||
|
||||
return '<p>' + hexToUtf8(hexArray) + '</p>';
|
||||
}
|
||||
|
||||
function hexToUtf8(hexArray: string[]) {
|
||||
let index = 0;
|
||||
let result = '';
|
||||
|
||||
while (index < hexArray.length) {
|
||||
const currentHex = parseInt(hexArray[index], 16) & 0xff;
|
||||
|
||||
if (currentHex < 128) {
|
||||
if (currentHex < 16) {
|
||||
switch (currentHex) {
|
||||
case 9:
|
||||
result += ' ';
|
||||
break;
|
||||
case 13:
|
||||
result += '\r';
|
||||
break;
|
||||
case 10:
|
||||
result += '\n';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
result += String.fromCharCode(currentHex);
|
||||
}
|
||||
index++;
|
||||
} else if (currentHex > 191 && currentHex < 224) {
|
||||
if (index + 1 < hexArray.length) {
|
||||
const nextHex = parseInt(hexArray[index + 1], 16) & 0xff;
|
||||
result += String.fromCharCode(
|
||||
((currentHex & 31) << 6) | (nextHex & 63),
|
||||
);
|
||||
}
|
||||
index += 2;
|
||||
} else {
|
||||
if (index + 2 < hexArray.length) {
|
||||
const nextHex = parseInt(hexArray[index + 1], 16) & 0xff;
|
||||
const thirdHex = parseInt(hexArray[index + 2], 16) & 0xff;
|
||||
result += String.fromCharCode(
|
||||
((currentHex & 15) << 12) | ((nextHex & 63) << 6) | (thirdHex & 63),
|
||||
);
|
||||
}
|
||||
index += 3;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default new Zelluloza();
|
||||
@@ -0,0 +1,141 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
class HasulTL implements Plugin.PluginBase {
|
||||
id = 'HasuTL';
|
||||
name = 'Hasu Translations';
|
||||
icon = 'src/es/hasutl/icon.jpg';
|
||||
site = 'https://hasutl.wordpress.com/';
|
||||
version = '1.0.0';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + 'light-novels-activas/';
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.wp-block-columns').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('.wp-block-button').text();
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
|
||||
let novelUrl = loadedCheerio(ele)
|
||||
.find('.wp-block-button > a')
|
||||
.attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
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 result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.post-header').text(),
|
||||
};
|
||||
novel.cover = loadedCheerio('.featured-media > img').attr('src');
|
||||
|
||||
let novelSummary = loadedCheerio('.post-content').find('p').html()!;
|
||||
novel.summary = novelSummary;
|
||||
|
||||
let novelChapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.wp-block-media-text__content')
|
||||
.find('a')
|
||||
.each((idx, ele) => {
|
||||
const chapterName = loadedCheerio(ele).text().trim();
|
||||
|
||||
const releaseDate = null;
|
||||
|
||||
let chapterUrl = loadedCheerio(ele).attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
const chapter = {
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novelChapters.push(chapter);
|
||||
});
|
||||
|
||||
novel.chapters = novelChapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.site + chapterPath;
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let chapterText = loadedCheerio('.post-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}?s=${searchTerm}&post_type=wp-manga`;
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.post-container').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('.post-header').text();
|
||||
if (
|
||||
!novelName.includes('Cap') &&
|
||||
!novelName.includes('Vol') &&
|
||||
!novelName.includes('Light Novels')
|
||||
) {
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
|
||||
let novelUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new HasulTL();
|
||||
@@ -0,0 +1,181 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
class Ligera implements Plugin.PluginBase {
|
||||
id = 'novelasligera';
|
||||
name = 'Novelas Ligera';
|
||||
icon = 'src/es/novelasligera/icon.png';
|
||||
site = 'https://novelasligera.com/';
|
||||
filters?: Filters | undefined;
|
||||
version = '1.0.0';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.elementor-column').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele)
|
||||
.find('.widget-image-caption.wp-caption-text')
|
||||
.text();
|
||||
if (novelName) {
|
||||
const novelCover = loadedCheerio(ele)
|
||||
.find('a > img')
|
||||
.attr('data-lazy-src');
|
||||
|
||||
let novelUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
if (!novelUrl) return;
|
||||
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;
|
||||
|
||||
// console.log(url);
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1').text(),
|
||||
};
|
||||
|
||||
novel.cover = loadedCheerio('.elementor-widget-container')
|
||||
.find('img')
|
||||
.attr('data-lazy-src');
|
||||
|
||||
loadedCheerio('.elementor-row')
|
||||
.find('p')
|
||||
.each(function () {
|
||||
if (loadedCheerio(this).text().includes('Autor:')) {
|
||||
novel.author = loadedCheerio(this)
|
||||
.text()
|
||||
.replace('Autor:', '')
|
||||
.trim();
|
||||
}
|
||||
if (loadedCheerio(this).text().includes('Estado:')) {
|
||||
novel.status = loadedCheerio(this)
|
||||
.text()
|
||||
.replace('Estado: ', '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (loadedCheerio(this).text().includes('Género:')) {
|
||||
loadedCheerio(this).find('span').remove();
|
||||
novel.genres = loadedCheerio(this).text().replace(/,\s/g, ',');
|
||||
}
|
||||
});
|
||||
|
||||
novel.summary = loadedCheerio(
|
||||
'.elementor-text-editor.elementor-clearfix',
|
||||
).text();
|
||||
|
||||
let novelChapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.elementor-accordion-item').remove();
|
||||
|
||||
loadedCheerio('.elementor-tab-content')
|
||||
.find('li')
|
||||
.each((idx, ele) => {
|
||||
const chapterName = loadedCheerio(ele).text();
|
||||
const releaseDate = null;
|
||||
const chapterUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
if (!chapterUrl) return;
|
||||
const chapter = {
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novelChapters.push(chapter);
|
||||
});
|
||||
|
||||
novel.chapters = novelChapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.site + chapterPath;
|
||||
// console.log(url);
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
loadedCheerio('.osny-nightmode.osny-nightmode--left').remove();
|
||||
loadedCheerio('.code-block.code-block-1').remove();
|
||||
loadedCheerio('.adsb30').remove();
|
||||
loadedCheerio('.saboxplugin-wrap').remove();
|
||||
loadedCheerio('.wp-post-navigation').remove();
|
||||
|
||||
let chapterText = loadedCheerio('.entry-content').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = this.site + '?s=' + searchTerm + '&post_type=wp-manga';
|
||||
// console.log(url);
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.inside-article').each((idx, ele) => {
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
let novelUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
|
||||
let novelName;
|
||||
|
||||
if (novelUrl) {
|
||||
novelName = novelUrl.replace(/-/g, ' ').replace(/^./, function (x) {
|
||||
return x.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
novelUrl += '/';
|
||||
|
||||
if (!novelName || !novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
novels = [{ ...novels[1] }];
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Ligera();
|
||||
@@ -0,0 +1,204 @@
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
class ReinoWuxia implements Plugin.PluginBase {
|
||||
id = 'reinowuxia';
|
||||
name = 'ReinoWuxia';
|
||||
icon = 'src/es/reinowuxia/icon.png';
|
||||
filters?: Filters | undefined;
|
||||
version = '1.0.0';
|
||||
site = 'http://www.reinowuxia.com/';
|
||||
getNovelName(y: string | undefined) {
|
||||
return y?.replace(/-/g, ' ').replace(/(?:^|\s)\S/g, a => a.toUpperCase());
|
||||
}
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + 'p/todas-las-novelas.html';
|
||||
|
||||
const result = await fetchApi(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.post-body.entry-content')
|
||||
.find('a')
|
||||
.each((idx, ele) => {
|
||||
let novelName = loadedCheerio(ele)
|
||||
.attr('href')
|
||||
?.split('/')
|
||||
.pop()
|
||||
?.replace('.html', '');
|
||||
novelName = this.getNovelName(novelName);
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
|
||||
let novelUrl = loadedCheerio(ele).attr('href');
|
||||
|
||||
if (!novelName || !novelUrl) return;
|
||||
|
||||
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 result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.post-title').text().trim(),
|
||||
};
|
||||
|
||||
novel.cover = loadedCheerio('div.separator').find('a').attr('href');
|
||||
|
||||
novel.status = '';
|
||||
|
||||
loadedCheerio('div > b').each(function () {
|
||||
const detailName = loadedCheerio(this).text();
|
||||
let detail = loadedCheerio(this)[0].nextSibling;
|
||||
|
||||
if (detailName && detail) {
|
||||
const text = loadedCheerio(detail).text();
|
||||
|
||||
if (detailName.includes('Autor')) {
|
||||
novel.author = text.replace('Autor:', '');
|
||||
}
|
||||
|
||||
if (detailName.includes('Estatus')) {
|
||||
novel.status = text.replace('Estatus: ', '');
|
||||
}
|
||||
if (detailName.includes('Géneros:')) {
|
||||
novel.genres = text.replace('Géneros: ', '').replace(/,\s/g, ',');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let novelChapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('div').each((idx, rootEle) => {
|
||||
const detailName = loadedCheerio(rootEle).text();
|
||||
if (detailName.includes('Sinopsis')) {
|
||||
novel.summary =
|
||||
loadedCheerio(rootEle).next().text() !== ''
|
||||
? loadedCheerio(rootEle)
|
||||
.next()
|
||||
.text()
|
||||
.replace('Sinopsis', '')
|
||||
.trim()
|
||||
: loadedCheerio(rootEle)
|
||||
.next()
|
||||
.next()
|
||||
.text()
|
||||
.replace('Sinopsis', '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (detailName.includes('Lista de Capítulos')) {
|
||||
loadedCheerio(rootEle)
|
||||
.find('a')
|
||||
.each((idx, ele) => {
|
||||
const chapterName = loadedCheerio(ele).text();
|
||||
const chapterPath = loadedCheerio(ele)
|
||||
.attr('href')
|
||||
?.replace(this.site, '');
|
||||
const releaseDate = null;
|
||||
|
||||
if (
|
||||
chapterName &&
|
||||
chapterPath &&
|
||||
chapterPath !== '/' &&
|
||||
!novelChapters.some(chap => chap.name === chapterName)
|
||||
) {
|
||||
const chapter = {
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterPath,
|
||||
};
|
||||
|
||||
novelChapters.push(chapter);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = novelChapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.site + chapterPath;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let chapterText = loadedCheerio('.post-body.entry-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search?q=${searchTerm}`;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.date-outer').each((idx, ele) => {
|
||||
let novelName = loadedCheerio(ele)
|
||||
.find('a')
|
||||
.attr('href')
|
||||
?.split('/')
|
||||
.pop()
|
||||
?.replace(/-capitulo(.*?).html/, '');
|
||||
|
||||
const novelUrl = novelName + '.html/';
|
||||
|
||||
novelName = this.getNovelName(novelName);
|
||||
|
||||
const exists = novels.some(novel => novel.name === novelName);
|
||||
|
||||
if (!exists) {
|
||||
const novelCover = defaultCover;
|
||||
|
||||
if (!novelUrl || !novelName) return;
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReinoWuxia();
|
||||
@@ -0,0 +1,166 @@
|
||||
import { fetchFile, fetchApi } from '@libs/fetch';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
class Oasis implements Plugin.PluginBase {
|
||||
id = 'oasistranslations';
|
||||
name = 'Oasis Translations';
|
||||
site = 'https://oasistranslations.wordpress.com/';
|
||||
version = '1.0.0';
|
||||
filters?: Filters | undefined;
|
||||
icon = 'src/es/oasistranslations/icon.png';
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.menu-item-1819')
|
||||
.find('.sub-menu > li')
|
||||
.each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).text();
|
||||
if (!novelName.match(/Activas|Finalizadas|Dropeadas/)) {
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
|
||||
let novelUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
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 result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.entry-title')
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '')
|
||||
.trim(),
|
||||
};
|
||||
novel.cover = loadedCheerio('img[loading="lazy"]').attr('src');
|
||||
|
||||
loadedCheerio('.entry-content > p').each(function (res) {
|
||||
if (loadedCheerio(this).text().includes('Autor')) {
|
||||
const details = loadedCheerio(this)
|
||||
.html()
|
||||
?.match(/<\/strong>(.|\n)*?<br>/g)
|
||||
?.map(detail => detail.replace(/<strong>|<\/strong>|<br>|:\s/g, ''));
|
||||
|
||||
novel.genres = '';
|
||||
if (details) {
|
||||
novel.author = details[2];
|
||||
novel.genres = details[4].replace(/\s| /g, '');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// let novelSummary = $(this).next().html();
|
||||
novel.summary = '';
|
||||
|
||||
let novelChapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
// if ($(".entry-content").find("li").length) {
|
||||
loadedCheerio('.entry-content')
|
||||
.find('a')
|
||||
.each((idx, ele) => {
|
||||
let chapterUrl = loadedCheerio(ele).attr('href');
|
||||
|
||||
if (chapterUrl && chapterUrl.includes(this.site)) {
|
||||
const chapterName = loadedCheerio(ele).text();
|
||||
const releaseDate = null;
|
||||
|
||||
const chapter = {
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novelChapters.push(chapter);
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = novelChapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.site + chapterPath;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
loadedCheerio('div#jp-post-flair').remove();
|
||||
|
||||
let chapterText = loadedCheerio('.entry-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
searchTerm = searchTerm.toLowerCase();
|
||||
|
||||
let url = this.site;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('.menu-item-1819')
|
||||
.find('.sub-menu > li')
|
||||
.each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).text();
|
||||
if (!novelName.match(/Activas|Finalizadas|Dropeadas/)) {
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
|
||||
let novelUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
|
||||
novels = novels.filter(novel =>
|
||||
novel.name.toLowerCase().includes(searchTerm),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Oasis();
|
||||
@@ -0,0 +1,266 @@
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
|
||||
class SkyNovels implements Plugin.PluginBase {
|
||||
id = 'skynovels';
|
||||
name = 'SkyNovels';
|
||||
site = 'https://www.skynovels.net/';
|
||||
version = '1.0.0';
|
||||
icon = 'src/es/skynovels/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = 'https://api.skynovels.net/api/novels?&q';
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = (await result.json()) as response;
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
body.novels?.forEach(res => {
|
||||
const novelName = res.nvl_title;
|
||||
const novelCover =
|
||||
'https://api.skynovels.net/api/get-image/' +
|
||||
res.image +
|
||||
'/novels/false';
|
||||
const path = 'novelas/' + res.id + '/' + res.nvl_name + '/';
|
||||
|
||||
const novel = { name: novelName, path, cover: novelCover };
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novelId = novelPath.split('/')[1];
|
||||
const url =
|
||||
'https://api.skynovels.net/api/novel/' + novelId + '/reading?&q';
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = (await result.json()) as responseBook;
|
||||
|
||||
const item = body?.novel?.[0];
|
||||
|
||||
let novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: item?.nvl_title || 'Untitled',
|
||||
};
|
||||
|
||||
novel.cover =
|
||||
'https://api.skynovels.net/api/get-image/' +
|
||||
item?.image +
|
||||
'/novels/false';
|
||||
|
||||
let genres: string[] = [];
|
||||
item?.genres?.forEach(genre => genres.push(genre.genre_name));
|
||||
novel.genres = genres.join(',');
|
||||
novel.author = item?.nvl_writer;
|
||||
novel.summary = item?.nvl_content;
|
||||
novel.status = item?.nvl_status;
|
||||
|
||||
let novelChapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
item?.volumes?.forEach(volume => {
|
||||
volume?.chapters?.forEach(chapter => {
|
||||
const chapterName = chapter.chp_index_title;
|
||||
const releaseDate = new Date(chapter.createdAt).toDateString();
|
||||
const chapterPath = novelPath + chapter.id + '/' + chapter.chp_name;
|
||||
|
||||
const chap = {
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterPath,
|
||||
};
|
||||
|
||||
novelChapters.push(chap);
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = novelChapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
let chapterId: string = chapterPath.split('/')[3];
|
||||
const url = `https://api.skynovels.net/api/novel-chapter/${chapterId}`;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = (await result.json()) as responseChapter;
|
||||
|
||||
const item = body?.chapter?.[0];
|
||||
|
||||
let chapterText = item?.chp_content || '404';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
searchTerm = searchTerm.toLowerCase();
|
||||
const url = 'https://api.skynovels.net/api/novels?&q';
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = (await result.json()) as response;
|
||||
|
||||
let results = body?.novels?.filter(novel =>
|
||||
novel.nvl_title.toLowerCase().includes(searchTerm),
|
||||
);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
results?.forEach(res => {
|
||||
const novelName = res.nvl_title;
|
||||
const novelCover =
|
||||
'https://api.skynovels.net/api/get-image/' +
|
||||
res.image +
|
||||
'/novels/false';
|
||||
const path = 'novelas/' + res.id + '/' + res.nvl_name + '/';
|
||||
|
||||
const novel = { name: novelName, path, cover: novelCover };
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new SkyNovels();
|
||||
|
||||
interface response {
|
||||
novels?: NovelsEntity[] | null;
|
||||
}
|
||||
interface NovelsEntity {
|
||||
id: number;
|
||||
nvl_author?: number | null;
|
||||
nvl_content: string;
|
||||
nvl_title: string;
|
||||
nvl_acronym?: string | null;
|
||||
nvl_status: string;
|
||||
nvl_publication_date?: string | null;
|
||||
nvl_name: string;
|
||||
nvl_recommended: number;
|
||||
nvl_writer: string;
|
||||
nvl_translator?: string | null;
|
||||
nvl_translator_eng?: string | null;
|
||||
image: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nvl_chapters: number;
|
||||
nvl_last_update: string;
|
||||
nvl_rating?: number | null;
|
||||
nvl_ratings_count: number;
|
||||
genres?: GenresEntity[] | null;
|
||||
}
|
||||
interface GenresEntity {
|
||||
id: number;
|
||||
genre_name: string;
|
||||
}
|
||||
|
||||
interface responseBook {
|
||||
novel?: NovelEntity[] | null;
|
||||
}
|
||||
interface NovelEntity {
|
||||
id: number;
|
||||
nvl_author: number;
|
||||
nvl_content: string;
|
||||
nvl_title: string;
|
||||
nvl_acronym: string;
|
||||
nvl_status: string;
|
||||
nvl_publication_date: string;
|
||||
nvl_name: string;
|
||||
nvl_recommended: number;
|
||||
nvl_writer: string;
|
||||
nvl_translator: string;
|
||||
nvl_translator_eng: string;
|
||||
image: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nvl_chapters: number;
|
||||
nvl_last_update: string;
|
||||
nvl_rating: number;
|
||||
bookmarks?: BookmarksEntity[] | null;
|
||||
volumes?: VolumesEntity[] | null;
|
||||
novel_ratings?: NovelRatingsEntity[] | null;
|
||||
collaborators?: CollaboratorsEntity[] | null;
|
||||
genres?: GenresEntity[] | null;
|
||||
}
|
||||
interface BookmarksEntity {
|
||||
id: number;
|
||||
user_id: number;
|
||||
chp_id: number;
|
||||
chp_name: string;
|
||||
}
|
||||
interface VolumesEntity {
|
||||
vlm_title: string;
|
||||
id: number;
|
||||
nvl_id: number;
|
||||
user_id?: number | null;
|
||||
chapters?: ChaptersEntity[] | null;
|
||||
}
|
||||
interface ChaptersEntity {
|
||||
id: number;
|
||||
chp_index_title: string;
|
||||
chp_name: string;
|
||||
chp_number: number;
|
||||
chp_status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
interface NovelRatingsEntity {
|
||||
user_id: number;
|
||||
rate_value: number;
|
||||
rate_comment: string;
|
||||
replys_count: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: number;
|
||||
user_login: string;
|
||||
image?: string | null;
|
||||
likes?: (LikesEntity | null)[] | null;
|
||||
}
|
||||
interface LikesEntity {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_login: string;
|
||||
}
|
||||
interface CollaboratorsEntity {
|
||||
user_id: number;
|
||||
user_login: string;
|
||||
}
|
||||
|
||||
interface responseChapter {
|
||||
chapter?: ChapterEntity[] | null;
|
||||
}
|
||||
interface ChapterEntity {
|
||||
id: number;
|
||||
chp_author: number;
|
||||
chp_translator?: null;
|
||||
nvl_id: number;
|
||||
vlm_id: number;
|
||||
chp_number: number;
|
||||
chp_content: string;
|
||||
chp_review?: null;
|
||||
chp_title?: null;
|
||||
chp_index_title: string;
|
||||
chp_status: string;
|
||||
chp_name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nvl_title: string;
|
||||
nvl_name: string;
|
||||
user_login: string;
|
||||
reactions_count: number;
|
||||
comments?: null[] | null;
|
||||
reactions?: null[] | null;
|
||||
total_reactions?: TotalReactionsEntity[] | null;
|
||||
}
|
||||
interface TotalReactionsEntity {
|
||||
reaction_id: number;
|
||||
reaction_name: string;
|
||||
reaction_count: number;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class TuNovelaLigera implements Plugin.PagePlugin {
|
||||
id = 'tunovelaligera';
|
||||
name = 'TuNovelaLigera';
|
||||
icon = 'src/es/tunovelaligera/icon.png';
|
||||
site = 'https://tunovelaligera.com';
|
||||
version = '1.1.0';
|
||||
|
||||
async sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site;
|
||||
|
||||
link += filters?.genres?.value
|
||||
? '/genero' + filters.genres.value
|
||||
: '/novelas';
|
||||
link += `/page/${pageNo}?m_orderby=`;
|
||||
link += showLatestNovels ? 'latest' : filters?.order?.value || 'rating';
|
||||
|
||||
const result = await fetchApi(link).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(result);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.page-item-detail').each((i, el) => {
|
||||
const name = loadedCheerio(el).find('.h5 > a').text();
|
||||
const cover = loadedCheerio(el).find('img').attr('src');
|
||||
const url = loadedCheerio(el).find('.h5 > a').attr('href');
|
||||
if (!url) return;
|
||||
|
||||
novels.push({ name, cover, path: url.replace(this.site, '') });
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseChapters(loadedCheerio: CheerioAPI): Plugin.ChapterItem[] {
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
loadedCheerio('#lcp_instance_0 li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el)
|
||||
.find('a')
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '')
|
||||
.trim();
|
||||
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
if (!chapterUrl) return;
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
return chapters;
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const novelUrl = this.site + novelPath;
|
||||
const result = await fetchApi(novelUrl);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
let lastPage = 1;
|
||||
loadedCheerio('ul.lcp_paginator > li > a').each(function () {
|
||||
const page = Number(this.attribs['title']);
|
||||
if (page && page > lastPage) lastPage = page;
|
||||
});
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
chapters: [],
|
||||
totalPages: lastPage,
|
||||
name: loadedCheerio('.post-title > h1').text().trim(),
|
||||
};
|
||||
|
||||
loadedCheerio('.manga-title-badges').remove();
|
||||
|
||||
let novelCover = loadedCheerio('.summary_image > a > img');
|
||||
|
||||
novel.cover =
|
||||
novelCover.attr('data-src') ||
|
||||
novelCover.attr('src') ||
|
||||
novelCover.attr('data-cfsrc') ||
|
||||
defaultCover;
|
||||
|
||||
loadedCheerio('.post-content_item').each(function () {
|
||||
const detailName = loadedCheerio(this)
|
||||
.find('.summary-heading > h5')
|
||||
.text()
|
||||
.trim();
|
||||
const detail = loadedCheerio(this).find('.summary-content').text().trim();
|
||||
|
||||
switch (detailName) {
|
||||
case 'Género(s)':
|
||||
novel.genres = detail.replace(/, /g, ',');
|
||||
break;
|
||||
case 'Autor(es)':
|
||||
novel.author = detail;
|
||||
break;
|
||||
case 'Estado':
|
||||
novel.status =
|
||||
detail.includes('OnGoing') || detail.includes('Updating')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
novel.summary = loadedCheerio('div.summary__content > p').text().trim();
|
||||
|
||||
novel.chapters = this.parseChapters(loadedCheerio);
|
||||
const latestChapterEle = loadedCheerio('#lcp_instance_0 li').first();
|
||||
const latestChapterUrl = loadedCheerio(latestChapterEle)
|
||||
.find('a')
|
||||
.attr('href');
|
||||
const latestChapterName = loadedCheerio(latestChapterEle)
|
||||
.find('a')
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '')
|
||||
.trim();
|
||||
novel.latestChapter = latestChapterUrl
|
||||
? {
|
||||
path: latestChapterUrl.replace(this.site, ''),
|
||||
name: latestChapterName,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const pageUrl = this.site + novelPath + '?lcp_page0=' + page;
|
||||
const pageText = await fetchApi(pageUrl).then(res => res.text());
|
||||
const chapters = this.parseChapters(parseHTML(pageText));
|
||||
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('.c-blog-post.post').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}/?s=${searchTerm}&post_type=wp-manga`;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.c-tabs-item__content').each((i, el) => {
|
||||
const name = loadedCheerio(el).find('.h4 > a').text();
|
||||
const cover = loadedCheerio(el).find('img').attr('src');
|
||||
const url = loadedCheerio(el).find('.h4 > a').attr('href');
|
||||
if (!url) return;
|
||||
|
||||
novels.push({ name, cover, path: url.replace(this.site, '') });
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'rating',
|
||||
label: 'Ordenado por',
|
||||
options: [
|
||||
{ label: 'Lo mas reciente', value: 'latest' },
|
||||
{ label: 'A-Z', value: 'alphabet' },
|
||||
{ label: 'Clasificación', value: 'rating' },
|
||||
{ label: 'Trending', value: 'trending' },
|
||||
{ label: 'Mas visto', value: 'views' },
|
||||
{ label: 'Nuevo', value: 'new-manga' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
value: '',
|
||||
label: 'Generos',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Acción', value: 'accion' },
|
||||
{ label: 'Adulto', value: 'adulto' },
|
||||
{ label: 'Artes Marciales', value: 'artes-marciales' },
|
||||
{ label: 'Aventura', value: 'aventura' },
|
||||
{ label: 'Ciencia Ficción', value: 'ciencia-ficcion' },
|
||||
{ label: 'Comedia', value: 'comedia' },
|
||||
{ label: 'Deportes', value: 'deportes' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Eastern Fantasy', value: 'eastern-fantasy' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'FanFiction', value: 'fan-fiction' },
|
||||
{ label: 'Fantasía', value: 'fantasia' },
|
||||
{ label: 'Fantasía oriental', value: 'fantasia-oriental' },
|
||||
{ label: 'Ficción Romántica', value: 'ficcion-romantica' },
|
||||
{ label: 'Gender Bender', value: 'gender-bender' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Histórico', value: 'historico' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ label: 'Maduro', value: 'maduro' },
|
||||
{ label: 'Mecha', value: 'mecha' },
|
||||
{ label: 'Misterio', value: 'misterio' },
|
||||
{ label: 'Novela China', value: 'novela-china' },
|
||||
{ label: 'Novela FanFiction', value: 'novela-fanfiction' },
|
||||
{ label: 'Novela Japonesa', value: 'novela-japonesa' },
|
||||
{ label: 'Novela Koreana', value: 'novela-koreana' },
|
||||
{ label: 'Novela ligera', value: 'novela-ligera' },
|
||||
{ label: 'Novela original', value: 'novela-original' },
|
||||
{ label: 'Novela Web', value: 'web-novel' },
|
||||
{ label: 'Psicológico', value: 'psicologico' },
|
||||
{ label: 'Realismo Mágico', value: 'realismo-magico' },
|
||||
{ label: 'Recuento de vida', value: 'recuento-de-vida' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ label: 'Romance contemporáneo', value: 'romance-contemporaneo' },
|
||||
{ label: 'Romance Moderno', value: 'romance-moderno' },
|
||||
{ label: 'Seinen', value: 'seinen' },
|
||||
{ label: 'Shoujo', value: 'shoujo' },
|
||||
{ label: 'Shounen', value: 'shounen' },
|
||||
{ label: 'Sobrenatural', value: 'sobrenatural' },
|
||||
{ label: 'Tragedia', value: 'tragedia' },
|
||||
{ label: 'Vampiros', value: 'vampiros' },
|
||||
{ label: 'Vida Escolar', value: 'vida-escolar' },
|
||||
{ label: 'Western Fantasy', value: 'western-fantasy' },
|
||||
{ label: 'Wuxia', value: 'wuxia' },
|
||||
{ label: 'Xianxia', value: 'xianxia' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
{ label: 'Yaoi', value: 'yaoi' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new TuNovelaLigera();
|
||||
@@ -0,0 +1,184 @@
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
|
||||
class Yuuki implements Plugin.PluginBase {
|
||||
id = 'yuukitls';
|
||||
name = 'Yuuki Tls';
|
||||
icon = 'src/es/yuukitls/icon.png';
|
||||
site = 'https://yuukitls.com/';
|
||||
filters?: Filters | undefined;
|
||||
version = '1.0.0';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.quadmenu-navbar-collapse ul li:nth-child(2)')
|
||||
.find('li')
|
||||
.each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele)
|
||||
.text()
|
||||
.replace(/[\s\n]+/g, ' ');
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
|
||||
let novelUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
if (!novelUrl) return;
|
||||
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 result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.entry-title')
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '')
|
||||
.trim(),
|
||||
};
|
||||
|
||||
novel.cover = loadedCheerio('img[loading="lazy"]').attr('src');
|
||||
|
||||
loadedCheerio('.entry-content')
|
||||
.find('div')
|
||||
.each(function () {
|
||||
if (loadedCheerio(this).text().includes('Escritor:')) {
|
||||
novel.author = loadedCheerio(this)
|
||||
.text()
|
||||
.replace('Escritor: ', '')
|
||||
.trim();
|
||||
}
|
||||
if (loadedCheerio(this).text().includes('Género:')) {
|
||||
novel.genres = loadedCheerio(this)
|
||||
.text()
|
||||
.replace(/Género: |\s/g, '');
|
||||
}
|
||||
|
||||
if (loadedCheerio(this).text().includes('Sinopsis:')) {
|
||||
novel.summary = loadedCheerio(this).next().text();
|
||||
}
|
||||
});
|
||||
|
||||
let novelChapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
if (loadedCheerio('.entry-content').find('li').length) {
|
||||
loadedCheerio('.entry-content')
|
||||
.find('li')
|
||||
.each((idx, ele) => {
|
||||
let chapterUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
|
||||
if (chapterUrl && chapterUrl.includes(this.site)) {
|
||||
const chapterName = loadedCheerio(ele).text();
|
||||
const releaseDate = null;
|
||||
|
||||
const chapter = {
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novelChapters.push(chapter);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
loadedCheerio('.entry-content')
|
||||
.find('p')
|
||||
.each((idx, ele) => {
|
||||
let chapterUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
|
||||
if (chapterUrl && chapterUrl.includes(this.site)) {
|
||||
const chapterName = loadedCheerio(ele).text();
|
||||
const releaseDate = null;
|
||||
|
||||
const chapter = {
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novelChapters.push(chapter);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
novel.chapters = novelChapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.site + chapterPath;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
let chapterText = loadedCheerio('.entry-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
searchTerm = searchTerm.toLowerCase();
|
||||
|
||||
let url = this.site;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.menu-item-2869')
|
||||
.find('.menu-item.menu-item-type-post_type.menu-item-object-post')
|
||||
.each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).text();
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
|
||||
let novelUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
novels = novels.filter(novel =>
|
||||
novel.name.toLowerCase().includes(searchTerm),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Yuuki();
|
||||
@@ -0,0 +1,164 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
class EpikNovel implements Plugin.PluginBase {
|
||||
id = 'epiknovel';
|
||||
name = 'EpikNovel';
|
||||
icon = 'src/tr/epiknovel/icon.png';
|
||||
site = 'https://www.epiknovel.com/';
|
||||
version = '1.0.0';
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + 'seri-listesi?Sayfa=' + pageNo;
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.col-lg-12.col-md-12').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('h3').text();
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('data-src');
|
||||
const novelUrl = loadedCheerio(ele).find('h3 > a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
// console.log(novels);
|
||||
|
||||
return novels;
|
||||
}
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
// console.log(url);
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1#tables').text().trim(),
|
||||
};
|
||||
|
||||
novel.cover = loadedCheerio('img.manga-cover').attr('src');
|
||||
|
||||
novel.summary = loadedCheerio(
|
||||
'#wrapper > div.row > div.col-md-9 > div:nth-child(6) > p:nth-child(3)',
|
||||
)
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
novel.status = loadedCheerio(
|
||||
'#wrapper > div.row > div.col-md-9 > div.row > div.col-md-9 > h4:nth-child(3) > a',
|
||||
)
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
novel.author = loadedCheerio('#NovelInfo > p:nth-child(4)')
|
||||
.text()
|
||||
.replace(/Publisher:|\s/g, '')
|
||||
.trim();
|
||||
|
||||
let novelChapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('table').find('tr').first().remove();
|
||||
|
||||
loadedCheerio('table')
|
||||
.find('tr')
|
||||
.each((idx, ele) => {
|
||||
const releaseDate = loadedCheerio(ele).find('td:nth-child(3)').text();
|
||||
|
||||
let chapterName = loadedCheerio(ele).find('td:nth-child(1) > a').text();
|
||||
|
||||
if (loadedCheerio(ele).find('td:nth-child(1) > span').length > 0) {
|
||||
chapterName = '🔒 ' + chapterName;
|
||||
}
|
||||
|
||||
const chapterUrl = loadedCheerio(ele)
|
||||
.find(' td:nth-child(1) > a')
|
||||
.attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
novelChapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = novelChapters;
|
||||
// console.log(novel);
|
||||
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.site + chapterPath;
|
||||
|
||||
// console.log(url);
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let chapterText = '';
|
||||
|
||||
if (result.url === 'https://www.epiknovel.com/login') {
|
||||
chapterText = 'Premium Chapter';
|
||||
} else {
|
||||
chapterText = loadedCheerio('div#icerik').html() || '';
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = this.site + 'seri-listesi?q=' + searchTerm + '&Sayfa=' + pageNo;
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.col-lg-12.col-md-12').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('h3').text();
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('data-src');
|
||||
const novelUrl = loadedCheerio(ele).find('h3 > a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new EpikNovel();
|
||||
@@ -0,0 +1,296 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
|
||||
class LSHNovel implements Plugin.PluginBase {
|
||||
id = 'lshnovel';
|
||||
name = 'Liebe Schnee Hiver Novel';
|
||||
icon = 'multisrc/wpmangastream/icons/lshnovel.png';
|
||||
site = 'https://lshnovel.com/';
|
||||
version = '1.0.0';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}series/?page=${pageNo}`;
|
||||
|
||||
if (filters.genres.value.length) {
|
||||
link += filters.genres.value.map(i => `&genre[]=${i}`).join('');
|
||||
}
|
||||
|
||||
if (filters.type.value.length)
|
||||
link += filters.type.value.map(i => `&lang[]=${i}`).join('');
|
||||
|
||||
link += '&status=' + filters.status;
|
||||
|
||||
link += '&order=' + filters.order;
|
||||
|
||||
const headers = new Headers();
|
||||
const body = await fetchApi(link, { headers }).then(result =>
|
||||
result.text(),
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('article.bs').each(function () {
|
||||
const novelName = loadedCheerio(this).find('.ntitle').text().trim();
|
||||
let image = loadedCheerio(this).find('img');
|
||||
const novelCover = image.attr('data-src') || image.attr('src');
|
||||
|
||||
const novelUrl = loadedCheerio(this).find('a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
url: novelUrl,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovelAndChapters(novelUrl: string): Promise<Plugin.SourceNovel> {
|
||||
const url = novelUrl;
|
||||
const headers = new Headers();
|
||||
const result = await fetchApi(url, { headers });
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
url,
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.name = loadedCheerio('.entry-title').text();
|
||||
|
||||
novel.cover =
|
||||
loadedCheerio('img.wp-post-image').attr('data-src') ||
|
||||
loadedCheerio('img.wp-post-image').attr('src');
|
||||
|
||||
loadedCheerio('div.spe > span').each(function () {
|
||||
const detailName = loadedCheerio(this).find('b').text().trim();
|
||||
const detail = loadedCheerio(this).find('b').remove().end().text().trim();
|
||||
|
||||
switch (detailName) {
|
||||
case 'Yazar:':
|
||||
novel.author = detail;
|
||||
break;
|
||||
case 'Seviye:':
|
||||
novel.status = detail;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
novel.genres = loadedCheerio('.genxed').text().trim().replace(/\s/g, ',');
|
||||
|
||||
loadedCheerio('div[itemprop="description"] h3,p.a,strong').remove();
|
||||
novel.summary = loadedCheerio('div[itemprop="description"]')
|
||||
.find('br')
|
||||
.replaceWith('\n')
|
||||
.end()
|
||||
.text();
|
||||
|
||||
let chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.eplister')
|
||||
.find('li')
|
||||
.each(function () {
|
||||
const chapterName =
|
||||
loadedCheerio(this).find('.epl-num').text() +
|
||||
' - ' +
|
||||
loadedCheerio(this).find('.epl-title').text();
|
||||
|
||||
const releaseDate = loadedCheerio(this).find('.epl-date').text().trim();
|
||||
|
||||
const chapterUrl = loadedCheerio(this).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
url: chapterUrl,
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterUrl: string): Promise<string> {
|
||||
const headers = new Headers();
|
||||
const result = await fetchApi(chapterUrl, { headers });
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
let chapterText = loadedCheerio('.epcontent').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo?: number | undefined,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}?s=${searchTerm}`;
|
||||
const headers = new Headers();
|
||||
const result = await fetchApi(url, { headers });
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('article.bs').each(function () {
|
||||
const novelName = loadedCheerio(this).find('.ntitle').text().trim();
|
||||
const novelCover = loadedCheerio(this).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(this).find('a').attr('href');
|
||||
if (!novelUrl) return;
|
||||
|
||||
novels.push({
|
||||
name: novelName,
|
||||
url: novelUrl,
|
||||
cover: novelCover,
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'popular',
|
||||
label: 'Önerilen',
|
||||
options: [
|
||||
{ label: 'Varsayılan', value: '' },
|
||||
|
||||
{ label: 'A-Z', value: 'title' },
|
||||
|
||||
{ label: 'Z-A', value: 'titlereverse' },
|
||||
|
||||
{ label: 'Son Yüklemeler', value: 'update' },
|
||||
|
||||
{ label: 'Son Eklenenler', value: 'latest' },
|
||||
|
||||
{ label: 'Bestimiz', value: 'popular' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
status: {
|
||||
label: 'Statü',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Tümü', value: '' },
|
||||
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
|
||||
{ label: 'Hiatus', value: 'hiatus' },
|
||||
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
type: {
|
||||
value: [],
|
||||
label: 'Tür',
|
||||
options: [
|
||||
{ label: 'Çeviri Novel', value: 'ceviri-novel' },
|
||||
|
||||
{ label: 'Liz-Chan', value: 'liz-chan' },
|
||||
|
||||
{ label: 'Manhwa', value: 'manhwa' },
|
||||
|
||||
{ label: 'Orijinal Novel', value: 'orijinal-novel' },
|
||||
|
||||
{ label: 'Web Novel', value: 'web-novel' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genres: {
|
||||
value: [],
|
||||
label: 'Kategori',
|
||||
options: [
|
||||
{ label: '+18', value: '18' },
|
||||
|
||||
{ label: 'Action', value: 'action' },
|
||||
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
|
||||
{ label: 'Aksiyon', value: 'aksiyon' },
|
||||
|
||||
{ label: 'BL', value: 'bl' },
|
||||
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
|
||||
{ label: 'Doğaüstü', value: 'dogaustu' },
|
||||
|
||||
{ label: 'Dram', value: 'dram' },
|
||||
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
|
||||
{ label: 'Fantastik', value: 'fantastik' },
|
||||
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
|
||||
{ label: 'Gizem', value: 'gizem' },
|
||||
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
|
||||
{ label: 'Macera', value: 'macera' },
|
||||
|
||||
{ label: 'Manhwa', value: 'manhwa' },
|
||||
|
||||
{ label: 'Martial Arts', value: 'martial-arts' },
|
||||
|
||||
{ label: 'Mature', value: 'mature' },
|
||||
|
||||
{ label: 'Novel', value: 'novel' },
|
||||
|
||||
{ label: 'Okul', value: 'okul' },
|
||||
|
||||
{ label: 'Psikolojik', value: 'psikolojik' },
|
||||
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
|
||||
{ label: 'Reverse Harem', value: 'reverse-harem' },
|
||||
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
|
||||
{ label: 'Romantik', value: 'romantik' },
|
||||
|
||||
{ label: 'Shoujo', value: 'shoujo' },
|
||||
|
||||
{ label: 'Slice Of Life', value: 'slice-of-life' },
|
||||
|
||||
{ label: 'Smut', value: 'smut' },
|
||||
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
|
||||
{ label: 'Tarihi', value: 'tarihi' },
|
||||
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
|
||||
{ label: 'Yaoi', value: 'yaoi' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new LSHNovel();
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class Smakolykytl implements Plugin.PluginBase {
|
||||
id = 'smakolykytl';
|
||||
name = 'Смаколики';
|
||||
site = 'https://smakolykytl.site/';
|
||||
version = '1.0.0';
|
||||
icon = 'src/uk/smakolykytl/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = showLatestNovels
|
||||
? 'https://api.smakolykytl.site/api/user/updates'
|
||||
: 'https://api.smakolykytl.site/api/user/projects';
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const json = (await result.json()) as response;
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
(json?.projects || json?.updates)?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel.image.url,
|
||||
path: 'titles/' + novel.id,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const id = novelPath.split('/').pop();
|
||||
const result = await fetchApi(
|
||||
'https://api.smakolykytl.site/api/user/projects/' + id,
|
||||
);
|
||||
const book = (await result.json()) as response;
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: book?.project?.title || '',
|
||||
cover: book?.project?.image?.url,
|
||||
summary: book?.project?.description,
|
||||
author: book?.project?.author,
|
||||
status: book?.project?.status_translate.includes('Триває')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed,
|
||||
};
|
||||
const tags = [book?.project?.genres, book?.project?.tags]
|
||||
.flat()
|
||||
.map(tags => tags?.title)
|
||||
.filter(tags => tags);
|
||||
|
||||
if (tags.length) {
|
||||
novel.genres = tags.join(', ');
|
||||
}
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
const res = await fetchApi(
|
||||
'https://api.smakolykytl.site/api/user/projects/' + id + '/books',
|
||||
);
|
||||
const data = (await res.json()) as response;
|
||||
data?.books?.forEach(volume =>
|
||||
volume?.chapters?.forEach(chapter =>
|
||||
chapters.push({
|
||||
name: volume.title + ' ' + chapter.title,
|
||||
path: 'read/' + chapter.id,
|
||||
releaseTime: dayjs(chapter.modifiedAt).format('LLL'),
|
||||
chapterNumber: chapters.length + 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const id = chapterPath.split('/').pop();
|
||||
const result = await fetchApi(
|
||||
'https://api.smakolykytl.site/api/user/chapters/' + id,
|
||||
);
|
||||
const json = (await result.json()) as response;
|
||||
const chapterRaw: HTML[] = JSON.parse(json?.chapter?.content || '[]');
|
||||
|
||||
const chapterText = jsonToHtml(chapterRaw);
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const result = await fetchApi(
|
||||
'https://api.smakolykytl.site/api/user/projects',
|
||||
);
|
||||
const json = (await result.json()) as response;
|
||||
const searchTitle = searchTerm.toLowerCase();
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
json?.projects
|
||||
?.filter(
|
||||
({ title, id }) =>
|
||||
title.toLowerCase().includes(searchTitle) || String(id) == searchTerm,
|
||||
)
|
||||
?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel.image.url,
|
||||
path: 'titles/' + novel.id,
|
||||
}),
|
||||
);
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Smakolykytl();
|
||||
|
||||
function jsonToHtml(json: HTML[], html: string = '') {
|
||||
json.forEach(element => {
|
||||
switch (element.type) {
|
||||
case 'hardBreak':
|
||||
html += '<br>';
|
||||
break;
|
||||
case 'horizontalRule':
|
||||
html += '<hr>';
|
||||
break;
|
||||
case 'image':
|
||||
if (element.attrs) {
|
||||
const attrs = Object.entries(element.attrs)
|
||||
.filter(attr => attr?.[1])
|
||||
.map(attr => `${attr[0]}="${attr[1]}"`);
|
||||
html += '<img ' + attrs.join('; ') + '>';
|
||||
}
|
||||
break;
|
||||
case 'paragraph':
|
||||
html +=
|
||||
'<p>' +
|
||||
(element.content ? jsonToHtml(element.content) : '<br>') +
|
||||
'</p>';
|
||||
break;
|
||||
case 'text':
|
||||
html += element.text;
|
||||
break;
|
||||
default:
|
||||
html += JSON.stringify(element, null, '\t'); //maybe I missed something.
|
||||
break;
|
||||
}
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
interface response {
|
||||
projects?: TopLevelProject[];
|
||||
updates?: Update[];
|
||||
project?: TopLevelProject;
|
||||
books?: BookElement[];
|
||||
chapter?: TopLevelChapter;
|
||||
}
|
||||
|
||||
interface BookElement {
|
||||
id: number;
|
||||
rank: number;
|
||||
title: string;
|
||||
chapters: PurpleChapter[];
|
||||
}
|
||||
|
||||
interface PurpleChapter {
|
||||
id: number;
|
||||
title: string;
|
||||
rank: string;
|
||||
modifiedAt: Date;
|
||||
}
|
||||
|
||||
interface TopLevelChapter {
|
||||
id: number;
|
||||
title: string;
|
||||
rank: string;
|
||||
content: string;
|
||||
modifiedAt: Date;
|
||||
book: ChapterBook;
|
||||
}
|
||||
|
||||
interface ChapterBook {
|
||||
id: number;
|
||||
rank: number;
|
||||
title: string;
|
||||
chapters: FluffyChapter[];
|
||||
project: BookProject;
|
||||
}
|
||||
|
||||
interface FluffyChapter {
|
||||
id: number;
|
||||
rank: string;
|
||||
}
|
||||
|
||||
interface BookProject {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface TopLevelProject {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
translator: string;
|
||||
modifiedAt: Date;
|
||||
alternatives: string;
|
||||
release: string;
|
||||
nation: string;
|
||||
status: string;
|
||||
status_translate: string;
|
||||
image: Image;
|
||||
tags?: Genre[];
|
||||
genres?: Genre[];
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Image {
|
||||
id: number;
|
||||
url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Update {
|
||||
id: number;
|
||||
title: string;
|
||||
author: string;
|
||||
translator: string;
|
||||
modifiedAt: Date;
|
||||
image: Image;
|
||||
}
|
||||
|
||||
interface HTML {
|
||||
type: string;
|
||||
content?: HTML[];
|
||||
attrs?: Attrs;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface Attrs {
|
||||
src: string;
|
||||
alt: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { HTMLParser2Util, Plugin } from '@typings/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
|
||||
enum ParseNovelAction {
|
||||
Unknown = 'Unknown',
|
||||
GetName = 'GetName',
|
||||
GetSummary = 'GetSummary',
|
||||
GetInfos = 'GetInfos',
|
||||
GetGenres = 'GetGenres',
|
||||
GetCover = 'GetCover',
|
||||
GetVolumes = 'GetVolumes',
|
||||
}
|
||||
|
||||
class HakoPlugin implements Plugin.PluginBase {
|
||||
id = 'ln.hako';
|
||||
name = 'Hako';
|
||||
icon = 'src/vi/hakolightnovel/icon.png';
|
||||
site = 'https://ln.hako.vn';
|
||||
version = '1.1.0';
|
||||
parseNovels(url: string) {
|
||||
return fetch(url)
|
||||
.then(res => res.text())
|
||||
.then(html => {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let tempNovel = {} as Plugin.NovelItem;
|
||||
let isGettingUrl = false;
|
||||
let isParsingNovel = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class']?.includes('thumb-item-flow')) {
|
||||
isParsingNovel = true;
|
||||
}
|
||||
if (isParsingNovel) {
|
||||
if (attribs['class']?.includes('series-title')) {
|
||||
isGettingUrl = true;
|
||||
}
|
||||
if (attribs['class']?.includes('img-in-ratio')) {
|
||||
tempNovel.cover = attribs['data-bg'];
|
||||
}
|
||||
if (isGettingUrl && name === 'a') {
|
||||
tempNovel.name = attribs['title'];
|
||||
tempNovel.path = attribs['href'];
|
||||
novels.push(tempNovel);
|
||||
tempNovel = {} as Plugin.NovelItem; // re-assign new reference
|
||||
isGettingUrl = false;
|
||||
isParsingNovel = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novels;
|
||||
});
|
||||
}
|
||||
popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site + '/danh-sach';
|
||||
if (filters) {
|
||||
if (filters.alphabet.value) {
|
||||
link += '/' + filters.alphabet.value;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
for (const novelType of filters.type.value) {
|
||||
params.append(novelType, '1');
|
||||
}
|
||||
for (const status of filters.status.value) {
|
||||
params.append(status, '1');
|
||||
}
|
||||
params.append('sapxep', filters.sort.value);
|
||||
link += '?' + params.toString() + '&page=' + pageNo;
|
||||
} else {
|
||||
link += '?page=' + pageNo;
|
||||
}
|
||||
return this.parseNovels(link);
|
||||
}
|
||||
|
||||
parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
author: '',
|
||||
artist: '',
|
||||
summary: '',
|
||||
genres: '',
|
||||
status: '',
|
||||
};
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
const getNameHandler: HTMLParser2Util.Handler = {
|
||||
isDone: false,
|
||||
isStarted: false,
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'a') {
|
||||
this.isStarted = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
novel.name += data;
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (this.isStarted) {
|
||||
this.isDone = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
const getSummaryHandler: HTMLParser2Util.Handler & {
|
||||
newLine: boolean;
|
||||
} = {
|
||||
newLine: false,
|
||||
ontext(data) {
|
||||
if (this.newLine) {
|
||||
this.newLine = false;
|
||||
novel.summary += '\n' + data;
|
||||
} else {
|
||||
novel.summary += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
this.newLine = true;
|
||||
},
|
||||
};
|
||||
const getGenresHandler: HTMLParser2Util.Handler = {
|
||||
ontext(data) {
|
||||
novel.genres += data;
|
||||
},
|
||||
};
|
||||
enum InfoItem {
|
||||
Author,
|
||||
Artist,
|
||||
Status,
|
||||
Unknown,
|
||||
}
|
||||
const getInfosHandler: HTMLParser2Util.Handler & { info: InfoItem } = {
|
||||
isStarted: false,
|
||||
info: InfoItem.Unknown,
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class'] === 'info-item') {
|
||||
switch (this.info) {
|
||||
case InfoItem.Unknown:
|
||||
if (!novel.author) {
|
||||
this.info = InfoItem.Author;
|
||||
}
|
||||
break;
|
||||
case InfoItem.Author:
|
||||
this.info = InfoItem.Artist;
|
||||
break;
|
||||
case InfoItem.Artist:
|
||||
this.info = InfoItem.Status;
|
||||
break;
|
||||
// we dont need the other info (if exist)
|
||||
case InfoItem.Status:
|
||||
this.info = InfoItem.Unknown;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (name === 'a') {
|
||||
this.isStarted = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (this.isStarted) {
|
||||
switch (this.info) {
|
||||
case InfoItem.Author:
|
||||
novel.author += data;
|
||||
break;
|
||||
case InfoItem.Artist:
|
||||
novel.artist += data;
|
||||
break;
|
||||
case InfoItem.Status:
|
||||
novel.status += data;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (this.isStarted) {
|
||||
this.isStarted = false;
|
||||
}
|
||||
if (name === 'a' && this.info === InfoItem.Status) {
|
||||
this.isDone = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
const getChapterListHandler: HTMLParser2Util.Handler & {
|
||||
currentVolume: string;
|
||||
num: number;
|
||||
part: number;
|
||||
readingTime: boolean;
|
||||
tempChapter: Plugin.ChapterItem;
|
||||
} = {
|
||||
currentVolume: '',
|
||||
num: 0,
|
||||
part: 1,
|
||||
isStarted: false,
|
||||
readingTime: false,
|
||||
tempChapter: {} as Plugin.ChapterItem,
|
||||
onopentag(name, attribs) {
|
||||
if (this.isStarted) {
|
||||
if (name === 'a' && attribs['title'] !== null) {
|
||||
const chapterName = attribs['title'];
|
||||
let chapterNumber = Number(
|
||||
chapterName.match(/Chương\s*(\d+)/i)?.[1],
|
||||
);
|
||||
if (chapterNumber) {
|
||||
if (this.num === chapterNumber) {
|
||||
chapterNumber = this.num + this.part / 10;
|
||||
this.part += 1;
|
||||
} else {
|
||||
this.num = chapterNumber;
|
||||
this.part = 1;
|
||||
}
|
||||
} else {
|
||||
chapterNumber = this.num + this.part / 10;
|
||||
this.part++;
|
||||
}
|
||||
this.tempChapter = {
|
||||
path: attribs['href'],
|
||||
name: chapterName,
|
||||
page: this.currentVolume,
|
||||
chapterNumber: chapterNumber,
|
||||
};
|
||||
} else if (attribs['class'] === 'chapter-time') {
|
||||
this.readingTime = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (this.readingTime) {
|
||||
const chapterTime = data.split('/').map(x => Number(x));
|
||||
this.tempChapter.releaseTime = new Date(
|
||||
chapterTime[2],
|
||||
chapterTime[1],
|
||||
chapterTime[0],
|
||||
).toISOString();
|
||||
chapters.push(this.tempChapter);
|
||||
this.readingTime = false;
|
||||
this.tempChapter = {} as Plugin.ChapterItem;
|
||||
}
|
||||
},
|
||||
onclosetag() {
|
||||
if (this.readingTime) this.readingTime = false;
|
||||
},
|
||||
};
|
||||
const getVolumesHandler: HTMLParser2Util.Handler & {
|
||||
isParsingChapterList: boolean;
|
||||
} = {
|
||||
isStarted: false,
|
||||
isDone: false,
|
||||
isParsingChapterList: false,
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class'] === 'sect-title') {
|
||||
this.isStarted = true;
|
||||
getChapterListHandler.currentVolume = '';
|
||||
}
|
||||
if (name === 'ul') {
|
||||
getChapterListHandler.isStarted = true;
|
||||
getChapterListHandler.num = 0;
|
||||
getChapterListHandler.part = 1;
|
||||
}
|
||||
getChapterListHandler.onopentag?.(name, attribs);
|
||||
},
|
||||
ontext(data) {
|
||||
if (this.isStarted) {
|
||||
getChapterListHandler.currentVolume += data.trim();
|
||||
}
|
||||
getChapterListHandler.ontext?.(data);
|
||||
},
|
||||
onclosetag(name, isImplied) {
|
||||
getChapterListHandler.onclosetag?.(name, isImplied);
|
||||
this.isStarted = false;
|
||||
if (name === 'ul') {
|
||||
getChapterListHandler.isStarted = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
const parseNovelRouter: HTMLParser2Util.HandlerRouter<ParseNovelAction> = {
|
||||
handlers: {
|
||||
Unknown: undefined,
|
||||
GetName: getNameHandler,
|
||||
GetCover: undefined,
|
||||
GetSummary: getSummaryHandler,
|
||||
GetGenres: getGenresHandler,
|
||||
GetInfos: getInfosHandler,
|
||||
GetVolumes: getVolumesHandler,
|
||||
},
|
||||
action: ParseNovelAction.Unknown,
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class'] === 'series-name') {
|
||||
this.action = ParseNovelAction.GetName;
|
||||
} else if (!novel.cover && attribs['class']?.includes('img-in-ratio')) {
|
||||
const background = attribs['style'];
|
||||
if (background) {
|
||||
novel.cover = background.substring(
|
||||
background.indexOf('http'),
|
||||
background.length - 2,
|
||||
);
|
||||
}
|
||||
} else if (attribs['class'] === 'summary-content') {
|
||||
this.action = ParseNovelAction.GetSummary;
|
||||
} else if (attribs['class'] === 'series-gerne-item') {
|
||||
this.action = ParseNovelAction.GetGenres;
|
||||
} else if (attribs['class'] === 'info-item') {
|
||||
this.action = ParseNovelAction.GetInfos;
|
||||
} else if (attribs['class']?.includes('volume-list')) {
|
||||
this.action = ParseNovelAction.GetVolumes;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
switch (this.action) {
|
||||
case ParseNovelAction.GetName:
|
||||
if (this.handlers.GetName?.isDone) {
|
||||
this.action = ParseNovelAction.Unknown;
|
||||
}
|
||||
break;
|
||||
case ParseNovelAction.GetSummary:
|
||||
if (name === 'div') {
|
||||
this.action = ParseNovelAction.Unknown;
|
||||
}
|
||||
break;
|
||||
case ParseNovelAction.GetGenres:
|
||||
this.action = ParseNovelAction.Unknown;
|
||||
novel.genres += ',';
|
||||
break;
|
||||
case ParseNovelAction.GetInfos:
|
||||
if (this.handlers.GetInfos?.isDone) {
|
||||
this.action = ParseNovelAction.Unknown;
|
||||
}
|
||||
break;
|
||||
case ParseNovelAction.GetVolumes:
|
||||
if (this.handlers.GetVolumes?.isDone) {
|
||||
this.action = ParseNovelAction.Unknown;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
return fetch(this.site + novelPath)
|
||||
.then(res => res.text())
|
||||
.then(html => {
|
||||
const parser = new Parser({
|
||||
onopentag(name, attributes) {
|
||||
parseNovelRouter.onopentag?.(name, attributes);
|
||||
if (parseNovelRouter.action) {
|
||||
parseNovelRouter.handlers[parseNovelRouter.action]?.onopentag?.(
|
||||
name,
|
||||
attributes,
|
||||
);
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (parseNovelRouter.action) {
|
||||
parseNovelRouter.handlers[parseNovelRouter.action]?.ontext?.(
|
||||
data,
|
||||
);
|
||||
}
|
||||
},
|
||||
onclosetag(name, isImplied) {
|
||||
if (parseNovelRouter.action) {
|
||||
parseNovelRouter.handlers[parseNovelRouter.action]?.onclosetag?.(
|
||||
name,
|
||||
isImplied,
|
||||
);
|
||||
}
|
||||
parseNovelRouter.onclosetag?.(name, isImplied);
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
novel.chapters = chapters;
|
||||
switch (novel.status?.trim()) {
|
||||
case 'Đang tiến hành':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'Tạm ngưng':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case 'Completed':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
}
|
||||
novel.genres = novel.genres?.replace(/,*\s*$/, '');
|
||||
novel.name = novel.name.trim();
|
||||
novel.summary = novel.summary?.trim();
|
||||
return novel;
|
||||
});
|
||||
}
|
||||
parseChapter(chapterPath: string): Promise<string> {
|
||||
return fetch(this.site + chapterPath)
|
||||
.then(res => res.text())
|
||||
.then(
|
||||
html =>
|
||||
html.match(
|
||||
/(<div id="chapter-content".+?>[^]+)<div style="text-align: center;/,
|
||||
)?.[1] || 'Không tìm thấy nội dung',
|
||||
);
|
||||
}
|
||||
searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site + '/tim-kiem?keywords=' + searchTerm + '&page=' + pageNo;
|
||||
return this.parseNovels(url);
|
||||
}
|
||||
imageRequestInit: Plugin.ImageRequestInit = {
|
||||
headers: {
|
||||
Referer: 'https://ln.hako.vn',
|
||||
},
|
||||
};
|
||||
filters = {
|
||||
alphabet: {
|
||||
type: FilterTypes.Picker,
|
||||
value: '',
|
||||
label: 'Chữ cái',
|
||||
options: [
|
||||
{ label: 'Tất cả', value: '' },
|
||||
{ label: 'Khác', value: 'khac' },
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
{ label: 'D', value: 'd' },
|
||||
{ label: 'E', value: 'e' },
|
||||
{ label: 'F', value: 'f' },
|
||||
{ label: 'G', value: 'g' },
|
||||
{ label: 'H', value: 'h' },
|
||||
{ label: 'I', value: 'i' },
|
||||
{ label: 'J', value: 'j' },
|
||||
{ label: 'K', value: 'k' },
|
||||
{ label: 'L', value: 'l' },
|
||||
{ label: 'M', value: 'm' },
|
||||
{ label: 'N', value: 'n' },
|
||||
{ label: 'O', value: 'o' },
|
||||
{ label: 'P', value: 'p' },
|
||||
{ label: 'Q', value: 'q' },
|
||||
{ label: 'R', value: 'r' },
|
||||
{ label: 'S', value: 's' },
|
||||
{ label: 'T', value: 't' },
|
||||
{ label: 'U', value: 'u' },
|
||||
{ label: 'V', value: 'v' },
|
||||
{ label: 'W', value: 'w' },
|
||||
{ label: 'X', value: 'x' },
|
||||
{ label: 'Y', value: 'y' },
|
||||
{ label: 'Z', value: 'z' },
|
||||
],
|
||||
},
|
||||
type: {
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
label: 'Phân loại',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Truyện dịch', value: 'truyendich' },
|
||||
{ label: 'Truyện sáng tác', value: 'sangtac' },
|
||||
{ label: 'Convert', value: 'convert' },
|
||||
],
|
||||
},
|
||||
status: {
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
label: 'Tình trạng',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Đang tiến hành', value: 'dangtienhanh' },
|
||||
{ label: 'Tạm ngưng', value: 'tamngung' },
|
||||
{ label: 'Đã hoàn thành', value: 'hoanthanh' },
|
||||
],
|
||||
},
|
||||
sort: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Sắp xếp',
|
||||
value: 'top',
|
||||
options: [
|
||||
{ label: 'A-Z', value: 'tentruyen' },
|
||||
{ label: 'Z-A', value: 'tentruyenza' },
|
||||
{ label: 'Mới cập nhật', value: 'capnhat' },
|
||||
{ label: 'Truyện mới', value: 'truyenmoi' },
|
||||
{ label: 'Theo dõi', value: 'theodoi' },
|
||||
{ label: 'Top toàn thời gian', value: 'top' },
|
||||
{ label: 'Top tháng', value: 'topthang' },
|
||||
{ label: 'Số từ', value: 'sotu' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new HakoPlugin();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user