broken-manga 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +257 -2
- package/index.d.ts +146 -0
- package/index.js +442 -0
- package/index.mjs +17 -0
- package/package.json +28 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akewushola Abdulbakri Temitope (brokenvzn)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,258 @@
|
|
|
1
|
-
#
|
|
1
|
+
# broken-manga
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Real manga data with zero dependencies.** Search, details, chapter lists and actual page images — via the public MangaDex API. No API key, no scraping, no headless browser.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install broken-manga
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
Most manga "APIs" are HTML scrapers: they run a headless browser, parse a page, and break the moment the site changes its markup. This talks to MangaDex's real JSON API instead, using the `fetch` that already ships with Node 18+.
|
|
12
|
+
|
|
13
|
+
**0 dependencies. No key. No browser. And it supports adult content properly** — through content ratings, not a separate sketchy endpoint.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const { search, getChapters, getPages } = require('broken-manga');
|
|
19
|
+
|
|
20
|
+
const hits = await search('berserk');
|
|
21
|
+
console.log(hits.results[0].title); // 'Berserk'
|
|
22
|
+
|
|
23
|
+
const chapters = await getChapters(hits.results[0].id);
|
|
24
|
+
const pages = await getPages(chapters.results[0].id);
|
|
25
|
+
console.log(pages.pages[0]); // direct image URL
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
ESM works too:
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import { search, getChapters, getPages } from 'broken-manga';
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Content ratings
|
|
35
|
+
|
|
36
|
+
MangaDex labels everything with one of four ratings:
|
|
37
|
+
|
|
38
|
+
| rating | meaning |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `safe` | nothing to warn about |
|
|
41
|
+
| `suggestive` | mild fan service |
|
|
42
|
+
| `erotica` | sexual content |
|
|
43
|
+
| `pornographic` | explicit |
|
|
44
|
+
|
|
45
|
+
**By default this package only returns `safe` and `suggestive`.** Adult results never leak into a normal search unless you ask for them — there's a test for exactly that.
|
|
46
|
+
|
|
47
|
+
### Getting adult content
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
// adult only
|
|
51
|
+
const r = await search('', { adultOnly: true, sort: 'followed' });
|
|
52
|
+
|
|
53
|
+
// mix safe and adult
|
|
54
|
+
const r2 = await search('romance', { ratings: ['safe', 'erotica', 'pornographic'] });
|
|
55
|
+
|
|
56
|
+
// everything
|
|
57
|
+
const r3 = await search('x', { ratings: ['safe', 'suggestive', 'erotica', 'pornographic'] });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every result also carries `isAdult`, so you can filter after the fact:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
const all = await search('yuri', { ratings: ['safe', 'suggestive', 'erotica'] });
|
|
64
|
+
const clean = all.results.filter((g) => !g.isAdult);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { RATINGS, ADULT_RATINGS, SAFE_RATINGS, ratings } from 'broken-manga';
|
|
69
|
+
ratings(); // { all: [...], adult: [...], safe: [...] }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
### `search(query?, options?)`
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
await search('one piece');
|
|
78
|
+
await search('one piece', { limit: 20, status: 'completed' });
|
|
79
|
+
await search('', { sort: 'rating' }); // browse, no query
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| option | default | notes |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `ratings` | `['safe','suggestive']` | single value or array |
|
|
85
|
+
| `adultOnly` | `false` | shorthand for adult ratings only |
|
|
86
|
+
| `limit` | `10` | 1–100 |
|
|
87
|
+
| `offset` | `0` | for paging |
|
|
88
|
+
| `status` | — | `ongoing` `completed` `hiatus` `cancelled` |
|
|
89
|
+
| `sort` | `relevance` | `followed` `rating` `latest` `created` `updated` |
|
|
90
|
+
| `language` | — | restrict to a translated language |
|
|
91
|
+
|
|
92
|
+
Returns `{ results, total, offset, limit }`.
|
|
93
|
+
|
|
94
|
+
### `getManga(id, options?)`
|
|
95
|
+
|
|
96
|
+
One manga, full metadata.
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const g = await getManga('801513ba-a712-498c-8f57-cae55b38cc92');
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| field | notes |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `id` | MangaDex UUID |
|
|
105
|
+
| `title` / `altTitles` | |
|
|
106
|
+
| `description` | plain text, localised |
|
|
107
|
+
| `contentRating` | one of the four ratings |
|
|
108
|
+
| `isAdult` | `true` for erotica / pornographic |
|
|
109
|
+
| `status` / `year` | |
|
|
110
|
+
| `tags` | genres |
|
|
111
|
+
| `demographic` | shounen, seinen, etc |
|
|
112
|
+
| `authors` / `artist` | |
|
|
113
|
+
| `cover` / `coverThumb` | full-size and 256px thumbnail |
|
|
114
|
+
| `availableLanguages` | which translations exist |
|
|
115
|
+
| `lastChapter` / `lastVolume` | |
|
|
116
|
+
|
|
117
|
+
Missing fields are `null`, never `undefined`.
|
|
118
|
+
|
|
119
|
+
### `getChapters(id, options?)`
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
await getChapters(mangaId); // English, oldest first
|
|
123
|
+
await getChapters(mangaId, { languages: ['en', 'fr'] });
|
|
124
|
+
await getChapters(mangaId, { ascending: false, limit: 20 }); // newest first
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| option | default | notes |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `languages` | `['en']` | string or array |
|
|
130
|
+
| `ascending` | `true` | chapter order |
|
|
131
|
+
| `limit` | `100` | 1–500 |
|
|
132
|
+
| `includeExternal` | `true` | chapters hosted on other sites |
|
|
133
|
+
|
|
134
|
+
Each chapter: `{ id, chapter, volume, title, language, pages, publishedAt, scanlationGroup, externalUrl }`.
|
|
135
|
+
|
|
136
|
+
### `getPages(chapterId, options?)`
|
|
137
|
+
|
|
138
|
+
The actual images.
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
const p = await getPages(chapterId);
|
|
142
|
+
p.pages; // ['https://...jpg', ...] — ready to load
|
|
143
|
+
p.pageCount; // 37
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
| option | default | notes |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `dataSaver` | `false` | use the compressed page set — good for mobile |
|
|
149
|
+
|
|
150
|
+
MangaDex hands out a short-lived CDN base URL per chapter, so **don't cache these URLs long-term**. Call `getPages` again when you need them.
|
|
151
|
+
|
|
152
|
+
### `getFull(id, options?)`
|
|
153
|
+
|
|
154
|
+
Details + chapters + the first chapter's pages, in one call. Three requests, so mind the rate limit.
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
const { manga, chapters, firstPages } = await getFull(mangaId);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### `popular(options?)` / `latest(options?)`
|
|
161
|
+
|
|
162
|
+
Trending and newly added. Same options as `search()`.
|
|
163
|
+
|
|
164
|
+
### `MangaError`
|
|
165
|
+
|
|
166
|
+
Every failure throws this, so you branch on `.code`.
|
|
167
|
+
|
|
168
|
+
| code | meaning |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `BAD_ID` | missing/invalid id |
|
|
171
|
+
| `BAD_QUERY` | the service rejected the query (includes its reason) |
|
|
172
|
+
| `BAD_RATING` | unknown content rating (message lists valid ones) |
|
|
173
|
+
| `BAD_SORT` | unknown sort value |
|
|
174
|
+
| `NOT_FOUND` | no such manga |
|
|
175
|
+
| `NO_PAGES` | chapter exists but has no images |
|
|
176
|
+
| `RATE_LIMITED` | HTTP 429 — backed off and retried |
|
|
177
|
+
| `TIMEOUT` / `NETWORK` | couldn't reach the service |
|
|
178
|
+
| `SERVER_ERROR` | 5xx — retried |
|
|
179
|
+
| `HTTP_ERROR` | other non-2xx |
|
|
180
|
+
| `BAD_RESPONSE` | body wasn't JSON |
|
|
181
|
+
| `API_ERROR` | the service reported an error in the body |
|
|
182
|
+
|
|
183
|
+
## Common patterns
|
|
184
|
+
|
|
185
|
+
**A reader**
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
const { getChapters, getPages } = require('broken-manga');
|
|
189
|
+
|
|
190
|
+
const chapters = await getChapters(mangaId, { limit: 500 });
|
|
191
|
+
let index = 0;
|
|
192
|
+
|
|
193
|
+
async function show(i) {
|
|
194
|
+
const pages = await getPages(chapters.results[i].id);
|
|
195
|
+
render(pages.pages); // your UI
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Next / previous chapter, skipping ones with no pages**
|
|
200
|
+
|
|
201
|
+
```js
|
|
202
|
+
function nextReadable(chapters, from) {
|
|
203
|
+
for (let i = from + 1; i < chapters.length; i++) {
|
|
204
|
+
if (chapters[i].pages > 0) return i;
|
|
205
|
+
}
|
|
206
|
+
return -1;
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Search that never shows adult content**
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
const r = await search(q, { ratings: ['safe'] });
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Adult-only search, most followed first**
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
const r = await search('', { adultOnly: true, sort: 'followed', limit: 20 });
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Handle rate limits**
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
try {
|
|
226
|
+
await getChapters(id);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (err.code === 'RATE_LIMITED') await sleep(3000);
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Rate limits
|
|
233
|
+
|
|
234
|
+
MangaDex is free and shared, and it is **strict**. Roughly 5 requests per second, and it penalises bursts.
|
|
235
|
+
|
|
236
|
+
- cache aggressively — manga metadata rarely changes
|
|
237
|
+
- `getPages` URLs expire, so cache the *chapter id*, not the URLs
|
|
238
|
+
- the client already retries `429` and `5xx` with backoff, but don't lean on it
|
|
239
|
+
- `getFull()` costs three requests. Don't call it in a loop.
|
|
240
|
+
|
|
241
|
+
## Tests
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
npm test # 34 offline tests, fetch stubbed, no network
|
|
245
|
+
npm run test:live # 9 tests against the real MangaDex API
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The offline suite covers mapping, ratings filtering, error codes, clamping and retries. The live suite proves real searches, real chapters and real page images load — and asserts that **safe search never leaks adult titles**, while adult search returns only adult titles.
|
|
249
|
+
|
|
250
|
+
## Credit
|
|
251
|
+
|
|
252
|
+
Data from [MangaDex](https://mangadex.org). This is an unofficial client and is
|
|
253
|
+
not affiliated with MangaDex. Please respect their terms and their rate limits,
|
|
254
|
+
and support the official releases where you can.
|
|
255
|
+
|
|
256
|
+
## Licence
|
|
257
|
+
|
|
258
|
+
MIT © Akewushola Abdulbakri Temitope (brokenvzn)
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** MangaDex content ratings. */
|
|
2
|
+
export type ContentRating = 'safe' | 'suggestive' | 'erotica' | 'pornographic';
|
|
3
|
+
|
|
4
|
+
export interface Manga {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string | null;
|
|
7
|
+
altTitles: string[];
|
|
8
|
+
description: string | null;
|
|
9
|
+
contentRating: ContentRating | null;
|
|
10
|
+
/** True when contentRating is erotica or pornographic. */
|
|
11
|
+
isAdult: boolean;
|
|
12
|
+
status: string | null;
|
|
13
|
+
year: number | null;
|
|
14
|
+
originalLanguage: string | null;
|
|
15
|
+
availableLanguages: string[];
|
|
16
|
+
lastChapter: string | null;
|
|
17
|
+
lastVolume: string | null;
|
|
18
|
+
tags: string[];
|
|
19
|
+
demographic: string | null;
|
|
20
|
+
authors: string[];
|
|
21
|
+
artist: string | null;
|
|
22
|
+
cover: string | null;
|
|
23
|
+
coverThumb: string | null;
|
|
24
|
+
links: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Chapter {
|
|
28
|
+
id: string;
|
|
29
|
+
chapter: string | null;
|
|
30
|
+
volume: string | null;
|
|
31
|
+
title: string | null;
|
|
32
|
+
language: string | null;
|
|
33
|
+
pages: number;
|
|
34
|
+
publishedAt: string | null;
|
|
35
|
+
readableAt: string | null;
|
|
36
|
+
externalUrl: string | null;
|
|
37
|
+
scanlationGroup: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SearchOptions {
|
|
41
|
+
/** Which ratings to include. Default ['safe','suggestive']. */
|
|
42
|
+
ratings?: ContentRating | ContentRating[];
|
|
43
|
+
/** Shortcut for adult ratings only. */
|
|
44
|
+
adultOnly?: boolean;
|
|
45
|
+
/** 1-100. Default 10. */
|
|
46
|
+
limit?: number;
|
|
47
|
+
offset?: number;
|
|
48
|
+
status?: 'ongoing' | 'completed' | 'hiatus' | 'cancelled';
|
|
49
|
+
sort?: 'relevance' | 'followed' | 'rating' | 'latest' | 'created' | 'updated';
|
|
50
|
+
language?: string;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
retries?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ChapterOptions {
|
|
56
|
+
/** Translated languages. Default ['en']. */
|
|
57
|
+
languages?: string | string[];
|
|
58
|
+
/** Oldest first. Default true. */
|
|
59
|
+
ascending?: boolean;
|
|
60
|
+
/** 1-500. Default 100. */
|
|
61
|
+
limit?: number;
|
|
62
|
+
offset?: number;
|
|
63
|
+
includeExternal?: boolean;
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
retries?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PageOptions {
|
|
69
|
+
/** Use the compressed page set. Default false. */
|
|
70
|
+
dataSaver?: boolean;
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
retries?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface Paged<T> {
|
|
76
|
+
results: T[];
|
|
77
|
+
total: number;
|
|
78
|
+
offset: number;
|
|
79
|
+
limit: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Pages {
|
|
83
|
+
baseUrl: string;
|
|
84
|
+
hash: string;
|
|
85
|
+
pages: string[];
|
|
86
|
+
pageCount: number;
|
|
87
|
+
dataSaver: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type MangaErrorCode =
|
|
91
|
+
| 'BAD_ID'
|
|
92
|
+
| 'BAD_QUERY'
|
|
93
|
+
| 'BAD_RATING'
|
|
94
|
+
| 'BAD_SORT'
|
|
95
|
+
| 'NOT_FOUND'
|
|
96
|
+
| 'NO_PAGES'
|
|
97
|
+
| 'RATE_LIMITED'
|
|
98
|
+
| 'TIMEOUT'
|
|
99
|
+
| 'NETWORK'
|
|
100
|
+
| 'SERVER_ERROR'
|
|
101
|
+
| 'HTTP_ERROR'
|
|
102
|
+
| 'BAD_RESPONSE'
|
|
103
|
+
| 'API_ERROR';
|
|
104
|
+
|
|
105
|
+
export declare class MangaError extends Error {
|
|
106
|
+
name: 'MangaError';
|
|
107
|
+
code: MangaErrorCode;
|
|
108
|
+
details?: unknown;
|
|
109
|
+
constructor(message: string, code: MangaErrorCode, details?: unknown);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export declare function search(query?: string, options?: SearchOptions): Promise<Paged<Manga>>;
|
|
113
|
+
export declare function getManga(id: string, options?: SearchOptions): Promise<Manga>;
|
|
114
|
+
export declare function getChapters(id: string, options?: ChapterOptions): Promise<Paged<Chapter>>;
|
|
115
|
+
export declare function getPages(chapterId: string, options?: PageOptions): Promise<Pages>;
|
|
116
|
+
export declare function getFull(id: string, options?: SearchOptions & ChapterOptions): Promise<{
|
|
117
|
+
manga: Manga;
|
|
118
|
+
chapters: Chapter[];
|
|
119
|
+
firstPages: Pages | null;
|
|
120
|
+
}>;
|
|
121
|
+
export declare function popular(options?: SearchOptions): Promise<Paged<Manga>>;
|
|
122
|
+
export declare function latest(options?: SearchOptions): Promise<Paged<Manga>>;
|
|
123
|
+
|
|
124
|
+
export declare function ratings(): {
|
|
125
|
+
all: ContentRating[];
|
|
126
|
+
adult: ContentRating[];
|
|
127
|
+
safe: ContentRating[];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export declare const RATINGS: readonly ContentRating[];
|
|
131
|
+
export declare const ADULT_RATINGS: readonly ContentRating[];
|
|
132
|
+
export declare const SAFE_RATINGS: readonly ContentRating[];
|
|
133
|
+
export declare const USER_AGENT: string;
|
|
134
|
+
|
|
135
|
+
declare const _default: {
|
|
136
|
+
search: typeof search;
|
|
137
|
+
getManga: typeof getManga;
|
|
138
|
+
getChapters: typeof getChapters;
|
|
139
|
+
getPages: typeof getPages;
|
|
140
|
+
getFull: typeof getFull;
|
|
141
|
+
popular: typeof popular;
|
|
142
|
+
latest: typeof latest;
|
|
143
|
+
ratings: typeof ratings;
|
|
144
|
+
MangaError: typeof MangaError;
|
|
145
|
+
};
|
|
146
|
+
export default _default;
|
package/index.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* broken-manga — real manga data, zero dependencies.
|
|
5
|
+
*
|
|
6
|
+
* A client for the public MangaDex API using the built-in global `fetch`.
|
|
7
|
+
* No API key, no signup, no scraping, no headless browser. Node 18+.
|
|
8
|
+
*
|
|
9
|
+
* Search, details, chapter lists, and actual page images — including adult
|
|
10
|
+
* content via MangaDex's content ratings.
|
|
11
|
+
*
|
|
12
|
+
* const { search, getManga, getChapters, getPages } = require('broken-manga');
|
|
13
|
+
* const hits = await search('berserk');
|
|
14
|
+
* const chapters = await getChapters(hits[0].id);
|
|
15
|
+
* const pages = await getPages(chapters[0].id);
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const API = 'https://api.mangadex.org';
|
|
19
|
+
|
|
20
|
+
/** MangaDex asks clients to identify themselves and not hammer the API. */
|
|
21
|
+
const USER_AGENT = 'broken-manga/1.0 (+https://github.com/Neaterry6/broken-manga)';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* MangaDex content ratings.
|
|
25
|
+
* safe — nothing to warn about
|
|
26
|
+
* suggestive — mild fan service
|
|
27
|
+
* erotica — sexual content
|
|
28
|
+
* pornographic — explicit
|
|
29
|
+
*/
|
|
30
|
+
const RATINGS = Object.freeze(['safe', 'suggestive', 'erotica', 'pornographic']);
|
|
31
|
+
|
|
32
|
+
/** Only these two are adult. */
|
|
33
|
+
const ADULT_RATINGS = Object.freeze(['erotica', 'pornographic']);
|
|
34
|
+
|
|
35
|
+
/** Ratings that are safe to show anywhere. */
|
|
36
|
+
const SAFE_RATINGS = Object.freeze(['safe', 'suggestive']);
|
|
37
|
+
|
|
38
|
+
class MangaError extends Error {
|
|
39
|
+
constructor(message, code, details) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'MangaError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
if (details !== undefined) this.details = details;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @private */
|
|
48
|
+
function sleep(ms) {
|
|
49
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One request, with timeout, retries and error mapping.
|
|
54
|
+
* @private
|
|
55
|
+
*/
|
|
56
|
+
async function request(path, { timeoutMs = 20000, retries = 2, query } = {}) {
|
|
57
|
+
let url = `${API}${path}`;
|
|
58
|
+
if (query) {
|
|
59
|
+
const qs = new URLSearchParams();
|
|
60
|
+
for (const [k, v] of Object.entries(query)) {
|
|
61
|
+
if (v === undefined || v === null) continue;
|
|
62
|
+
if (Array.isArray(v)) v.forEach((item) => qs.append(k, String(item)));
|
|
63
|
+
else qs.append(k, String(v));
|
|
64
|
+
}
|
|
65
|
+
const s = qs.toString();
|
|
66
|
+
if (s) url += (url.includes('?') ? '&' : '?') + s;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let lastError;
|
|
70
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
73
|
+
let res;
|
|
74
|
+
try {
|
|
75
|
+
res = await fetch(url, {
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
headers: { 'User-Agent': USER_AGENT, accept: 'application/json' },
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
const code = err.name === 'AbortError' ? 'TIMEOUT' : 'NETWORK';
|
|
82
|
+
lastError = new MangaError(
|
|
83
|
+
code === 'TIMEOUT'
|
|
84
|
+
? `Request timed out after ${timeoutMs}ms.`
|
|
85
|
+
: `Could not reach MangaDex: ${err.message}`,
|
|
86
|
+
code
|
|
87
|
+
);
|
|
88
|
+
if (code === 'TIMEOUT' || attempt === retries) throw lastError;
|
|
89
|
+
await sleep(500 * Math.pow(2, attempt));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
|
|
94
|
+
if (res.status === 404) throw new MangaError('Not found on MangaDex.', 'NOT_FOUND');
|
|
95
|
+
if (res.status === 429) {
|
|
96
|
+
lastError = new MangaError('Rate limited by MangaDex. Slow down.', 'RATE_LIMITED');
|
|
97
|
+
if (attempt === retries) throw lastError;
|
|
98
|
+
// MangaDex rate limits are strict; back off generously.
|
|
99
|
+
await sleep(1500 * Math.pow(2, attempt));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (res.status === 400) {
|
|
103
|
+
let detail = '';
|
|
104
|
+
try { detail = await res.text(); } catch {}
|
|
105
|
+
throw new MangaError(
|
|
106
|
+
`MangaDex rejected the query: ${String(detail).slice(0, 200) || 'bad request'}`,
|
|
107
|
+
'BAD_QUERY',
|
|
108
|
+
detail
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (res.status >= 500) {
|
|
112
|
+
lastError = new MangaError(`MangaDex returned ${res.status}.`, 'SERVER_ERROR');
|
|
113
|
+
if (attempt === retries) throw lastError;
|
|
114
|
+
await sleep(500 * Math.pow(2, attempt));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!res.ok) throw new MangaError(`MangaDex returned ${res.status}.`, 'HTTP_ERROR', res.status);
|
|
118
|
+
|
|
119
|
+
let json;
|
|
120
|
+
try {
|
|
121
|
+
json = await res.json();
|
|
122
|
+
} catch {
|
|
123
|
+
throw new MangaError('MangaDex returned something that is not JSON.', 'BAD_RESPONSE');
|
|
124
|
+
}
|
|
125
|
+
if (json && json.result === 'error') {
|
|
126
|
+
const msg = json.errors?.[0]?.detail || 'MangaDex reported an error.';
|
|
127
|
+
throw new MangaError(msg, 'API_ERROR', json.errors);
|
|
128
|
+
}
|
|
129
|
+
return json;
|
|
130
|
+
}
|
|
131
|
+
throw lastError;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Normalise a ratings option into the array MangaDex wants. */
|
|
135
|
+
function normaliseRatings(input, fallback) {
|
|
136
|
+
if (input === undefined || input === null) return fallback.slice();
|
|
137
|
+
const list = Array.isArray(input) ? input : [input];
|
|
138
|
+
const clean = list
|
|
139
|
+
.map((r) => String(r).trim().toLowerCase())
|
|
140
|
+
.filter((r) => RATINGS.includes(r));
|
|
141
|
+
if (!clean.length) {
|
|
142
|
+
throw new MangaError(
|
|
143
|
+
`No valid content ratings. Use any of: ${RATINGS.join(', ')}.`,
|
|
144
|
+
'BAD_RATING'
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return [...new Set(clean)];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Pull the best title out of MangaDex's multilingual title object. */
|
|
151
|
+
function pickTitle(titles) {
|
|
152
|
+
if (!titles || typeof titles !== 'object') return null;
|
|
153
|
+
return titles.en || titles['ja-ro'] || titles.ja || Object.values(titles)[0] || null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Localised string field (description, etc). */
|
|
157
|
+
function pickLocalised(obj, lang = 'en') {
|
|
158
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
159
|
+
return obj[lang] || obj.en || Object.values(obj)[0] || null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function findRelationship(entity, type) {
|
|
163
|
+
return (entity?.relationships || []).find((r) => r.type === type) || null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Map a MangaDex manga entity into a stable shape. */
|
|
167
|
+
function mapManga(m) {
|
|
168
|
+
if (!m || !m.attributes) return null;
|
|
169
|
+
const a = m.attributes;
|
|
170
|
+
const cover = findRelationship(m, 'cover_art');
|
|
171
|
+
const author = findRelationship(m, 'author');
|
|
172
|
+
const artist = findRelationship(m, 'artist');
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
id: m.id,
|
|
176
|
+
title: pickTitle(a.title),
|
|
177
|
+
altTitles: (a.altTitles || []).map((t) => pickTitle(t)).filter(Boolean),
|
|
178
|
+
description: pickLocalised(a.description),
|
|
179
|
+
/** 'safe' | 'suggestive' | 'erotica' | 'pornographic' */
|
|
180
|
+
contentRating: a.contentRating || null,
|
|
181
|
+
/** true when contentRating is erotica or pornographic. */
|
|
182
|
+
isAdult: ADULT_RATINGS.includes(a.contentRating),
|
|
183
|
+
status: a.status || null,
|
|
184
|
+
year: a.year ?? null,
|
|
185
|
+
originalLanguage: a.originalLanguage || null,
|
|
186
|
+
availableLanguages: a.availableTranslatedLanguages || [],
|
|
187
|
+
lastChapter: a.lastChapter || null,
|
|
188
|
+
lastVolume: a.lastVolume || null,
|
|
189
|
+
tags: (a.tags || []).map((t) => pickLocalised(t.attributes?.name)).filter(Boolean),
|
|
190
|
+
demographic: a.publicationDemographic || null,
|
|
191
|
+
authors: author?.attributes?.name ? [author.attributes.name] : [],
|
|
192
|
+
artist: artist?.attributes?.name || null,
|
|
193
|
+
/** Cover URL, already assembled from the filename. */
|
|
194
|
+
cover: cover?.attributes?.fileName
|
|
195
|
+
? `https://uploads.mangadex.org/covers/${m.id}/${cover.attributes.fileName}`
|
|
196
|
+
: null,
|
|
197
|
+
coverThumb: cover?.attributes?.fileName
|
|
198
|
+
? `https://uploads.mangadex.org/covers/${m.id}/${cover.attributes.fileName}.256.jpg`
|
|
199
|
+
: null,
|
|
200
|
+
links: a.links || {},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Map a chapter entity. */
|
|
205
|
+
function mapChapter(c) {
|
|
206
|
+
if (!c || !c.attributes) return null;
|
|
207
|
+
const a = c.attributes;
|
|
208
|
+
const group = findRelationship(c, 'scanlation_group');
|
|
209
|
+
return {
|
|
210
|
+
id: c.id,
|
|
211
|
+
chapter: a.chapter || null,
|
|
212
|
+
volume: a.volume || null,
|
|
213
|
+
title: a.title || null,
|
|
214
|
+
language: a.translatedLanguage || null,
|
|
215
|
+
pages: a.pages ?? 0,
|
|
216
|
+
publishedAt: a.publishAt || null,
|
|
217
|
+
readableAt: a.readableAt || null,
|
|
218
|
+
externalUrl: a.externalUrl || null,
|
|
219
|
+
scanlationGroup: group?.attributes?.name || null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Search manga.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} [query] Title text. Omit to browse.
|
|
227
|
+
* @param {object} [options]
|
|
228
|
+
* @param {string|string[]} [options.ratings] Content ratings to include.
|
|
229
|
+
* Defaults to ['safe', 'suggestive']. Pass 'pornographic' or
|
|
230
|
+
* ['erotica','pornographic'] for adult content.
|
|
231
|
+
* @param {boolean} [options.adultOnly=false] Shortcut for adult ratings only.
|
|
232
|
+
* @param {number} [options.limit=10] 1-100.
|
|
233
|
+
* @param {number} [options.offset=0]
|
|
234
|
+
* @param {string} [options.status] ongoing | completed | hiatus | cancelled
|
|
235
|
+
* @param {string} [options.sort='relevance'] relevance | followed | rating | latest | created
|
|
236
|
+
* @param {string} [options.language] Restrict to manga with this translated language.
|
|
237
|
+
* @returns {Promise<{results: object[], total: number, offset: number, limit: number}>}
|
|
238
|
+
*/
|
|
239
|
+
async function search(query, options = {}) {
|
|
240
|
+
const limit = Math.max(1, Math.min(100, Number(options.limit) || 10));
|
|
241
|
+
const offset = Math.max(0, Number(options.offset) || 0);
|
|
242
|
+
|
|
243
|
+
let ratings;
|
|
244
|
+
if (options.adultOnly) ratings = ADULT_RATINGS.slice();
|
|
245
|
+
else ratings = normaliseRatings(options.ratings, SAFE_RATINGS);
|
|
246
|
+
|
|
247
|
+
const sortMap = {
|
|
248
|
+
relevance: null,
|
|
249
|
+
followed: 'followedCount',
|
|
250
|
+
rating: 'rating',
|
|
251
|
+
latest: 'latestUploadedChapter',
|
|
252
|
+
created: 'createdAt',
|
|
253
|
+
updated: 'updatedAt',
|
|
254
|
+
};
|
|
255
|
+
const sortKey = sortMap[String(options.sort || 'relevance').toLowerCase()];
|
|
256
|
+
if (options.sort && options.sort !== 'relevance' && !sortKey) {
|
|
257
|
+
throw new MangaError(
|
|
258
|
+
`Unknown sort "${options.sort}". Use one of: ${Object.keys(sortMap).join(', ')}.`,
|
|
259
|
+
'BAD_SORT'
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const q = {
|
|
264
|
+
limit,
|
|
265
|
+
offset,
|
|
266
|
+
'contentRating[]': ratings,
|
|
267
|
+
'includes[]': ['cover_art', 'author', 'artist'],
|
|
268
|
+
'order[followedCount]': 'desc',
|
|
269
|
+
};
|
|
270
|
+
if (typeof query === 'string' && query.trim()) q.title = query.trim();
|
|
271
|
+
if (options.status) q.status = String(options.status);
|
|
272
|
+
if (options.language) q['availableTranslatedLanguage[]'] = String(options.language);
|
|
273
|
+
if (sortKey) {
|
|
274
|
+
delete q['order[followedCount]'];
|
|
275
|
+
q[`order[${sortKey}]`] = 'desc';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const body = await request('/manga', { ...options, query: q });
|
|
279
|
+
return {
|
|
280
|
+
results: (body.data || []).map(mapManga).filter(Boolean),
|
|
281
|
+
total: body.total ?? 0,
|
|
282
|
+
offset: body.offset ?? offset,
|
|
283
|
+
limit: body.limit ?? limit,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Fetch one manga by id.
|
|
289
|
+
* @param {string} id MangaDex UUID.
|
|
290
|
+
*/
|
|
291
|
+
async function getManga(id, options = {}) {
|
|
292
|
+
if (!id || typeof id !== 'string') {
|
|
293
|
+
throw new MangaError('getManga() needs a MangaDex id.', 'BAD_ID');
|
|
294
|
+
}
|
|
295
|
+
const body = await request(`/manga/${encodeURIComponent(id)}`, {
|
|
296
|
+
...options,
|
|
297
|
+
query: { 'includes[]': ['cover_art', 'author', 'artist'] },
|
|
298
|
+
});
|
|
299
|
+
const mapped = mapManga(body.data);
|
|
300
|
+
if (!mapped) throw new MangaError('Manga not found.', 'NOT_FOUND');
|
|
301
|
+
return mapped;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Chapter list for a manga.
|
|
306
|
+
*
|
|
307
|
+
* @param {string} id MangaDex manga id.
|
|
308
|
+
* @param {object} [options]
|
|
309
|
+
* @param {string|string[]} [options.languages=['en']] Translated languages.
|
|
310
|
+
* @param {boolean} [options.ascending=true] Oldest chapter first.
|
|
311
|
+
* @param {number} [options.limit=100] 1-500.
|
|
312
|
+
* @param {number} [options.offset=0]
|
|
313
|
+
* @param {boolean} [options.includeExternal=true] Include chapters hosted elsewhere.
|
|
314
|
+
*/
|
|
315
|
+
async function getChapters(id, options = {}) {
|
|
316
|
+
if (!id || typeof id !== 'string') {
|
|
317
|
+
throw new MangaError('getChapters() needs a MangaDex manga id.', 'BAD_ID');
|
|
318
|
+
}
|
|
319
|
+
const langs = options.languages
|
|
320
|
+
? (Array.isArray(options.languages) ? options.languages : [options.languages])
|
|
321
|
+
: ['en'];
|
|
322
|
+
const limit = Math.max(1, Math.min(500, Number(options.limit) || 100));
|
|
323
|
+
const offset = Math.max(0, Number(options.offset) || 0);
|
|
324
|
+
const asc = options.ascending !== false;
|
|
325
|
+
|
|
326
|
+
const q = {
|
|
327
|
+
limit,
|
|
328
|
+
offset,
|
|
329
|
+
'translatedLanguage[]': langs.map(String),
|
|
330
|
+
'order[chapter]': asc ? 'asc' : 'desc',
|
|
331
|
+
'includes[]': ['scanlation_group'],
|
|
332
|
+
};
|
|
333
|
+
if (options.includeExternal === false) q.includeExternalUrl = '0';
|
|
334
|
+
|
|
335
|
+
const body = await request(`/manga/${encodeURIComponent(id)}/feed`, { ...options, query: q });
|
|
336
|
+
return {
|
|
337
|
+
results: (body.data || []).map(mapChapter).filter(Boolean),
|
|
338
|
+
total: body.total ?? 0,
|
|
339
|
+
offset: body.offset ?? offset,
|
|
340
|
+
limit: body.limit ?? limit,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Page image URLs for a chapter.
|
|
346
|
+
*
|
|
347
|
+
* Returns fully-formed URLs ready to load or proxy. MangaDex hands out a
|
|
348
|
+
* short-lived base URL per chapter, so don't cache these forever.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} chapterId Chapter UUID.
|
|
351
|
+
* @param {object} [options]
|
|
352
|
+
* @param {boolean} [options.dataSaver=false] Use the compressed page set.
|
|
353
|
+
* @returns {Promise<{baseUrl: string, hash: string, pages: string[], pageCount: number, dataSaver: boolean}>}
|
|
354
|
+
*/
|
|
355
|
+
async function getPages(chapterId, options = {}) {
|
|
356
|
+
if (!chapterId || typeof chapterId !== 'string') {
|
|
357
|
+
throw new MangaError('getPages() needs a chapter id.', 'BAD_ID');
|
|
358
|
+
}
|
|
359
|
+
const body = await request(`/at-home/server/${encodeURIComponent(chapterId)}`, options);
|
|
360
|
+
const base = body.baseUrl;
|
|
361
|
+
const hash = body.chapter?.hash;
|
|
362
|
+
if (!base || !hash) {
|
|
363
|
+
throw new MangaError('MangaDex returned no page data for that chapter.', 'NO_PAGES', body);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const dataSaver = options.dataSaver === true;
|
|
367
|
+
const list = dataSaver ? body.chapter.dataSaver : body.chapter.data;
|
|
368
|
+
const segment = dataSaver ? 'data-saver' : 'data';
|
|
369
|
+
|
|
370
|
+
const pages = (list || []).map((file) => `${base}/${segment}/${hash}/${file}`);
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
baseUrl: base,
|
|
374
|
+
hash,
|
|
375
|
+
pages,
|
|
376
|
+
pageCount: pages.length,
|
|
377
|
+
dataSaver,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Everything about a manga in one call: details, chapters, and the first
|
|
383
|
+
* chapter's pages. Convenient, but it's three requests — mind the rate limit.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} id
|
|
386
|
+
* @param {object} [options]
|
|
387
|
+
*/
|
|
388
|
+
async function getFull(id, options = {}) {
|
|
389
|
+
const manga = await getManga(id, options);
|
|
390
|
+
const chapters = await getChapters(id, { ...options, limit: options.limit || 20 });
|
|
391
|
+
let firstPages = null;
|
|
392
|
+
const first = chapters.results.find((c) => c.pages > 0);
|
|
393
|
+
if (first) {
|
|
394
|
+
try {
|
|
395
|
+
firstPages = await getPages(first.id, options);
|
|
396
|
+
} catch {
|
|
397
|
+
firstPages = null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { manga, chapters: chapters.results, firstPages };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Trending / most-followed manga.
|
|
405
|
+
* @param {object} [options] Same as search().
|
|
406
|
+
*/
|
|
407
|
+
async function popular(options = {}) {
|
|
408
|
+
return search('', { ...options, sort: 'followed' });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Newly added manga.
|
|
413
|
+
* @param {object} [options] Same as search().
|
|
414
|
+
*/
|
|
415
|
+
async function latest(options = {}) {
|
|
416
|
+
return search('', { ...options, sort: 'created' });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The ratings this package understands. */
|
|
420
|
+
function ratings() {
|
|
421
|
+
return {
|
|
422
|
+
all: RATINGS.slice(),
|
|
423
|
+
adult: ADULT_RATINGS.slice(),
|
|
424
|
+
safe: SAFE_RATINGS.slice(),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
module.exports = {
|
|
429
|
+
search,
|
|
430
|
+
getManga,
|
|
431
|
+
getChapters,
|
|
432
|
+
getPages,
|
|
433
|
+
getFull,
|
|
434
|
+
popular,
|
|
435
|
+
latest,
|
|
436
|
+
ratings,
|
|
437
|
+
MangaError,
|
|
438
|
+
RATINGS,
|
|
439
|
+
ADULT_RATINGS,
|
|
440
|
+
SAFE_RATINGS,
|
|
441
|
+
USER_AGENT,
|
|
442
|
+
};
|
package/index.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// ESM wrapper around the CommonJS implementation.
|
|
2
|
+
import mod from './index.js';
|
|
3
|
+
|
|
4
|
+
export const search = mod.search;
|
|
5
|
+
export const getManga = mod.getManga;
|
|
6
|
+
export const getChapters = mod.getChapters;
|
|
7
|
+
export const getPages = mod.getPages;
|
|
8
|
+
export const getFull = mod.getFull;
|
|
9
|
+
export const popular = mod.popular;
|
|
10
|
+
export const latest = mod.latest;
|
|
11
|
+
export const ratings = mod.ratings;
|
|
12
|
+
export const MangaError = mod.MangaError;
|
|
13
|
+
export const RATINGS = mod.RATINGS;
|
|
14
|
+
export const ADULT_RATINGS = mod.ADULT_RATINGS;
|
|
15
|
+
export const SAFE_RATINGS = mod.SAFE_RATINGS;
|
|
16
|
+
|
|
17
|
+
export default mod;
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "broken-manga",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Real manga data with zero dependencies — search, details, chapters and page images via MangaDex. Supports adult content ratings. No API key.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"manga", "manga-api", "mangadex", "comic", "hentai", "doujin", "ecchi",
|
|
7
|
+
"search", "chapters", "reader", "zero-dependency", "fetch"
|
|
8
|
+
],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Akewushola Abdulbakri Temitope (brokenvzn) <akewusholaabdulbakri101@gmail.com>",
|
|
11
|
+
"main": "index.js",
|
|
12
|
+
"types": "index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./index.d.ts",
|
|
16
|
+
"require": "./index.js",
|
|
17
|
+
"import": "./index.mjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": ["index.js", "index.mjs", "index.d.ts", "README.md", "LICENSE"],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test.js",
|
|
23
|
+
"test:live": "node --test test-live.js"
|
|
24
|
+
},
|
|
25
|
+
"engines": { "node": ">=18" },
|
|
26
|
+
"repository": { "type": "git", "url": "git+https://github.com/Neaterry6/broken-manga.git" },
|
|
27
|
+
"bugs": { "url": "https://github.com/Neaterry6/broken-manga/issues" },
|
|
28
|
+
"homepage": "https://github.com/Neaterry6/broken-manga#readme",
|
|
29
|
+
"sideEffects": false
|
|
30
|
+
}
|