Replaces Instagram pages with their decrapped versions (only media & titles)
// ==UserScript==
// @name InstaDecrapper
// @version 1.5.1
// @description Replaces Instagram pages with their decrapped versions (only media & titles)
// @author GreasyPangolin
// @license AGPL-3.0-only
// @match https://www.instagram.com/*
// @match https://instagram.com/*
// @match http://localhost:8000/*
// @run-at document-start
// @grant none
// @namespace https://greasyfork.org/users/1448662
// ==/UserScript==
function isDebug() {
return window.location.href.includes('localhost')
}
async function fixture(variables) {
let url
if (variables?.shortcode) {
url = `http://localhost:8000/post_${variables.shortcode}.json`
} else if (variables?.id) {
url = `http://localhost:8000/next_page.json`
} else {
url = `http://localhost:8000/profile.json`
}
const resp = await fetch(url)
if (!resp.ok) {
throw new Error(`Fixture fetch failed with status ${resp.status}: ${await resp.text()}`)
}
return await resp.json()
}
function filterNonNull(obj) {
return Object.fromEntries(
Object.entries(obj).filter(([_, v]) => v != null)
);
}
function extractDataAndRemoveScripts(runId) {
// Extract CSRF token and App ID from scripts
let csrfToken = ''
let appId = ''
let profileId = null
const scripts = document.querySelectorAll('script')
const jsons = Array.prototype.map.call(
document.querySelectorAll('script[type="application/json"]'),
(i) => JSON.parse(i.textContent)
)
for (var i = 0; i < scripts.length; i++) {
// scan for the script that contains the CSRF token and App ID
const csrfMatch = scripts[i].textContent.match(/"csrf_token":"([^"]+)"/)
const appIdMatch = scripts[i].textContent.match(/"app_id":"([^"]+)"/)
const profileIdMatch = scripts[i].textContent.match(/"profile_id":"([^"]+)"/)
if (csrfMatch && csrfMatch[1]) {
csrfToken = csrfMatch[1]
console.log(`[Run ${runId}] Found CSRF token: ${csrfToken} `)
}
if (appIdMatch && appIdMatch[1]) {
appId = appIdMatch[1]
console.log(`[Run ${runId}] Found App ID: ${appId} `)
}
if (profileIdMatch && profileIdMatch[1]) {
profileId = profileIdMatch[1]
console.log(`[Run ${runId}] Found profile ID: ${profileId} `)
}
// we don't need this script anymore
scripts[i].remove()
if (csrfToken && appId && profileId) {
return {
secrets: { csrfToken, appId },
profileId,
}
}
}
// secrets found but profile ID is missing (possibly a post page)
if (csrfToken && appId) {
return {
secrets: { csrfToken, appId },
profileId: null,
jsons: jsons,
}
}
console.log(`[Run ${runId}] Could not find CSRF token and App ID`)
return {
secrets: null,
profileId: null,
jsons: jsons,
}
}
function renderProfileHeader(user) {
const header = document.createElement('div')
header.style.cssText = 'display: flex; align-items: center; padding: 20px;'
const info = document.createElement('div')
info.style.display = 'flex'
info.style.alignItems = 'start'
const profilePic = document.createElement('img')
profilePic.src = user.profilePicUrl
profilePic.width = 64
profilePic.height = 64
profilePic.style.borderRadius = '50%'
profilePic.style.marginRight = '20px'
info.appendChild(profilePic)
const textInfo = document.createElement('div')
const nameContainer = document.createElement('div')
nameContainer.style.display = 'flex'
nameContainer.style.alignItems = 'center'
nameContainer.style.gap = '5px'
const name = document.createElement('h1')
name.textContent = user.fullName
name.style.margin = '0 0 10px 0'
name.style.fontFamily = 'sans-serif'
name.style.fontSize = '18px'
nameContainer.appendChild(name)
if (user.isVerified) {
const checkmark = document.createElement('span')
checkmark.textContent = '✓'
checkmark.style.margin = '0 0 10px'
checkmark.style.color = '#00acff'
checkmark.style.fontSize = '18px'
checkmark.style.fontWeight = 'bold'
nameContainer.appendChild(checkmark)
}
textInfo.appendChild(nameContainer)
if (user.username) {
const username = document.createElement('a')
username.href = '/' + user.username
username.textContent = '@' + user.username
username.style.margin = '0 0 10px 0'
username.style.fontFamily = 'sans-serif'
username.style.fontSize = '14px'
username.style.textDecoration = 'none'
username.style.color = '#00376b'
username.target = '_blank'
textInfo.appendChild(username)
}
if (user.biography) {
const bio = document.createElement('p')
bio.textContent = user.biography
bio.style.margin = '0 0 10px 0'
bio.style.whiteSpace = 'pre-line'
bio.style.fontFamily = 'sans-serif'
bio.style.fontSize = '14px'
textInfo.appendChild(bio)
}
if (user.bioLinks && user.bioLinks.length > 0) {
const links = document.createElement('div')
user.bioLinks.forEach(link => {
const a = document.createElement('a')
a.href = link.url
a.textContent = link.title
a.target = '_blank'
a.style.display = 'block'
a.style.fontFamily = 'sans-serif'
a.style.fontSize = '14px'
links.appendChild(a)
})
textInfo.appendChild(links)
}
info.appendChild(textInfo)
header.appendChild(info)
document.body.appendChild(header)
}
function parsePolarisMedia(media) {
if (media.__typename === "XIGPolarisImageMedia") {
return {
imageUrl: child.display_url,
}
}
}
function parseMediaNode(media) {
if (!media) return []; // Handle cases where media node might be null or undefined
const taken_at = media.taken_at_timestamp || media.taken_at
const date = taken_at ? new Date(taken_at * 1000).toISOString().slice(0, 19).replace('T', ' ') : 'Unknown date'
const title = media.edge_media_to_caption?.edges?.at(0)?.node?.text || media.caption?.text || "No title"
const shortcode = media.shortcode || media.code
const children = (
media.__typename === 'GraphSidecar' ||
media.__typename === 'XDTGraphSidecar' ||
media.__typename === "XIGPolarisCarouselMedia"
) ?
(media.edge_sidecar_to_children?.edges || media.carousel_media || []) :
[media]
return children
.map(child => {
return parseSingleMedia({ date, title, shortcode }, child)
})
.filter(item => item !== null); // Filter out any null items from invalid child nodes
}
function parseSingleMedia({ date, title, shortcode }, media) {
const child = media.node || media
if (!child) return null; // Skip if child node is invalid
const videoUrl = child.video_versions?.at(0)?.url || child.video_url
const imageUrl = child.image_versions2?.candidates?.at(0)?.url || child.display_url || child.video_image?.uri
if (!videoUrl && !imageUrl) return null;
return {
date: date, // Use parent's date
title: title, // Use parent's title
isVideo: !!videoUrl,
videoUrl: videoUrl,
imageUrl: imageUrl,
shortcode: shortcode // Use parent's shortcode
}
}
function renderTitle(el, text) {
el.textContent = "";
const re = /(?:^|(?<=[\s.,!?;:()[\]{}"']))(@[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)|(https?:\/\/[^\s]+)/g;
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
if (match.index > lastIndex) {
el.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
if (match[1]) {
const mention = match[1];
const a = document.createElement("a");
a.href = `/${mention.slice(1)}`;
a.textContent = mention;
a.target = "_blank";
a.rel = "noopener noreferrer";
el.appendChild(a);
} else if (match[2]) {
try {
const url = new URL(match[2]);
if (url.protocol === "http:" || url.protocol === "https:") {
url.protocol = "https:";
const a = document.createElement("a");
a.href = url.toString();
a.textContent = match[2];
a.target = "_blank";
a.rel = "noopener noreferrer";
el.appendChild(a);
} else {
el.appendChild(document.createTextNode(match[2]));
}
} catch {
el.appendChild(document.createTextNode(match[2]));
}
}
lastIndex = re.lastIndex;
}
if (lastIndex < text.length) {
el.appendChild(document.createTextNode(text.slice(lastIndex)));
}
}
function renderMedia(mediaItems, collapsible = false) {
const mediaContainer = document.createElement('div');
mediaContainer.style.display = 'grid';
mediaContainer.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
mediaContainer.style.gap = '20px';
mediaContainer.style.padding = '20px';
mediaItems.forEach(item => {
const mediaDiv = document.createElement('div');
mediaDiv.className = 'media';
mediaDiv.style.display = 'flex';
mediaDiv.style.flexDirection = 'column';
mediaDiv.style.alignItems = 'center';
if (item.isVideo) {
const videoElement = document.createElement('video');
videoElement.controls = true;
videoElement.crossorigin = 'use-credentials'; // IG videos need cross-origin stuff
videoElement.loading = 'lazy';
videoElement.preload = 'none'; // fixes 'No video with supported format and MIME type found' issue
videoElement.poster = item.imageUrl;
videoElement.width = 320;
const source = document.createElement('source');
source.src = item.videoUrl;
source.type = 'video/mp4';
videoElement.appendChild(source);
mediaDiv.appendChild(videoElement);
} else {
const imageElement = document.createElement('img');
imageElement.src = item.imageUrl;
imageElement.width = 320;
imageElement.style.height = 'auto';
const linkElement = document.createElement('a');
linkElement.href = item.imageUrl;
linkElement.target = '_blank';
linkElement.appendChild(imageElement);
mediaDiv.appendChild(linkElement);
}
const dateContainer = document.createElement('div');
dateContainer.style.display = 'flex';
dateContainer.style.alignItems = 'center';
dateContainer.style.justifyContent = 'center';
dateContainer.style.gap = '10px';
dateContainer.style.width = '320px';
const date = document.createElement('p');
date.textContent = item.date;
date.style.fontFamily = 'sans-serif';
date.style.fontSize = '12px';
date.style.margin = '5px 0';
dateContainer.appendChild(date);
if (item.shortcode) {
const postLink = document.createElement('a');
postLink.href = `/p/${item.shortcode}`;
postLink.textContent = '[post]';
postLink.style.fontFamily = 'sans-serif';
postLink.style.fontSize = '12px';
postLink.style.color = 'blue';
postLink.style.textDecoration = 'none';
dateContainer.appendChild(postLink);
}
if (item.isVideo) {
const previewLink = document.createElement('a');
previewLink.href = item.imageUrl;
previewLink.textContent = '[preview]';
previewLink.style.fontFamily = 'sans-serif';
previewLink.style.fontSize = '12px';
previewLink.style.color = 'blue';
previewLink.style.textDecoration = 'none';
dateContainer.appendChild(previewLink);
}
mediaDiv.appendChild(dateContainer);
const title = document.createElement('p');
renderTitle(title, item.title);
title.style.fontFamily = 'sans-serif';
title.style.fontSize = '12px';
title.style.width = '320px';
title.style.textAlign = 'center';
mediaDiv.appendChild(title);
mediaContainer.appendChild(mediaDiv);
});
if (collapsible) {
const details = document.createElement('details');
details.style.margin = '20px';
const summary = document.createElement('summary');
summary.textContent = 'Pinned';
summary.style.cursor = 'pointer';
summary.style.fontFamily = 'sans-serif';
summary.style.fontSize = '16px';
summary.style.fontWeight = 'bold';
summary.style.marginBottom = '10px';
details.appendChild(summary);
details.appendChild(mediaContainer);
document.body.appendChild(details);
} else {
document.body.appendChild(mediaContainer);
}
}
function renderLine() {
const line = document.createElement('hr')
line.style.margin = '20px 0'
document.body.appendChild(line)
}
function renderLoadMoreButton(secrets, profileId, pageInfo) {
let loadMoreButton = document.getElementById('loadMoreBtn')
// Remove old button
if (loadMoreButton) {
loadMoreButton.remove()
}
// Add new "Load More" button
if (pageInfo?.has_next_page && pageInfo.end_cursor) {
// Create a horizontal line
renderLine()
// Create "Load More" button
loadMoreButton = document.createElement('button')
loadMoreButton.id = 'loadMoreBtn'
loadMoreButton.style.cssText = 'display: block; margin: 20px auto; padding: 10px 20px; font-size: 16px; cursor: pointer;'
document.body.appendChild(loadMoreButton)
// Update button's state and event listener
loadMoreButton.textContent = 'Load More'
loadMoreButton.disabled = false
// Clone and replace to ensure the event listener is fresh and doesn't stack
const newButton = loadMoreButton.cloneNode(true)
loadMoreButton.parentNode.replaceChild(newButton, loadMoreButton)
newButton.onclick = () => {
newButton.disabled = true; // Prevent multiple clicks while loading
newButton.textContent = 'Loading...'
// Call loadNextPage with the new cursor
loadNextPage(secrets, profileId, pageInfo.end_cursor)
}
}
}
// Helper function for GraphQL API calls
async function fetchGraphQL({ csrfToken, appId }, { variables, doc_id }) {
if (isDebug()) {
return fixture(variables)
}
const resp = await fetch(`https://www.instagram.com/graphql/query`, {
"method": "POST",
"credentials": "include",
"headers": {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": csrfToken,
"X-IG-App-ID": appId,
"Origin": "https://www.instagram.com",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
},
"body": new URLSearchParams({
"av": "0",
"hl": "en",
"__d": "www",
"__user": "0",
"__a": "1",
"__req": "a",
"__hs": "20168.HYP:instagram_web_pkg.2.1...0",
"dpr": "2",
"__ccg": "EXCELLENT",
"fb_api_caller_class": "RelayModern",
"variables": JSON.stringify(filterNonNull(variables)),
"server_timestamps": "true",
"doc_id": doc_id,
}).toString()
})
if (!resp.ok) {
throw new Error(`GraphQL fetch failed with status ${resp.status}: ${await resp.text()}`)
}
return await resp.json()
}
// Helper function for standard Web API calls
async function fetchProfile({ csrfToken, appId }, username) {
if (isDebug()) {
return fixture()
}
const resp = await fetch(`https://www.instagram.com/api/v1/users/web_profile_info/?username=${username}&hl=en`, {
method: 'GET',
credentials: "include",
headers: {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0",
"Accept": "*/*",
"Accept-Language": "en,en-US;q=0.5",
"X-CSRFToken": csrfToken,
"X-IG-App-ID": appId,
"X-IG-WWW-Claim": "0",
"X-Requested-With": "XMLHttpRequest",
"Alt-Used": "www.instagram.com",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Pragma": "no-cache",
"Cache-Control": "no-cache"
},
referrer: `https://www.instagram.com/${username}/?hl=en`,
mode: "cors",
})
if (!resp.ok) {
throw new Error(`API fetch failed with status ${resp.status}: ${await resp.text()}`)
}
return await resp.json()
}
async function loadSinglePost(_, _, jsons) {
console.log(jsons)
function isPolarisMedia(shit) {
return isObject(shit) && !!shit?.result?.data?.xig_polaris_media
}
function isArray(shit) {
return shit instanceof Array
}
function isObject(shit) {
return shit instanceof Object
}
function isBBox(shit) {
return isObject(shit) && !!shit?.__bbox
}
function isRequire(shit) {
return isObject(shit) && !!shit?.require
}
function parseShit(shit) {
if (isArray(shit)) {
return parseArray(shit)
}
if (isRequire(shit)) {
return parseArray(shit.require)
}
if (isBBox(shit)) {
return parseShit(shit.__bbox)
}
if (isPolarisMedia(shit)) {
const container = shit?.result?.data?.xig_polaris_media
const media = container?.if_not_gated_logged_out || container
if (!media) {
return null
}
console.log("found!", media)
const user = media.owner || media.user
if (user) {
renderProfileHeader({
username: user.username,
fullName: user.full_name || '',
profilePicUrl: user.profile_pic_url,
isVerified: !!user.is_verified
})
}
const mediaItems = parseMediaNode(media)
renderMedia(mediaItems)
return null
}
return null
}
function parseArray(arr) {
for (const a of arr) {
parseShit(a)
}
}
parseShit(jsons)
}
// Refactored loadNextPage
async function loadNextPage(secrets, profileId, after) {
try {
const data = await fetchGraphQL(secrets, {
doc_id: "7950326061742207",
variables: {
"id": profileId,
"after": after,
"first": 12 // Number of posts to fetch per page
},
})
// Parse `data` and fill `mediaItems` using the parsing function
const mediaEdges = data?.data?.user?.edge_owner_to_timeline_media?.edges
const mediaItems = mediaEdges ? mediaEdges.flatMap(edge => parseMediaNode(edge.node)) : []
if (!mediaEdges) {
console.error("Could not find media edges in the response data:", data)
}
renderMedia(mediaItems)
// Handle pagination
const pageInfo = data?.data?.user?.edge_owner_to_timeline_media?.page_info
renderLoadMoreButton(secrets, profileId, pageInfo)
} catch (e) {
console.error(e)
// Update button's state and return
const loadMoreButton = document.getElementById('loadMoreBtn')
if (loadMoreButton) {
loadMoreButton.textContent = 'Load More (Retry)'
loadMoreButton.disabled = false
}
return
}
}
async function loadFullProfile(secrets, username) {
const data = await fetchProfile(secrets, username)
// Check if user data exists
if (!data || !data.data || !data.data.user) {
console.error("Profile data is missing or invalid:", data)
document.body.innerHTML = `<p style="color: orange; font-family: sans-serif; padding: 20px;">Could not find profile data for ${username}. The profile might be private or does not exist.</p>`
return; // Stop execution
}
// Header
const user = data.data.user
renderProfileHeader({
fullName: user.full_name,
biography: user.biography,
profilePicUrl: user.profile_pic_url_hd || user.profile_pic_url, // Fallback for profile pic
bioLinks: user.bio_links,
isVerified: user.is_verified,
})
// Stories or whatever
const felixVideoEdges = user.edge_felix_video_timeline?.edges || []
if (felixVideoEdges.length > 0) {
renderMedia(felixVideoEdges.flatMap(edge => parseMediaNode(edge.node)), true)
renderLine()
}
// Timeline
const timelineEdges = user.edge_owner_to_timeline_media?.edges || []
const timelineMedia = timelineEdges.flatMap(edge => parseMediaNode(edge?.node)); // Add null check for edge
renderMedia(timelineMedia)
// Show more button
const pageInfo = user.edge_owner_to_timeline_media?.page_info
const profileId = user.id
renderLoadMoreButton(secrets, profileId, pageInfo)
}
async function run({ secrets, profileId, jsons }) {
// first, stop the page from loading
window.stop()
document.head.innerHTML = ''
document.body.innerHTML = ''
// and now execute our code
const postID = window.location.pathname.match(/(p|reel|reels)\/([^\/]*)/)
const isFullPost = !!window.location.search.match(/[&?]l=1(&|$)/)
if (postID) {
if ((postID[1] == "reel" && !isFullPost) || postID[1] == "reels") {
window.location.href = `https://instagram.com${window.location.pathname.replace('reels', 'reel')}?l=1`
return
}
const shortcode = postID[2]
console.log(`Loading post: ${shortcode}`)
loadSinglePost(secrets, shortcode, jsons)
} else {
const username = window.location.pathname.split('/')[1]
console.log(`Loading profile: ${username}`)
try {
await loadFullProfile(secrets, username)
} catch (error) {
console.error("Error loading full profile:", error)
// most probably access errro, let's try loading a limited profile
loadNextPage(secrets, profileId, null)
}
}
}
(function () {
'use strict'
if (isDebug()) {
console.log("Debug mode enabled")
document.body.innerHTML = ""
const shortcode = window.location.pathname.split('/').pop()
if (shortcode && shortcode == "limited") {
loadNextPage({ /* no secrets */ }, profileId)
} else if (shortcode) {
loadSinglePost({ /* no secrets */ }, shortcode, []) // not implemented!
} else {
loadFullProfile({/* no secrets */ })
}
return
}
// let's try to stop it from blinking
const style = document.createElement('style')
style.textContent = '#splash-screen { display: none !important; }'
document.head.appendChild(style)
// we try to extract the secrets and run the app right away,
// sometimes it works :)
const { secrets, profileId, jsons } = extractDataAndRemoveScripts(1)
if (!secrets) {
// but since the user-script injection is kinda unpredictable
// especially across different browsers and extensions,
// we also fallback to a DOMContentLoaded event listener
document.addEventListener('DOMContentLoaded', function () {
window.stop() // we know that the secrets are in the DOM, so we can stop loading all other garbage
const { secrets, profileId, jsons } = extractDataAndRemoveScripts(2)
if (!secrets) {
console.log("Failed to extract secrets")
return
}
run({ secrets, profileId, jsons })
})
return
}
run({ secrets, profileId, jsons })
})()
// vim: set ts=4 sw=4 ss=4 et: