'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.id; // URL slugs are not unique, some pages on original site unreachable! release.attributes = { dataEntryId: data.id, // not used in URL, store as secondary slug: data.slug, }; 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 = videoId; release.attributes = { dataEntryId: videoId, slug: new URL(url).pathname.match(/\/video\/([\w-]+)/)?.[1], }; 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.hairColor = getBioItem(bioData, 'hair'); 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, };