diff --git a/README.md b/README.md index 9e5892b..4d2395b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ Don't make it long, make it short, all processed through single rest | `xvideos` | [![xvideos](https://github.com/sinkaroid/lustpress/workflows/Xvideos%20test/badge.svg)](https://github.com/sinkaroid/lustpress/actions/workflows/xvideos.yml) | `Yes` | `Yes` | `Yes` | `Yes` | | `xhamster` | [![xhamster](https://github.com/sinkaroid/lustpress/workflows/Xhamster%20test/badge.svg)](https://github.com/sinkaroid/lustpress/actions/workflows/xhamster.yml) | `Yes` | `Yes` | `Yes` | `Yes` | | `youporn` | [![youporn](https://github.com/sinkaroid/lustpress/workflows/Youporn%20test/badge.svg)](https://github.com/sinkaroid/lustpress/actions/workflows/youporn.yml) | `Yes` | `Yes` | `Yes` | `Yes` | +| `eporner` | — | `Yes` | `Yes` | `Yes` | `Yes` | +| `txxx` | — | `Yes` | `Yes` | `Yes` | `Yes` | ## Prerequisites diff --git a/src/controller/eporner/epornerGet.ts b/src/controller/eporner/epornerGet.ts new file mode 100644 index 0000000..6f78be9 --- /dev/null +++ b/src/controller/eporner/epornerGet.ts @@ -0,0 +1,66 @@ +import { scrapeContent } from "../../scraper/eporner/epornerGetController"; +import c from "../../utils/options"; +import { logger } from "../../utils/logger"; +import { maybeError } from "../../utils/modifier"; +import { Request, Response } from "express"; + +export async function getEporner(req: Request, res: Response) { + try { + const id = req.query.id as string; + if (!id) throw Error("Parameter id is required"); + + /** + * @api {get} /eporner/get?id=:id Get eporner + * @apiName Get eporner + * @apiGroup eporner + * @apiDescription Get a eporner video based on id + * + * @apiParam {String} id Video ID + * + * @apiSuccessExample {json} Success-Response: + * HTTP/1.1 200 OK + * HTTP/1.1 400 Bad Request + * + * @apiExample {curl} curl + * curl -i https://lust.scathach.id/eporner/get?id=ibvqvezXzcs + * + * @apiExample {js} JS/TS + * import axios from "axios" + * + * axios.get("https://lust.scathach.id/eporner/get?id=ibvqvezXzcs") + * .then(res => console.log(res.data)) + * .catch(err => console.error(err)) + * + * @apiExample {python} Python + * import aiohttp + * async with aiohttp.ClientSession() as session: + * async with session.get("https://lust.scathach.id/eporner/get?id=ibvqvezXzcs") as resp: + * print(await resp.json()) + */ + // https://www.eporner.com/video-ibvqvezXzcs/ivy-seduces-her-dad-s-friend/ + // https://www.eporner.com/hd-porn/hgovoiPexQe/Risa-Tsukino/ + + let path: string; + + if (id.startsWith("video-")) { + path = id; + } else { + path = `hd-porn/${id}`; + } + + const url = `${c.EPORNER}/${path}`; + + const data = await scrapeContent(url); + logger.info({ + path: req.path, + query: req.query, + method: req.method, + ip: req.ip, + useragent: req.get("User-Agent"), + }); + return res.json(data); + } catch (err) { + const e = err as Error; + res.status(400).json(maybeError(false, e.message)); + } +} diff --git a/src/controller/eporner/epornerGetRelated.ts b/src/controller/eporner/epornerGetRelated.ts new file mode 100644 index 0000000..b9ee25d --- /dev/null +++ b/src/controller/eporner/epornerGetRelated.ts @@ -0,0 +1,28 @@ +import { scrapeContent } from "../../scraper/eporner/epornerGetRelatedController"; +import c from "../../utils/options"; +import { logger } from "../../utils/logger"; +import { maybeError } from "../../utils/modifier"; +import { Request, Response } from "express"; + +export async function relatedEporner(req: Request, res: Response) { + try { + const id = req.query.id as string; + if (!id) throw Error("Parameter id is required"); + + const url = `${c.EPORNER}/video-${id}`; + const data = await scrapeContent(url); + + logger.info({ + path: req.path, + query: req.query, + method: req.method, + ip: req.ip, + useragent: req.get("User-Agent") + }); + + return res.json(data); + } catch (err) { + const e = err as Error; + res.status(400).json(maybeError(false, e.message)); + } +} diff --git a/src/controller/eporner/epornerRandom.ts b/src/controller/eporner/epornerRandom.ts new file mode 100644 index 0000000..4589168 --- /dev/null +++ b/src/controller/eporner/epornerRandom.ts @@ -0,0 +1,75 @@ +import { scrapeContent as searchScrape } from "../../scraper/eporner/epornerSearchController"; +import { scrapeContent as videoScrape } from "../../scraper/eporner/epornerGetController"; +import c from "../../utils/options"; +import { logger } from "../../utils/logger"; +import { maybeError } from "../../utils/modifier"; +import { Request, Response } from "express"; +import LustPress from "../../LustPress"; + +const lust = new LustPress(); + +export async function randomEporner(req: Request, res: Response) { + /** + * @api {get} /eporner/random Get random eporner + * @apiName Get random eporner + * @apiGroup eporner + * @apiDescription Get a random eporner video + * + * @apiParam {String} id Video ID + * + * @apiSuccessExample {json} Success-Response: + * HTTP/1.1 200 OK + * HTTP/1.1 400 Bad Request + * + * @apiExample {curl} curl + * curl -i https://lust.scathach.id/eporner/random + * + * @apiExample {js} JS/TS + * import axios from "axios" + * + * axios.get("https://lust.scathach.id/eporner/random") + * .then(res => console.log(res.data)) + * .catch(err => console.error(err)) + * + * @apiExample {python} Python + * import aiohttp + * async with aiohttp.ClientSession() as session: + * async with session.get("https://lust.scathach.id/eporner/random") as resp: + * print(await resp.json()) + */ + + // cat/all/SORT-top-weekly/ + + try { + const weeklyUrl = `${c.EPORNER}/cat/all/SORT-top-weekly/`; + const list = await searchScrape(weeklyUrl); + + if (!list.data.length) { + throw new Error("No weekly top videos found"); + } + + const random = list.data[Math.floor(Math.random() * list.data.length)]; + + let path: string; + + if (random.id.startsWith("video-")) { + path = random.id; + } else { + path = `hd-porn/${random.id}`; + } + + const url = `${c.EPORNER}/${path}`; + const data = await videoScrape(url); + + logger.info({ + path: req.path, + method: req.method, + ip: req.ip, + useragent: req.get("User-Agent"), + }); + + return res.json(data); + } catch (err) { + res.status(400).json(maybeError(false, (err as Error).message)); + } +} \ No newline at end of file diff --git a/src/controller/eporner/epornerSearch.ts b/src/controller/eporner/epornerSearch.ts new file mode 100644 index 0000000..1daa093 --- /dev/null +++ b/src/controller/eporner/epornerSearch.ts @@ -0,0 +1,72 @@ +import { scrapeContent } from "../../scraper/eporner/epornerSearchController"; +import c from "../../utils/options"; +import { logger } from "../../utils/logger"; +import { maybeError, spacer } from "../../utils/modifier"; +import { Request, Response } from "express"; + +export async function searchEporner(req: Request, res: Response) { + try { + /** + * @api {get} /eporner/search Search eporner videos + * @apiName Search eporner + * @apiGroup eporner + * @apiDescription Search eporner videos + * @apiParam {String} key Keyword to search + * @apiParam {Number} [page=1] Page number + * + * @apiSuccessExample {json} Success-Response: + * HTTP/1.1 200 OK + * HTTP/1.1 400 Bad Request + * + * @apiExample {curl} curl + * curl -i https://lust.scathach.id/eporner/search?key=milf + * curl -i https://lust.scathach.id/eporner/search?key=milf&page=2 + * + * @apiExample {js} JS/TS + * import axios from "axios" + * + * axios.get("https://lust.scathach.id/eporner/search?key=milf") + * .then(res => console.log(res.data)) + * .catch(err => console.error(err)) + * + * @apiExample {python} Python + * import aiohttp + * async with aiohttp.ClientSession() as session: + * async with session.get("https://lust.scathach.id/eporner/search?key=milf") as resp: + * print(await resp.json()) + */ + + // https://www.eporner.com/tag/milf/ + + const key = req.query.key as string; + const page = Number(req.query.page || 1); + + if (!key) throw Error("Parameter key is required"); + if (isNaN(page)) throw Error("Parameter page must be a number"); + + const slug = key + .toLowerCase() + .trim() + .replace(/\s+/g, "-"); + + const url = + page === 1 + ? `${c.EPORNER}/tag/${slug}/` + : `${c.EPORNER}/tag/${slug}/${page}/`; + + const data = await scrapeContent(url); + + logger.info({ + path: req.path, + query: req.query, + method: req.method, + ip: req.ip, + useragent: req.get("User-Agent") + }); + + return res.json(data); + } catch (err) { + const e = err as Error; + res.status(400).json(maybeError(false, e.message)); + } +} \ No newline at end of file diff --git a/src/controller/txxx/txxxGet.ts b/src/controller/txxx/txxxGet.ts new file mode 100644 index 0000000..134d810 --- /dev/null +++ b/src/controller/txxx/txxxGet.ts @@ -0,0 +1,82 @@ +import { Request, Response } from "express"; +import LustPress from "../../LustPress"; +import { maybeError } from "../../utils/modifier"; +import { logger } from "../../utils/logger"; +import { IVideoData } from "../../interfaces"; + +const lust = new LustPress(); + +// Generate sharded API URL exactly like TXXX expects +function getApiUrl(videoId: string): string { + const id = Number(videoId); + if (Number.isNaN(id)) throw new Error("Invalid video id"); + + const million = Math.floor(id / 1_000_000) * 1_000_000; + const thousand = Math.floor(id / 1_000) * 1_000; + + return `https://txxx.com/api/json/video/86400/${million}/${thousand}/${id}.json`; +} + +export async function getTxxx(req: Request, res: Response) { + try { + const id = String(req.query.id || "").trim(); + if (!id) throw new Error("Parameter id is required"); + + const apiUrl = getApiUrl(id); + + const buffer = await lust.fetchBody(apiUrl); + const parsed = JSON.parse(buffer.toString("utf-8")); + + if (!parsed?.video) { + throw new Error("Invalid API response"); + } + + const video = parsed.video; + + const categories = Object.values(video.categories || {}).map( + (c: any) => c.title, + ); + + const tags = Object.values(video.tags || {}).map((t: any) => t.title); + + const models = Object.values(video.models || {}).map((m: any) => m.title); + + const videoDir = video.dir || ""; + const videoId = video.video_id; + + const embed = `https://txxx.com/embed/${videoId}/`; + + const response: IVideoData = { + success: true, + data: { + title: video.title || "None", + id: `${videoId}`, + image: video.thumbsrc || video.thumb || "None", + duration: video.duration || "None", + views: video.statistics?.viewed || "0", + rating: video.statistics?.rating || "0.00", + uploaded: video.post_date || "None", + upvoted: String(video.statistics?.likes || "0"), + downvoted: String(video.statistics?.dislikes || "0"), + channel: video.channel?.title || "", + models: models.length > 0 ? models : video.models_suggested || [], + tags: [...categories, ...tags], + }, + source: `https://txxx.com/videos/${videoId}/${videoDir}/`, + assets: [embed, video.thumbsrc].filter(Boolean), + }; + + logger.info({ + path: req.path, + query: req.query, + method: req.method, + ip: req.ip, + useragent: req.get("User-Agent"), + }); + + return res.json(response); + } catch (err) { + const e = err as Error; + return res.status(400).json(maybeError(false, e.message)); + } +} diff --git a/src/controller/txxx/txxxGetRelated.ts b/src/controller/txxx/txxxGetRelated.ts new file mode 100644 index 0000000..c21e6ba --- /dev/null +++ b/src/controller/txxx/txxxGetRelated.ts @@ -0,0 +1,69 @@ +import { Request, Response } from "express"; +import LustPress from "../../LustPress"; +import { maybeError } from "../../utils/modifier"; +import { logger } from "../../utils/logger"; +import { ISearchVideoData } from "../../interfaces"; + +const lust = new LustPress(); + +function getRelatedApiUrl(videoId: string, page = 1, count = 50): string { + const id = Number(videoId); + if (Number.isNaN(id)) throw new Error("Invalid video id"); + + const million = Math.floor(id / 1_000_000) * 1_000_000; + const thousand = Math.floor(id / 1_000) * 1_000; + + return ( + `https://txxx.com/api/json/videos_related2/` + + `432000/${count}/${million}/${thousand}/${id}.all.${page}.json` + ); +} + +export async function relatedTxxx(req: Request, res: Response) { + try { + const id = String(req.query.id || "").trim(); + const page = Number(req.query.page || 1); + + if (!id) throw new Error("Parameter id is required"); + if (Number.isNaN(page)) throw new Error("Parameter page must be a number"); + + const apiUrl = getRelatedApiUrl(id, page); + + const buffer = await lust.fetchBody(apiUrl); + const rawData = JSON.parse(buffer.toString("utf-8")); + + const videos = Array.isArray(rawData.videos) ? rawData.videos : []; + + const data = videos.map((v: any) => ({ + id: v.video_id, + title: v.title, + image: v.scr || v.thumb || null, + duration: v.duration || "None", + views: v.video_viewed || "0", + rating: v.rating || "0", + uploader: v.username || v.display_name || "", + link: `https://txxx.com/videos/${v.video_id}/${v.dir}/`, + video: `https://txxx.com/embed/${v.video_id}/`, + })); + + logger.info({ + path: req.path, + query: req.query, + method: req.method, + ip: req.ip, + useragent: req.get("User-Agent"), + }); + + return res.json({ + success: true, + total_count: rawData.total_count || "0", + pages: rawData.pages || 1, + page, + data, + source: `https://txxx.com/videos/${id}/`, + } as ISearchVideoData); + } catch (err) { + const e = err as Error; + return res.status(400).json(maybeError(false, e.message)); + } +} diff --git a/src/controller/txxx/txxxRandom.ts b/src/controller/txxx/txxxRandom.ts new file mode 100644 index 0000000..be198c4 --- /dev/null +++ b/src/controller/txxx/txxxRandom.ts @@ -0,0 +1,58 @@ +import { Request, Response } from "express"; +import LustPress from "../../LustPress"; +import { maybeError } from "../../utils/modifier"; +import { logger } from "../../utils/logger"; + +const lust = new LustPress(); + +export async function randomTxxx(req: Request, res: Response) { + try { + const apiUrl = + "https://txxx.com/api/json/videos2/14400/str/most-popular/60/..1.all..day.json"; + + const buffer = await lust.fetchBody(apiUrl); + const rawData = JSON.parse(buffer.toString("utf-8")); + + const videos = Array.isArray(rawData.videos) + ? rawData.videos + : []; + + if (videos.length === 0) { + throw new Error("No videos returned from upstream"); + } + + // Pick one random video + const v = videos[Math.floor(Math.random() * videos.length)]; + + const data = { + video_id: v.video_id, + title: v.title, + dir: v.dir, + duration: v.duration, + views: v.video_viewed, + rating: v.rating, + uploaded: v.post_date, + likes: v.likes, + dislikes: v.dislikes, + image: v.scr, + categories: v.categories ? v.categories.split(",") : [], + embed: `https://txxx.com/embed/${v.video_id}/`, + }; + + logger.info({ + path: req.path, + method: req.method, + ip: req.ip, + useragent: req.get("User-Agent"), + }); + + return res.json({ + success: true, + data, + source: apiUrl, + }); + } catch (err) { + const e = err as Error; + return res.status(400).json(maybeError(false, e.message)); + } +} diff --git a/src/controller/txxx/txxxSearch.ts b/src/controller/txxx/txxxSearch.ts new file mode 100644 index 0000000..4a43986 --- /dev/null +++ b/src/controller/txxx/txxxSearch.ts @@ -0,0 +1,63 @@ +import { Request, Response } from "express"; +import LustPress from "../../LustPress"; +import { maybeError } from "../../utils/modifier"; + +const lust = new LustPress(); + +export async function searchTxxx(req: Request, res: Response) { + try { + const key = String(req.query.key || "").trim(); + const page = Number(req.query.page || 1); + + if (!key) { + return res.json({ + success: false, + error: "Parameter key is required", + }); + } + + if (Number.isNaN(page)) { + return res.json({ + success: false, + error: "Parameter page must be a number", + }); + } + + const apiUrl = + `https://txxx.com/api/videos2.php` + + `?params=259200/str/relevance/60/search..${page}.all..` + + `&s=${encodeURIComponent(key)}`; + + // Fetch from API directly + const buffer = await lust.fetchBody(apiUrl); + const rawData = JSON.parse(buffer.toString("utf-8")); + + const videos = Array.isArray(rawData.videos) ? rawData.videos : []; + + const data = videos.map((v: any) => ({ + video_id: v.video_id, + title: v.title, + dir: v.dir, + duration: v.duration, + views: v.video_viewed, + rating: v.rating, + uploaded: v.post_date, + likes: v.likes, + dislikes: v.dislikes, + image: v.scr, + categories: v.categories ? v.categories.split(",") : [], + embed: `https://txxx.com/embed/${v.video_id}/`, + })); + + return res.json({ + success: true, + total_count: String(rawData.total_count ?? videos.length), + pages: Number(rawData.pages ?? 1), + page, + data, + }); + } catch (err) { + const e = err as Error; + return res.json(maybeError(false, e.message)); + } +} diff --git a/src/controller/xhamster/xhamsterGet.ts b/src/controller/xhamster/xhamsterGet.ts index 981b67a..a9159ec 100644 --- a/src/controller/xhamster/xhamsterGet.ts +++ b/src/controller/xhamster/xhamsterGet.ts @@ -14,23 +14,23 @@ export async function getXhamster(req: Request, res: Response) { * @apiName Get xhamster * @apiGroup xhamster * @apiDescription Get a xhamster video based on id - * + * * @apiParam {String} id Video ID - * + * * @apiSuccessExample {json} Success-Response: * HTTP/1.1 200 OK * HTTP/1.1 400 Bad Request - * + * * @apiExample {curl} curl * curl -i https://lust.scathach.id/xhamster/get?id=videos/horny-makima-tests-new-toy-and-cums-intensely-xhAa5wx - * + * * @apiExample {js} JS/TS * import axios from "axios" - * + * * axios.get("https://lust.scathach.id/xhamster/get?id=videos/horny-makima-tests-new-toy-and-cums-intensely-xhAa5wx") * .then(res => console.log(res.data)) * .catch(err => console.error(err)) - * + * * @apiExample {python} Python * import aiohttp * async with aiohttp.ClientSession() as session: @@ -38,14 +38,14 @@ export async function getXhamster(req: Request, res: Response) { * print(await resp.json()) */ - const url = `${c.XHAMSTER}/${id}`; + const url = `${c.XHAMSTER}/videos/${id}`; const data = await scrapeContent(url); logger.info({ path: req.path, query: req.query, method: req.method, ip: req.ip, - useragent: req.get("User-Agent") + useragent: req.get("User-Agent"), }); return res.json(data); } catch (err) { diff --git a/src/controller/xhamster/xhamsterRandom.ts b/src/controller/xhamster/xhamsterRandom.ts index b2dd4ae..f72d324 100644 --- a/src/controller/xhamster/xhamsterRandom.ts +++ b/src/controller/xhamster/xhamsterRandom.ts @@ -10,51 +10,58 @@ const lust = new LustPress(); export async function randomXhamster(req: Request, res: Response) { try { - - /** - * @api {get} /xhamster/random Get random xhamster + * @api {get} /xhamster/random Get random xhamster video * @apiName Get random xhamster * @apiGroup xhamster - * @apiDescription Get a random xhamster video - * + * @apiDescription Get a random xhamster video from the list of newest videos. + * * @apiSuccessExample {json} Success-Response: * HTTP/1.1 200 OK * HTTP/1.1 400 Bad Request - * + * * @apiExample {curl} curl * curl -i https://lust.scathach.id/xhamster/random - * + * * @apiExample {js} JS/TS * import axios from "axios" - * + * * axios.get("https://lust.scathach.id/xhamster/random") * .then(res => console.log(res.data)) * .catch(err => console.error(err)) - * + * * @apiExample {python} Python * import aiohttp * async with aiohttp.ClientSession() as session: * async with session.get("https://lust.scathach.id/xhamster/random") as resp: * print(await resp.json()) */ + const resolve = await lust.fetchBody(`${c.XHAMSTER}/newest`); const $ = load(resolve); - const search = $("a.root-9d8b4.video-thumb-info__name.role-pop.with-dropdown") - .map((i, el) => $(el).attr("href")) - .get(); - const search_ = search.map((el) => el.replace(c.XHAMSTER, "")); - const random = Math.floor(Math.random() * search_.length); - const url = c.XHAMSTER + search_[random]; - const data = await scrapeContent(url); + const videoLinks = $("div.thumb-list__item[data-video-id]") + .map((_, el) => { + const href = $(el).find("a[data-role='thumb-link']").attr("href"); + return href && href.includes("/videos/") ? href : null; + }) + .get() + .filter(Boolean); + + // Select a random video URL from the list + const randomIndex = Math.floor(Math.random() * videoLinks.length); + const randomUrl = videoLinks[randomIndex]; + + const data = await scrapeContent(randomUrl); + logger.info({ path: req.path, query: req.query, method: req.method, ip: req.ip, - useragent: req.get("User-Agent") + useragent: req.get("User-Agent"), }); + return res.json(data); } catch (err) { const e = err as Error; diff --git a/src/interfaces.ts b/src/interfaces.ts index 5131eb2..49c35f9 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -3,13 +3,14 @@ export interface IVideoData { data: { title: string; id: string; - image: string; + image?: string; duration: string; views: string; - rating: string; + rating?: string; uploaded: string; upvoted: string | null; downvoted: string | null; + channel?: string; models: string[]; tags: string[]; }; @@ -23,6 +24,19 @@ export interface ISearchVideoData { source: string; } +export interface ISearchItem { + link: string; + id: string; + title?: string; + image?: string; + duration?: string; + rating?: string; + views?: string; + uploader?: string; + video?: string; +} + + export interface MaybeError { message: string; } diff --git a/src/router/endpoint.ts b/src/router/endpoint.ts index 8303d73..379ccc5 100644 --- a/src/router/endpoint.ts +++ b/src/router/endpoint.ts @@ -2,6 +2,19 @@ import cors from "cors"; import { Router } from "express"; import { slow, limiter } from "../utils/limit-options"; + +// EPorner +import { getEporner } from "../controller/eporner/epornerGet"; +import { searchEporner } from "../controller/eporner/epornerSearch"; +import { relatedEporner } from "../controller/eporner/epornerGetRelated"; +import { randomEporner } from "../controller/eporner/epornerRandom"; + +// TXXX +import { getTxxx } from "../controller/txxx/txxxGet"; +import { searchTxxx } from "../controller/txxx/txxxSearch"; +import { relatedTxxx } from "../controller/txxx/txxxGetRelated"; +import { randomTxxx } from "../controller/txxx/txxxRandom"; + // PornHub import { getPornhub } from "../controller/pornhub/pornhubGet"; import { searchPornhub } from "../controller/pornhub/pornhubSearch"; @@ -65,6 +78,14 @@ function scrapeRoutes() { router.get("/youporn/search", cors(), slow, limiter, searchYouporn); router.get("/youporn/related", cors(), slow, limiter, relatedYouporn); router.get("/youporn/random", cors(), slow, limiter, randomYouporn); + router.get("/eporner/get", cors(), slow, limiter, getEporner); + router.get("/eporner/search", cors(), slow, limiter, searchEporner); + router.get("/eporner/related", cors(), slow, limiter, relatedEporner); + router.get("/eporner/random", cors(), slow, limiter, randomEporner); + router.get("/txxx/get", cors(), slow, limiter, getTxxx); + router.get("/txxx/search", cors(), slow, limiter, searchTxxx); + router.get("/txxx/related", cors(), slow, limiter, relatedTxxx); + router.get("/txxx/random", cors(), slow, limiter, randomTxxx); return router; } diff --git a/src/scraper/eporner/epornerGetController.ts b/src/scraper/eporner/epornerGetController.ts new file mode 100644 index 0000000..101f1b1 --- /dev/null +++ b/src/scraper/eporner/epornerGetController.ts @@ -0,0 +1,96 @@ +import { load } from "cheerio"; +import LustPress from "../../LustPress"; +import { IVideoData } from "../../interfaces"; + +const lust = new LustPress(); + +function calculateRatingFromStrings( + upVote: string, + downVote: string +): number { + const up = parseInt(upVote.replace(/,/g, ""), 10) || 0; + const down = parseInt(downVote.replace(/,/g, ""), 10) || 0; + + const total = up + down; + if (total === 0) return 0; + + return up / total; +} + +export async function scrapeContent(url: string) { + try { + const resolve = await lust.fetchBody(url); + const $ = load(resolve); + + class EPorner { + link: string; + id: string; + title: string; + image: string; + duration: string; + views: string; + rating: string; + publish: string; + upVote: string; + downVote: string; + video: string; + tags: string[]; + models: string[]; + constructor() { + // https://www.eporner.com/video-ibvqvezXzcs/ivy-seduces-her-dad-s-friend/ + + this.link = $("link[rel='canonical']").attr("href") || "None"; + this.id = this.link.split("/")[3] + "/" + this.link.split("/")[4] || "None"; + + this.title = $("meta[property='og:title']").attr("content") || "None"; + this.image = $("meta[property='og:image']").attr("content") || "None"; + this.duration = $("meta[property='og:duration']").attr("content") || "0"; + this.views = $("#cinemaviews1").text().trim() || "0"; + this.upVote = $(".likeup i").first().text().trim() || "0"; + this.downVote = $(".likedown i").first().text().trim() || "0"; + + const jsonLdText = $('script[type="application/ld+json"]').first().html() || "{}"; + const jsonLd = JSON.parse(jsonLdText); + const isoDuration = jsonLd.duration; // "PT00H8M12S" + this.publish = jsonLd.uploadDate || "None"; + + const ratingValue = calculateRatingFromStrings(this.upVote, this.downVote); + this.rating = (ratingValue * 100).toFixed(2); + + const videoPart = this.link.split("/")[3] || "None"; + const code = videoPart.replace("video-", ""); + this.video = `https://www.eporner.com/embed/${code}/`; + + this.tags = $("li.vit-category a") + .map((_, el) => $(el).text().trim()).get(); + + this.models = $("li.vit-pornstar a") + .map((_, el) => $(el).text().trim()).get(); + } + } + + const ep = new EPorner(); + const data: IVideoData = { + success: true, + data: { + title: lust.removeHtmlTagWithoutSpace(ep.title), + id: ep.id, + image: ep.image, + duration: lust.secondToMinute(Number(ep.duration)), + views: ep.views, + rating: ep.rating, + uploaded: ep.publish, + upvoted: ep.upVote, + downvoted: ep.downVote, + models: ep.models, + tags: ep.tags + }, + source: ep.link, + assets: [ep.video, ep.image] + }; + return data; + } catch (err) { + const e = err as Error; + throw Error(e.message); + } +} \ No newline at end of file diff --git a/src/scraper/eporner/epornerGetRelatedController.ts b/src/scraper/eporner/epornerGetRelatedController.ts new file mode 100644 index 0000000..6d55c7a --- /dev/null +++ b/src/scraper/eporner/epornerGetRelatedController.ts @@ -0,0 +1,46 @@ +import { load } from "cheerio"; +import LustPress from "../../LustPress"; +import { ISearchVideoData } from "../../interfaces"; + +const lust = new LustPress(); + +export async function scrapeContent(url: string) { + const html = await lust.fetchBody(url); + const $ = load(html); + + const data = $("#relateddiv .mb") + .map((i, el) => { + const link = $(el).find(".mbimg a").first().attr("href") || ""; + const match = link.match(/\/(video-|hd-porn\/)([^/]+)/); + let id = ""; + if (match) { + if (match[1] === "video-") { + id = `video-${match[2]}`; + } else { + // hd-porn + id = match[2]; + } + } + + return { + link: `https://www.eporner.com${link}`, + id, + title: $(el).find(".mbtit a").text().trim(), + image: + $(el).find("img").attr("data-src") || $(el).find("img").attr("src"), + duration: $(el).find(".mbtim").text().trim(), + rating: $(el).find(".mbrate").text().trim(), + views: $(el).find(".mbvie").text().trim(), + uploader: $(el).find(".mb-uploader a").text().trim(), + video: `https://www.eporner.com/embed/${id}`, + }; + }) + .get() + .filter((v) => v.id && v.image); + + return { + success: true, + data: data as unknown as string[], + source: url, + } as ISearchVideoData; +} \ No newline at end of file diff --git a/src/scraper/eporner/epornerSearchController.ts b/src/scraper/eporner/epornerSearchController.ts new file mode 100644 index 0000000..0855ee7 --- /dev/null +++ b/src/scraper/eporner/epornerSearchController.ts @@ -0,0 +1,70 @@ +import { load } from "cheerio"; +import LustPress from "../../LustPress"; +import { ISearchItem } from "../../interfaces"; + +const lust = new LustPress(); + +export async function scrapeContent(url: string) { + try { + const res = await lust.fetchBody(url); + const $ = load(res); + + class EPornerSearch { + data: ISearchItem[]; + + constructor() { + this.data = $("#vidresults .mb") + .map((i, el) => { + const link = + $(el).find(".mbimg a").first().attr("href") || ""; + + const match = link.match(/\/(video-|hd-porn\/)([^/]+)/); + + let id = ""; + + if (match) { + if (match[1] === "video-") { + id = `video-${match[2]}`; + } else { + id = match[2]; + } + } + + const code = id.startsWith("video-") + ? id.replace("video-", "") + : id; + + return { + link: `https://www.eporner.com${link}`, + id, + title: $(el).find(".mbtit a").text().trim(), + image: $(el).find(".mbimg img").attr("src"), + duration: $(el).find(".mbtim").text().trim(), + rating: $(el).find(".mbrate").text().trim(), + views: $(el).find(".mbvie").text().trim(), + uploader: $(el) + .find(".mb-uploader a") + .text() + .trim(), + video: code + ? `https://www.eporner.com/embed/${code}/` + : "None", + }; + }) + .get(); + } + } + + const ep = new EPornerSearch(); + if (ep.data.length === 0) throw Error("No result found"); + + return { + success: true, + data: ep.data as ISearchItem[], + source: url, + }; + } catch (err) { + const e = err as Error; + throw Error(e.message); + } +} \ No newline at end of file diff --git a/src/scraper/pornhub/pornhubGetController.ts b/src/scraper/pornhub/pornhubGetController.ts index 14d5248..0d3a091 100644 --- a/src/scraper/pornhub/pornhubGetController.ts +++ b/src/scraper/pornhub/pornhubGetController.ts @@ -9,7 +9,7 @@ export async function scrapeContent(url: string) { const resolve = await lust.fetchBody(url); const $ = load(resolve); - class PornHub { + class PornHub { link: string; id: string; title: string; @@ -29,28 +29,29 @@ export async function scrapeContent(url: string) { this.title = $("meta[property='og:title']").attr("content") || "None"; this.image = $("meta[property='og:image']").attr("content") || "None"; //get span.count").text() || "None"; this.rating = $("div.ratingPercent > span.percent").text() || "None"; this.videoInfo = $("div.videoInfo").text() || "None"; this.upVote = $("span.votesUp").attr("data-rating") || "None"; this.downVote = $("span.votesDown").attr("data-rating") || "None"; - this.video = $("meta[property='og:video:url']").attr("content") || "None"; + this.video = + $("meta[property='og:video:url']").attr("content") || "None"; this.tags = $("div.video-info-row") .find("a") .map((i, el) => { return $(el).text(); - }).get(); + }) + .get(); this.tags.shift(); this.tags = this.tags.map((el) => lust.removeHtmlTagWithoutSpace(el)); - this.models = $("div.pornstarsWrapper.js-pornstarsWrapper") - .find("a") - .map((i, el) => { - return $(el).attr("data-mxptext"); - }).get(); + this.models = $("div.pornstarsWrapper.js-pornstarsWrapper a") + .map((i, el) => $(el).text().trim()) + .get(); } } - + const ph = new PornHub(); const data: IVideoData = { success: true, @@ -65,14 +66,14 @@ export async function scrapeContent(url: string) { upvoted: ph.upVote, downvoted: ph.downVote, models: ph.models, - tags: ph.tags.filter((el) => el !== "Suggest" && el !== " Suggest") + tags: ph.tags.filter((el) => el !== "Suggest" && el !== " Suggest"), }, source: ph.link, - assets: [ph.video, ph.image] + assets: [ph.video, ph.image], }; return data; } catch (err) { const e = err as Error; throw Error(e.message); } -} \ No newline at end of file +} diff --git a/src/scraper/xhamster/xhamsterGetController.ts b/src/scraper/xhamster/xhamsterGetController.ts index 7e73ac5..18ccba8 100644 --- a/src/scraper/xhamster/xhamsterGetController.ts +++ b/src/scraper/xhamster/xhamsterGetController.ts @@ -6,78 +6,106 @@ const lust = new LustPress(); export async function scrapeContent(url: string) { try { - const resolve = await lust.fetchBody(url); - const $ = load(resolve); + const buffer = await lust.fetchBody(url); + const $ = load(buffer.toString("utf8")); - class Xhamster { + const raw = $("#initials-script").html(); + const initials = raw + ? JSON.parse( + raw.replace(/^window\.initials\s*=\s*/, "").replace(/;$/, ""), + ) + : null; + + class Xhamster { link: string; id: string; title: string; image: string; - duration: any; + duration: string; views: string; rating: string; - publish: string; upVote: string; downVote: string; - video: string; + publish: string; tags: string[]; models: string[]; + video: string; + constructor() { this.link = $("link[rel='canonical']").attr("href") || "None"; - this.id = this.link.split("/")[3] + "/" + this.link.split("/")[4] || "None"; + this.id = this.link.split("/")[4] || "None"; this.title = $("meta[property='og:title']").attr("content") || "None"; - this.image = $("meta[property='og:image']").attr("content") || "None"; - this.duration = $("script#initials-script").html() || "None"; - //remove window.initials={ and }; - this.duration = this.duration.replace("window.initials=", ""); - this.duration = this.duration.replace(/;/g, ""); - this.duration = JSON.parse(this.duration); - this.duration = this.duration.videoModel.duration || "None"; - this.views = $("div.header-icons").find("span").first().text() || "None"; - this.rating = $("div.header-icons").find("span").eq(1).text() || "None"; - this.publish = $("div.entity-info-container__date").attr("data-tooltip") || "None"; - this.upVote = $("div.rb-new__info").text().split("/")[0].trim() || "None"; - this.downVote = $("div.rb-new__info").text().split("/")[1].trim() || "None"; - this.video = "https://xheve2.com/embed/" + this.link.split("-").pop() || "None"; - this.tags = $("a.video-tag") - .map((i, el) => { - return $(el).text(); - }).get(); - this.tags = this.tags.map((el) => lust.removeHtmlTagWithoutSpace(el)); - this.models = $("a.video-tag") - .map((i, el) => { - return $(el).attr("href"); - } - ).get(); - this.models = this.models.filter((el) => el.startsWith("https://xheve2.com/pornstars/")); - this.models = this.models.map((el) => el.replace("https://xheve2.com/pornstars/", "")); + this.image = $("meta[property='og:image']").attr("content") || "None"; + + // defaults + this.duration = "None"; + this.views = "None"; + + const scripts = $("script") + .map((i, el) => $(el).html()) + .get() + .filter(Boolean); + + const videoScript = scripts.find( + (s) => s.includes('"videoModel"') && s.includes('"duration"'), + ); + + if (videoScript) { + const durMatch = videoScript.match(/"duration"\s*:\s*(\d+)/); + if (durMatch) this.duration = durMatch[1]; + + const viewMatch = videoScript.match(/"views"\s*:\s*(\d+)/); + if (viewMatch) this.views = viewMatch[1]; + } + + this.rating = + initials?.ratingComponent?.ratingModel?.value?.toString() || "None"; + this.upVote = + initials?.ratingComponent?.ratingModel?.likes?.toString() || "None"; + this.downVote = + initials?.ratingComponent?.ratingModel?.dislikes?.toString() || + "None"; + + this.publish = + $("div.entity-info-container__date").attr("data-tooltip") || "None"; + + this.tags = + initials?.videoTagsComponent?.tags + ?.filter((t: any) => t.isTag) + .map((t: any) => t.name) || []; + + this.models = + initials?.videoTagsComponent?.tags + ?.filter((t: any) => t.isPornstar) + .map((t: any) => t.name) || []; + + const embedId = this.link.split("-").pop()?.replace("/", ""); + this.video = embedId ? `https://xhamster.com/embed/${embedId}` : "None"; } } - + const xh = new Xhamster(); - const data: IVideoData = { + + return { success: true, data: { title: lust.removeHtmlTagWithoutSpace(xh.title), id: xh.id, image: xh.image, - duration: lust.secondToMinute(Number(xh.duration)), + duration: xh.duration, views: xh.views, rating: xh.rating, uploaded: xh.publish, upvoted: xh.upVote, downvoted: xh.downVote, models: xh.models, - tags: xh.tags + tags: xh.tags, }, source: xh.link, - assets: [xh.video, xh.image] - }; - return data; - + assets: [xh.video, xh.image], + } satisfies IVideoData; } catch (err) { const e = err as Error; - throw Error(e.message); + throw new Error(e.message); } -} \ No newline at end of file +} diff --git a/src/scraper/xhamster/xhamsterSearchController.ts b/src/scraper/xhamster/xhamsterSearchController.ts index 07608c1..8ff2dea 100644 --- a/src/scraper/xhamster/xhamsterSearchController.ts +++ b/src/scraper/xhamster/xhamsterSearchController.ts @@ -17,29 +17,32 @@ export async function scrapeContent(url: string) { .map((i, el) => { const views = $(el).text(); return views; - }).get(); + }) + .get(); const duration = $("span[data-role='video-duration']") .map((i, el) => { const duration = $(el).text(); return duration; - }).get(); + }) + .get(); this.search = $("a.video-thumb__image-container") .map((i, el) => { const link = $(el).attr("href"); return { link: `${link}`, - id: link?.split("/")[3] + "/" + link?.split("/")[4], + id: link?.split("/")[4], title: $(el).find("img").attr("alt"), image: $(el).find("img").attr("src"), duration: duration[i], views: views[i], - video: `${c.XHAMSTER}/embed/${link?.split("-").pop()}` + video: `${c.XHAMSTER}/embed/${link?.split("-").pop()}`, }; - }).get(); + }) + .get(); } } - + const xh = new XhamsterSearch(); if (xh.search.length === 0) throw Error("No result found"); const data = xh.search as unknown as string[]; @@ -49,7 +52,6 @@ export async function scrapeContent(url: string) { source: url, }; return result; - } catch (err) { const e = err as Error; throw Error(e.message); diff --git a/src/scraper/youporn/youpornGetController.ts b/src/scraper/youporn/youpornGetController.ts index 6518a7d..2e86985 100644 --- a/src/scraper/youporn/youpornGetController.ts +++ b/src/scraper/youporn/youpornGetController.ts @@ -9,7 +9,7 @@ export async function scrapeContent(url: string) { const resolve = await lust.fetchBody(url); const $ = load(resolve); - class YouPorn { + class YouPorn { link: string; id: string; title: string; @@ -25,27 +25,37 @@ export async function scrapeContent(url: string) { models: string[]; constructor() { this.link = $("link[rel='canonical']").attr("href") || "None"; - this.id = this.link.replace("https://www.youporn.com/watch/", "") || "None"; + this.id = + this.link.replace("https://www.youporn.com/watch/", "") || "None"; this.title = $("meta[property='og:title']").attr("content") || "None"; this.image = $("meta[property='og:image']").attr("content") || "None"; - this.duration = $("meta[property='video:duration']").attr("content") || "0"; - this.views = $("div.feature.infoValueBlock").find("div[data-value]").attr("data-value") || "0"; - this.rating = $("div.feature").find("span").text().replace(/[^0-9.,%]/g, "") || "0"; - this.publish = $("div.video-uploaded").find("span").text() || "None"; - this.upVote = this.views; + this.duration = + $("meta[property='video:duration']").attr("content") || "0"; + this.publish = + $("span.publishedDate").text().replace("Published on", "").trim() || + "None"; + + this.upVote = "None"; this.downVote = "None"; this.video = `https://www.youporn.com/embed/${this.id}`; - this.tags = $("a[data-espnode='category_tag'], a[data-espnode='porntag_tag']") - .map((i, el) => { - return $(el).text(); - }).get(); - this.models = $("a[data-espnode='pornstar_tag']") - .map((i, el) => { - return $(el).text(); - }).get(); + this.views = $("span.tm_infoValue").first().text() || "0"; + this.rating = $("span.tm_rating_percent").text() || "0"; + + this.tags = $( + "div.js_scrollableContent a.bubble-porntag, \ + a[data-espnode='category_tag'], \ + a[data-espnode='porntag_tag']", + ) + .map((i, el) => $(el).text().trim()) + .get() + .filter(Boolean); + + this.models = $("#metaDataPornstarInfo a.tm_pornstar_link span") + .map((i, el) => $(el).text().replace(",", "").trim()) + .get(); } } - + const yp = new YouPorn(); const data: IVideoData = { success: true, @@ -60,14 +70,14 @@ export async function scrapeContent(url: string) { upvoted: yp.upVote, downvoted: yp.downVote, models: yp.models, - tags: yp.tags + tags: yp.tags, }, source: yp.link, - assets: [yp.video, yp.image] + assets: [yp.video, yp.image], }; return data; } catch (err) { const e = err as Error; throw Error(e.message); } -} \ No newline at end of file +} diff --git a/src/scraper/youporn/youpornSearchController.ts b/src/scraper/youporn/youpornSearchController.ts index 2382fe3..b6f8d24 100644 --- a/src/scraper/youporn/youpornSearchController.ts +++ b/src/scraper/youporn/youpornSearchController.ts @@ -11,31 +11,74 @@ export async function scrapeContent(url: string) { const $ = load(res); class YouPornSearch { - dur: string[]; + links: string[]; + ids: string[]; + titles: string[]; + images: string[]; + durations: string[]; + views: string[]; search: object[]; + constructor() { - this.dur = $("div.video-duration").map((i, el) => { - return $(el).text(); - }).get(); - this.search = $("a[href^='/watch/']") + const cards = $("div.video-box.pc"); + + this.links = cards + .map((i, el) => { + return $(el).find("a.tm_video_link").attr("href"); + }) + .get(); + + this.ids = this.links.map((link) => link?.split("/")[2]); + + this.titles = cards + .map((i, el) => { + return $(el).find("a.tm_video_title span").text().trim(); + }) + .get(); + + this.images = cards + .map((i, el) => { + return ( + $(el).find("img.thumb-image").attr("data-src") || + $(el).find("img.thumb-image").attr("src") + ); + }) + .get(); + + this.durations = cards + .map((i, el) => { + return $(el).find("div.tm_video_duration span").text().trim(); + }) + .get(); + + this.views = cards + .map((i, el) => { + return ( + $(el) + .find(".view-rating-container .info-views") + .first() + .text() + .trim() || "None" + ); + }) + .get(); + + this.search = cards .map((i, el) => { - const link = $(el).attr("href"); - const id = `${link}`.split("/")[2] + "/" + `${link}`.split("/")[3]; - const title = $(el).find("div.video-box-title").text(); - const image = $(el).find("img").attr("data-thumbnail"); return { - link: `${c.YOUPORN}${link}`, - id: id, - title: lust.removeHtmlTagWithoutSpace(title), - image: image, - duration: this.dur[i], - views: "None", - video: `https://www.youporn.com/embed/${id}`, + link: `${c.YOUPORN}${this.links[i]}`, + id: this.ids[i], + title: lust.removeHtmlTagWithoutSpace(this.titles[i]), + image: this.images[i], + duration: this.durations[i], + views: this.views[i], + video: `https://www.youporn.com/embed/${this.ids[i]}`, }; - }).get(); + }) + .get(); } } - + const yp = new YouPornSearch(); if (yp.search.length === 0) throw Error("No result found"); const data = yp.search as unknown as string[]; @@ -45,9 +88,8 @@ export async function scrapeContent(url: string) { source: url, }; return result; - } catch (err) { const e = err as Error; throw Error(e.message); } -} \ No newline at end of file +} diff --git a/src/utils/options.ts b/src/utils/options.ts index ffcdbb3..e42da93 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -1,9 +1,11 @@ export default { + EPORNER: "https://www.eporner.com", PORNHUB: "https://www.pornhub.com", XNXX: "https://www.xnxx.com", REDTUBE: "https://www.redtube.com", XVIDEOS: "https://www.xvideos.com", - XHAMSTER: "https://xheve2.com", + XHAMSTER: "https://xhamster.com/", YOUPORN: "https://www.youporn.com", + TXXX: "https://txxx.com/", JAVHD: "https://javhd.today" }; \ No newline at end of file diff --git a/test/test.ts b/test/test.ts index 2bd6a71..ac3a2c1 100644 --- a/test/test.ts +++ b/test/test.ts @@ -2,7 +2,7 @@ import c from "../src/utils/options"; import p from "phin"; for (const url of - [c.PORNHUB, c.XNXX, c.REDTUBE, c.XVIDEOS, c.XHAMSTER, c.YOUPORN]) { + [c.PORNHUB, c.XNXX, c.REDTUBE, c.XVIDEOS, c.XHAMSTER, c.YOUPORN, c.EPORNER, c.TXXX]) { p({ url }).then(res => { if (res.statusCode !== 200) { console.log(`${url} is not available, status code: ${res.statusCode}, check the sites or your own user-agent`);