@gxaym1/anime 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/README.md +186 -0
- package/package.json +40 -0
- package/src/api.js +75 -0
- package/src/index.js +47 -0
- package/src/scrapers/browse.js +52 -0
- package/src/scrapers/details.js +91 -0
- package/src/scrapers/download.js +75 -0
- package/src/scrapers/search.js +49 -0
- package/src/scrapers/vidtube.js +123 -0
- package/src/utils/http.js +45 -0
- package/src/utils/parser.js +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# @gxaym1/anime
|
|
2
|
+
|
|
3
|
+
Orx Scraper — **topcinemaa.cam** anime scraper.
|
|
4
|
+
Browse, search, get details, extract download links by quality, and resolve VidTube direct `.mp4` URLs.
|
|
5
|
+
Pure ESM, plain JavaScript, no build step.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @gxaym1/anime
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import Anime from "@gxaym1/anime";
|
|
17
|
+
|
|
18
|
+
// 1. Browse anime category (paginated)
|
|
19
|
+
const page1 = await Anime.browse({ page: 1 });
|
|
20
|
+
console.log(page1.results); // [{ title, url, thumbnail, quality, genre, rating }, ...]
|
|
21
|
+
console.log(page1.hasNext); // true/false
|
|
22
|
+
|
|
23
|
+
// 2. Search
|
|
24
|
+
const results = await Anime.search("dragon ball");
|
|
25
|
+
console.log(results.results);
|
|
26
|
+
|
|
27
|
+
// 3. Details of a post
|
|
28
|
+
const film = await Anime.details("https://topcinemaa.cam/film-slug/");
|
|
29
|
+
console.log(film.title, film.story, film.genres, film.year);
|
|
30
|
+
|
|
31
|
+
// 4. Download links (per quality + VidTube multi-quality link)
|
|
32
|
+
const dl = await Anime.downloadFromPost("https://topcinemaa.cam/film-slug/");
|
|
33
|
+
console.log(dl.vidtube); // "https://down.vidtube.one/d/XXXXX.html"
|
|
34
|
+
console.log(dl.byQuality); // { "1080p": [{ server:"UpDown", url:"..." }, ...] }
|
|
35
|
+
console.log(dl.all); // flat array
|
|
36
|
+
|
|
37
|
+
// 5. Resolve VidTube direct .mp4 links with quality labels
|
|
38
|
+
const vt = await Anime.vidtube(dl.vidtube);
|
|
39
|
+
console.log(vt.links); // [{ quality:"1080p", url:"https://...mp4", type:"video/mp4" }, ...]
|
|
40
|
+
|
|
41
|
+
// 6. All-in-one (details + download + vidtube) in one call
|
|
42
|
+
const full = await Anime.getAll("https://topcinemaa.cam/film-slug/");
|
|
43
|
+
console.log(full.details.title);
|
|
44
|
+
console.log(full.download.byQuality);
|
|
45
|
+
console.log(full.vidtube?.links);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Named imports
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { browse, search, details, download, downloadFromPost, vidtubeLinks } from "@gxaym1/anime";
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Sub-path imports
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { browse } from "@gxaym1/anime/browse";
|
|
58
|
+
import { search } from "@gxaym1/anime/search";
|
|
59
|
+
import { details } from "@gxaym1/anime/details";
|
|
60
|
+
import { download, downloadFromPost } from "@gxaym1/anime/download";
|
|
61
|
+
import { vidtubeLinks } from "@gxaym1/anime/vidtube";
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
### `browse(options?)`
|
|
69
|
+
|
|
70
|
+
Browse the anime category: `https://topcinemaa.cam/category/افلام-انمي-2/`
|
|
71
|
+
|
|
72
|
+
| Option | Type | Default |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| page | number | 1 |
|
|
75
|
+
| timeout | number | 30000 |
|
|
76
|
+
| headers | object | — |
|
|
77
|
+
| proxy | string | — |
|
|
78
|
+
|
|
79
|
+
**Returns:**
|
|
80
|
+
```js
|
|
81
|
+
{ results: AnimeCard[], page: number, hasNext: boolean }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
### `search(query, options?)`
|
|
87
|
+
|
|
88
|
+
Full-text search on topcinemaa.cam.
|
|
89
|
+
|
|
90
|
+
**Returns:**
|
|
91
|
+
```js
|
|
92
|
+
{ query: string, results: AnimeCard[], page: number, hasNext: boolean }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### `details(postUrl, options?)`
|
|
98
|
+
|
|
99
|
+
Get full info from the anime post page.
|
|
100
|
+
|
|
101
|
+
**Returns:**
|
|
102
|
+
```js
|
|
103
|
+
{
|
|
104
|
+
title: string,
|
|
105
|
+
url: string,
|
|
106
|
+
thumbnail?: string,
|
|
107
|
+
story?: string, // Arabic plot summary
|
|
108
|
+
quality?: string, // e.g. "1080p WEB-DL"
|
|
109
|
+
genres: string[],
|
|
110
|
+
year?: string,
|
|
111
|
+
duration?: string,
|
|
112
|
+
language?: string,
|
|
113
|
+
rating?: string,
|
|
114
|
+
watchUrl?: string, // /watch/ page
|
|
115
|
+
downloadUrl?: string // /download/ page
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
### `download(downloadPageUrl, options?)`
|
|
122
|
+
|
|
123
|
+
Scrape a `/download/` page for all download links.
|
|
124
|
+
|
|
125
|
+
**Returns:**
|
|
126
|
+
```js
|
|
127
|
+
{
|
|
128
|
+
vidtube?: string, // VidTube multi-quality URL
|
|
129
|
+
byQuality: Record<string, { server, url }[]>, // e.g. { "1080p": [...] }
|
|
130
|
+
all: { quality, server, url }[] // flat list
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### `downloadFromPost(postUrl, options?)`
|
|
137
|
+
|
|
138
|
+
Same as `download()` but auto-appends `/download/` to the post URL.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### `vidtubeLinks(vidtubeUrl, options?)`
|
|
143
|
+
|
|
144
|
+
Resolve a VidTube URL into direct per-quality `.mp4` links.
|
|
145
|
+
|
|
146
|
+
Accepts `down.vidtube.one/d/ID.html` or `down.vidtube.one/embed-ID.html`.
|
|
147
|
+
|
|
148
|
+
**Returns:**
|
|
149
|
+
```js
|
|
150
|
+
{
|
|
151
|
+
sourceUrl: string,
|
|
152
|
+
downloadUrl?: string,
|
|
153
|
+
embedUrl?: string,
|
|
154
|
+
links: [{ quality: "1080p", url: "https://...mp4", type: "video/mp4" }, ...]
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### `getAll(postUrl, options?)`
|
|
161
|
+
|
|
162
|
+
One-shot call that runs `details` + `downloadFromPost` + `vidtubeLinks` in sequence.
|
|
163
|
+
|
|
164
|
+
**Returns:**
|
|
165
|
+
```js
|
|
166
|
+
{ details: AnimeDetails, download: DownloadResult, vidtube?: VidTubeResult }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Data shapes
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
// AnimeCard (from browse/search)
|
|
175
|
+
{ title, url, thumbnail?, quality?, genre?, rating? }
|
|
176
|
+
|
|
177
|
+
// DownloadLink
|
|
178
|
+
{ server: "UpDown", url: "https://updown.cam/..." }
|
|
179
|
+
|
|
180
|
+
// VidTubeLink
|
|
181
|
+
{ quality: "1080p", url: "https://...mp4", type: "video/mp4" }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT © gxaym1
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gxaym1/anime",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Orx Scraper — topcinemaa.cam anime scraper: browse, search, details, download links with qualities, and VidTube direct link resolver.",
|
|
5
|
+
"author": "gxaym1",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.js",
|
|
9
|
+
"module": "./src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./browse": "./src/scrapers/browse.js",
|
|
13
|
+
"./search": "./src/scrapers/search.js",
|
|
14
|
+
"./details": "./src/scrapers/details.js",
|
|
15
|
+
"./download": "./src/scrapers/download.js",
|
|
16
|
+
"./vidtube": "./src/scrapers/vidtube.js",
|
|
17
|
+
"./utils/http": "./src/utils/http.js",
|
|
18
|
+
"./utils/parser": "./src/utils/parser.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"scraper",
|
|
26
|
+
"orx",
|
|
27
|
+
"anime",
|
|
28
|
+
"topcinemaa",
|
|
29
|
+
"vidtube",
|
|
30
|
+
"download",
|
|
31
|
+
"esm"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"axios": "^1.7.2",
|
|
38
|
+
"cheerio": "^1.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
const WP_API = "https://topcinemaa.cam/wp-json/wp/v2";
|
|
4
|
+
const ANIME_CAT = 5; // افلام انمي — category ID confirmed from /wp-json/wp/v2/categories
|
|
5
|
+
const EMBED_PARAM = "_embed=wp:featuredmedia,wp:term";
|
|
6
|
+
const BASE_FIELDS = "id,date,slug,link,title,excerpt,featured_media,categories,tags";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_HEADERS = {
|
|
9
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
10
|
+
Accept: "application/json",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Raw GET against the WP REST API.
|
|
15
|
+
* @param {string} path
|
|
16
|
+
* @param {Record<string,string|number>} params
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @returns {Promise<{ data: any[], total: number, totalPages: number }>}
|
|
19
|
+
*/
|
|
20
|
+
export async function wpGet(path, params = {}, options = {}) {
|
|
21
|
+
const url = `${WP_API}${path}`;
|
|
22
|
+
const res = await axios.get(url, {
|
|
23
|
+
params,
|
|
24
|
+
timeout: options.timeout ?? 20000,
|
|
25
|
+
headers: { ...DEFAULT_HEADERS, ...(options.headers ?? {}) },
|
|
26
|
+
...(options.proxy
|
|
27
|
+
? (() => { const u = new URL(options.proxy); return { proxy: { host: u.hostname, port: Number(u.port), protocol: u.protocol.replace(":", "") } }; })()
|
|
28
|
+
: {}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
data: res.data,
|
|
33
|
+
total: Number(res.headers["x-wp-total"] ?? 0),
|
|
34
|
+
totalPages: Number(res.headers["x-wp-totalpages"] ?? 1),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Convert a raw WP post object into a clean AnimeCard.
|
|
40
|
+
* @param {object} post
|
|
41
|
+
* @returns {AnimeCard}
|
|
42
|
+
*
|
|
43
|
+
* @typedef {{
|
|
44
|
+
* id: number,
|
|
45
|
+
* title: string,
|
|
46
|
+
* url: string,
|
|
47
|
+
* date: string,
|
|
48
|
+
* thumbnail?: string,
|
|
49
|
+
* excerpt?: string,
|
|
50
|
+
* genres: string[]
|
|
51
|
+
* }} AnimeCard
|
|
52
|
+
*/
|
|
53
|
+
export function toCard(post) {
|
|
54
|
+
const media = post._embedded?.["wp:featuredmedia"]?.[0];
|
|
55
|
+
const terms = post._embedded?.["wp:term"]?.flat() ?? [];
|
|
56
|
+
const genres = terms
|
|
57
|
+
.filter(t => t.taxonomy === "post_tag" || t.taxonomy === "category")
|
|
58
|
+
.map(t => t.name)
|
|
59
|
+
.filter(n => n !== "افلام انمي");
|
|
60
|
+
|
|
61
|
+
const excerptHtml = post.excerpt?.rendered ?? "";
|
|
62
|
+
const excerpt = excerptHtml.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
id: post.id,
|
|
66
|
+
title: post.title?.rendered ?? "",
|
|
67
|
+
url: post.link,
|
|
68
|
+
date: post.date,
|
|
69
|
+
thumbnail: media?.source_url ?? media?.media_details?.sizes?.medium?.source_url ?? undefined,
|
|
70
|
+
excerpt: excerpt || undefined,
|
|
71
|
+
genres,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { WP_API, ANIME_CAT, EMBED_PARAM, BASE_FIELDS };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export { browse } from "./scrapers/browse.js";
|
|
2
|
+
export { search } from "./scrapers/search.js";
|
|
3
|
+
export { details } from "./scrapers/details.js";
|
|
4
|
+
export { download, downloadFromPost } from "./scrapers/download.js";
|
|
5
|
+
export { vidtubeLinks, isVidTubeUrl } from "./scrapers/vidtube.js";
|
|
6
|
+
export { fetchHtml, createClient } from "./utils/http.js";
|
|
7
|
+
export { load, absoluteUrl, cleanText } from "./utils/parser.js";
|
|
8
|
+
|
|
9
|
+
import { browse } from "./scrapers/browse.js";
|
|
10
|
+
import { search } from "./scrapers/search.js";
|
|
11
|
+
import { details } from "./scrapers/details.js";
|
|
12
|
+
import { download, downloadFromPost } from "./scrapers/download.js";
|
|
13
|
+
import { vidtubeLinks, isVidTubeUrl } from "./scrapers/vidtube.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* All-in-one helper: details + download links + resolved VidTube direct links.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} postUrl
|
|
19
|
+
* @param {object} [options]
|
|
20
|
+
* @returns {Promise<{
|
|
21
|
+
* details: import("./scrapers/details.js").AnimeDetails,
|
|
22
|
+
* download: import("./scrapers/download.js").DownloadResult,
|
|
23
|
+
* vidtube?: import("./scrapers/vidtube.js").VidTubeResult
|
|
24
|
+
* }>}
|
|
25
|
+
*/
|
|
26
|
+
async function getAll(postUrl, options = {}) {
|
|
27
|
+
const det = await details(postUrl, options);
|
|
28
|
+
const dl = await downloadFromPost(postUrl, options);
|
|
29
|
+
let vt = undefined;
|
|
30
|
+
if (dl.vidtube) {
|
|
31
|
+
try { vt = await vidtubeLinks(dl.vidtube, options); } catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
return { details: det, download: dl, vidtube: vt };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const Anime = {
|
|
37
|
+
browse: (options) => browse(options),
|
|
38
|
+
search: (query, options) => search(query, options),
|
|
39
|
+
details: (url, options) => details(url, options),
|
|
40
|
+
download: (url, options) => download(url, options),
|
|
41
|
+
downloadFromPost: (url, options) => downloadFromPost(url, options),
|
|
42
|
+
vidtube: (url, options) => vidtubeLinks(url, options),
|
|
43
|
+
getAll: (url, options) => getAll(url, options),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export default Anime;
|
|
47
|
+
export { Anime };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { wpGet, toCard, ANIME_CAT, BASE_FIELDS } from "../api.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Browse the anime category via WP REST API (paginated, newest first).
|
|
5
|
+
*
|
|
6
|
+
* @param {{
|
|
7
|
+
* page?: number,
|
|
8
|
+
* perPage?: number,
|
|
9
|
+
* orderby?: "date"|"title"|"modified",
|
|
10
|
+
* order?: "asc"|"desc",
|
|
11
|
+
* timeout?: number,
|
|
12
|
+
* headers?: Record<string, string>,
|
|
13
|
+
* proxy?: string
|
|
14
|
+
* }} [options]
|
|
15
|
+
* @returns {Promise<{
|
|
16
|
+
* results: import("../api.js").AnimeCard[],
|
|
17
|
+
* page: number,
|
|
18
|
+
* perPage: number,
|
|
19
|
+
* total: number,
|
|
20
|
+
* totalPages: number,
|
|
21
|
+
* hasNext: boolean
|
|
22
|
+
* }>}
|
|
23
|
+
*/
|
|
24
|
+
export async function browse(options = {}) {
|
|
25
|
+
const page = options.page ?? 1;
|
|
26
|
+
const perPage = options.perPage ?? 20;
|
|
27
|
+
const orderby = options.orderby ?? "date";
|
|
28
|
+
const order = options.order ?? "desc";
|
|
29
|
+
|
|
30
|
+
const { data, total, totalPages } = await wpGet(
|
|
31
|
+
"/posts",
|
|
32
|
+
{
|
|
33
|
+
categories: ANIME_CAT,
|
|
34
|
+
per_page: perPage,
|
|
35
|
+
page,
|
|
36
|
+
orderby,
|
|
37
|
+
order,
|
|
38
|
+
_fields: BASE_FIELDS,
|
|
39
|
+
_embed: "wp:featuredmedia,wp:term",
|
|
40
|
+
},
|
|
41
|
+
options
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
results: data.map(toCard),
|
|
46
|
+
page,
|
|
47
|
+
perPage,
|
|
48
|
+
total,
|
|
49
|
+
totalPages,
|
|
50
|
+
hasNext: page < totalPages,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { load, extractText, extractAttr, absoluteUrl, cleanText } from "../utils/parser.js";
|
|
2
|
+
import { fetchHtml } from "../utils/http.js";
|
|
3
|
+
|
|
4
|
+
const BASE_URL = "https://topcinemaa.cam";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get full details for an anime page.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} pageUrl Full URL of the anime post
|
|
10
|
+
* @param {{
|
|
11
|
+
* timeout?: number,
|
|
12
|
+
* headers?: Record<string, string>,
|
|
13
|
+
* proxy?: string
|
|
14
|
+
* }} [options]
|
|
15
|
+
* @returns {Promise<AnimeDetails>}
|
|
16
|
+
*
|
|
17
|
+
* @typedef {{
|
|
18
|
+
* title: string,
|
|
19
|
+
* url: string,
|
|
20
|
+
* thumbnail?: string,
|
|
21
|
+
* story?: string,
|
|
22
|
+
* quality?: string,
|
|
23
|
+
* genres: string[],
|
|
24
|
+
* year?: string,
|
|
25
|
+
* duration?: string,
|
|
26
|
+
* language?: string,
|
|
27
|
+
* rating?: string,
|
|
28
|
+
* watchUrl?: string,
|
|
29
|
+
* downloadUrl?: string
|
|
30
|
+
* }} AnimeDetails
|
|
31
|
+
*/
|
|
32
|
+
export async function details(pageUrl, options = {}) {
|
|
33
|
+
const html = await fetchHtml(pageUrl, options);
|
|
34
|
+
const $ = load(html);
|
|
35
|
+
|
|
36
|
+
const title = cleanText(
|
|
37
|
+
extractText($("h1.post-title a").first()) ||
|
|
38
|
+
extractAttr($("meta[property='og:title']"), "content")
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const thumbnail =
|
|
42
|
+
extractAttr($("meta[property='og:image']"), "content") ||
|
|
43
|
+
extractAttr($(".image img").first(), "data-src") ||
|
|
44
|
+
extractAttr($(".image img").first(), "src");
|
|
45
|
+
|
|
46
|
+
const story = cleanText(extractText($(".story").first()));
|
|
47
|
+
|
|
48
|
+
const genres = [];
|
|
49
|
+
$(".RightTaxContent li a[href*='genre']").each((_i, el) => {
|
|
50
|
+
const g = cleanText($(el).text());
|
|
51
|
+
if (g) genres.push(g);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let quality = "", year = "", duration = "", language = "", rating = "";
|
|
55
|
+
|
|
56
|
+
$(".RightTaxContent li").each((_i, el) => {
|
|
57
|
+
const text = cleanText($(el).text());
|
|
58
|
+
const span = cleanText($(el).find("span").first().text());
|
|
59
|
+
|
|
60
|
+
if (span.includes("جودة")) {
|
|
61
|
+
quality = cleanText($(el).find("a").first().text());
|
|
62
|
+
} else if (span.includes("صدور") || span.includes("سنة")) {
|
|
63
|
+
year = cleanText($(el).find("a").first().text());
|
|
64
|
+
} else if (span.includes("توقيت") || span.includes("مدة")) {
|
|
65
|
+
duration = cleanText($(el).find("strong").first().text() || text.replace(span, ""));
|
|
66
|
+
} else if (span.includes("لغة")) {
|
|
67
|
+
language = cleanText($(el).find("a, strong").first().text());
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const ratingRaw = cleanText(extractText($(".imdbR").first()));
|
|
72
|
+
rating = ratingRaw.replace(/[^\d.]/g, "") || undefined;
|
|
73
|
+
|
|
74
|
+
const watchUrl = extractAttr($(".BTNSDownWatch a.watch"), "href") || undefined;
|
|
75
|
+
const downloadUrl = extractAttr($(".BTNSDownWatch a.download"), "href") || undefined;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
title,
|
|
79
|
+
url: pageUrl,
|
|
80
|
+
thumbnail: thumbnail ? absoluteUrl(BASE_URL, thumbnail) : undefined,
|
|
81
|
+
story: story || undefined,
|
|
82
|
+
quality: quality || undefined,
|
|
83
|
+
genres,
|
|
84
|
+
year: year || undefined,
|
|
85
|
+
duration: duration || undefined,
|
|
86
|
+
language: language || undefined,
|
|
87
|
+
rating: rating || undefined,
|
|
88
|
+
watchUrl,
|
|
89
|
+
downloadUrl,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { load, extractText, extractAttr, cleanText } from "../utils/parser.js";
|
|
2
|
+
import { fetchHtml } from "../utils/http.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Scrape all download links from a topcinemaa.cam /download/ page.
|
|
6
|
+
*
|
|
7
|
+
* Returns:
|
|
8
|
+
* - `vidtube` — the VidTube multi-quality link (use vidtubeLinks() to resolve direct .mp4 URLs)
|
|
9
|
+
* - `byQuality` — map of quality → array of { server, url }
|
|
10
|
+
*
|
|
11
|
+
* @param {string} downloadPageUrl URL ending in /download/
|
|
12
|
+
* @param {{
|
|
13
|
+
* timeout?: number,
|
|
14
|
+
* headers?: Record<string, string>,
|
|
15
|
+
* proxy?: string
|
|
16
|
+
* }} [options]
|
|
17
|
+
* @returns {Promise<DownloadResult>}
|
|
18
|
+
*
|
|
19
|
+
* @typedef {{ server: string, url: string }} DownloadLink
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* vidtube?: string,
|
|
22
|
+
* byQuality: Record<string, DownloadLink[]>,
|
|
23
|
+
* all: Array<{ quality: string, server: string, url: string }>
|
|
24
|
+
* }} DownloadResult
|
|
25
|
+
*/
|
|
26
|
+
export async function download(downloadPageUrl, options = {}) {
|
|
27
|
+
const html = await fetchHtml(downloadPageUrl, options);
|
|
28
|
+
const $ = load(html);
|
|
29
|
+
|
|
30
|
+
let vidtube = undefined;
|
|
31
|
+
|
|
32
|
+
const proLink = $(".proServer .downloadsLink[href]").first();
|
|
33
|
+
if (proLink.length) {
|
|
34
|
+
const href = extractAttr(proLink, "href");
|
|
35
|
+
if (href && href.includes("vidtube")) vidtube = href;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @type {Record<string, DownloadLink[]>} */
|
|
39
|
+
const byQuality = {};
|
|
40
|
+
/** @type {Array<{ quality: string, server: string, url: string }>} */
|
|
41
|
+
const all = [];
|
|
42
|
+
|
|
43
|
+
$(".DownloadBlock").each((_i, blockEl) => {
|
|
44
|
+
const block = $(blockEl);
|
|
45
|
+
|
|
46
|
+
const qualityRaw = cleanText(extractText(block.find(".download-title span").first()));
|
|
47
|
+
const quality = qualityRaw || "unknown";
|
|
48
|
+
|
|
49
|
+
if (!byQuality[quality]) byQuality[quality] = [];
|
|
50
|
+
|
|
51
|
+
block.find(".download-items li a.downloadsLink[href]").each((_j, el) => {
|
|
52
|
+
const a = $(el);
|
|
53
|
+
const url = extractAttr(a, "href");
|
|
54
|
+
const server = cleanText(extractText(a.find(".text span").first()));
|
|
55
|
+
if (!url) return;
|
|
56
|
+
|
|
57
|
+
byQuality[quality].push({ server, url });
|
|
58
|
+
all.push({ quality, server, url });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return { vidtube, byQuality, all };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Convenience: given a post's base URL, automatically appends /download/ and scrapes it.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} postUrl e.g. https://topcinemaa.cam/film-slug/
|
|
69
|
+
* @param {object} [options]
|
|
70
|
+
* @returns {Promise<DownloadResult>}
|
|
71
|
+
*/
|
|
72
|
+
export async function downloadFromPost(postUrl, options = {}) {
|
|
73
|
+
const base = postUrl.endsWith("/") ? postUrl : postUrl + "/";
|
|
74
|
+
return download(base + "download/", options);
|
|
75
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { wpGet, toCard, ANIME_CAT, EMBED_PARAM, BASE_FIELDS } from "../api.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Search for anime using the WordPress REST API.
|
|
5
|
+
* Results are scoped to category 5 (افلام انمي) only — no series, no foreign films.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} query
|
|
8
|
+
* @param {{
|
|
9
|
+
* page?: number,
|
|
10
|
+
* perPage?: number,
|
|
11
|
+
* timeout?: number,
|
|
12
|
+
* headers?: Record<string, string>,
|
|
13
|
+
* proxy?: string
|
|
14
|
+
* }} [options]
|
|
15
|
+
* @returns {Promise<{
|
|
16
|
+
* query: string,
|
|
17
|
+
* results: import("../api.js").AnimeCard[],
|
|
18
|
+
* page: number,
|
|
19
|
+
* perPage: number,
|
|
20
|
+
* total: number,
|
|
21
|
+
* totalPages: number
|
|
22
|
+
* }>}
|
|
23
|
+
*/
|
|
24
|
+
export async function search(query, options = {}) {
|
|
25
|
+
const page = options.page ?? 1;
|
|
26
|
+
const perPage = options.perPage ?? 20;
|
|
27
|
+
|
|
28
|
+
const { data, total, totalPages } = await wpGet(
|
|
29
|
+
"/posts",
|
|
30
|
+
{
|
|
31
|
+
search: query,
|
|
32
|
+
categories: ANIME_CAT,
|
|
33
|
+
per_page: perPage,
|
|
34
|
+
page,
|
|
35
|
+
_fields: BASE_FIELDS,
|
|
36
|
+
_embed: "wp:featuredmedia,wp:term",
|
|
37
|
+
},
|
|
38
|
+
options
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
query,
|
|
43
|
+
results: data.map(toCard),
|
|
44
|
+
page,
|
|
45
|
+
perPage,
|
|
46
|
+
total,
|
|
47
|
+
totalPages,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { load, extractText, extractAttr, absoluteUrl, cleanText } from "../utils/parser.js";
|
|
2
|
+
import { fetchHtml } from "../utils/http.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a VidTube download/embed URL into direct per-quality .mp4 links.
|
|
6
|
+
*
|
|
7
|
+
* Accepts any of:
|
|
8
|
+
* - https://down.vidtube.one/d/FILE_ID.html (download page)
|
|
9
|
+
* - https://down.vidtube.one/embed-FILE_ID.html (embed page)
|
|
10
|
+
* - https://vidtube.one/embed-FILE_ID.html
|
|
11
|
+
*
|
|
12
|
+
* @param {string} vidtubeUrl
|
|
13
|
+
* @param {{
|
|
14
|
+
* timeout?: number,
|
|
15
|
+
* headers?: Record<string, string>,
|
|
16
|
+
* proxy?: string
|
|
17
|
+
* }} [options]
|
|
18
|
+
* @returns {Promise<VidTubeResult>}
|
|
19
|
+
*
|
|
20
|
+
* @typedef {{ quality: string, url: string, type: string }} VidTubeLink
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* sourceUrl: string,
|
|
23
|
+
* downloadUrl?: string,
|
|
24
|
+
* embedUrl?: string,
|
|
25
|
+
* links: VidTubeLink[]
|
|
26
|
+
* }} VidTubeResult
|
|
27
|
+
*/
|
|
28
|
+
export async function vidtubeLinks(vidtubeUrl, options = {}) {
|
|
29
|
+
const { downloadUrl, embedUrl } = _normalizeUrls(vidtubeUrl);
|
|
30
|
+
const targetUrl = downloadUrl ?? embedUrl ?? vidtubeUrl;
|
|
31
|
+
|
|
32
|
+
const html = await fetchHtml(targetUrl, {
|
|
33
|
+
...options,
|
|
34
|
+
headers: {
|
|
35
|
+
Referer: "https://topcinemaa.cam/",
|
|
36
|
+
Origin: "https://topcinemaa.cam",
|
|
37
|
+
...(options.headers ?? {}),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const links = [];
|
|
42
|
+
|
|
43
|
+
const $ = load(html);
|
|
44
|
+
|
|
45
|
+
$("a[href]").each((_i, el) => {
|
|
46
|
+
const a = $(el);
|
|
47
|
+
const href = extractAttr(a, "href");
|
|
48
|
+
if (!href) return;
|
|
49
|
+
|
|
50
|
+
if (href.includes(".mp4") || href.includes("download") || href.includes("video")) {
|
|
51
|
+
const text = cleanText(extractText(a));
|
|
52
|
+
const quality = _detectQuality(text, href);
|
|
53
|
+
links.push({ quality, url: absoluteUrl(targetUrl, href), type: "direct" });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
$("source[src]").each((_i, el) => {
|
|
58
|
+
const src = extractAttr($(el), "src");
|
|
59
|
+
const type = extractAttr($(el), "type");
|
|
60
|
+
const res = extractAttr($(el), "res") || extractAttr($(el), "data-res") || "";
|
|
61
|
+
if (!src) return;
|
|
62
|
+
links.push({
|
|
63
|
+
quality: res ? `${res}p` : _detectQuality("", src),
|
|
64
|
+
url: absoluteUrl(targetUrl, src),
|
|
65
|
+
type: type || "video/mp4",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
for (const m of html.matchAll(/"file"\s*:\s*"([^"]+\.mp4[^"]*)"/g))
|
|
70
|
+
links.push({ quality: _detectQuality("", m[1]), url: m[1], type: "video/mp4" });
|
|
71
|
+
|
|
72
|
+
for (const m of html.matchAll(/"([^"]+\.mp4[^"]*)"/g)) {
|
|
73
|
+
const url = m[1];
|
|
74
|
+
if (url.startsWith("http") && !links.some(l => l.url === url))
|
|
75
|
+
links.push({ quality: _detectQuality("", url), url, type: "video/mp4" });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const unique = links.filter(l => {
|
|
80
|
+
if (seen.has(l.url)) return false;
|
|
81
|
+
seen.add(l.url);
|
|
82
|
+
return true;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return { sourceUrl: vidtubeUrl, downloadUrl, embedUrl, links: unique };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if a URL is a VidTube URL.
|
|
90
|
+
* @param {string} url
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function isVidTubeUrl(url) {
|
|
94
|
+
return url.includes("vidtube.one") || url.includes("down.vidtube");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _normalizeUrls(url) {
|
|
98
|
+
const fileIdMatch =
|
|
99
|
+
url.match(/\/embed-([a-z0-9]+)\.html/) ||
|
|
100
|
+
url.match(/\/d\/([a-z0-9]+)\.html/) ||
|
|
101
|
+
url.match(/\/([a-z0-9]{10,})\.html/);
|
|
102
|
+
|
|
103
|
+
if (!fileIdMatch) return { downloadUrl: url, embedUrl: undefined };
|
|
104
|
+
|
|
105
|
+
const id = fileIdMatch[1];
|
|
106
|
+
const domain = url.includes("down.vidtube") ? "https://down.vidtube.one" : "https://vidtube.one";
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
downloadUrl: `${domain}/d/${id}.html`,
|
|
110
|
+
embedUrl: `${domain}/embed-${id}.html`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _detectQuality(text, href) {
|
|
115
|
+
const s = `${text} ${href}`.toLowerCase();
|
|
116
|
+
if (s.includes("4k") || s.includes("2160")) return "4K";
|
|
117
|
+
if (s.includes("1080")) return "1080p";
|
|
118
|
+
if (s.includes("720")) return "720p";
|
|
119
|
+
if (s.includes("480")) return "480p";
|
|
120
|
+
if (s.includes("360")) return "360p";
|
|
121
|
+
if (s.includes("240")) return "240p";
|
|
122
|
+
return "auto";
|
|
123
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_HEADERS = {
|
|
4
|
+
"User-Agent":
|
|
5
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
6
|
+
Accept:
|
|
7
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
8
|
+
"Accept-Language": "ar,en;q=0.9",
|
|
9
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
10
|
+
"Cache-Control": "no-cache",
|
|
11
|
+
Connection: "keep-alive",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create an axios instance.
|
|
16
|
+
* @param {object} [options]
|
|
17
|
+
* @param {number} [options.timeout]
|
|
18
|
+
* @param {object} [options.headers]
|
|
19
|
+
* @param {string} [options.proxy]
|
|
20
|
+
* @returns {import("axios").AxiosInstance}
|
|
21
|
+
*/
|
|
22
|
+
export function createClient(options = {}) {
|
|
23
|
+
const config = {
|
|
24
|
+
timeout: options.timeout ?? 30000,
|
|
25
|
+
headers: { ...DEFAULT_HEADERS, ...(options.headers ?? {}) },
|
|
26
|
+
maxRedirects: 10,
|
|
27
|
+
};
|
|
28
|
+
if (options.proxy) {
|
|
29
|
+
const u = new URL(options.proxy);
|
|
30
|
+
config.proxy = { host: u.hostname, port: Number(u.port), protocol: u.protocol.replace(":", "") };
|
|
31
|
+
}
|
|
32
|
+
return axios.create(config);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fetch a URL and return the HTML string.
|
|
37
|
+
* @param {string} url
|
|
38
|
+
* @param {object} [options]
|
|
39
|
+
* @returns {Promise<string>}
|
|
40
|
+
*/
|
|
41
|
+
export async function fetchHtml(url, options = {}) {
|
|
42
|
+
const client = createClient(options);
|
|
43
|
+
const res = await client.get(url, { responseType: "text" });
|
|
44
|
+
return res.data;
|
|
45
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
2
|
+
|
|
3
|
+
/** @param {string} html @returns {import("cheerio").CheerioAPI} */
|
|
4
|
+
export function load(html) { return cheerio.load(html); }
|
|
5
|
+
|
|
6
|
+
/** @param {import("cheerio").Cheerio<any>} el @returns {string} */
|
|
7
|
+
export function extractText(el) { return el.text().trim(); }
|
|
8
|
+
|
|
9
|
+
/** @param {import("cheerio").Cheerio<any>} el @param {string} attr @returns {string} */
|
|
10
|
+
export function extractAttr(el, attr) { return (el.attr(attr) ?? "").trim(); }
|
|
11
|
+
|
|
12
|
+
/** @param {string} base @param {string} rel @returns {string} */
|
|
13
|
+
export function absoluteUrl(base, rel) {
|
|
14
|
+
if (!rel) return "";
|
|
15
|
+
if (rel.startsWith("http://") || rel.startsWith("https://")) return rel;
|
|
16
|
+
if (rel.startsWith("//")) return "https:" + rel;
|
|
17
|
+
try { return new URL(rel, base).href; } catch { return rel; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {string} text @returns {string} */
|
|
21
|
+
export function cleanText(text) { return text.replace(/\s+/g, " ").trim(); }
|