Files
traxxx/src/scrapers/kink.js
2026-01-06 02:54:07 +01:00

415 lines
12 KiB
JavaScript
Executable File

'use strict';
const unprint = require('unprint');
const slugify = require('../utils/slugify');
const { stripQuery } = require('../utils/url');
function scrapeAll(scenes, entity) {
return scenes.map(({ query }) => {
const release = {};
const networkUrl = entity.type === 'channel' ? entity.parent.url : entity.url;
const href = query.url('a[href*="/shoot"]');
release.url = unprint.prefixUrl(href, networkUrl);
release.shootId = href.split('/').slice(-1)[0];
release.entryId = release.shootId;
release.title = query.content('.card-body a[href*="/shoot"]').trim();
release.date = query.date('small > span', 'MMM D, YYYY');
release.actors = query.all('a[href*="/model"]').map((actorEl) => ({
name: unprint.query.content(actorEl),
url: unprint.query.url(actorEl, null, { origin: networkUrl }),
}));
const poster = query.img('.ratio-thumbnail img');
release.poster = [
stripQuery(poster).replace('_thumb', '_full'),
stripQuery(poster),
poster,
].filter(Boolean).map((src) => ({
src,
expectType: {
PNG: 'image/png',
},
}));
try {
release.photos = JSON.parse(query.attribute('.ratio-thumbnail img', 'data-cycle'))
.map((src) => Array.from(new Set([
stripQuery(src).replace('_thumb', '_full'),
stripQuery(src),
src,
])).filter(Boolean).map((source) => ({
src: source,
expectType: {
PNG: 'image/png',
},
})));
} catch (error) {
// no photos
}
release.trailer = `https://cdnp.kink.com/imagedb/${release.entryId}/trailer/${release.entryId}_trailer_high.mp4`;
release.channel = slugify(query.content('.shoot-thumbnail-footer a[href*="/channel"]'), '');
release.rating = query.number('.thumb-up') / 10;
return release;
});
}
async function fetchLatest(channel, page = 1) {
const url = `${channel.parent.url}/search?type=shoots&channelIds=${channel.parameters?.slug || channel.slug}&sort=published&page=${page}`;
const res = await unprint.browser(url, {
selectAll: '.container .card',
});
if (res.status === 200) {
const scenes = scrapeAll(res.context, channel);
return scenes;
}
return res.status;
}
function scrapeAllVr(scenes, channel) {
return scenes.map(({ query }) => {
const release = {};
const url = query.url('a.image-link, a.video-title');
const { pathname } = new URL(url);
release.url = url;
// legacy ID in slug preferred to match old entries, but prepare for retirement just in case
release.entryId = pathname.match(/-(\d+)\/?$/)?.[1] || pathname.match(/\/vd\/(\d+)\//)[1];
release.title = query.content('.video-title');
release.description = query.content('.description');
release.date = query.date('.main-info', 'MMM Do YYYY', { match: /\w{3} \d+\w+ \d{4}/ });
release.actors = query.all('.actors a').map((actorEl) => ({
name: unprint.query.content(actorEl),
url: unprint.query.url(actorEl, null, { origin: channel.url }),
}));
release.poster = query.sourceSet('.image-link img');
release.photos = query.dataset('.image-link div[data-gallery-images]', 'galleryImages')?.split(',').filter(Boolean); // can sometimes be ,,,, with no URLs
return release;
});
}
async function fetchLatestVr(channel, page = 1) {
const url = `${channel.url}/videos/page${page}/`;
const res = await unprint.get(url, {
selectAll: '#listView .video-list-view', // more details than #gridView
headers: {
Cookie: 'agreedToDisclaimer=true',
},
});
if (res.ok) {
const scenes = scrapeAllVr(res.context, channel);
return scenes;
}
return res.status;
}
function scrapeScene({ query }, url, entity) {
const release = { url };
const data = query.json('div[data-setup]', { attribute: 'data-setup' });
release.shootId = data?.id || new URL(url).pathname.split('/')[2];
release.entryId = data?.id || release.shootId;
release.title = data?.title || query.attribute('#shootPage #favoriteShootButton', 'data-title') || query.content('#shootPage h1');
release.description = query.content('//*[contains(text(), \'Description\')]/following-sibling::span/p');
release.date = query.date('.shoot-detail-legend', 'MMM D, YYYY');
release.duration = data?.duration
? Math.round(data.duration / 1000) // duration actually accurate down to the millisecond, not rounded to the nearest thousand
: query.duration('#shootPage .clock');
release.actors = query.elements('#shootPage h1 + span a[href*="/model"]').map((actorEl) => ({
name: unprint.query.content(actorEl).replace(/,\s*/, ''),
url: unprint.query.url(actorEl, null, { origin: entity.type === 'channel' ? entity.parent.url : entity.url }),
}));
release.director = query.content('.director-name')?.trim();
const poster = data?.posterUrl || query.poster();
release.poster = [
stripQuery(poster),
poster,
].filter(Boolean).map((src) => ({
src,
expectType: {
PNG: 'image/png',
},
}));
release.photos = query.json('#galleryImagesContainer', { attribute: 'data-images' })?.map((src) => [
src.fullPath,
src.thumbFullPath,
].filter(Boolean).map((source) => ({
src: source,
expectType: {
PNG: 'image/png',
},
})));
release.trailer = [
...(data?.trailer?.sources?.map((source) => ({
src: source.url,
quality: source.resolution,
})) || []),
`https://cdnp.kink.com/imagedb/${release.entryId}/trailer/${release.entryId}_trailer_high.mp4`,
];
release.tags = query.contents('#shootPage a[href*="/tag"]').map((tag) => tag.replace(',', '').trim());
release.channel = data?.channelName?.name || slugify(query.url('.shoot-detail-legend a[href*="/channel"]')?.split('/').slice(-1)[0], '');
release.qualities = data?.resolutions
? Object.entries(data.resolutions).filter(([, enabled]) => enabled).map(([res]) => parseInt(res, 10))
: null;
return release;
}
async function fetchScene(url, channel) {
const res = await unprint.browser(url);
if (res.status === 200) {
const scene = scrapeScene(res.context, url, channel);
return scene;
}
return res.status;
}
const qualityMap = {
psvr: 1080, // as of recent, might've been lower in the past
'4k': 2160,
'5k': 2280,
'8k': 4320,
};
function scrapeSceneVr({ query }, url, channel) {
const release = {};
const { pathname } = new URL(url);
// legacy ID in slug preferred to match old entries, but prepare for retirement just in case
release.entryId = pathname.match(/-(\d+)\/?$/)?.[1] || pathname.match(/\/vd\/(\d+)\//)[1];
release.title = query.content('.page-title');
release.description = query.content('#collapseDescription .accordion-body') || query.attribute('meta[name="description"]', 'content');
release.date = query.date('.video-description-list', 'MMMM D, YYYY');
release.actors = query.all('.video-description-list a[href*="/girl"]').map((actorEl) => ({
name: unprint.query.content(actorEl),
url: unprint.query.url(actorEl, null, { origin: channel.url }),
})); // no sign of boys
release.tags = query.contents('.video-description-list a[href*="/category"]');
release.poster = query.poster('dl8-video');
release.photos = query.sourceSets('.carousel .item img');
if (query.exists('dl8-video source[src*=".mp4"]')) {
// sometimes the trailer URL is missing the filename, it won't play on their site either
release.trailer = {
src: query.video('dl8-video source'),
vr: true,
};
}
release.qualities = query
.contents('#downloadsData a')
.map((button) => qualityMap[button.match(/download (\w+)/i)?.[1]?.toLowerCase()])
.filter(Boolean);
return release;
}
async function fetchSceneVr(url, channel) {
const res = await unprint.get(url, {
headers: {
Cookie: 'agreedToDisclaimer=true',
},
});
if (res.ok) {
return scrapeSceneVr(res.context, url, channel);
}
return res.status;
}
async function scrapeProfile({ query }, actorUrl) {
const profile = { url: actorUrl };
profile.entryId = actorUrl.match(/\/model\/(\d+)\//)?.[1] || query.attribute('h1 + button[data-id]', 'data-id');
profile.description = query.content('.content-container #expand-text')?.trim();
const tags = query.contents('.content-container a[href*="/tag"]').map((tag) => tag.toLowerCase().trim());
if (tags.includes('brunette') || tags.includes('brunet')) profile.hairColor = 'brown';
if (tags.includes('blonde') || tags.includes('blond')) profile.hairColor = 'blonde';
if (tags.includes('black hair')) profile.hairColor = 'black';
if (tags.includes('redhead')) profile.hairColor = 'red';
if (tags.includes('natural boobs')) profile.naturalBoobs = true;
if (tags.includes('fake boobs')) profile.naturalBoobs = false;
if (tags.includes('white')) profile.ethnicity = 'white';
if (tags.includes('latin')) profile.ethnicity = 'latin';
if (tags.includes('Black')) profile.ethnicity = 'black';
if (tags.includes('pierced nipples')) profile.hasPiercings = true;
if (tags.includes('tattoo')) profile.hasTattoos = true;
if (tags.includes('foreskin')) profile.hasForeskin = true;
if ((tags.includes('big dick') || tags.includes('foreskin'))
&& (tags.includes('fake boobs') || tags.includes('big tits'))) profile.gender = 'transsexual';
[profile.avatar, ...profile.photos] = query.imgs('.kink-slider-images img:not([data-src*="missing"])', { attribute: 'data-src' });
profile.social = query.urls('.content-container a[href*="twitter.com"], .content-container a[href*="x.com"]');
return profile;
}
async function getActorUrl({ name: actorName, url }, networkUrl) {
if (url) {
return url;
}
// const searchRes = await tab.goto(`${networkUrl}/search?type=performers&q=${actorName}`);
const searchApiRes = await unprint.browser(`https://www.kink.com/api/v2/search/suggestions/performers?term=${actorName}`);
if (searchApiRes.status === 200) {
const data = searchApiRes.context.query.json('body pre');
const actorId = data.find((actor) => actor.label === actorName)?.id;
if (actorId) {
const actorUrl = `${networkUrl}/model/${actorId}/${slugify(actorName)}`;
return actorUrl;
}
}
return null;
}
async function fetchProfile(actor, entity) {
const networkUrl = entity.type === 'channel' ? entity.parent.url : entity.url;
const actorUrl = await getActorUrl(actor, networkUrl);
if (actorUrl) {
const actorRes = await unprint.browser(actorUrl);
if (actorRes.status === 200) {
return scrapeProfile(actorRes.context, actorUrl);
}
return actorRes.status;
}
return null;
}
async function getActorUrlVr(actor, entity) {
if (actor.url) {
return actor.url;
}
const res = await unprint.get(`${entity.url}/search/`, {
selectAll: '#actors option',
headers: {
Cookie: 'agreedToDisclaimer=true',
},
});
if (res.ok) {
const actors = res.context.map(({ query }) => ({
name: query.content(),
id: query.attribute(null, 'value'),
}));
const targetActor = actors.find((actorOption) => actor.slug === slugify(actorOption.name));
if (targetActor?.id) {
return `${entity.url}/girl/${targetActor.id}/${slugify(targetActor.name)}`;
}
}
return null;
}
function scrapeProfileVr({ query }, url) {
const profile = { url };
const keys = query.contents('.info .key');
const values = query.contents('.info .value', { filter: false });
const bio = Object.fromEntries(keys.map((key, index) => [slugify(key, '_'), values[index]]));
profile.description = query.content('#readMoreFull');
profile.avatar = query.sourceSet('.images img');
if (bio.birthdate) profile.dateOfBirth = unprint.extractDate(bio.birthdate, 'MMMM DD, YYYY');
if (bio.country) profile.birthPlace = bio.country;
if (bio.cup) profile.cup = bio.cup;
if (bio.height) profile.height = Number(bio.height.match(/(\d+) cm/i)?.[1]) || null;
if (bio.weight) profile.weight = Number(bio.weight.match(/(\d+) kg/i)?.[1]) || null;
profile.socials = query.urls('.value.social a');
return profile;
}
async function fetchProfileVr(actor, entity) {
const url = await getActorUrlVr(actor, entity);
if (url) {
const res = await unprint.get(url, {
headers: {
Cookie: 'agreedToDisclaimer=true',
},
});
if (res.ok) {
return scrapeProfileVr(res.context, url, entity);
}
return res.status;
}
return null;
}
module.exports = {
fetchLatest,
fetchScene,
fetchProfile,
vr: {
fetchLatest: fetchLatestVr,
fetchScene: fetchSceneVr,
fetchProfile: fetchProfileVr,
},
};