diff --git a/package-lock.json b/package-lock.json index e74a58c7..e6cc44b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@graphile-contrib/pg-order-by-related": "^1.0.0", "@graphile-contrib/pg-simplify-inflector": "^6.1.0", "@graphile/pg-aggregates": "^0.1.1", + "@rsc-parser/react-client": "^1.1.2", "@tensorflow/tfjs-node": "^4.22.0", "@vladmandic/human": "^3.3.6", "acorn": "^8.11.2", @@ -4204,6 +4205,11 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@rsc-parser/react-client": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rsc-parser/react-client/-/react-client-1.1.2.tgz", + "integrity": "sha512-IXW7ViicNACwI1vxAQgndtRKZlM9HgGpxSZdqnXn8fCY2VvUkqas03WuHv4LnVlCdC3HGwhbh3r3TBLxRrYSgw==" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", diff --git a/package.json b/package.json index 808cd8df..f2cc3893 100755 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@graphile-contrib/pg-order-by-related": "^1.0.0", "@graphile-contrib/pg-simplify-inflector": "^6.1.0", "@graphile/pg-aggregates": "^0.1.1", + "@rsc-parser/react-client": "^1.1.2", "@tensorflow/tfjs-node": "^4.22.0", "@vladmandic/human": "^3.3.6", "acorn": "^8.11.2", diff --git a/seeds/02_sites.js b/seeds/02_sites.js index 5d3f9ef2..9695e7cb 100755 --- a/seeds/02_sites.js +++ b/seeds/02_sites.js @@ -6135,6 +6135,12 @@ const sites = [ scene: false, }, }, + // JAX SLAYHER + { + slug: 'jaxslayher', + name: 'Jax Slayher', + url: 'https://jaxslayher.com', + }, // JESSE LOADS MONSTER FACIALS { slug: 'jesseloadsmonsterfacials', diff --git a/src/scrapers/actors.js b/src/scrapers/actors.js index 5974393e..646365e1 100644 --- a/src/scrapers/actors.js +++ b/src/scrapers/actors.js @@ -23,6 +23,7 @@ const hitzefrei = require('./hitzefrei'); const hookuphotshot = require('./hookuphotshot'); const hush = require('./hush'); const inthecrack = require('./inthecrack'); +const jaxslayher = require('./jaxslayher'); const julesjordan = require('./julesjordan'); const karups = require('./karups'); const kellymadison = require('./kellymadison'); @@ -230,6 +231,7 @@ module.exports = { hitzefrei, hookuphotshot, inthecrack, + jaxslayher, karups, boyfun: karups, kellymadison, diff --git a/src/scrapers/jaxslayher.js b/src/scrapers/jaxslayher.js new file mode 100755 index 00000000..dc7f1cf4 --- /dev/null +++ b/src/scrapers/jaxslayher.js @@ -0,0 +1,254 @@ +'use strict'; + +const unprint = require('unprint'); + +const slugify = require('../utils/slugify'); +const { convert } = require('../utils/convert'); +const tryUrls = require('../utils/try-urls'); + +// parse Next/React data tree +async function parsePayload(rawText) { + const { createFlightResponse, processBinaryChunk } = await import('@rsc-parser/react-client'); // eslint-disable-line import/no-unresolved + + const response = createFlightResponse(false); + const bytes = new TextEncoder().encode(rawText); + + processBinaryChunk(response, bytes); + + return response._chunks; +} + +function traverseData(node, predicate, resultDepth = 3, seen = new WeakSet(), ancestors = []) { + if (node === null || typeof node !== 'object') { + return null; + } + + if (seen.has(node)) { + return null; + } + + seen.add(node); + + if (predicate(node)) { + if (resultDepth === 0) { + return node; + } + + const index = ancestors.length - resultDepth; + + return ancestors[Math.max(index, 0)] ?? node; + } + + const values = Array.isArray(node) ? node : Object.values(node); + const nextAncestors = [...ancestors, node]; + + let result = null; + + values.some((value) => { + result = traverseData(value, predicate, resultDepth, seen, nextAncestors); + return result !== null; + }); + + return result; +} + +function getPoster(videoId, channel) { + if (!videoId) { + return null; + } + + return [ + `${channel.origin}/images/updates/${videoId}-1280.jpg`, + `${channel.origin}/images/updates/${videoId}-720.jpg`, + `${channel.origin}/images/updates/${videoId}.jpg`, // 502px + `${channel.origin}/images/updates/${videoId}-360.jpg`, + ]; +} + +function getVideo(videoId, path = '/trailers', channel) { + if (!videoId) { + return null; + } + + return [ + `${channel.origin}${path}/${videoId}.mp4`, + `${channel.origin}${path}/${videoId}-med.mp4`, + ]; +} + +function scrapeAll(scenes, channel) { + return scenes.map((data) => { + const release = {}; + + release.url = `${channel.origin}/tour/video/${data.slug}`; + release.entryId = data.slug; + + release.attributes = { + dataEntryId: data.id, // not used in URL, store as secondary + }; + + release.title = data.title; + release.date = new Date(data.release_date); + + release.actors = data.models.map((model) => ({ + name: model.name, + url: `${channel.origin}/tour/model/${slugify(model.slug)}`, // for some reason they are underscored, while URL expects dashes + })); + + release.tags = data.tags.map((tag) => tag.tag_name); + + release.poster = getPoster(data.id, channel); + + release.teaser = getVideo(data.id, '/video_thumbs', channel); + release.trailer = getVideo(data.id, '/trailers', channel); + + return release; + }); +} + +async function fetchLatest(channel, page = 1) { + // HTML does not contain tags, hence we jump through hoops to fetch the data directly + const res = await unprint.get(`${channel.origin}/tour/videos/most-recent/page/${page}.txt`, { + headers: { + rsc: 1, + }, + }); + + if (res.ok) { + const payload = await parsePayload(res.body); + const sceneData = traverseData(payload, (item) => item.content_type === 'video'); + + if (sceneData) { + return scrapeAll(sceneData.map((item) => item.props.item), channel); + } + } + + return res.status; +} + +function scrapeScene(payload, { url, channel, baseRelease }) { + const release = {}; + + const sceneData = traverseData(payload, (item) => Object.hasOwn(item, 'videoID'), 2); + const durationData = traverseData(sceneData, (item) => /\d{2}:\d{2}:\d{2}/.test(item.children), 0); + const videoData = traverseData(sceneData, (item) => Object.hasOwn(item, 'videoID'), 0); + const dateData = traverseData(sceneData, (item) => item.children?.includes?.('2026'), 0); + + const videoId = videoData?.videoID; + + release.duration = unprint.extractDuration(durationData?.children); + + if (baseRelease?.entryId) { + // base release data is much more concise, duration only thing missing + return release; + } + + release.entryId = new URL(url).pathname.match(/\/video\/([\w-]+)/)?.[1]; + + release.attributes = { + dataEntryId: videoId, + }; + + release.title = videoData?.title; + release.date = unprint.extractDate(dateData.children, 'MMM DD/YYYY', { match: null }); + + release.poster = getPoster(videoId, channel); + release.teaser = getVideo(videoId, '/video_thumbs', channel); + release.trailer = getVideo(videoId, '/trailers', channel); + + return release; +} + +async function fetchScene(url, channel, baseRelease) { + const dataUrl = `${url.replace(/\/*$/, '')}.txt`; + + const res = await unprint.get(dataUrl, { + headers: { + rsc: 1, + }, + }); + + if (res.ok) { + const payload = await parsePayload(res.body); + + return scrapeScene(payload, { url, channel, baseRelease }); + } + + return res.status; +} + +function getBioItem(bioData, key) { + if (!bioData) { + return null; + } + + const data = traverseData(bioData, (item) => typeof item.children === 'string' && item.children.toLowerCase() === `${key}:`, 2); + const values = data[1]?.props.children; + + const value = Array.isArray(values) + ? values.find((itemValue) => (typeof itemValue === 'string' ? itemValue.trim() : itemValue)) + : values; + + if (value && String(value).toLowerCase() !== 'n/a') { + return value; + } + + return null; +} + +function scrapeProfile(payload, actorName, channel, url) { + const profile = { url }; + + const actorData = traverseData(payload, (item) => slugify(item.name) === slugify(actorName), 0); + const bioData = traverseData(payload, (item) => item.className === 'description_main', 0); + const descriptionData = traverseData(payload, (item) => item.className === 'description_top', 0); + const description = traverseData(descriptionData, (item) => item.className === 'info', 0)?.children; + + profile.entryId = actorData?.id; + + profile.birthplace = getBioItem(bioData, 'born'); + + profile.dateOfBirth = getBioItem(bioData, 'birthdate'); + profile.age = getBioItem(bioData, 'age'); + + profile.eyes = getBioItem(bioData, 'eyes'); + profile.weight = convert(getBioItem(bioData, 'weight'), 'lb', 'kg'); + profile.height = convert(getBioItem(bioData, 'height'), 'cm'); + profile.measurements = getBioItem(bioData, 'measurements'); + + if (!/no bio available/i.test(description)) { + profile.description = description; + } + + if (actorData.is_thumbed && actorData.id) { + profile.avatar = [ + `${channel.origin}/images/models/${actorData.id}.jpg`, // 628px + `${channel.origin}/images/models/${actorData.id}-516.jpg`, + `${channel.origin}/images/models/${actorData.id}-408.jpg`, + `${channel.origin}/images/models/${actorData.id}-300.jpg`, + ]; + } + + return profile; +} + +async function fetchProfile({ name: actorName, url: actorUrl }, entity) { + const { res, url } = await tryUrls([ + actorUrl && `${actorUrl}.txt`, + `${entity.url}/tour/model/${slugify(actorName, '-')}.txt`, + ]); + + if (res.ok) { + const payload = await parsePayload(res.body); + + return scrapeProfile(payload, actorName, entity, url.replace(/\.txt$/, '')); + } + + return res.status; +} + +module.exports = { + fetchLatest, + fetchProfile, + fetchScene, +}; diff --git a/src/scrapers/releases.js b/src/scrapers/releases.js index dce85bfd..c9473d80 100644 --- a/src/scrapers/releases.js +++ b/src/scrapers/releases.js @@ -32,6 +32,7 @@ const hush = require('./hush'); const innofsin = require('./innofsin'); const insex = require('./insex'); const inthecrack = require('./inthecrack'); +const jaxslayher = require('./jaxslayher'); const jesseloadsmonsterfacials = require('./jesseloadsmonsterfacials'); const julesjordan = require('./julesjordan'); const karups = require('./karups'); @@ -145,6 +146,7 @@ module.exports = { julesjordan, karups, // etc. + jaxslayher, littlecapricedreams, loveherfilms, mamacitaz: porndoe,