This commit is contained in:
pfych 2025-04-20 12:52:11 +10:00
commit d97532c660
14 changed files with 3176 additions and 0 deletions

46
src/db/lazyKv.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}"`,
},
],
});
};