@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 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(); }