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

29
.eslintrc.js Normal file
View File

@ -0,0 +1,29 @@
module.exports = {
env: {
browser: true,
es6: true,
node: true,
},
extends: ['prettier'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2018,
sourceType: 'module',
project: './tsconfig.json',
},
plugins: [
'@typescript-eslint/eslint-plugin',
'prettier',
],
rules: {
camelcase: 'error',
'@typescript-eslint/return-await': 'off',
'@typescript-eslint/camelcase': 'off',
'no-param-reassign': ['error', { props: false }],
'no-underscore-dangle': ['error', { allow: ['_id'] }],
},
reportUnusedDisableDirectives: true,
};

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
.idea/
.out/
config.json
mangaHistory.json

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# Manga Update Tracker
Small Node script that tracks updates on MangaDex and sends a Discord webhook when a new chapter is released.
## Setup
```shell
pnpm install
pnpm run compile
```
### Config
Create a `config.json` in the root directory of the project, or alongside the script. It should look like the below example, you can add as many webhooks as you want. The Check Interval is how often the script will check for updates, in seconds.
```json
{
"checkInterval": 900,
"mangaByWebhook": {
"DISCORD_WEBHOOK_URL_1": [
"MANGADEX_MANGA_ID_1",
"MANGADEX_MANGA_ID_2",
"MANGADEX_MANGA_ID_3"
],
"DISCORD_WEBHOOK_URL_2": [
"MANGADEX_MANGA_ID_4",
"MANGADEX_MANGA_ID_5"
]
}
}
```
Once the config is set up you can run the script:
```shell
node .out/build.js # Or wherever the script is located
```

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "chapter-tracker",
"version": "1.0.0",
"description": "",
"main": "./out/build.js",
"scripts": {
"compile": "esbuild src/index.ts --bundle --outfile=.out/build.js --minify --sourcemap --platform=node --external:esbuild",
"compile:watch": "esbuild src/index.ts --bundle --outfile=.out/build.js --sourcemap --platform=node --external:esbuild --watch",
"run": "node .out/build.js",
"run:watch": "node --watch .out/build.js"
},
"keywords": [],
"author": "pfych <contact@pfy.ch>",
"license": "MIT",
"packageManager": "pnpm@10.7.1",
"dependencies": {
"@types/lodash-es": "4.17.12",
"@types/node": "22.14.1",
"@typescript-eslint/eslint-plugin": "7.0.2",
"@typescript-eslint/parser": "7.0.2",
"@typescript-eslint/typescript-estree": "8.22.0",
"axios": "1.8.4",
"esbuild": "0.25.2",
"eslint": "8.25.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "5.1.3",
"lodash-es": "4.17.21",
"prettier": "3.5.3",
"typescript": "5.8.3"
},
"prettier": {
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"arrowParens": "always",
"printWidth": 80
}
}

2750
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

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}"`,
},
],
});
};

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es6",
"lib": ["dom", "dom.iterable", "es2021"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"outDir": "./out",
},
"include": ["**/*.tsx", "**/*.ts"],
"exclude": ["node_modules"]
}