feat: implement most of the app

This commit is contained in:
Ashhhleyyy 2022-09-18 18:39:31 +01:00
parent 76d6ccb9f1
commit 705068d795
Signed by: ash
GPG key ID: 83B789081A0878FB
17 changed files with 5115 additions and 146 deletions

47
App.tsx
View file

@ -1,20 +1,39 @@
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
import { QueryClient, QueryClientProvider } from 'react-query';
import Chapter from './src/pages/chapter';
import Comic from './src/pages/comic';
import ComicProfile from './src/pages/comicprofile';
import Home from './src/pages/home';
import { ComicProviderKey } from './src/provider';
import { NonLocalProviderKey } from './src/provider/local';
import Save from './src/pages/save';
const Stack = createNativeStackNavigator<RootStackParamList>();
const queryClient = new QueryClient();
export type RootStackParamList = {
Home: undefined;
ComicProfile: { provider: ComicProviderKey; comicId: string; };
Chapter: { provider: ComicProviderKey; comicId: string; chapterId: string; };
Page: { provider: ComicProviderKey; comicId: string; pageId: string; };
Save: { provider: NonLocalProviderKey; comicId: string; };
}
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={Home} />
<Stack.Screen name="ComicProfile" component={ComicProfile} />
<Stack.Screen name="Chapter" component={Chapter} />
<Stack.Screen name="Page" component={Comic} />
<Stack.Screen name="Save" component={Save} />
</Stack.Navigator>
<StatusBar style='dark' />
</NavigationContainer>
</QueryClientProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});

View file

@ -5,7 +5,7 @@
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",

View file

@ -9,12 +9,25 @@
"web": "expo start --web"
},
"dependencies": {
"@expo/webpack-config": "^0.17.0",
"@react-native-picker/picker": "2.4.2",
"@react-navigation/native": "^6.0.12",
"@react-navigation/native-stack": "^6.8.0",
"@types/react-native-vector-icons": "^6.4.12",
"cheerio": "^1.0.0-rc.12",
"expo": "~46.0.9",
"expo-file-system": "~14.1.0",
"expo-status-bar": "~1.4.0",
"react": "18.0.0",
"react-dom": "18.0.0",
"react-native": "0.69.5",
"react-native-web": "~0.18.7"
"react-native-gesture-handler": "~2.5.0",
"react-native-modalize": "^2.1.1",
"react-native-safe-area-context": "4.3.1",
"react-native-screens": "~3.15.0",
"react-native-vector-icons": "^9.2.0",
"react-native-web": "~0.18.7",
"react-query": "^3.39.2"
},
"devDependencies": {
"@babel/core": "^7.12.9",

View file

@ -0,0 +1,46 @@
import { useNavigation } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { FC } from "react";
import { ActivityIndicator, FlatList, StyleSheet, Text, View } from "react-native";
import { RootStackParamList } from "../../App";
import { SimpleChapter as ChapterData, ComicProviderKey, useComicChapters } from "../provider";
import Touchable from "./Touchable";
interface Props {
provider: ComicProviderKey;
comicId: string;
}
const Chapter: FC<ChapterData & { onClick: (id: string) => void }> = (props) => {
return <Touchable onPress={() => props.onClick(props.id)}>
<View style={styles.chapter}>
<Text>{props.title}</Text>
</View>
</Touchable>
}
const ChapterList: FC<Props> = (props) => {
const { data, error, isLoading } = useComicChapters(props.provider, props.comicId);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
if (isLoading) return <ActivityIndicator />;
if (error) return <Text>Failed to load comic: {error.toString()}</Text>;
if (!data) return <Text>Whoops! We couldn't find that comic :/</Text>;
function openChapter(chapterId: string) {
navigation.navigate('Chapter', {
...props,
chapterId,
});
}
return <FlatList data={data} renderItem={({ item }) => <Chapter onClick={openChapter} {...item} />} keyExtractor={item => item.id} />
}
export default ChapterList;
const styles = StyleSheet.create({
chapter: {
padding: 8,
},
});

View file

@ -0,0 +1,47 @@
import { FC } from "react";
import { Image, StyleSheet, Text, View } from "react-native";
import { ComicMetadata } from "../provider";
interface Props {
comic: ComicMetadata
}
const ComicCard: FC<Props> = (props) => {
return <View style={styles.aboutRow}>
<Image source={{ uri: props.comic.avatarUrl }} style={styles.avatar} />
<View style={styles.titleContainer}>
<Text style={styles.title}>{props.comic.title}</Text>
<Text style={styles.subtitle}>{props.comic.shortDescription}</Text>
<Text style={styles.author}>By {props.comic.author}</Text>
</View>
</View>
}
export default ComicCard;
const styles = StyleSheet.create({
aboutRow: {
flexDirection: 'row',
alignContent: 'center',
},
titleContainer: {
flex: 1,
flexDirection: 'column',
paddingLeft: 8,
},
title: {
fontSize: 24,
},
subtitle: {
fontSize: 16,
},
author: {
fontSize: 16,
color: '#888',
},
avatar: {
borderRadius: 8,
width: 128,
height: 128,
},
});

View file

@ -0,0 +1,10 @@
import { View } from "react-native";
export default function Divider() {
return <View style={{
borderColor: '#ccc',
borderBottomWidth: 2,
width: '100%',
paddingVertical: 8,
}} />
}

View file

@ -0,0 +1,12 @@
import { FC } from "react";
import { Platform, TouchableNativeFeedback, TouchableOpacity, TouchableOpacityProps } from "react-native";
const Touchable: FC<TouchableOpacityProps> = (props) => {
if (Platform.OS === 'android') {
return <TouchableNativeFeedback {...props} />
} else {
return <TouchableOpacity {...props} />
}
}
export default Touchable;

54
src/pages/chapter.tsx Normal file
View file

@ -0,0 +1,54 @@
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { FC } from "react";
import { ActivityIndicator, FlatList, Platform, StyleSheet, Text, TouchableNativeFeedback, TouchableOpacity, View } from "react-native";
import { RootStackParamList } from "../../App";
import Divider from "../components/Divider";
import Touchable from "../components/Touchable";
import { SimplePage, useComicChapter } from "../provider";
const Page: FC<SimplePage & { onClick: (id: string) => void }> = (props) => {
return <Touchable onPress={() => props.onClick(props.id)}>
<View style={styles.page}>
<Text>{props.title}</Text>
</View>
</Touchable>
}
export default function Chapter(props: Props) {
const { data, error, isLoading } = useComicChapter(props.route.params.provider, props.route.params.comicId, props.route.params.chapterId, props.navigation);
if (isLoading) return <ActivityIndicator />;
if (error) return <Text>Failed to load comic: {error.toString()}</Text>;
if (!data) return <Text>Whoops! We couldn't find that chapter :/</Text>;
function openPage(pageId: string) {
props.navigation.navigate('Page', {
...props.route.params,
pageId,
});
}
return <View style={styles.container}>
<Text style={styles.description}>
{data.description}
</Text>
<Divider />
<FlatList data={data.pages} renderItem={({ item }) => <Page {...item} onClick={openPage} />} keyExtractor={item => item.id} />
</View>
}
type Props = NativeStackScreenProps<RootStackParamList, 'Chapter'>;
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
description: {
fontSize: 16,
paddingLeft: 8,
},
page: {
padding: 8,
},
});

72
src/pages/comic.tsx Normal file
View file

@ -0,0 +1,72 @@
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { ActivityIndicator, Image, ScrollView, StyleSheet, Text, View } from "react-native";
import { RootStackParamList } from "../../App";
import Touchable from "../components/Touchable";
import { useComicPage } from "../provider";
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
export default function Comic(props: Props) {
const { data, error, isLoading } = useComicPage(props.route.params.provider, props.route.params.comicId, props.route.params.pageId, props.navigation);
if (isLoading) return <ActivityIndicator />;
if (error) return <Text>Failed to load comic: {error.toString()}</Text>;
if (!data) return <Text>Whoops! We couldn't find that page :/</Text>;
function newPage(pageId?: string) {
if (!pageId) return;
props.navigation.pop();
props.navigation.push('Page', {
...props.route.params,
pageId,
});
}
return <ScrollView>
{data.imageSegments.map((item) => <Image style={{
width: '100%',
aspectRatio: item.width / item.height,
}} source={{ uri: item.url }} resizeMode='center' key={item.url} />)}
<View style={styles.navigationRow}>
<Touchable onPress={() => newPage(data.previousPageId)} disabled={data.previousPageId === undefined}>
<View style={styles.button}>
<MaterialCommunityIcons size={32} name='arrow-left-circle' color={data.previousPageId === undefined ? '#aaa' : '#555'} />
<Text style={styles.buttonLabel}>
Previous
</Text>
</View>
</Touchable>
<Touchable onPress={() => newPage(data.nextPageId)} disabled={data.nextPageId === undefined}>
<View style={styles.button}>
<MaterialCommunityIcons size={32} name='arrow-right-circle' color={data.nextPageId === undefined ? '#aaa' : '#555'} />
<Text style={styles.buttonLabel}>
Next
</Text>
</View>
</Touchable>
</View>
</ScrollView>
}
type Props = NativeStackScreenProps<RootStackParamList, 'Page'>;
const styles = StyleSheet.create({
image: {
width: '100%',
},
navigationRow: {
flexDirection: 'row',
width: '100%',
// justifyContent: 'space-evenly',
},
button: {
// padding: 4,
flexDirection: 'column',
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
buttonLabel: {
paddingTop: 4,
}
});

View file

@ -0,0 +1,48 @@
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { RootStackParamList } from "../../App";
import ComicCard from "../components/ComicCard";
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import { useComicMetadata } from "../provider";
import Divider from "../components/Divider";
import ChapterList from "../components/ChapterList";
export default function ComicProfile(props: Props) {
const comicQuery = useComicMetadata(props.route.params.provider, props.route.params.comicId, (data) => {
props.navigation.setOptions({
title: data.title,
headerRight: props.route.params.provider === 'local' ? undefined : () => (
<TouchableOpacity onPress={() => {
const { provider, comicId } = props.route.params;
if (provider !== 'local') {
props.navigation.push('Save', {
provider,
comicId,
});
}
}}>
<MaterialCommunityIcons name='plus-circle' size={24} style={{ color: 'black' }} />
</TouchableOpacity>
),
});
});
if (comicQuery.isLoading) return <ActivityIndicator />;
if (comicQuery.error) return <Text>Failed to load comic: {comicQuery.error.toString()}</Text>;
if (!comicQuery.data) return <Text>Whoops! We couldn't find that comic :/</Text>;
return <View style={styles.container}>
<ComicCard comic={comicQuery.data} />
<Divider />
<ChapterList provider={props.route.params.provider} comicId={props.route.params.comicId} />
</View>
}
type Props = NativeStackScreenProps<RootStackParamList, 'ComicProfile'>;
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
})

53
src/pages/home.tsx Normal file
View file

@ -0,0 +1,53 @@
import { Picker } from "@react-native-picker/picker";
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { useState } from "react";
import { Button, StyleSheet, Text, TextInput, View } from "react-native";
import { useQueryClient } from "react-query";
import { RootStackParamList } from "../../App";
import { ComicProviderKey } from "../provider";
import * as FileSystem from 'expo-file-system';
export default function Home(props: Props) {
const [selectedProvider, setSelectedProvider] = useState<ComicProviderKey>('comicfury');
const [comicId, setComicId] = useState('');
const queryClient = useQueryClient();
return <View style={styles.container}>
<Text>Hello, World!</Text>
<Picker<ComicProviderKey> style={styles.input} selectedValue={selectedProvider} onValueChange={(v) => setSelectedProvider(v)}>
<Picker.Item value='comicfury' label='Comicfury' />
<Picker.Item value='local' label='Local' />
</Picker>
<TextInput style={styles.input} onChangeText={setComicId} value={comicId} placeholder='Comic ID' />
<View style={styles.button}>
<Button title="Load comic" onPress={() => props.navigation.navigate('ComicProfile', { provider: selectedProvider, comicId })} />
</View>
<View style={styles.button}>
<Button title="Yeet cache" onPress={() => queryClient.getQueryCache().clear()} />
</View>
<View style={styles.button}>
<Button title="Yeet library" onPress={async () => {
await FileSystem.deleteAsync(FileSystem.documentDirectory + '/comics', { idempotent: true });
alert('library yeeted!');
}} />
</View>
</View>
}
type Props = NativeStackScreenProps<RootStackParamList, 'Home'>;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
input: {
width: '100%',
},
button: {
paddingVertical: 8,
},
});

54
src/pages/save.tsx Normal file
View file

@ -0,0 +1,54 @@
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { useEffect, useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { RootStackParamList } from "../../App";
import localProvider from "../provider/local";
export default function Save(props: Props) {
const [newId, setNewId] = useState<string | null>(null);
const [error, setError] = useState<any | null>(null);
const [message, setMessage] = useState('');
useEffect(() => props.navigation.addListener('beforeRemove', (e) => {
if (newId || error) return;
e.preventDefault();
}), [props.navigation, newId, error]);
useEffect(() => {
if (newId) {
props.navigation.pop(2);
props.navigation.push('ComicProfile', {
provider: 'local',
comicId: newId,
});
}
}, [newId]);
useEffect(() => {
localProvider.downloadComicData(props.route.params.provider, props.route.params.comicId, setMessage)
.then((newId) => {
if (newId) {
setNewId(newId);
}
}).catch((err) => {
setError(err);
setMessage('Failed: ' + err);
});
}, []);
return <View style={styles.container}>
<ActivityIndicator size='large' />
<Text>Saving comic...</Text>
<Text>{message}</Text>
</View>
}
type Props = NativeStackScreenProps<RootStackParamList, 'Save'>;
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});

129
src/provider/comicfury.ts Normal file
View file

@ -0,0 +1,129 @@
import type { SimpleChapter, ComicProvider, SimplePage, ImageSegment } from ".";
import scrapePage from "../util/fetch";
const comicfuryProvider: ComicProvider = {
async getComicInfo(comicId) {
const baseUrl = `https://comicfury.com/comicprofile.php?url=${encodeURIComponent(comicId)}`;
const $ = await scrapePage(baseUrl);
if (!$) return null;
const title = $('.profile .profileinfobox .username-and-title .authorname').text().trim();
let avatarUrl = $('.profile-avatar img[alt="Webcomic avatar"]').attr('src') ?? '';
const shortDescription = $('.profile .profileinfobox .username-and-title em').text().trim();
let description = '';
let author = '';
let authorAvatarUrl = '';
$('.profilecategory').each(function () {
const header = $(this).find('.pchead').text().trim();
const content = $(this).find('.pccontent')
if (header === 'Webcomic description') {
content.find('.description-tags').remove(); // Remove the tags to ease fetching the description text.
description = content.text().trim();
} else if (header === 'Authors') {
authorAvatarUrl = content.find('.pcleft-user img').attr('src') ?? '';
author = content.find('.authorname').text().trim();
}
});
if (avatarUrl !== '') {
avatarUrl = new URL(avatarUrl, 'https://comicfury.com').toString();
}
if (authorAvatarUrl !== '') {
authorAvatarUrl = new URL(authorAvatarUrl, 'https://comicfury.com').toString();
}
return {
title,
shortDescription,
avatarUrl,
author,
authorAvatarUrl,
description,
};
},
async listChapters(comicId) {
const baseUrl = `https://comicfury.com/read/${encodeURIComponent(comicId)}/archive`;
const $ = await scrapePage(baseUrl);
if (!$) return null;
const chapters: SimpleChapter[] = [];
$('.archive-chapter').each(function () {
const url = $(this).parent().attr('href')!;
const id = url.substring(`/read/${encodeURIComponent(comicId)}/archive/chapter/`.length);
const title = $(this).text().trim();
chapters.push({
id,
title,
});
});
return chapters;
},
async chapterInfo(comicId, chapterId) {
const baseUrl = `https://comicfury.com/read/${encodeURIComponent(comicId)}/archive/chapter/${encodeURIComponent(chapterId)}`;
const $ = await scrapePage(baseUrl);
if (!$) return null;
const title = $('.webcomic-title-content-inner').text().trim();
const description = $('.webcomic-title-subtext').text().trim();
const pages: SimplePage[] = [];
$('.archive-comic').each(function () {
const url = $(this).parent().attr('href')!;
const id = url.substring(`/read/${encodeURIComponent(comicId)}/comics/`.length);
const title = $(this).find('.archive-comic-title').text().trim();
const published = $(this).find('.archive-comic-date').text().trim();
pages.push({
id,
title,
published,
});
});
return {
id: chapterId,
title,
description,
pages,
};
},
async getPage(comicId, pageId) {
const baseUrl = `https://comicfury.com/read/${encodeURIComponent(comicId)}/comics/${encodeURIComponent(pageId)}`;
const $ = await scrapePage(baseUrl);
if (!$) return null;
const pageRoot = $(`.is--comic-page#comic-${pageId}`);
const title = pageRoot.find('.is--title').text().trim();
const published = pageRoot.find('.is--author-notes .is--comment-time').text().trim();
const nextPageId = pageRoot.attr('data-next-comicid');
const previousPageId = pageRoot.attr('data-prev-comicid');
const imageSegmentUrls: ImageSegment[] = [];
pageRoot.find('.is--image-segment img').each(function () {
const url = $(this).attr('src')!;
const width = parseInt($(this).attr('width')!);
const height = parseInt($(this).attr('height')!);
imageSegmentUrls.push({
url,
width,
height,
});
});
return {
id: pageId,
title,
published,
imageSegments: imageSegmentUrls,
previousPageId: previousPageId === '-1' ? undefined : previousPageId,
nextPageId: nextPageId === '-1' ? undefined : nextPageId,
};
},
};
export default comicfuryProvider;

114
src/provider/index.ts Normal file
View file

@ -0,0 +1,114 @@
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useQuery } from "react-query";
import { RootStackParamList } from "../../App";
import comicfuryProvider from "./comicfury";
import localProvider from "./local";
export interface ComicProvider {
getComicInfo(comicId: string): Promise<ComicMetadata | null>;
listChapters(comicId: string): Promise<SimpleChapter[] | null>;
chapterInfo(comicId: string, chapterId: string): Promise<FullChapter | null>;
getPage(comicId: string, pageId: string): Promise<FullPage | null>;
}
export interface ComicMetadata {
title: string;
author: string;
authorAvatarUrl: string;
shortDescription: string;
description: string;
avatarUrl: string;
}
export interface SimpleChapter {
id: string;
title: string;
}
export interface FullChapter {
id: string;
title: string;
description: string;
pages: SimplePage[];
}
export interface SimplePage {
id: string;
title: string;
published: string;
}
export interface FullPage {
id: string;
title: string;
published: string;
imageSegments: ImageSegment[];
previousPageId?: string;
nextPageId?: string;
}
export interface ImageSegment {
url: string;
width: number;
height: number;
}
export const COMIC_PROVIDERS = {
comicfury: comicfuryProvider,
local: localProvider,
};
export type ComicProviderKey = keyof typeof COMIC_PROVIDERS;
export function useComicMetadata(provider: ComicProviderKey, comicId: string, onLoaded?: (data: ComicMetadata) => void) {
return useQuery<ComicMetadata | null, Error>(
['comic', provider, comicId],
() => COMIC_PROVIDERS[provider].getComicInfo(comicId),
{
onSuccess(data) {
if (onLoaded && data) {
onLoaded(data);
}
}
}
);
}
export function useComicChapters(provider: ComicProviderKey, comicId: string) {
return useQuery<SimpleChapter[] | null, Error>(
['comicChapters', provider, comicId],
() => COMIC_PROVIDERS[provider].listChapters(comicId)
);
}
export function useComicChapter(provider: ComicProviderKey, comicId: string, chapterId: string, navigation?: NativeStackNavigationProp<RootStackParamList>) {
return useQuery<FullChapter | null, Error>(
['comicChapter', provider, comicId, chapterId],
() => COMIC_PROVIDERS[provider].chapterInfo(comicId, chapterId),
{
onSuccess(data) {
if (navigation && data) {
navigation.setOptions({
title: data.title,
});
}
}
}
);
}
export function useComicPage(provider: ComicProviderKey, comicId: string, pageId: string, navigation?: NativeStackNavigationProp<RootStackParamList>) {
return useQuery<FullPage | null, Error>(
['comicPage', provider, comicId, pageId],
() => COMIC_PROVIDERS[provider].getPage(comicId, pageId),
{
onSuccess(data) {
if (navigation && data) {
navigation.setOptions({
title: data.title,
});
}
}
}
);
}

105
src/provider/local.ts Normal file
View file

@ -0,0 +1,105 @@
import { ComicProvider, ComicProviderKey, COMIC_PROVIDERS, FullPage } from ".";
import * as FileSystem from 'expo-file-system';
export type NonLocalProviderKey = Exclude<ComicProviderKey, 'local'>;
type LocalComicId = `${NonLocalProviderKey}-${string}`;
type LocalProvider = ComicProvider & {
downloadComicData(providerKey: NonLocalProviderKey, comicId: string, statusCallback?: (message: string) => void): Promise<LocalComicId | null>;
};
function dataPath(path: string): string {
return FileSystem.documentDirectory + '/' + path;
}
async function ensureDataDirectory(path: string) {
const dirInfo = await FileSystem.getInfoAsync(dataPath(path));
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(dataPath(path), { intermediates: true });
}
}
function parseComicId(comicId: string): [providerKey: string, comicId: string] | null {
const pts = comicId.split('-', 2);
if (pts.length !== 2) {
return null;
}
return pts as [string, string]; // typescript inference bad.
}
async function pathExists(path: string): Promise<boolean> {
const info = await FileSystem.getInfoAsync(path);
return info.exists;
}
async function localJson<T>(comicId: string, path: string): Promise<T | null> {
const parsed = parseComicId(comicId);
if (parsed === null) return null;
const [providerKey, id] = parsed;
const pth = dataPath(`comics/${providerKey}/${id}/${path}`);
if (!pathExists(pth)) return null;
return JSON.parse(await FileSystem.readAsStringAsync(pth));
}
const localProvider: LocalProvider = {
getComicInfo(comicId) {
return localJson(comicId, 'comic.json');
},
listChapters(comicId) {
return localJson(comicId, 'chapters.json');
},
chapterInfo(comicId, chapterId) {
return localJson(comicId, `chapters/${chapterId}.json`);
},
getPage(comicId, pageId) {
return localJson(comicId, `pages/${pageId}.json`);
},
async downloadComicData(providerKey, comicId, statusCallback) {
const provider = COMIC_PROVIDERS[providerKey];
const info = provider.getComicInfo(comicId);
if (info === null) return null;
const comicPath = `comics/${providerKey}/${comicId}`;
await ensureDataDirectory(comicPath);
await ensureDataDirectory(`${comicPath}/chapters`);
await ensureDataDirectory(`${comicPath}/pages`);
await FileSystem.writeAsStringAsync(dataPath(`${comicPath}/comic.json`), JSON.stringify(info));
const chapters = await provider.listChapters(comicId);
if (chapters === null) throw new Error('invalid state');
await FileSystem.writeAsStringAsync(dataPath(`${comicPath}/chapters.json`), JSON.stringify(chapters));
let chapterIndex = 0;
for (const incompleteChapter of chapters) {
chapterIndex += 1;
if (statusCallback) statusCallback(`Chapter ${chapterIndex}/${chapters.length}`);
const chapter = await provider.chapterInfo(comicId, incompleteChapter.id);
if (chapter === null) throw new Error('invalid state');
await FileSystem.writeAsStringAsync(dataPath(`${comicPath}/chapters/${chapter.id}.json`), JSON.stringify(chapter));
let completedPages = 0;
const pageData = (await Promise.all(chapter.pages.map(async (incompletePage) => {
const fileInfo = await FileSystem.getInfoAsync(dataPath(`${comicPath}/pages/${incompletePage.id}.json`));
if (fileInfo.exists) return null;
const page = await provider.getPage(comicId, incompletePage.id);
if (page === null) throw new Error('invalid state');
completedPages += 1;
if (statusCallback) statusCallback(`Chapter ${chapterIndex}/${chapters.length} - ${completedPages}/${chapter.pages.length}`)
return page;
}))).filter(page => page !== null) as FullPage[]; // typescript can't infer this either somehow
let i = 0
for (const page of pageData) {
i += 1;
await FileSystem.writeAsStringAsync(dataPath(`${comicPath}/pages/${page.id}.json`), JSON.stringify(page));
if (statusCallback) statusCallback(`Chapter ${chapterIndex}/${chapters.length} - Written ${i}/${pageData.length}`)
}
}
return `${providerKey}-${comicId}`;
},
}
export default localProvider;

26
src/util/fetch.ts Normal file
View file

@ -0,0 +1,26 @@
import { CheerioAPI, load as cheerioLoad } from 'cheerio';
export default async function scrapePage(url: string): Promise<CheerioAPI | null> {
const res = await fetch(url, {
headers: {
'user-agent': 'comet/1.0 (+https://git.ashhhleyyy.dev/ash/comet/)',
},
});
if (res.ok) {
return res.text()
.then(html => cheerioLoad(html))
.then(($) => {
if ($('.errorbox').length !== 0) {
return null;
} else {
return $;
}
});
} else {
if (res.status === 404) {
return null;
} else {
throw new Error(`request failed with code: ${res.status} (${res.statusText})`);
}
}
}

4427
yarn.lock

File diff suppressed because it is too large Load diff