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