From 5a210451e0bcf8f7db0c53054158d310c9c0f681 Mon Sep 17 00:00:00 2001 From: DebaucheryLibrarian Date: Fri, 6 Feb 2026 04:55:40 +0100 Subject: [PATCH] Added Model Media API layout, renamed AsiaM. --- package-lock.json | 8 +- package.json | 2 +- seeds/02_sites.js | 11 ++- src/scrapers/actors.js | 11 ++- src/scrapers/freeones.js | 2 +- src/scrapers/mariskax.js | 2 +- src/scrapers/modelmedia.js | 154 +++++++++++++++++++++++++++++++++++-- src/scrapers/nubiles.js | 2 +- src/scrapers/porndoe.js | 2 +- src/scrapers/releases.js | 2 +- src/store-releases.js | 1 + tests/profiles.js | 5 ++ 12 files changed, 178 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09dfd557..22e41f40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,7 +94,7 @@ "tunnel": "0.0.6", "ua-parser-js": "^1.0.37", "undici": "^5.28.1", - "unprint": "^0.18.25", + "unprint": "^0.18.26", "url-pattern": "^1.0.3", "v-tooltip": "^2.1.3", "video.js": "^8.6.1", @@ -20380,9 +20380,9 @@ } }, "node_modules/unprint": { - "version": "0.18.25", - "resolved": "https://registry.npmjs.org/unprint/-/unprint-0.18.25.tgz", - "integrity": "sha512-eGUq818vUOHOqJ1niie/2SH3nu3zf6yPWQrlPgpJJOi8QqxS1XLHVStwU7Ql7VdBPXjS1BbWoHbX+JQptKGQAQ==", + "version": "0.18.26", + "resolved": "https://registry.npmjs.org/unprint/-/unprint-0.18.26.tgz", + "integrity": "sha512-E9DAwwBtwZmg+2A6y7Ili94XGCkjuyl5saVA+G5oEy6lnFvum58aubOtfQO1Qasiy/xVeBZbwk5QZ/VPzGIchw==", "dependencies": { "bottleneck": "^2.19.5", "cookie": "^1.1.1", diff --git a/package.json b/package.json index 6302c122..6a8bd4cd 100755 --- a/package.json +++ b/package.json @@ -153,7 +153,7 @@ "tunnel": "0.0.6", "ua-parser-js": "^1.0.37", "undici": "^5.28.1", - "unprint": "^0.18.25", + "unprint": "^0.18.26", "url-pattern": "^1.0.3", "v-tooltip": "^2.1.3", "video.js": "^8.6.1", diff --git a/seeds/02_sites.js b/seeds/02_sites.js index 65bf720e..52b7ea31 100755 --- a/seeds/02_sites.js +++ b/seeds/02_sites.js @@ -7908,11 +7908,18 @@ const sites = [ parent: 'modelmedia', }, { - slug: 'asiam', - name: 'AsiaM', + slug: 'modelmediaasia', + rename: 'asiam', + name: 'Model Media Asia', url: 'https://www.modelmediaasia.com', independent: true, + tags: ['asian'], parent: 'modelmedia', + parameters: { + layout: 'api', + basePath: '/en-US', + api: 'https://model-api.bvncmsldo.com/api/v2', + }, }, { slug: 'jerkaoke', diff --git a/src/scrapers/actors.js b/src/scrapers/actors.js index 1d9924b4..46f2fa2d 100644 --- a/src/scrapers/actors.js +++ b/src/scrapers/actors.js @@ -171,9 +171,6 @@ module.exports = { aziani, '2poles1hole': aziani, creampiled: aziani, - // woodman - pierrewoodman, - wakeupnfuck: pierrewoodman, // naughty america naughtyamerica, tonightsgirlfriend: naughtyamerica, @@ -200,21 +197,24 @@ module.exports = { swappz: teamskeet, freeuse: teamskeet, familystrokes: teamskeet, + // model media + jerkaoke: modelmedia, + modelmediaasia: modelmedia, + // delphine: modelmedia, // etc '18vr': badoink, theflourishxxx: theflourish, + pierrewoodman, exploitedx, // only from known URL that will specify site fullpornnetwork, adultempire, allherluv: missax, americanpornstar, angelogodshackoriginal, - asiam: modelmedia, babevr: badoink, badoinkvr: badoink, bamvisions, bang, - // delphine: modelmedia, meidenvanholland: bluedonkeymedia, // Vurig Vlaanderen uses same database boobpedia, bradmontana, @@ -225,7 +225,6 @@ module.exports = { hitzefrei, hookuphotshot, inthecrack, - jerkaoke: modelmedia, karups, boyfun: karups, kellymadison, diff --git a/src/scrapers/freeones.js b/src/scrapers/freeones.js index e9582640..632feb1f 100755 --- a/src/scrapers/freeones.js +++ b/src/scrapers/freeones.js @@ -38,7 +38,7 @@ function scrapeProfile(html, actorName) { if (bio.dateOfBirth) profile.birthdate = moment.utc(bio.dateOfBirth, 'YYYY-MM-DD').toDate(); - if (profile.placeOfBirth && bio.country) profile.birthPlace = `${bio.placeOfBirth}, ${bio.country}`; + if (bio.placeOfBirth && bio.country) profile.birthPlace = `${bio.placeOfBirth}, ${bio.country}`; else if (bio.country) profile.birthPlace = bio.country; profile.eyes = bio.eyeColor; diff --git a/src/scrapers/mariskax.js b/src/scrapers/mariskax.js index 46aef32e..1bcce9c3 100644 --- a/src/scrapers/mariskax.js +++ b/src/scrapers/mariskax.js @@ -96,7 +96,7 @@ function scrapeProfile(data) { profile.dateOfBirth = unprint.extractDate(bio.birthdate, 'YYYY-MM-DD'); profile.age = bio.age; - profile.placeOfBirth = bio.born; + profile.birthPlace = bio.born; profile.measurements = bio.measurements; profile.height = convert(bio.height, 'cm'); diff --git a/src/scrapers/modelmedia.js b/src/scrapers/modelmedia.js index 556250a7..e0c47c75 100644 --- a/src/scrapers/modelmedia.js +++ b/src/scrapers/modelmedia.js @@ -2,6 +2,71 @@ const unprint = require('unprint'); +const slugify = require('../utils/slugify'); + +function scrapeSceneApi(scene, channel, parameters) { + const release = {}; + + release.entryId = scene.id; + release.shootId = scene.serial_number; + + release.url = `${channel.origin}${parameters.basePath || ''}/videos/${release.shootId}`; + + release.title = scene.title; + release.altTitles = [scene.title_cn].filter(Boolean); + + release.description = scene.description; + release.altDescriptions = [scene.description_cn].filter(Boolean); + + release.date = new Date(scene.published_at); + release.duration = scene.duration; + + release.actors = scene.models?.map((model) => ({ + name: model.name, + alias: [model.name_cn].filter(Boolean), + gender: model.gender, + entryId: model.id, + avatar: Array.from(new Set([ + model.avatar, + model.avatar?.replace('_compressed', ''), // this is often a wider image, not just uncompressed + ])).filter(Boolean), + })); + + release.tags = scene.tags?.map((tag) => tag.name); + + release.poster = scene.cover; + release.trailer = scene.preview_video; + + return release; +} + +async function fetchLatestApi(channel, page, { parameters }) { + const res = await unprint.get(`${parameters.api}/videos?page=${page}&pageSize=12&sort=published_at`); + + if (res.ok && res.data?.status) { + return res.data.data.list.map((scene) => scrapeSceneApi(scene, channel, parameters)); + } + + return res.status; +} + +async function fetchSceneApi(url, channel, _baseRelease, { parameters }) { + // shallow data missing actors and tags + const shootId = new URL(url).pathname.match(/\/videos\/([\w-]+)/)?.[1]; + + if (!shootId) { + return null; + } + + const res = await unprint.get(`${parameters.api}/videos/${shootId}`); + + if (res.ok) { + return scrapeSceneApi(res.data.data, channel, parameters); + } + + return res.status; +} + function scrapeAll(scenes) { return scenes.map(({ query }) => { const release = {}; @@ -10,7 +75,7 @@ function scrapeAll(scenes) { const url = query.url(null); - if (url) { + if (url && !url.includes('/plans')) { const { origin, pathname, searchParams } = new URL(url); release.url = `${origin}${pathname}`; @@ -63,6 +128,78 @@ function scrapeAll(scenes) { }); } +function scrapeProfileApi(model, channel, parameters) { + const profile = {}; + + profile.entryId = model.id; + profile.url = `${channel.origin}${parameters.basePath || ''}/models/${model.id}`; + + profile.description = model.description || null; + + profile.gender = model.gender; + profile.alias = [model.name_cn].filter(Boolean); + + if (!model.birth_day?.includes('0001')) { + profile.dateOfBirth = unprint.extractDate(model.birth_day, 'YYYY-MM-DD'); + } + + profile.birthPlace = model.birth_place || null; + + profile.height = model.height_cm || null; + profile.weight = model.weight_kg || null; + + profile.bust = model.measurements_chest; + profile.waist = model.measurements_waist; + profile.hip = model.measurements_hips; + + profile.avatar = Array.from(new Set([ + model.avatar, + model.avatar?.replace('_compressed', ''), // this is often a wider image, not just uncompressed + ])).filter(Boolean); + + profile.socials = model.socialmedia; + + profile.scenes = model.videos.map((scene) => scrapeSceneApi(scene, channel, parameters)); + + return profile; +} + +async function getModelId(actor, parameters) { + if (actor.url) { + const modelId = new URL(actor.url).pathname.match(/\/models\/\d+/)?.[1]; + + if (modelId) { + return Number(modelId); + } + } + + const res = await unprint.get(`${parameters.api}/search?keyword=${slugify(actor.name, '+')}`); + + if (res.ok) { + const model = res.data.data?.models?.find((modelResult) => slugify(modelResult.name) === actor.slug); + + if (model) { + return model.id; + } + } + + return null; +} + +async function fetchProfileApi(actor, { entity, parameters }) { + const modelId = await getModelId(actor, parameters); + + if (modelId) { + const res = await unprint.get(`${parameters.api}/models/${modelId}`); + + if (res.ok && res.data.data) { + return scrapeProfileApi(res.data.data, entity, parameters); + } + } + + return null; +} + function scrapeProfile({ query }) { const profile = {}; const avatar = query.img('div[class*="prof-pic"] > img'); @@ -88,7 +225,7 @@ function scrapeProfile({ query }) { return profile; } -async function getCookie(channel) { +async function getCookie(channel, _parameters) { const tokenRes = await unprint.get(channel.url); if (!tokenRes.ok) { @@ -116,8 +253,8 @@ async function getCookie(channel) { return cookie; } -async function fetchLatest(channel, page) { - const cookie = await getCookie(channel); +async function fetchLatest(channel, page, context) { + const cookie = await getCookie(channel, context.parameters); const res = await unprint.get(`${channel.url}/videos?sort=published_at&page=${page}`, { selectAll: '.row a[video-id]', @@ -136,7 +273,7 @@ async function fetchLatest(channel, page) { // deep pages are paywalled async function searchProfile(actor, context, cookie) { - const searchRes = await unprint.get(`${context.channel.url}/livesearch?keyword=${actor.name}`, { + const searchRes = await unprint.get(`${context.channel.url}${context.parameters.searchPath || '/livesearch'}?${context.parameters.searchParameter || 'keyword'}=${actor.name}`, { headers: { cookie, }, @@ -150,7 +287,7 @@ async function searchProfile(actor, context, cookie) { } async function fetchProfile(actor, context) { - const cookie = await getCookie(context.entity); + const cookie = await getCookie(context.entity, context.parameters); const actorUrl = actor.url || await searchProfile(actor, context, cookie); if (!actorUrl) { @@ -173,4 +310,9 @@ async function fetchProfile(actor, context) { module.exports = { fetchLatest, fetchProfile, + api: { + fetchLatest: fetchLatestApi, + fetchScene: fetchSceneApi, + fetchProfile: fetchProfileApi, + }, }; diff --git a/src/scrapers/nubiles.js b/src/scrapers/nubiles.js index 4b4406be..53d2e6ee 100755 --- a/src/scrapers/nubiles.js +++ b/src/scrapers/nubiles.js @@ -196,7 +196,7 @@ async function findModel(actor, entity) { const modelEl = resModels.context.query.all('.content-grid-item').find((el) => slugify(unprint.query.content(el, 'a.title')) === slugify(actor.name)); if (modelEl) { - const modelUrl = `${origin}${unprint.query.url(modelEl, 'a.title')}`; + const modelUrl = unprint.query.url(modelEl, 'a.title', { origin: entity.origin }); const modelAvatar = unprint.query.sourceSet(modelEl, 'a picture img', 'data-srcset'); return { diff --git a/src/scrapers/porndoe.js b/src/scrapers/porndoe.js index 00eb11e7..0a664122 100755 --- a/src/scrapers/porndoe.js +++ b/src/scrapers/porndoe.js @@ -99,7 +99,7 @@ async function scrapeProfile({ query }, url, include) { `); profile.nationality = bio.nationality; - profile.placeOfBirth = bio.birth_place; + profile.birthPlace = bio.birth_place; profile.age = unprint.extractNumber(bio.age); profile.dateOfBirth = unprint.extractDate(bio.birth_date, 'MMM D, YYYY'); diff --git a/src/scrapers/releases.js b/src/scrapers/releases.js index c38064d8..4bedaa25 100644 --- a/src/scrapers/releases.js +++ b/src/scrapers/releases.js @@ -107,7 +107,7 @@ module.exports = { amateureuro: porndoe, amnesiac, angelogodshackoriginal, - asiam: modelmedia, + modelmediaasia: modelmedia, assylum, aziani, badoink, diff --git a/src/store-releases.js b/src/store-releases.js index c95133ba..650d0146 100755 --- a/src/store-releases.js +++ b/src/store-releases.js @@ -53,6 +53,7 @@ async function curateReleaseEntry(release, batchId, existingRelease, type = 'sce date_precision: release.datePrecision, slug, description: decode(release.description), + alt_descriptions: release.altDescriptions?.map((description) => decode(description)), comment: release.comment, attributes: release.attributes, photo_count: Number(release.photoCount) || null, diff --git a/tests/profiles.js b/tests/profiles.js index 8e4da05a..d6f75552 100644 --- a/tests/profiles.js +++ b/tests/profiles.js @@ -161,6 +161,7 @@ const actors = [ { entity: 'nfbusty', name: 'Ella Reese', fields: ['avatar', 'age', 'residencePlace', 'height', 'measurements', 'photos'] }, { entity: 'nubilefilms', name: 'Jade Kimiko', fields: ['avatar', 'age', 'residencePlace', 'height', 'measurements', 'photos'] }, { entity: 'thatsitcomshow', name: 'Casey Calvert', fields: ['avatar', 'age', 'residencePlace', 'height', 'measurements', 'photos'] }, + { entity: 'brattysis', name: 'Scarlett Alexis', fields: ['avatar', 'age', 'height', 'measurements', 'description', 'residencePlace', 'photos'] }, // porndoe { entity: 'vipsexvault', name: 'Amirah Adara', fields: ['avatar', 'nationality', 'placeOfBirth', 'age', 'naturalBoobs', 'hairColor', 'description'] }, { entity: 'amateureuro', name: 'Luna Oara', fields: ['avatar', 'nationality', 'placeOfBirth', 'age', 'naturalBoobs', 'description'] }, @@ -211,6 +212,9 @@ const actors = [ { entity: 'exploitedx', name: 'Megan Marx', url: 'https://excogigirls.com/models/megan-marx.html', fields: ['avatar', 'description', 'age', 'height', 'measurements'] }, { entity: 'exploitedx', name: 'Sophie Hunt', url: 'https://www.backroomcastingcouch.com/models/Sophie-Hunt.html', fields: ['avatar', 'age'] }, { entity: 'exploitedx', name: 'Lao Latina', url: 'https://hotmilfsfuck.com/models/Lao-Latina.html', fields: ['avatar', 'description', 'age', 'height', 'measurements'] }, + // model media + { entity: 'jerkaoke', name: 'Harley Haze', fields: ['avatar', 'description', 'height', 'weight', 'banner', 'photos'] }, + { entity: 'modelmediaasia', name: 'Li WeiWei', fields: ['avatar', 'entryId', 'gender', 'alias', 'height', 'weight', 'bust', 'waist', 'hip', 'socials'] }, // etc. { entity: 'analvids', name: 'Veronica Leal', fields: ['avatar', 'gender', 'birthCountry', 'nationality', 'age', 'aliases', 'nationality'] }, { entity: 'bangbros', name: 'Kira Perez', fields: ['avatar', 'gender', 'ethnicity', 'hairColor'] }, @@ -227,6 +231,7 @@ const actors = [ { entity: 'porncz', name: 'Kama Oxi', fields: ['avatar', 'gender', 'birthCountry', 'ethnicity', 'age', 'hairColor', 'cup', 'naturalBoobs', 'hasTattoos'] }, { entity: 'score', name: 'Vanessa Blue', fields: ['avatar', 'gender', 'placeOfResidence', 'ethnicity', 'height', 'weight', 'measurements', 'hairColor', 'dateOfBirth'] }, { entity: 'pierrewoodman', name: 'Abby Lee Brazil', fields: ['avatar', 'nationality'] }, + { entity: 'wakeupnfuck', name: 'Abby Lee Brazil', fields: ['avatar', 'nationality'] }, { entity: 'dorcelclub', name: 'Clea Gaultier', fields: ['avatar'] }, { entity: 'hitzefrei', name: 'Jolee Love', fields: ['avatar', 'dateOfBirth', 'birthPlace', 'measurements', 'height', 'weight', 'eyes', 'hair', 'description'] }, { entity: 'mariskax', name: 'Honey Demon', fields: ['avatar', 'gender', 'dateOfBirth', 'placeOfBirth', 'measurements', 'height', 'weight', 'hairColor', 'eyes'] },