InstaDecrapper

Replaces Instagram pages with their decrapped versions (only media & titles)

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         InstaDecrapper
// @version      1.3.2
// @description  Replaces Instagram pages with their decrapped versions (only media & titles)
// @author       GreasyPangolin
// @license      MIT
// @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

    var scripts = document.querySelectorAll('script')

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

    console.log(`[Run ${runId}] Could not find CSRF token and App ID`)

    return {
        secrets: null,
        profileId: null,
    }
}

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 parseMediaNode(media) {
    if (!media) return []; // Handle cases where media node might be null or undefined

    const date = new Date(media.taken_at_timestamp * 1000).toISOString().slice(0, 19).replace('T', ' ')
    const title = media.edge_media_to_caption?.edges[0]?.node.text || "No title"
    const shortcode = media.shortcode

    // Handle sidecar (carousel) posts
    if ((media.__typename === 'GraphSidecar' || media.__typename === 'XDTGraphSidecar') && media.edge_sidecar_to_children?.edges) {
        return media.edge_sidecar_to_children.edges.map(childEdge => {
            const child = childEdge.node
            if (!child) return null; // Skip if child node is invalid
            const isChildVideo = child.__typename === 'GraphVideo' || child.__typename === 'XDTGraphVideo'
            return {
                date: date, // Use parent's date
                title: title, // Use parent's title
                isVideo: isChildVideo,
                videoUrl: isChildVideo ? child.video_url : null,
                imageUrl: child.display_url,
                shortcode: shortcode // Use parent's shortcode
            }
        }).filter(item => item !== null); // Filter out any null items from invalid child nodes
    }
    // Handle single image or video posts
    else {
        const isVideo = media.is_video || media.__typename === 'GraphVideo' || media.__typename === 'XDTGraphVideo'
        return [{
            date: date,
            title: title,
            isVideo: isVideo,
            videoUrl: isVideo ? media.video_url : null,
            imageUrl: media.display_url,
            shortcode: shortcode
        }]
    }
}

function renderMedia(mediaItems) {
    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.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')
        title.textContent = 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)
    })

    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(secrets, shortcode) {
    const data = await fetchGraphQL(secrets, {
        doc_id: "8845758582119845",
        variables: {
            "shortcode": shortcode,
            "fetch_tagged_user_count": null,
            "hoisted_comment_id": null,
            "hoisted_reply_id": null
        },
    })

    // Check if media data exists
    if (!data || !data.data || !data.data.xdt_shortcode_media) {
        console.error(`Media data is missing or invalid for post ${shortcode}:`, data)
        document.body.innerHTML = `<p style="color: orange; font-family: sans-serif; padding: 20px;">Could not find media data for post ${shortcode}. It might be private or unavailable.</p>`
        return; // Stop execution
    }

    const media = data.data.xdt_shortcode_media

    // Use the new parsing function
    const mediaItems = parseMediaNode(media)

    renderProfileHeader({
        username: media.owner.username,
        fullName: media.owner.full_name,
        profilePicUrl: media.owner.profile_pic_url,
        isVerified: media.owner.is_verified
    })

    renderMedia(mediaItems)
}


// Refactored loadNextPage
async function loadNextPage(secrets, profileId, after) {
    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)
}

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)))
        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)
}

function run({ secrets, profileId }) {
    // 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)\/([^\/]*)/)

    if (postID) {
        const shortcode = postID[1]
        console.log(`Loading post: ${shortcode}`)
        loadSinglePost(secrets, shortcode)
    } else {
        const username = window.location.pathname.split('/')[1]
        console.log(`Loading profile: ${username}`)
        try {
            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)
        } 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 } = 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 } = extractDataAndRemoveScripts(2)
            if (!secrets) {
                console.log("Failed to extract secrets")
                return
            }

            run({ secrets, profileId })
        })

        return
    }

    run(secrets)
})()