Files
traxxx/src/scrapers/jaxslayher.js

258 lines
6.9 KiB
JavaScript
Executable File

'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,
};