Init
This commit is contained in:
commit
d97532c660
14 changed files with 3176 additions and 0 deletions
46
src/db/lazyKv.ts
Normal file
46
src/db/lazyKv.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
export class lazyKv {
|
||||
path: string;
|
||||
|
||||
constructor(_path: string) {
|
||||
this.path = _path;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init(): void {
|
||||
try {
|
||||
readFileSync(this.path, 'utf-8');
|
||||
} catch {
|
||||
writeFileSync(this.path, '{}', 'utf-8');
|
||||
} finally {
|
||||
console.log(`LazyKv: initialized ${this.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(key: string, fallback?: T): Promise<T> {
|
||||
const data = await readFile(this.path, 'utf-8');
|
||||
const json = JSON.parse(data);
|
||||
|
||||
return json[key] ?? (fallback || null);
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
const data = await readFile(this.path, 'utf-8');
|
||||
const json = JSON.parse(data);
|
||||
|
||||
json[key] = value;
|
||||
|
||||
await writeFile(this.path, JSON.stringify(json, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const data = await readFile(this.path, 'utf-8');
|
||||
const json = JSON.parse(data);
|
||||
|
||||
delete json[key];
|
||||
|
||||
await writeFile(this.path, JSON.stringify(json, null, 2), 'utf-8');
|
||||
}
|
||||
}
|
69
src/index.ts
Normal file
69
src/index.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { getAllChapters, getLatestChapter } from './mangadex/chapters';
|
||||
import { getManga } from './mangadex/manga';
|
||||
import { getCover } from './mangadex/cover';
|
||||
import { lazyKv } from './db/lazyKv';
|
||||
import { sendWebhook } from './utils/webhook';
|
||||
import { flatten, uniq } from 'lodash-es';
|
||||
import { ChapterId, MangaByWebhook } from './types';
|
||||
|
||||
const config = new lazyKv('./config.json');
|
||||
const mangaHistory = new lazyKv('./mangaHistory.json');
|
||||
|
||||
void (async () => {
|
||||
const checkInterval = await config.get<number>('checkInterval', 900);
|
||||
const mangaByWebhook = await config.get<MangaByWebhook>('mangaByWebhook', {});
|
||||
|
||||
const uniqueMangaIds = uniq(flatten(Object.values(mangaByWebhook)));
|
||||
const mangaIdsToWebhooks = uniqueMangaIds.reduce((acc, mangaId) => {
|
||||
return {
|
||||
...acc,
|
||||
[mangaId]: Object.keys(mangaByWebhook).filter((webhookUrl) =>
|
||||
mangaByWebhook[webhookUrl].includes(mangaId),
|
||||
),
|
||||
};
|
||||
}, {} as MangaByWebhook);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
console.log('\nChecking for updates...');
|
||||
|
||||
for (const mangaId of uniqueMangaIds) {
|
||||
const lastChapterId = await mangaHistory.get<ChapterId>(mangaId);
|
||||
|
||||
const manga = await getManga(mangaId);
|
||||
const chapters = await getAllChapters(mangaId);
|
||||
const latestChapter = await getLatestChapter(chapters);
|
||||
const cover = await getCover(manga);
|
||||
|
||||
if (lastChapterId !== latestChapter.id) {
|
||||
console.log(
|
||||
'Update found for manga:',
|
||||
manga.attributes.title.en || `MISSING ENGLISH TITLE (${mangaId})`,
|
||||
);
|
||||
|
||||
const webhooksForManga = mangaIdsToWebhooks[mangaId];
|
||||
|
||||
await Promise.all(
|
||||
webhooksForManga.map(
|
||||
async (webhookUrl) =>
|
||||
await sendWebhook({
|
||||
webhookUrl,
|
||||
manga,
|
||||
latestChapter,
|
||||
cover,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await mangaHistory.set(mangaId, latestChapter.id);
|
||||
} else {
|
||||
console.log(
|
||||
'No Updates found for manga:',
|
||||
manga.attributes.title.en || `MISSING ENGLISH TITLE (${mangaId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setInterval(async () => checkForUpdates(), checkInterval * 1000);
|
||||
await checkForUpdates();
|
||||
})();
|
67
src/mangadex/chapters.ts
Normal file
67
src/mangadex/chapters.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import axios from 'axios';
|
||||
import { sleep } from '../utils/sleep';
|
||||
import { Chapter } from '../types';
|
||||
|
||||
export const getAllChapters = async (id: string): Promise<Chapter[]> => {
|
||||
let nextPage = false;
|
||||
let offset = 0;
|
||||
|
||||
let chapters: Chapter[] = [];
|
||||
do {
|
||||
const response = await axios.get<{
|
||||
data: Chapter[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}>(`https://api.mangadex.org/manga/${id}/feed`, {
|
||||
params: {
|
||||
translatedLanguage: ['en'],
|
||||
offset,
|
||||
},
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Personal Chapter Update Tracker (Maintained by: https://pfy.ch)',
|
||||
},
|
||||
});
|
||||
|
||||
chapters.push(...response.data.data);
|
||||
|
||||
// Instead of handling timeout & rate limiting, we're just sleeping for a bit
|
||||
// Gross - but it works! This isn't time-critical code.
|
||||
await sleep(500);
|
||||
|
||||
if (response.data.offset < response.data.total - response.data.limit) {
|
||||
offset += response.data.limit;
|
||||
nextPage = true;
|
||||
} else {
|
||||
nextPage = false;
|
||||
}
|
||||
} while (nextPage);
|
||||
|
||||
return chapters;
|
||||
};
|
||||
|
||||
export const getLatestChapter = async (
|
||||
chapters: Chapter[],
|
||||
): Promise<Chapter> => {
|
||||
let latestFoundByChapterNumber = {
|
||||
id: '',
|
||||
chapter: 0,
|
||||
};
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const chapterId = chapter.id;
|
||||
const chapterNumber = parseFloat(chapter.attributes.chapter);
|
||||
|
||||
if (chapterNumber > latestFoundByChapterNumber.chapter) {
|
||||
latestFoundByChapterNumber = {
|
||||
id: chapterId,
|
||||
chapter: chapterNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return chapters.find(
|
||||
(chapter) => chapter.id === latestFoundByChapterNumber.id,
|
||||
) as Chapter;
|
||||
};
|
24
src/mangadex/cover.ts
Normal file
24
src/mangadex/cover.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import axios from 'axios';
|
||||
import { Manga } from './manga';
|
||||
|
||||
export const getCover = async (manga: Manga): Promise<string> => {
|
||||
const coverId = Object.values(manga.relationships).find(
|
||||
(relationship) => relationship.type === 'cover_art',
|
||||
)?.id;
|
||||
|
||||
if (!coverId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
`https://api.mangadex.org/cover/${coverId}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Personal Chapter Update Tracker (Maintained by: https://pfy.ch)',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return `https://mangadex.org/covers/${manga.id}/${response.data.data.attributes.fileName}`;
|
||||
};
|
18
src/mangadex/manga.ts
Normal file
18
src/mangadex/manga.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import axios from 'axios';
|
||||
import { Manga } from '../types';
|
||||
|
||||
export const getManga = async (id: string): Promise<Manga> => {
|
||||
const response = await axios.get<{
|
||||
data: Manga;
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}>(`https://api.mangadex.org/manga/${id}`, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Personal Chapter Update Tracker (Maintained by: https://pfy.ch)',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
};
|
36
src/types.d.ts
vendored
Normal file
36
src/types.d.ts
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
type MangaId = string;
|
||||
type ChapterId = string;
|
||||
type WebhookUrl = string;
|
||||
type MangaByWebhook = Record<WebhookUrl, MangaId[]>;
|
||||
type RelationshipType = 'author' | 'artist' | 'cover_art' | 'creator';
|
||||
type Language = 'en' | 'ja' | 'zh';
|
||||
|
||||
export interface Chapter {
|
||||
id: ChapterId;
|
||||
type: string;
|
||||
attributes: {
|
||||
volume: string;
|
||||
chapter: string;
|
||||
title: string;
|
||||
translatedLanguage: string;
|
||||
externalUrl: unknown;
|
||||
publishAt: string;
|
||||
readableAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
pages: number;
|
||||
version: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Manga {
|
||||
id: MangaId;
|
||||
type: string;
|
||||
attributes: {
|
||||
title: Record<Language, string>; // Record<Language, Title>
|
||||
};
|
||||
relationships: {
|
||||
id: string;
|
||||
type: RelationshipType;
|
||||
}[];
|
||||
}
|
2
src/utils/sleep.ts
Normal file
2
src/utils/sleep.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
31
src/utils/webhook.ts
Normal file
31
src/utils/webhook.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
/* eslint-disable camelcase */
|
||||
import axios from 'axios';
|
||||
import { Manga } from '../mangadex/manga';
|
||||
import { Chapter } from '../mangadex/chapters';
|
||||
|
||||
export const sendWebhook = async (args: {
|
||||
webhookUrl: string;
|
||||
manga: Manga;
|
||||
latestChapter: Chapter;
|
||||
cover: string;
|
||||
}) => {
|
||||
const { webhookUrl, manga, latestChapter, cover } = args;
|
||||
|
||||
await axios.post(webhookUrl, {
|
||||
username: 'Manga Updates',
|
||||
avatar_url: 'https://assets.pfy.ch/icons/manga.png',
|
||||
content: `[New chapter for ${manga.attributes.title.en || 'MISSING ENGLISH TITLE'}](https://mangadex.org/chapter/${latestChapter.id})`,
|
||||
embeds: [
|
||||
{
|
||||
author: {
|
||||
icon_url: 'https://assets.pfy.ch/icons/manga.png',
|
||||
name: manga.attributes.title.en || 'MISSING ENGLISH TITLE',
|
||||
},
|
||||
thumbnail: {
|
||||
url: cover,
|
||||
},
|
||||
description: `Chapter ${latestChapter.attributes.chapter}: "${latestChapter.attributes.title}"`,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue