|
// ==UserScript== |
|
// @name Fansly - Download single posts & messages |
|
// @namespace github.com/M-rcus |
|
// @match https://fansly.com/* |
|
// @grant unsafeWindow |
|
// @grant GM_download |
|
// @grant GM_setValue |
|
// @grant GM_getValue |
|
// @require https://m.leak.fans/ujs/violentmonkey-dom-v1.0.9.js |
|
// @downloadUrL https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7/raw/fansly-download.user.js |
|
// @updateUrl https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7/raw/fansly-download.user.js |
|
// @icon https://m.leak.fans/ujs/fansly-icon.png |
|
// @version 0.6.0 |
|
// @author M |
|
// @description Work in progress userscript for download media of single posts & message media on Fansly. |
|
// ==/UserScript== |
|
|
|
/** |
|
* Usage: |
|
* - Make sure to visit a singular "post" (url = fansly.com/post/111222333444). Click the three dots top-right of the post and there should be a "Download media" option. |
|
* - v0.2.0 introduced experimental messages support as well: |
|
* - Go to your Fansly messages and select a "message thread" on the sidebar. |
|
* - Above the message thread list, there should be a download icon that pops up: https://i.im.ge/2022/08/17/OqDqtc.2022-08-17-nkWxV3.png |
|
* - v0.6.0 should fix image downloading. Video downloading is still kind of low resolutions for newer posts, as Fansly uses M3U8 playlists (which can't really be merged into MP4s easily via a simple userscript). |
|
* - Advanced users are recommended to set the `SCRIPT_DOWNLOAD` value to true, which will give you a Bash script that utilizes `curl` and `yt-dlp` to download images/videos. |
|
*/ |
|
|
|
const downloadIconClasses = 'fal fa-fw fa-file-upload fa-rotate-180 pointer'; |
|
|
|
/** |
|
* curl and yt-dlp (for m3u8 files) commands will be put into a .sh script and that will be downloaded instead. |
|
* Alternative method, since browsers have a tendency to get a bit sluggish when you're downloading 30+ files all at once. |
|
* |
|
* For the time being, if you want this to work, you'll have to go on the "Values" tab at the top of this script and set `SCRIPT_DOWNLOAD` to true. |
|
*/ |
|
const scriptDownload = GM_getValue('SCRIPT_DOWNLOAD', false); |
|
|
|
/** |
|
* Helper function to save text as a file (primarily for scriptDownload). |
|
*/ |
|
const saveAs = (function () { |
|
var a = document.createElement("a"); |
|
document.body.appendChild(a); |
|
a.style = "display: none"; |
|
return function (data, fileName) { |
|
var blob = new Blob([data], {type: "octet/stream"}); |
|
var url = window.URL.createObjectURL(blob); |
|
a.href = url; |
|
a.download = fileName; |
|
a.click(); |
|
window.URL.revokeObjectURL(url); |
|
}; |
|
}()); |
|
|
|
/** |
|
* Create a timestamp |
|
*/ |
|
function formatTimestamp(timestamp) |
|
{ |
|
const date = new Date(timestamp * 1000); |
|
return date.toISOString().split('T')[0]; |
|
} |
|
|
|
function copyToClipboard(str) |
|
{ |
|
const el = document.createElement('textarea'); |
|
el.value = str; |
|
document.body.appendChild(el); |
|
el.select(); |
|
document.execCommand('copy'); |
|
document.body.removeChild(el); |
|
} |
|
|
|
function getAngularAttribute(element) |
|
{ |
|
const attributes = Array.from(element.attributes); |
|
const relevantAttribute = attributes.find(x => x.name.includes('_ngcontent')); |
|
|
|
if (!relevantAttribute) { |
|
console.error('Has no relevant attributes', element, attributes); |
|
return 'unable-to-find-it'; |
|
} |
|
|
|
return relevantAttribute.name; |
|
} |
|
|
|
/** |
|
* Extract token from localStorage |
|
*/ |
|
function getToken() |
|
{ |
|
const ls = unsafeWindow.localStorage; |
|
const session = JSON.parse(ls.getItem('session_active_session')); |
|
return session.token; |
|
} |
|
|
|
unsafeWindow.getAuthToken = getToken; |
|
|
|
/** |
|
* Gets the position of the current accountMedia |
|
* |
|
* @param {Object} input Full response of a "get posts" request |
|
* @param {Object} accountMedia Current accountMedia object. |
|
* @param {Boolean} asNumber Return the position as a number, instead of a formatted string. Default: false |
|
*/ |
|
function getPosition(input, accountMedia, asNumber) |
|
{ |
|
const accountMediaId = accountMedia.id; |
|
const { accountMediaBundles } = input.response; |
|
let position = null; |
|
|
|
if (!accountMediaBundles) { |
|
return position; |
|
} |
|
|
|
const bundle = accountMediaBundles.find(x => x.accountMediaIds.includes(accountMediaId)); |
|
if (bundle) { |
|
const bundleContent = bundle.bundleContent; |
|
const getPosition = bundleContent.find(x => x.accountMediaId === accountMediaId); |
|
|
|
if (getPosition) { |
|
// Positions start from 0, so we add 1. |
|
position = getPosition.pos + 1; |
|
} |
|
} |
|
|
|
if (asNumber || position === null) { |
|
return position; |
|
} |
|
|
|
if (position < 10) { |
|
position = `0${position}`; |
|
} |
|
|
|
return `${position}`; |
|
} |
|
|
|
let fileIncrements = {}; |
|
|
|
/** |
|
* For handling M3U8 playlists |
|
* @param {Object} media |
|
* @param {String} filename |
|
* @param {Boolean} asCurl |
|
* @returns String|null Either a curl/yt-dlp command or null if no playlist is found. |
|
*/ |
|
function getVideoDownloadCommand(media, filename, asCurl) |
|
{ |
|
const { variants } = media; |
|
const playlist = variants.find(file => file.type === 202); |
|
|
|
if (!playlist || playlist.locations.length === 0) { |
|
return null; |
|
} |
|
|
|
const metadata = JSON.parse(playlist.metadata); |
|
let width = 360; |
|
const resolutionVariants = metadata.variants || []; |
|
for (const variant of resolutionVariants) |
|
{ |
|
const w = variant.w; |
|
if (w > width) { |
|
width = w; |
|
} |
|
} |
|
|
|
const location = playlist.locations[0]; |
|
const url = location.location.replace('.m3u8', `_${width}.m3u8`); |
|
const cookies = location.metadata; |
|
let cookieHeader = []; |
|
for (const name in cookies) |
|
{ |
|
const value = cookies[name]; |
|
cookieHeader.push(`CloudFront-${name}=${value}`); |
|
} |
|
|
|
if (asCurl) { |
|
return `curl -L -o "${filename}" -H "Origin: https://fansly.com" -H "Referer: https://fansly.com/" -H "Cookie: ${cookieHeader.join('; ')}" "${url}"` |
|
} |
|
|
|
return `yt-dlp -o "${filename}" --add-header "Origin:https://fansly.com" --add-header "Referer:https://fansly.com/" --add-header "Cookie:${cookieHeader.join('; ')}" "${url}"`; |
|
} |
|
|
|
let cmds = []; |
|
|
|
/** |
|
* @param {Object} input The whole post API response |
|
* @param {Object} accountMedia The `accountMedia` object |
|
* @param {Number} createdAt Timestamp in seconds (not milliseconds) |
|
* @param {Object} media The `media` key inside the `accountMedia` object (legacy) |
|
* @param {Object} metaType Used for differentiating between "preview" and unlocked posts. |
|
*/ |
|
function extractMediaAndPreview(input, accountMedia, createdAt, media, metaType) |
|
{ |
|
let { filename, locations, id, variants, mimetype, post } = media; |
|
let usesVariants = false; |
|
|
|
if (!locations || locations.length === 0) { |
|
if (!variants || variants.length === 0) { |
|
return; |
|
} |
|
|
|
usesVariants = true; |
|
locations = variants; |
|
} |
|
|
|
/** |
|
* Download best quality of video even if the "original" quality currently isn't available |
|
* Seems like Fansly isn't the quickest when it comes to processing videos. |
|
*/ |
|
let url; |
|
let fileId = id; |
|
|
|
/** |
|
* Variants aka... quality options? Rescaled/reencoded lower resolutions I believe. |
|
* See if statement above. |
|
* |
|
* This handles the 'variants' section and retrieves file ID, mimetype etc. from the variant. |
|
* The default/fallback `location` is basically the "root" media object. |
|
*/ |
|
if (usesVariants) { |
|
for (const variant of locations) |
|
{ |
|
const loc = variant.locations; |
|
if (!loc[0] || !loc[0].location) { |
|
continue; |
|
} |
|
|
|
url = loc[0].location; |
|
filename = variant.filename; |
|
mimetype = variant.mimetype; |
|
fileId = variant.id; |
|
|
|
console.log('Variant', variant); |
|
|
|
// End the loop on first match, or else it will overwrite with the worse qualities |
|
break; |
|
} |
|
} else { |
|
url = locations[0].location; |
|
} |
|
|
|
if (!url) { |
|
console.log(`No file found for media: ${id}`); |
|
return; |
|
} |
|
|
|
/** |
|
* Remove the file extension from the filename |
|
* And use the mimetype for the final file extension |
|
*/ |
|
let fileIncrement = parseInt(fileIncrements[fileId], 10); |
|
if (isNaN(fileIncrement)) { |
|
fileIncrement = 0; |
|
} |
|
|
|
fileIncrement++; |
|
fileIncrements[fileId] = fileIncrement; |
|
|
|
if (filename) { |
|
filename = filename.replace(/\.+[\w]+$/, ''); |
|
} |
|
else { |
|
filename = fileIncrement < 10 ? `0${fileIncrement}` : `${fileIncrement}`; |
|
} |
|
const filetype = mimetype.replace(/^[\w]+\//, ''); |
|
|
|
/** |
|
* Make sure metaType is formatted properly for use in filename. |
|
*/ |
|
if (!metaType) { |
|
metaType = ''; |
|
} else { |
|
metaType = metaType + '_'; |
|
} |
|
|
|
let postId = createdAt; |
|
if (post) { |
|
postId = post.id; |
|
} |
|
|
|
const position = getPosition(input, accountMedia); |
|
|
|
const date = formatTimestamp(createdAt); |
|
let filenameSegments = [ |
|
date, |
|
postId, |
|
id, |
|
fileId, |
|
]; |
|
|
|
if (position !== null) { |
|
filenameSegments.splice(2, 0, position); |
|
} |
|
|
|
const finalFilename = `${filenameSegments.join('_')}.${filetype}`; |
|
let downloadCmd = `curl -Lo "${finalFilename}" -H "Origin: https://fansly.com" -H "Referer: https://fansly.com/" "${url}"`; |
|
if (filetype === 'mp4' && scriptDownload) { |
|
const newCmd = getVideoDownloadCommand(media, finalFilename); |
|
if (newCmd) { |
|
downloadCmd = newCmd; |
|
} |
|
} |
|
|
|
console.log(`Found file: ${finalFilename} - Triggering download...`); |
|
|
|
if (!scriptDownload) { |
|
GM_download({ |
|
method: 'GET', |
|
url: url, |
|
name: finalFilename, |
|
saveAs: false, |
|
}); |
|
} |
|
else { |
|
cmds.push(downloadCmd); |
|
} |
|
} |
|
|
|
async function getMediaByIds(mediaIds) |
|
{ |
|
const response = await apiFetch(`/account/media?ids=${mediaIds.join(',')}&ngsw-bypass=true`); |
|
const medias = await response.json(); |
|
return medias; |
|
} |
|
|
|
/** |
|
* Filters media and attempts to download available media. |
|
* Some posts are locked, but have open previews. Open previews will be downloaded. |
|
*/ |
|
async function filterMedia(input, noPreview, maxCount) |
|
{ |
|
cmds = []; |
|
if (!input) { |
|
if (!unsafeWindow.temp1) { |
|
console.error('No temp1 var'); |
|
return; |
|
} |
|
|
|
input = unsafeWindow.temp1; |
|
} |
|
|
|
/** |
|
* New in v0.6.0 |
|
*/ |
|
let mediaIds = []; |
|
let medias = input.response.accountMedia || input.response.aggregationData.accountMedia; |
|
const bundles = input.response.accountMediaBundles || []; |
|
for (const bundle of bundles) |
|
{ |
|
const bundleMediaIds = bundle.accountMediaIds || []; |
|
mediaIds = [...mediaIds, ...bundleMediaIds]; |
|
} |
|
|
|
// Get rid of dupes |
|
mediaIds = [... new Set(mediaIds)]; |
|
|
|
// Get rid of any media objects we're about to fetch from the API. |
|
medias = medias.filter(x => !mediaIds.includes(x.id)); |
|
|
|
const mediaResponse = await getMediaByIds(mediaIds); |
|
medias = [...medias, ...mediaResponse.response]; |
|
|
|
const mediaCount = medias.length; |
|
maxCount = maxCount || mediaCount; |
|
let currentCount = 0; |
|
for (const entry of medias) |
|
{ |
|
currentCount++; |
|
if (currentCount > maxCount) { |
|
break; |
|
} |
|
|
|
const { createdAt, media, preview } = entry; |
|
|
|
const mediaId = media.id; |
|
const posts = input.response.posts || []; |
|
|
|
let thePost = null; |
|
if (posts.length === 1) { |
|
thePost = posts[0]; |
|
} |
|
|
|
media.post = thePost; |
|
|
|
// Trigger download for `media` (unlocked) |
|
extractMediaAndPreview(input, entry, createdAt, media); |
|
|
|
if (!preview || noPreview) { |
|
continue; |
|
} |
|
|
|
const previewId = preview.id; |
|
const previewPost = posts.find((post) => { |
|
const attachments = post.attachments || []; |
|
|
|
if (attachments.length === 0) { |
|
return false; |
|
} |
|
|
|
const attachment = attachments.find(att => att.contentId === mediaId); |
|
return attachment !== undefined; |
|
}); |
|
|
|
preview.post = thePost; |
|
|
|
// Trigger download for locked media, with available previews. |
|
extractMediaAndPreview(input, entry, createdAt, preview, 'preview_'); |
|
} |
|
|
|
if (scriptDownload) { |
|
saveAs(cmds.join('\n'), `fansly_${Date.now()}.sh`); |
|
} |
|
} |
|
|
|
unsafeWindow.filterMedia = filterMedia; |
|
|
|
async function apiFetch(path) |
|
{ |
|
if (!path) { |
|
console.error('No path specified in apiFetch!'); |
|
return; |
|
} |
|
|
|
let finalUrl = ''; |
|
/** |
|
* If a complete URL is specified, we just request it directly. |
|
*/ |
|
if (path.includes('https://')) { |
|
finalUrl = path; |
|
} |
|
else { |
|
if (path[0] !== '/') { |
|
path = '/' + path; |
|
} |
|
|
|
finalUrl = `https://apiv3.fansly.com/api/v1${path}`; |
|
} |
|
|
|
const request = await fetch(finalUrl, { |
|
'headers': { |
|
'accept': 'application/json', |
|
'authorization': getToken(), |
|
}, |
|
'referrer': 'https://fansly.com/', |
|
'referrerPolicy': 'strict-origin-when-cross-origin', |
|
'method': 'GET', |
|
'mode': 'cors', |
|
'credentials': 'include', |
|
}); |
|
|
|
return request; |
|
} |
|
|
|
async function apiPost(path, body = {}) |
|
{ |
|
if (!path) { |
|
console.error('No path specified in apiFetch!'); |
|
return; |
|
} |
|
|
|
let finalUrl = ''; |
|
/** |
|
* If a complete URL is specified, we just request it directly. |
|
*/ |
|
if (path.includes('https://')) { |
|
finalUrl = path; |
|
} |
|
else { |
|
if (path[0] !== '/') { |
|
path = '/' + path; |
|
} |
|
|
|
finalUrl = `https://apiv3.fansly.com/api/v1${path}`; |
|
} |
|
|
|
const request = await fetch(finalUrl, { |
|
'headers': { |
|
'accept': 'application/json', |
|
'authorization': getToken(), |
|
}, |
|
'referrer': 'https://fansly.com/', |
|
'referrerPolicy': 'strict-origin-when-cross-origin', |
|
'method': 'POST', |
|
'body': JSON.stringify(body), |
|
'mode': 'cors', |
|
'credentials': 'include', |
|
}); |
|
|
|
return request; |
|
} |
|
|
|
unsafeWindow.apiFetch = apiFetch; |
|
|
|
/** |
|
* Get post data for a post ID and print cURL commands. |
|
*/ |
|
async function getPost(postId, returnValue) |
|
{ |
|
const request = await apiFetch(`/post?ids=${postId}`); |
|
const response = await request.json(); |
|
if (returnValue) { |
|
console.log('Post response', response); |
|
return response; |
|
} |
|
|
|
filterMedia(response); |
|
} |
|
|
|
unsafeWindow.getPost = getPost; |
|
|
|
const cachedMessageGroups = {}; |
|
async function fetchAllMessageGroups() |
|
{ |
|
const request = await apiFetch('/messaging/groups?limit=100000'); |
|
const apiResponse = await request.json(); |
|
|
|
if (!apiResponse.success) { |
|
console.error(apiResponse); |
|
return null; |
|
} |
|
|
|
const { response } = apiResponse; |
|
for (const groupMeta of response.data) |
|
{ |
|
const { groupId, partnerAccountId } = groupMeta; |
|
const accountMeta = response.aggregationData.accounts.find(x => x.id === partnerAccountId) || null; |
|
const messageMeta = response.aggregationData.groups.find(x => x.createdBy === partnerAccountId) || null; |
|
|
|
cachedMessageGroups[groupId] = { |
|
group: groupMeta, |
|
account: accountMeta, |
|
messageMeta, |
|
}; |
|
} |
|
|
|
return apiResponse; |
|
} |
|
|
|
/** |
|
* Insert 'Download media' entry in the post dropdown |
|
*/ |
|
async function handleSinglePost(dropdown, postId) |
|
{ |
|
const btn = document.createElement('div'); |
|
btn.classList.add('dropdown-item'); |
|
btn.innerHTML = '<i class="fa-fw fal fa-download"></i>Download media'; |
|
btn.setAttribute('_ngcontent-yeo-c123', ''); |
|
|
|
btn.addEventListener('click', async () => { |
|
await getPost(postId); |
|
}); |
|
|
|
dropdown.insertAdjacentElement('beforeend', btn); |
|
} |
|
|
|
/** |
|
* Fetch messages and cache them during navigation. |
|
*/ |
|
const cachedMessages = {}; |
|
const messageSyncSelector = '.fal.fa-arrows-rotate'; |
|
async function handleMessages(groupId, force) |
|
{ |
|
if (!force && cachedMessages[groupId]) { |
|
addDownloadMessageMediaButton(); |
|
return; |
|
} |
|
|
|
fetchAllMessageGroups(); |
|
|
|
const request = await apiFetch(`/message?groupId=${groupId}&limit=200000`); |
|
const messages = await request.json(); |
|
|
|
cachedMessages[groupId] = messages; |
|
addDownloadMessageMediaButton(); |
|
console.log('Messages', messages); |
|
} |
|
|
|
async function getMessageMedia(groupId, messageId) |
|
{ |
|
const cached = cachedMessages[groupId]; |
|
if (!cached) { |
|
await handleMessages(groupId, true); |
|
} |
|
|
|
const messages = cached.response.messages; |
|
const message = messages.find(x => x.id === messageId); |
|
|
|
if (!message) { |
|
console.error(`Could not find message ID ${messageId} for group ID ${groupId}`); |
|
return; |
|
} |
|
|
|
const data = cached.response; |
|
let medias = []; |
|
let bundles = []; |
|
for (const attachment of message.attachments) |
|
{ |
|
const { contentId, contentType } = attachment; |
|
|
|
let messageMedias = data.accountMedia.filter(x => x.id === contentId); |
|
|
|
/** |
|
* From what I know: |
|
* contentType = 1 = accountMedia |
|
* contentType = 2 = accountMediaBundle |
|
*/ |
|
if (contentType === 2) { |
|
const bundle = data.accountMediaBundles.find(x => x.id === contentId); |
|
if (!bundle) { |
|
continue; |
|
} |
|
|
|
const mediaIds = bundle.accountMediaIds; |
|
const accountMedias = data.accountMedia.filter(x => mediaIds.includes(x.id)); |
|
|
|
messageMedias = [...messageMedias, ...accountMedias]; |
|
bundles.push(bundle); |
|
} |
|
|
|
medias = [...medias, ...messageMedias]; |
|
} |
|
|
|
return { |
|
medias, |
|
bundles, |
|
}; |
|
} |
|
|
|
/** |
|
* Adds download button in the message view |
|
*/ |
|
function addDownloadMessageMediaButton() |
|
{ |
|
if (hasDownloadMessageMediaButton()) { |
|
return; |
|
} |
|
|
|
const sync = document.querySelector(messageSyncSelector); |
|
if (!sync) { |
|
console.log('Cannot find sync selector', messageSyncSelector); |
|
return; |
|
} |
|
|
|
const parent = sync.parentElement; |
|
let cloned = parent.cloneNode(false); |
|
cloned.innerHTML = `<i _ngcontent-opw-c157="" class="${downloadIconClasses} blue-1"></i>`; |
|
cloned.setAttribute('id', 'downloadMessageBundles'); |
|
|
|
cloned.addEventListener('click', async function() { |
|
const groupId = getCurrentUrlPaths()[1] || null; |
|
|
|
if (!groupId) { |
|
return; |
|
} |
|
|
|
const modalWrapper = document.querySelector('.modal-wrapper'); |
|
if (!modalWrapper) { |
|
return; |
|
} |
|
|
|
if (!cachedMessageGroups[groupId]) { |
|
await fetchAllMessageGroups(); |
|
} |
|
|
|
const messageGroup = cachedMessageGroups[groupId]; |
|
const { account } = messageGroup; |
|
|
|
/** |
|
* Set certain modal classes to other elements |
|
*/ |
|
const body = document.querySelector('body'); |
|
const xdModal = modalWrapper.querySelector('.xdModal'); |
|
xdModal.classList.add('back-drop'); |
|
body.classList.add('modal-opened'); |
|
|
|
/** |
|
* Add the modal to the page and allow for functionality. |
|
*/ |
|
const messageOverview = cachedMessages[groupId].response; |
|
const messages = messageOverview.messages; |
|
|
|
let messageOptions = ``; |
|
|
|
for (const message of messages) |
|
{ |
|
let messageMedia = await getMessageMedia(groupId, message.id); |
|
messageMedia = messageMedia.medias; |
|
if (messageMedia.length === 0) { |
|
continue; |
|
} |
|
|
|
const option = document.createElement('option'); |
|
const date = new Date(message.createdAt * 1000); |
|
const text = message.content.trim(); |
|
option.textContent = `${date.toLocaleString()} | ${text.length > 83 ? text.slice(0, 80) : text}${text.length > 83 ? '...' : ''}`; |
|
option.setAttribute('value', message.id); |
|
|
|
messageOptions += option.outerHTML; |
|
} |
|
|
|
const username = account.username; |
|
const displayName = account.displayName || username; |
|
const modal = `<div class="active-modal" id="downloadModal"> |
|
<div class="modal"> |
|
<div class="modal-header"> |
|
<div class="title flex-1"> |
|
<p>Download media message from ${displayName} (@${username})</p> |
|
</div> |
|
<div class="actions"><i class="fa-fw fa fa-times pointer blue-1-hover-only hover-effect"></i></div> |
|
</div> |
|
<div class="modal-content"> |
|
<p class="introduction">Select the message you want to grab the media from:</p> |
|
<select><option value="">-- No selection --</option>${messageOptions}</select> |
|
<div class="btn large outline-dark-blue disabled" style="margin-top: 1.5em;" id="downloadModalButton" disabled="1"><i class="${downloadIconClasses}"></i> Download! <span></span></div> |
|
|
|
<div style="margin-top: 1.5em;" class="introduction"> |
|
The file count shown on the download button assumes that the message media is unlocked for you. |
|
<br /> |
|
It may be inaccurate if it is a PPV that hasn't been purchased yet. Messages with 0 media are not listed. |
|
</div> |
|
|
|
<div style="margin-top: 1.5em;" class="introduction"> |
|
If you wish to download message media from another creator, close this modal and select their message thread. |
|
<br /> |
|
A new download icon should show up above the thread list, click it. |
|
</div> |
|
</div> |
|
</div> |
|
</div>`; |
|
|
|
modalWrapper.insertAdjacentHTML('beforeend', modal); |
|
|
|
// Get the modal element after adding it, so that we can add event listeners |
|
const modalElem = document.querySelector('#downloadModal'); |
|
|
|
/** |
|
* Handle selection and download |
|
*/ |
|
const selectElem = modalElem.querySelector('select'); |
|
const downloadButton = modalElem.querySelector('#downloadModalButton'); |
|
const downloadCount = downloadButton.querySelector('span'); |
|
const downloadIcons = downloadButton.querySelector('.fal'); |
|
|
|
function disableDownload() |
|
{ |
|
downloadButton.setAttribute('disabled', '1'); |
|
downloadButton.classList.add('disabled'); |
|
} |
|
|
|
function enableDownload() |
|
{ |
|
downloadButton.removeAttribute('disabled'); |
|
downloadButton.classList.remove('disabled'); |
|
} |
|
|
|
selectElem.addEventListener('change', async function(ev) { |
|
const selectedMessageId = selectElem.value; |
|
if (!selectedMessageId) { |
|
disableDownload(); |
|
downloadCount.textContent = ''; |
|
return; |
|
} |
|
|
|
const messageMedia = await getMessageMedia(groupId, selectedMessageId); |
|
enableDownload(); |
|
downloadCount.textContent = `(${messageMedia.medias.length} files)`; |
|
}); |
|
|
|
downloadButton.addEventListener('click', async function() { |
|
if (downloadButton.hasAttribute('disabled')) { |
|
return; |
|
} |
|
|
|
const selectedMessageId = selectElem.value; |
|
|
|
console.log('Group ID', groupId, 'Selected Message ID', selectedMessageId); |
|
const { bundles, medias } = await getMessageMedia(groupId, selectedMessageId); |
|
|
|
// Disable the button and add spinner |
|
disableDownload(); |
|
downloadIcons.classList.add('fa-circle-notch'); |
|
downloadIcons.classList.add('fa-spin'); |
|
downloadIcons.classList.remove('fa-download'); |
|
|
|
// Since `filterMedia` just triggers downloads in the background, we're just adding a small delay before re-enabling the button. |
|
setTimeout(() => { |
|
enableDownload(); |
|
downloadIcons.classList.remove('fa-circle-notch'); |
|
downloadIcons.classList.remove('fa-spin'); |
|
downloadIcons.classList.add('fa-download'); |
|
}, 1500); |
|
|
|
const parameter = { |
|
response: { |
|
accountMediaBundles: bundles, |
|
accountMedia: medias, |
|
}, |
|
}; |
|
|
|
filterMedia(parameter); |
|
}); |
|
|
|
/** |
|
* Add handlers for closing the modal. |
|
*/ |
|
const closeButton = modalElem.querySelector('.fa-times'); |
|
function removeModal() { |
|
modalElem.remove(); |
|
xdModal.classList.remove('back-drop'); |
|
body.classList.remove('modal-opened'); |
|
} |
|
|
|
closeButton.addEventListener('click', removeModal); |
|
xdModal.addEventListener('click', removeModal); |
|
}); |
|
|
|
parent.insertAdjacentElement('afterend', cloned); |
|
} |
|
|
|
/** |
|
* Helpers for getting the download media button (if it already exists) |
|
*/ |
|
function getDownloadMessageMediaButton() |
|
{ |
|
return document.querySelector('#downloadMessageBundles'); |
|
} |
|
|
|
function hasDownloadMessageMediaButton() |
|
{ |
|
if (getDownloadMessageMediaButton()) { |
|
return true; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
/** |
|
* Begin profile page handling |
|
* |
|
* TODO: This is very incomplete as of right now. |
|
*/ |
|
async function fetchProfile(username) |
|
{ |
|
const response = await apiFetch(`/account?usernames=${username}`); |
|
const json = await response.json(); |
|
|
|
if (!json.success || json.response.length < 1) { |
|
return; |
|
} |
|
|
|
const profile = json.response[0]; |
|
const neighborButton = document.querySelector('.dm-profile') || document.querySelector('.tip-profile') || document.querySelector('.follow-profile'); |
|
const relevantAttribute = getAngularAttribute(neighborButton); |
|
|
|
// Don't add another button |
|
const downloadButtonId = 'profile-dl'; |
|
if (document.getElementById(downloadButtonId)) { |
|
return; |
|
} |
|
|
|
const downloadButton = document.createElement('div'); |
|
downloadButton.setAttribute(relevantAttribute, ''); |
|
downloadButton.setAttribute('class', 'dm-profile'); |
|
downloadButton.setAttribute('id', downloadButtonId); |
|
downloadButton.innerHTML = '<i class="${downloadIconClasses}"></i>'; |
|
neighborButton.insertAdjacentElement('beforebegin', downloadButton); |
|
|
|
console.log('Profile', profile); |
|
} |
|
|
|
/** |
|
* Helpers for dealing with page load, page changing etc. |
|
*/ |
|
function getCurrentUrlPaths() |
|
{ |
|
const url = new URL(window.location.href); |
|
const paths = url.pathname.split('/').slice(1); |
|
return paths; |
|
} |
|
|
|
async function handleLoad() |
|
{ |
|
const paths = getCurrentUrlPaths(); |
|
|
|
const root = paths[0] || ''; |
|
const secondary = paths[1] || null; |
|
|
|
const selectors = { |
|
dropdown: 'div.feed-item-title > div.feed-item-actions.dropdown-trigger.more-dropdown > div.dropdown-list', |
|
}; |
|
|
|
if (root === 'post' && secondary) { |
|
console.log('Found post - Post ID:', secondary); |
|
VM.observe(document.body, async () => { |
|
const dropdown = document.querySelector(selectors.dropdown); |
|
|
|
if (dropdown) { |
|
console.log('Found dropdown', dropdown); |
|
await handleSinglePost(dropdown, secondary); |
|
return true; |
|
} |
|
}); |
|
} |
|
|
|
if (root === 'messages' && secondary) { |
|
await handleMessages(secondary); |
|
} |
|
|
|
if (root !== '' && secondary === 'posts') { |
|
await fetchProfile(root); |
|
} |
|
} |
|
|
|
let oldUrl = ''; |
|
function checkNewUrl() |
|
{ |
|
const newUrl = window.location.href; |
|
|
|
if (oldUrl === newUrl) { |
|
return; |
|
} |
|
|
|
oldUrl = newUrl; |
|
|
|
if (hasDownloadMessageMediaButton()) { |
|
const button = getDownloadMessageMediaButton(); |
|
button.remove(); |
|
} |
|
|
|
handleLoad(); |
|
} |
|
|
|
let interval; |
|
function init() |
|
{ |
|
setTimeout(handleLoad, 1500); |
|
|
|
if (!interval) { |
|
oldUrl = window.location.href; |
|
interval = setInterval(checkNewUrl, 100); |
|
} |
|
} |
|
|
|
init(); |
@schleeb
You didn't exactly mention this the first time. I've only had the script give me a GIF like once or twice before (from the same creator), so I made the assumption based on that experience.
I'm not 100% sure what happened then. It's possible this (arguably crappy) logic triggers and picks a bad option, instead of the intended "highest quality" option - again, not certain: https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7#file-fansly-download-user-js-L221-L239
You could enable
SCRIPT_DOWNLOAD
and see if the resulting script gives you the proper video, since it uses slightly different logic for picking out the video (mainly this: https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7#file-fansly-download-user-js-L150)