Greasy Fork

IMDb TMDB Linker

Opens the corresponding IMDB/TMDB/Letterboxd movie/tv page in just one click. Also adds the ability to see IMDB ratings on TMDB and Letterboxd pages.

目前为 2024-06-11 提交的版本。查看 最新版本

// ==UserScript==
// @name         IMDb TMDB Linker
// @description  Opens the corresponding IMDB/TMDB/Letterboxd movie/tv page in just one click. Also adds the ability to see IMDB ratings on TMDB and Letterboxd pages.
// @author       Tetrax-10
// @namespace    https://github.com/Tetrax-10/imdb-tmdb-linker
// @version      1.2
// @license      MIT
// @match        *://*.imdb.com/title/tt*
// @match        *://*.themoviedb.org/movie/*
// @match        *://*.themoviedb.org/tv/*
// @match        *://*.letterboxd.com/film/*
// @connect      imdb.com
// @connect      themoviedb.org
// @homepageURL  https://github.com/Tetrax-10/imdb-tmdb-linker
// @supportURL   https://github.com/Tetrax-10/imdb-tmdb-linker/issues
// @icon         https://tetrax-10.github.io/imdb-tmdb-linker/assets/icon.png
// @run-at       document-end
// @grant        GM_xmlhttpRequest
// ==/UserScript==

;(function () {
    const tmdbApi = "YOUR_TMDB_API_KEY"

    const imdbCss = `
#linker-parent {
    display: flex;
    align-self: center;
}
#linker-letterboxd-a {
    align-self: center;
}
#linker-letterboxd {
    display: flex;
    height: 30px;
    border-radius: 4px;
}
#linker-divider {
    border-left: 3px solid rgba(232, 230, 227, 0.5);
    height: 25px;
    border-radius: 10px;
    margin-left: 10px;
    align-self: center;
}
#linker-loading {
    height: 20px;
    align-self: center;
    text-align: center;
    margin-left: 10px;
    margin-right: 40px;
}
#linker-tmdb-link {
    height: 26px;
    width: 70px;
    background: #022036 !important;
    color: #51b4ad !important;
    border: solid #51b4ad 2px !important;
    border-radius: 6px;
    align-self: center;
    margin-left: 10px;
    margin-right: 20px;
    font-weight: bold;
    text-align: center;
}
@media only screen and (max-width: 767px) {
    #linker-loading {
        margin-right: 6px;
    }
    #linker-tmdb-link {
        width: 48px;
        margin-left: 10px;
        margin-right: 10px;
        font-size: smaller;
    }
}    
`
    const tmdbCss = `
#linker-parent {
    margin-top: 20px;
    display: flex;
    align-items: flex-start;
}
#linker-imdb-svg-bg {
    fill: #c59f00 !important;
}
#linker-divider {
    border-left: 2px solid rgba(232, 230, 227, 0.5);
    height: 20px;
    border-radius: 10px;
    margin-left: 10px;
}
#linker-letterboxd {
    height: 22px;
    border-radius: 4px;
}
#linker-loading {
    height: 20px;
    margin-left: 10px;
}
#linker-imdb-container {
    display: flex;
    align-items: flex-start;
    margin-left: 10px;
}
#linker-imdb-rating {
    margin-left: 10px;
}
html.k-mobile #linker-parent {
    margin-top: unset;
    margin-left: auto;
    margin-right: auto;
}    
`

    const letterboxdCss = `
#linker-loading {
    height: 14px;
    margin-left: 4px;
}
`
    async function waitForElement(selector, timeout = null, nthElement = 1) {
        nthElement -= 1

        return new Promise((resolve) => {
            if (document.querySelectorAll(selector)?.[nthElement]) {
                return resolve(document.querySelectorAll(selector)?.[nthElement])
            }

            const observer = new MutationObserver(async () => {
                if (document.querySelectorAll(selector)?.[nthElement]) {
                    resolve(document.querySelectorAll(selector)?.[nthElement])
                    observer.disconnect()
                } else {
                    if (timeout) {
                        async function timeOver() {
                            return new Promise((resolve) => {
                                setTimeout(() => {
                                    observer.disconnect()
                                    resolve(false)
                                }, timeout)
                            })
                        }
                        resolve(await timeOver())
                    }
                }
            })

            observer.observe(document.body, {
                childList: true,
                subtree: true,
            })
        })
    }

    async function getImdbRating(imdbId) {
        if (!imdbId) return [undefined, undefined]

        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: `https://www.imdb.com/title/${imdbId}/ratings`,
                onload: function (response) {
                    const parser = new DOMParser()
                    const dom = parser.parseFromString(response.responseText, "text/html")

                    const rating = dom.querySelector(`div[data-testid="rating-button__aggregate-rating__score"] > span`)?.innerText
                    const numRating = dom.querySelector(`div[data-testid="rating-button__aggregate-rating__score"] + div`)?.innerText

                    resolve([rating, numRating])
                },
                onerror: function (error) {
                    console.error("Request failed:", error)
                },
            })
        })
    }

    function injectCSS(css) {
        const style = document.createElement("style")
        style.appendChild(document.createTextNode(css))
        document.head.appendChild(style)
    }

    const imdbUtils = (() => {
        function createParentElement() {
            const parentElement = document.createElement("div")
            parentElement.id = "linker-parent"

            return parentElement
        }

        function createLetterboxdElement(imdbId) {
            const letterboxdElement = document.createElement("a")
            letterboxdElement.id = "linker-letterboxd-a"
            letterboxdElement.href = `https://letterboxd.com/imdb/${imdbId}/`
            letterboxdElement.target = "_blank"

            const letterboxdImage = document.createElement("img")
            letterboxdImage.id = "linker-letterboxd"
            letterboxdImage.src = "https://tetrax-10.github.io/imdb-tmdb-linker/assets/letterboxd.png"

            letterboxdElement.appendChild(letterboxdImage)

            return letterboxdElement
        }

        function createDivider() {
            const divider = document.createElement("div")
            divider.id = "linker-divider"

            return divider
        }

        function createLoadingElement() {
            const loadingElement = document.createElement("img")
            loadingElement.id = "linker-loading"
            loadingElement.src = "https://tetrax-10.github.io/imdb-tmdb-linker/assets/loading.gif"

            return loadingElement
        }

        function createTmdbButtonElement(tmdbId) {
            const tmdbElement = document.createElement("a")
            tmdbElement.id = "linker-tmdb-link"
            tmdbElement.target = "_blank"
            tmdbElement.innerText = "TMDB"

            if (tmdbId["media_type"] !== "tv_episode") {
                tmdbElement.href = `https://www.themoviedb.org/${tmdbId["media_type"]}/${tmdbId.id}`
            } else {
                tmdbElement.href = `https://www.themoviedb.org/tv/${tmdbId["show_id"]}/season/${tmdbId["season_number"]}/episode/${tmdbId["episode_number"]}`
            }

            return tmdbElement
        }

        return {
            element: {
                parent: createParentElement,
                letterboxd: createLetterboxdElement,
                divider: createDivider,
                loading: createLoadingElement,
                tmdbButton: createTmdbButtonElement,
            },
        }
    })()

    async function imdb() {
        const isMobile = location.host.includes("m.imdb")

        const path = location.pathname.split("/")
        const imdbId = path[2] || null

        if (imdbId) {
            const parentElement = imdbUtils.element.parent()
            const letterboxdElement = imdbUtils.element.letterboxd(imdbId)
            const dividerElement = imdbUtils.element.divider()
            const loadingElement = imdbUtils.element.loading()

            waitForElement("div:has( > div[data-testid='hero-rating-bar__user-rating'])", 10000, isMobile ? 2 : 1).then((location) => {
                location.insertBefore(parentElement, location.firstChild)
                parentElement.appendChild(letterboxdElement)
                parentElement.appendChild(dividerElement)
                parentElement.appendChild(loadingElement)
            })

            const tmdbRawRes = await fetch(`https://api.themoviedb.org/3/find/${imdbId}?api_key=${tmdbApi}&external_source=imdb_id`)
            const tmdbRes = await tmdbRawRes.json()
            const tmdbData = tmdbRes["movie_results"]?.[0] || tmdbRes["tv_results"]?.[0] || tmdbRes["tv_episode_results"]?.[0]

            if (tmdbData) {
                const imdbElement = imdbUtils.element.tmdbButton(tmdbData)
                parentElement.removeChild(loadingElement)
                parentElement.appendChild(imdbElement)
            } else {
                parentElement.removeChild(dividerElement)
                parentElement.removeChild(loadingElement)
            }
        }
    }

    const ImdbSvg = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83" width="40" height="20"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use id="linker-imdb-svg-bg" xlink:href="#d1pwhf9wy2" opacity="1" fill="#c59f00" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000 !important" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000 !important" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000 !important" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000 !important" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg>` // prettier-ignore

    const tmdbUtils = (() => {
        function createParentElement() {
            const parentElement = document.createElement("div")
            parentElement.id = "linker-parent"

            return parentElement
        }

        function createLetterboxdElement(tmdbId, type) {
            const letterboxdElement = document.createElement("a")
            letterboxdElement.href = `https://letterboxd.com/tmdb/${tmdbId}/${type === "tv" ? "tv" : ""}`
            letterboxdElement.target = "_blank"

            const letterboxdImage = document.createElement("img")
            letterboxdImage.id = "linker-letterboxd"
            letterboxdImage.src = "https://tetrax-10.github.io/imdb-tmdb-linker/assets/letterboxd.png"

            letterboxdElement.appendChild(letterboxdImage)

            return letterboxdElement
        }

        function createDivider() {
            const divider = document.createElement("div")
            divider.id = "linker-divider"

            return divider
        }

        function createLoadingElement() {
            const loadingElement = document.createElement("img")
            loadingElement.id = "linker-loading"
            loadingElement.src = "https://tetrax-10.github.io/imdb-tmdb-linker/assets/loading.gif"

            return loadingElement
        }

        function createImdbContainer() {
            const imdbContainer = document.createElement("div")
            imdbContainer.id = "linker-imdb-container"

            return imdbContainer
        }

        function createImdbLinkElement(imdbId, svg) {
            const link = document.createElement("a")
            link.href = `https://imdb.com/title/${imdbId}`
            link.target = "_blank"
            link.innerHTML = svg

            return link
        }

        function createImdbRatingElement(rating, numRatings) {
            const text = rating !== undefined ? `${rating}${numRatings !== undefined ? ` ( ${numRatings} )` : ""}` : null

            const ratingElement = document.createElement("div")
            ratingElement.id = "linker-imdb-rating"
            ratingElement.innerText = text

            if (text) {
                return ratingElement
            } else {
                return null
            }
        }

        return {
            element: {
                parent: createParentElement,
                letterboxd: createLetterboxdElement,
                divider: createDivider,
                loading: createLoadingElement,
                imdbContainer: createImdbContainer,
                imdbLink: createImdbLinkElement,
                imdbRating: createImdbRatingElement,
            },
        }
    })()

    async function tmdb() {
        const isMobile = document.querySelector("html.k-mobile")
        const path = location.pathname.split("/")
        const tmdbId = path[2].match(/\d+/)?.[0] || null

        if (tmdbId) {
            const parentElement = tmdbUtils.element.parent()
            const letterboxdElement = tmdbUtils.element.letterboxd(tmdbId, path[1])
            const divider = tmdbUtils.element.divider()
            const imdbContainer = tmdbUtils.element.imdbContainer()
            const loadingElement = tmdbUtils.element.loading()

            waitForElement(`.header.poster${isMobile ? " > .title" : ""}`, 10000).then((location) => {
                if (isMobile) {
                    location.insertBefore(parentElement, location?.firstChild?.nextSibling?.nextSibling)
                } else {
                    location.appendChild(parentElement)
                }

                parentElement.appendChild(letterboxdElement)
                parentElement.appendChild(divider)
                parentElement.appendChild(imdbContainer)
                imdbContainer.appendChild(loadingElement)
            })

            // fetch imdb id
            const tmdbRawRes = await fetch(`https://api.themoviedb.org/3/${path[1]}/${tmdbId}/external_ids?api_key=${tmdbApi}`)
            if (tmdbRawRes.status !== 200) return
            const tmdbRes = await tmdbRawRes.json()

            const imdbId = tmdbRes["imdb_id"] || null
            if (!imdbId) {
                parentElement.removeChild(divider)
                parentElement.removeChild(imdbContainer)
                return
            }

            // inject imdb link
            const imdbLink = tmdbUtils.element.imdbLink(imdbId, ImdbSvg)
            imdbContainer.insertBefore(imdbLink, loadingElement)

            // inject imdb rating
            const [imdbRating, imdbNumRating] = await getImdbRating(imdbId)
            const imdbRatingElement = tmdbUtils.element.imdbRating(imdbRating, imdbNumRating)
            imdbContainer.removeChild(loadingElement)
            if (!imdbRatingElement) return
            imdbContainer.appendChild(imdbRatingElement)
        }
    }

    function letterboxd() {
        waitForElement(`.micro-button.track-event[data-track-action="IMDb"]`, 10000).then(async (element) => {
            const originalDisplay = element.style.display

            // add loading element
            const loadingElement = tmdbUtils.element.loading()
            element.style.display = "inline-flex"
            element.appendChild(loadingElement)

            // fetch imdb id and get ratings
            const imdbId = element.href?.match(/\/title\/(tt\d+)\/?/)?.[1] ?? null
            const [imdbRating, imdbNumRating] = await getImdbRating(imdbId)

            // remove loading element
            element.removeChild(loadingElement)
            element.style.display = originalDisplay

            // update element
            element.innerText = `IMDB${imdbRating ? ` | ${imdbRating}` : ""}${imdbNumRating !== undefined ? ` (${imdbNumRating})` : ""}`
        })
    }

    const currentURL = window.location.protocol + "//" + window.location.hostname + window.location.pathname

    if (/^(https?:\/\/[^.]+\.imdb\.com\/title\/tt[^\/]+(?:\/\?.*)?\/?)$/.test(currentURL)) {
        injectCSS(imdbCss)
        imdb()
    }
    if (/^(https?:\/\/[^.]+\.themoviedb\.org\/(movie|tv)\/\d[^\/]+(?:\/\?.*)?\/?)$/.test(currentURL)) {
        injectCSS(tmdbCss)
        tmdb()
    }
    if (/^(https?:\/\/letterboxd\.com\/film\/[^\/]+(?:\/\?.*)?\/?(crew|details|genres)?)$/.test(currentURL)) {
        injectCSS(letterboxdCss)
        letterboxd()
    }
})()