258 lines
6.9 KiB
JavaScript
Executable File
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,
|
|
};
|