Init
This commit is contained in:
commit
d97532c660
29
.eslintrc.js
Normal file
29
.eslintrc.js
Normal 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
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.idea/
|
||||
.out/
|
||||
|
||||
config.json
|
||||
mangaHistory.json
|
38
README.md
Normal file
38
README.md
Normal 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
40
package.json
Normal 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
2750
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
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}"`,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
20
tsconfig.json
Normal file
20
tsconfig.json
Normal 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"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user