[
  {
    "path": "README.md",
    "content": "# Downloader for fansly.com\n🟢 This is a Google Chrome extension that adds download buttons to fansly feed and image gallerys. It can download images, animated images (gifs), audio and videos.<br><br>\n🔴 I don't know if downloading for private use is allowed, so use it at your own responsibility. But obviously you are not allowed to reproduce, publish, or distribute any content downloaded. Please do not do this.\n\n## Installation\n\n### Manual Installation\n* <a href=\"https://github.com/Motyldrogi/fansly-downloader/releases/latest\" target=\"_blank\">Download the extension</a> and unzip it to a folder.\n* Then go to the extension page manually or with url **chrome://extensions/** and turn on Developer Mode in the top right corner. In the new menu select \"Load unpacked\" and select the folder with the unzipped files.\n\n### Webstore\n* 🔴 EXTENSION GOT REMOVED FROM WEBSTORE \"This functionality is not allowed per Chrome Web Store policies.\" 🔴\n\n## Best quality download:\n* Open an image and click on the dots icon at the top right corner and select \"High Quality Media\". The quality option for images is persistent. Videos get always downloaded in the selected quality (gear icon).\n* Now click the download button at the top left to download the file.\n\n## Notice:\n* You need to be followed/subscribed to the fansly.com creator.\n* No paywall is bypassed.\n* No unauthorized access or download is encouraged, facilitated or enabled.\n\n## Features:\n* Download fansly.com videos.\n* Download fansly.com images/photos/gifs/audio.\n* Also works for profile pic, banner and private messages.\n\n## Disclaimer:\n\"Fansly\" or fansly.com is operated by Select Media LLC as stated on their \"Contact\" page. This Chrome extension (Downloader for fansly.com) isn't in any way affiliated with, sponsored by, or endorsed by Select Media LLC or \"Fansly\".\n"
  },
  {
    "path": "content.js",
    "content": "const addDownloadButtonToModal = (modalItem, node) => {\n  const closeButton = modalItem.getElementsByClassName(\"modal-close-button\")[0];\n\n  // Private message video\n  if (node.classList.contains(\"video-element-wrapper\")) {\n    node = node.querySelector(\".video\");\n  }\n  if (node.closest(\".preview\")) {\n    return;\n  }\n\n  // If already added or not found return\n  if (node.parentNode.querySelector(\".modal-download-button\") != null) {\n    return;\n  }\n\n  if (closeButton != null) {\n    // Add new download button after node\n    const button = buildDownloadButtonModal(closeButton);\n    button.addEventListener(\"click\", onDownloadClickModal);\n\n    setButtonVisibility(button);\n\n    node.parentNode.insertBefore(button, node.nextSibling);\n  }\n};\n\nconst setButtonVisibility = (button) => {\n  // Make the button visible\n  button.style.setProperty(\"display\", \"flex\", \"important\");\n  button.style.setProperty(\"opacity\", \"1\", \"important\");\n};\n\nconst buildDownloadButtonModal = (closeButton) => {\n  // Create new div button\n  const button = document.createElement(\"div\");\n  button.classList.add(...closeButton.classList);\n  button.classList.add(\"modal-download-button\", \"modal-pulse\");\n\n  // Copy ngcontent for styles\n  button.setAttribute(closeButton.attributes[0].name, closeButton.attributes[0].value);\n\n  // Add download icon to container\n  const buttonIcon = document.createElement(\"div\");\n  buttonIcon.classList.add(\"download-icon\");\n  buttonIcon.innerHTML =\n    \"<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-download'><path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'></path><polyline points='7 10 12 15 17 10'></polyline><line x1='12' y1='15' x2='12' y2='3'></line></svg>\";\n\n  // Copy ngcontent for styles\n  buttonIcon.setAttribute(closeButton.attributes[0].name, closeButton.attributes[0].value);\n  button.appendChild(buttonIcon);\n\n  return button;\n};\n\nconst getPathInfo = (event) => {\n  const path = event.path || (event.composedPath && event.composedPath());\n  if (!path) {\n    console.log(\"Unable to get path information from browser.\");\n    return;\n  }\n  return path;\n};\n\nconst onDownloadClickModal = (event) => {\n  // Get image or video relative to button\n\n  const path = getPathInfo(event);\n\n  const downloadLink = path[2].querySelector(\"video\")?.src || path[2].querySelectorAll(\".image\")[1]?.src || path[2].querySelectorAll(\".image\")[0]?.src;\n\n  const feedUsername = \"fansly\";\n\n  if (downloadLink != null && !downloadLink.includes(\"mp4\")) {\n    fetch(downloadLink)\n      .then((res) => res.text())\n      .then((data) => {\n        const type = getTypeFromBlobStart(data.slice(0, 10));\n\n        const name = feedUsername + \"-\" + Math.random().toString(36).substr(2) + type;\n\n        downloadFile(downloadLink, name);\n      });\n  } else if (downloadLink != null && downloadLink.includes(\"mp4\")) {\n    const name = feedUsername + \"-\" + downloadLink.split(\"/\")[4].split(\"?\")[0];\n\n    downloadFile(downloadLink, name);\n  }\n};\n\nconst addDownloadButtonToFeed = (feedItem) => {\n  const hasMedia = hasItemMedia(feedItem);\n  if (hasMedia == false) {\n    return;\n  }\n\n  const lastElem = feedItem.getElementsByClassName(\"feed-item-stats\").length - 1;\n  const stats = feedItem.getElementsByClassName(\"feed-item-stats\")[lastElem];\n\n  // If already added or not found return\n  if (!stats || stats.querySelectorAll(\".download\").length > 0) {\n    return;\n  }\n\n  const tipsButton = stats.getElementsByClassName(\"tips\")[0];\n  if (tipsButton != null) {\n    // Add new download button after tip button\n    const button = buildDownloadButtonFeed(tipsButton);\n    button.addEventListener(\"click\", onDownloadClickFeed);\n\n    setButtonVisibility(button);\n\n    tipsButton.parentNode.insertBefore(button, tipsButton.nextSibling);\n  }\n};\n\nconst buildDownloadButtonFeed = (tipsButton) => {\n  // Create new div button\n  const button = document.createElement(\"div\");\n  button.classList.add(...tipsButton.classList);\n  button.classList.replace(\"tips\", \"download\");\n\n  // Copy ngcontent for styles\n  button.setAttribute(tipsButton.attributes[0].name, tipsButton.attributes[0].value);\n\n  // Create new icon container\n  const buttonIconContainer = document.createElement(\"div\");\n  buttonIconContainer.classList.add(...tipsButton.children[0].classList);\n  buttonIconContainer.classList.replace(\"green\", \"pink\");\n\n  // Copy ngcontent for styles\n  buttonIconContainer.setAttribute(tipsButton.attributes[0].name, tipsButton.attributes[0].value);\n  button.appendChild(buttonIconContainer);\n\n  // Add text\n  button.appendChild(document.createTextNode(\"Download\"));\n\n  // Add download icon to container\n  const buttonIcon = document.createElement(\"div\");\n  buttonIcon.classList.add(\"download-icon\");\n  buttonIcon.innerHTML =\n    \"<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-download'><path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'></path><polyline points='7 10 12 15 17 10'></polyline><line x1='12' y1='15' x2='12' y2='3'></line></svg>\";\n\n  // Copy ngcontent for styles\n  buttonIcon.setAttribute(tipsButton.attributes[0].name, tipsButton.attributes[0].value);\n  buttonIconContainer.appendChild(buttonIcon);\n\n  return button;\n};\n\nconst onDownloadClickFeed = (event) => {\n  // Stop angular click events\n  event.stopPropagation();\n\n  const path = getPathInfo(event);\n\n  const feedItemContent = path[2].closest(\".feed-item-content\");\n\n  const preview = feedItemContent.querySelectorAll(\".feed-item-preview\")[0];\n  if (!preview.classList.contains(\"single-preview\")) {\n    preview.querySelectorAll(\".image\")[0].click();\n    return;\n  }\n\n  downloadLinks = getBlobUrls(feedItemContent);\n\n  const feedUsername = feedItemContent.querySelector(\".display-name\").textContent.replace(/\\s+/g, \"\");\n\n  // Check if image or video\n  downloadLinks.forEach((downloadLink) => {\n    if (downloadLink.startsWith(\"img:\")) {\n      fetch(downloadLink.substr(4))\n        .then((res) => res.text())\n        .then((data) => {\n          const type = getTypeFromBlobStart(data.slice(0, 10));\n\n          const name = feedUsername + \"-\" + Math.random().toString(36).substr(2) + type;\n\n          downloadFile(downloadLink.substr(4), name);\n        });\n    } else {\n      const name = feedUsername + \"-\" + downloadLink.split(\"/\")[4].split(\"?\")[0];\n\n      downloadFile(downloadLink.substr(4), name);\n    }\n  });\n};\n\nconst getTypeFromBlobStart = (blobStr) => {\n  let type = \".png\";\n\n  if (blobStr.includes(\"GIF\")) {\n    type = \".gif\";\n  } else if (blobStr.includes(\"JPG\")) {\n    type = \".jpg\";\n  } else if (blobStr.includes(\"JPEG\")) {\n    type = \".jpeg\";\n  } else if (blobStr.includes(\"ftyp\")) {\n    type = \".mp4\";\n  }\n\n  return type;\n};\n\nconst hasItemMedia = (feedItem) => {\n  let returnable = true;\n  const preview = feedItem.querySelector(\".feed-item-preview\");\n\n  if (preview) {\n    let count = 0;\n    const images = preview.querySelectorAll(\".image\");\n\n    for (let i = 0; i < images.length; i++) {\n      const img = images[i].querySelector(\".image\");\n      if (img != null && img.src != null) {\n        count++;\n      }\n    }\n\n    if (count == 0) {\n      returnable = false;\n    }\n  } else if (!preview) {\n    returnable = false;\n  }\n\n  return returnable;\n};\n\nconst getBlobUrls = (feedItem) => {\n  let returnable = [];\n\n  const preview = feedItem.querySelector(\".feed-item-preview\");\n\n  let video = false;\n  if (preview.querySelectorAll(\"video\").length > 0) {\n    video = true;\n  }\n\n  const images = preview.querySelectorAll(\".image\");\n\n  for (let i = 0; i < images.length; i++) {\n    if (images[i].querySelector(\".image\") != null) {\n      const img = images[i].querySelector(\".image\");\n\n      if (img.src != null && !video) {\n        returnable.push(\"img:\" + img.src);\n      } else {\n        console.log(preview);\n        const vid = preview.querySelector(\"video\");\n        if (vid) {\n          returnable.push(\"vid:\" + vid.src);\n        }\n      }\n    }\n  }\n\n  return returnable;\n};\n\nconst downloadFile = (url, name) => {\n  // Download video\n  if (url.includes(\"mp4\")) {\n    downloadVideo(url, name);\n    return;\n  }\n\n  // Download image\n  const link = document.createElement(\"a\");\n  link.href = url;\n\n  link.setAttribute(\"download\", name);\n  document.body.appendChild(link);\n\n  link.click();\n  link.parentNode.removeChild(link);\n};\n\nconst buildProgressItem = (name) => {\n  // Create progress item\n  const progressItem = document.createElement(\"div\");\n  progressItem.classList.add(\"item\");\n  progressItem.innerText = name;\n\n  const progressBar = document.createElement(\"div\");\n  progressBar.classList.add(\"progress\");\n\n  const progressBarInner = document.createElement(\"div\");\n  progressBar.appendChild(progressBarInner);\n\n  progressItem.appendChild(progressBar);\n\n  return progressItem;\n};\n\nconst updateProgressItem = ({ loaded, total }, progressItem) => {\n  const percentage = Math.round((loaded / total) * 100) + \"%\";\n  progressItem.style.width = percentage;\n  progressItem.innerText = percentage;\n};\n\nconst createProgressContainer = () => {\n  // Create container for progress items\n  const progressContainer = document.createElement(\"div\");\n  progressContainer.setAttribute(\"id\", \"progress-container\");\n\n  document.body.insertBefore(progressContainer, document.body.firstChild);\n};\n\nconst finishedProgress = (progressItem, progressBar) => {\n  // Remove progress item\n  progressBar.innerText = \"Finished download!\";\n\n  progressItem.style.transition = \"opacity 1s ease-in 3s\";\n\n  progressItem.style.opacity = 0;\n  setTimeout(() => {\n    progressItem.parentNode.removeChild(progressItem);\n  }, 4000);\n};\n\nconst downloadVideo = async (url, name) => {\n  const response = await fetch(url, {\n    cache: \"no-store\",\n    headers: new Headers({\n      Origin: location.origin\n    }),\n    mode: \"cors\"\n  });\n  const contentLength = response.headers.get(\"content-length\");\n  const total = parseInt(contentLength, 10);\n  let loaded = 0;\n\n  const res = new Response(\n    new ReadableStream({\n      async start(controller) {\n        const reader = response.body.getReader();\n\n        // Add progress bar\n        const progressItem = buildProgressItem(name);\n        const progressContainer = document.getElementById(\"progress-container\");\n        progressContainer.insertBefore(progressItem, progressContainer.firstChild);\n\n        // Update progress\n        const progressBar = progressItem.querySelector(\".progress\").firstChild;\n        for (;;) {\n          const { done, value } = await reader.read();\n          if (done) break;\n          loaded += value.byteLength;\n          updateProgressItem({ loaded, total }, progressBar);\n          controller.enqueue(value);\n        }\n        controller.close();\n\n        finishedProgress(progressItem, progressBar);\n      }\n    })\n  );\n  const blob = await res.blob();\n\n  if (blob.size != 0) {\n    let blobUrl = window.URL.createObjectURL(blob);\n\n    const link = document.createElement(\"a\");\n    link.href = blobUrl;\n\n    // Trigger download\n    link.setAttribute(\"download\", name);\n    document.body.appendChild(link);\n\n    link.click();\n    link.parentNode.removeChild(link);\n  }\n};\n\nconst checkVersionUpdate = () => {\n  var currentVer = chrome.runtime.getManifest().version;\n\n  fetch(\"https://api.github.com/repos/Motyldrogi/fansly-downloader/releases/latest\")\n    .then((res) => res.json())\n    .then((data) => {\n      if (currentVer != data.tag_name) {\n        createUpdateContainer(currentVer, data.tag_name);\n      }\n    });\n};\n\nconst createUpdateContainer = (currentVer, latestVer) => {\n  const container = document.createElement(\"div\");\n  container.setAttribute(\"id\", \"update-container\");\n  container.innerHTML = `<a href=\"https://github.com/Motyldrogi/fansly-downloader/releases\" target=\"_blank\">\n    Click here to update Fansly Downloader!</a>Your Version: <b>${currentVer}</b> - Latest Version: <b>${latestVer}</b>`;\n  document.body.insertBefore(container, document.body.firstChild);\n\n  const dismiss = document.createElement(\"div\");\n  dismiss.setAttribute(\"class\", \"dismiss\");\n  dismiss.innerText = \"X\";\n  dismiss.addEventListener(\"click\", dismissUpdateContainer);\n  container.appendChild(dismiss);\n};\n\nconst dismissUpdateContainer = (event) => {\n  const path = getPathInfo(event);\n  const container = path[1];\n  container.parentNode.removeChild(container);\n};\n\nconst afterPageLoad = () => {\n  // Init after page load\n  createProgressContainer();\n  checkVersionUpdate();\n};\n\nconst observerCallback = (mutationsList) => {\n  mutationsList.forEach((mutation) => {\n    if (!mutation.addedNodes) return;\n\n    for (let i = 0; i < mutation.addedNodes.length; i++) {\n      const node = mutation.addedNodes[i];\n      const classList = node.classList;\n\n      if (classList == null) return;\n\n      // Image was added\n      if (classList.contains(\"image\")) {\n        const feedItem = node.closest(\".feed-item-content\");\n        if (feedItem) {\n          addDownloadButtonToFeed(feedItem);\n        }\n\n        const modalItem = node.closest(\".active-modal\");\n        if (modalItem) {\n          addDownloadButtonToModal(modalItem, node);\n        }\n      }\n    }\n  });\n};\n\nconst config = {\n  attributes: true,\n  childList: true,\n  subtree: true,\n  characterData: true\n};\n\nconst observer = new MutationObserver(observerCallback);\n\nwindow.addEventListener(\"load\", function () {\n  observer.observe(document.body, config);\n  afterPageLoad();\n});\n"
  },
  {
    "path": "manifest.json",
    "content": "{\n  \"name\": \"Fansly.com Downloader\",\n  \"description\": \"Adds a download button to fansly feed and image gallery.\",\n  \"version\": \"2.0.6\",\n  \"manifest_version\": 3,\n  \"content_scripts\": [{\n    \"matches\": [\"https://fansly.com/*\"],\n    \"js\": [\"content.js\"],\n    \"css\" : [\"style.css\"],\n    \"all_frames\": true\n  }],\n  \"icons\": {\n    \"16\": \"/images/icon-16.png\",\n    \"32\": \"/images/icon-32.png\",\n    \"48\": \"/images/icon-48.png\",\n    \"128\": \"/images/icon-128.png\"\n  }\n}\n"
  },
  {
    "path": "style.css",
    "content": ".modal-download-button {\n  top: 25px !important;\n  left: 190px !important;\n  background-color: #1b1c21 !important;\n  z-index: 999 !important;\n  border-radius: 50% !important;\n  width: 80px !important;\n  height: 80px !important;\n}\n\n.modal-download-button .download-icon svg {\n  stroke: rgb(244, 154, 255);\n}\n\n.modal-download-button:hover .download-icon svg {\n  stroke: #c46fcf !important;\n}\n\n.download-icon {\n  pointer-events: none;\n}\n\n@media (max-width: 700px) {\n  .modal-download-button {\n    left: 130px !important;\n  }\n}\n\n.feed-item-stat.custom-hover-trigger .icon-container .download-icon svg {\n  stroke: var(--dark-blue-1);\n}\n\n.feed-item-stat.custom-hover-trigger:hover .icon-container .download-icon svg {\n  stroke: #f49aff;\n}\n\n.custom-hover-effect.pink:after {\n  background-color: rgba(244, 154, 255, 0.137);\n}\n\n#progress-container {\n  max-height: 200px;\n  overflow: hidden auto;\n  z-index: 999 !important;\n  position: fixed;\n  bottom: 50px;\n  right: 50px;\n  min-width: 300px;\n}\n\n#progress-container .item {\n  background: #222;\n  border: 1px solid var(--blue-2);\n  border-radius: 6px;\n  height: 50px;\n  color: #fff;\n  display: flex;\n  flex-direction: column;\n  align-items: flex-start;\n  padding: 5px 10px 5px 5px;\n  margin-bottom: 3px;\n}\n\n#progress-container .progress {\n  background: #333;\n  border-radius: 9px;\n  height: 20px;\n  width: 100%;\n  padding: 3px;\n}\n\n#progress-container .progress > div {\n  background: var(--blue-2);\n  color: #fff;\n  height: 100%;\n  border-radius: 6px;\n  text-align: center;\n}\n\n#update-container {\n  position: fixed;\n  top: 85px;\n  background: #1b1c21;\n  right: 20px;\n  border-radius: 12px;\n  padding: 15px;\n  z-index: 3 !important;\n  overflow: hidden auto;\n  min-width: 320px;\n  box-shadow: var(--box-shadow-1);\n}\n\n#update-container a {\n  color: #2699f6;\n  text-decoration: none;\n  margin-bottom: 5px;\n  display: block;\n}\n\n#update-container .dismiss {\n  position: absolute;\n  right: 20px;\n  top: 15px;\n  font-size: 16px;\n  cursor: pointer;\n  color: #fff;\n  font-weight: 300;\n  font-family: Arial, sans-serif;\n}\n\n.modal-pulse {\n  box-shadow: 0 0 0 rgba(244, 154, 255, 0.4);\n  animation: pulse 2s infinite;\n  border: 1px solid rgba(244, 154, 255, 0.4);\n}\n\n@-webkit-keyframes pulse {\n  0% {\n    -webkit-box-shadow: 0 0 0 0 rgba(244, 154, 255, 0.4);\n  }\n  70% {\n    -webkit-box-shadow: 0 0 0 20px rgba(244, 154, 255, 0);\n  }\n  100% {\n    -webkit-box-shadow: 0 0 0 0 rgba(244, 154, 255, 0);\n  }\n}\n@keyframes pulse {\n  0% {\n    -moz-box-shadow: 0 0 0 0 rgba(244, 154, 255, 0.4);\n    box-shadow: 0 0 0 0 rgba(244, 154, 255, 0.4);\n  }\n  70% {\n    -moz-box-shadow: 0 0 0 20px rgba(244, 154, 255, 0);\n    box-shadow: 0 0 0 20px rgba(244, 154, 255, 0);\n  }\n  100% {\n    -moz-box-shadow: 0 0 0 0 rgba(244, 154, 255, 0);\n    box-shadow: 0 0 0 0 rgba(244, 154, 255, 0);\n  }\n}\n"
  }
]