哔哩哔哩主页 IP 属地

在哔哩哔哩主页显示 IP 属地。仅支持显示个人主页。为了获得完整体验,建议配合使用 哔哩哔哩网页版显示 IP 属地(https://greasyfork.org/scripts/466815)脚本。

当前为 2025-05-03 提交的版本,查看 最新版本

// ==UserScript==
// @name         哔哩哔哩主页 IP 属地
// @namespace    https://maxchang.me
// @version      0.0.1
// @description  在哔哩哔哩主页显示 IP 属地。仅支持显示个人主页。为了获得完整体验,建议配合使用 哔哩哔哩网页版显示 IP 属地(https://greasyfork.org/scripts/466815)脚本。
// @author       maxchang3
// @match        https://space.bilibili.com/*
// @icon         https://www.bilibili.com/favicon.ico
// @grant        GM.registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM.xmlHttpRequest
// @grant        unsafeWindow
// @require      https://update.greasyfork.org/scripts/400945/1055319/libBilibiliToken.js
// @require      https://fastly.jsdelivr.net/npm/[email protected]
// @run-at       document-idle
// @license      MIT
// ==/UserScript==
/// <reference path="./types/global.d.ts" />
// @ts-check
/**
 * @typedef {'log' | 'error'} LogLevel
 */

// biome-ignore format: keep type annotation
const logger = (/*** @returns {Record<LogLevel, (...args: unknown[]) => void>} */() => {
        const { name: scriptname, version: scriptversion } = GM_info.script
        /**
         * @param {LogLevel} logMethod
         * @param {string} tag
         * @param {unknown[]} args
         */
        const log = (logMethod, tag, ...args) => {
            const colors = {
                log: '#2c3e50',
                error: '#ff4500',
            }
            const fontFamily =
                "font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;"

            console[logMethod](
                `%c ${scriptname} %c v${scriptversion} %c ${tag} `,
                `padding: 2px 6px; border-radius: 3px 0 0 3px; color: #fff; background: #FF6699; font-weight: bold; ${fontFamily}`,
                `padding: 2px 6px; color: #fff; background: #FF9999; font-weight: bold; ${fontFamily}`,
                `padding: 2px 6px; border-radius: 0 3px 3px 0; color: #fff; background: ${colors[logMethod]}; font-weight: bold; ${fontFamily}`,
                ...args
            )
        }
        return {
            log: (...args) => log('log', 'LOG', ...args),
            error: (...args) => log('error', 'ERROR', ...args),
        }
    })()

const tokenClient = new BilibiliToken()

const updateAccessKey = async () => {
    const tokenData = await tokenClient.getToken()
    if (tokenData) {
        GM_setValue('aceess_key', tokenData.access_token)
        return true
    }
    logger.error('获取 token 失败')
    return false
}

const queryStringify = (/** @type {Record<string, string>} */ data) =>
    Object.entries(data)
        .map(([k, v]) => `${k}=${v}`)
        .join('&')

const getLocation = async (/** @type {string} */ vmid) => {
    if (!hasToken) {
        logger.error('请先获取 Access Key')
        return null
    }
    const params = BilibiliToken.signQuery(
        queryStringify({
            access_key: accessKey,
            appkey: BilibiliToken.appKey,
            build: tokenClient.build,
            mobi_app: tokenClient.mobiApp,
            vmid,
        })
    )

    try {
        const data = await BilibiliToken.XHR({
            GM: true,
            anonymous: true,
            method: 'GET',
            url: `https://app.bilibili.com/x/v2/space?${params}`,
            responseType: 'json',
            headers: tokenClient.headers,
        })

        if (!data?.body) {
            logger.error('获取数据失败', data)
            return null
        }

        /**
         * @type {import('./types/space').SpaceResponse}
         */
        const spaceResponse = data.body
        if (spaceResponse.code !== 0) {
            logger.error('获取数据失败', spaceResponse)
            return null
        }

        const locationCards = spaceResponse.data.card.space_tag.filter(
            (tag) => tag.type === 'location'
        )
        if (locationCards.length === 0) {
            logger.error('该 UP 主无 IP 属地')
            return null
        }

        return locationCards[0].title
    } catch (error) {
        logger.error('请求出错', error)
        return null
    }
}

const injectLocation = (
    /** @type {string} */ location,
    /** @type {HTMLDivElement} */ upInfoMainElement
) => {
    const upInfoTopElement = upInfoMainElement.querySelector(
        '.upinfo-detail__top'
    )
    if (!upInfoTopElement) {
        logger.error('未找到 UP 主信息元素')
        return
    }

    const locationElement = document.createElement('div')

    Object.assign(locationElement.style, {
        color: '#fff',
        fontSize: '10px',
        backgroundColor: 'rgba(0, 0, 0, 0.5)',
        borderRadius: '4px',
        padding: '.4em',
        marginLeft: '.4em',
        display: 'inline-block',
    })

    locationElement.className = 'location'
    locationElement.innerText = location
    upInfoTopElement.appendChild(locationElement)
}

logger.log('脚本加载完成')

const acquireAccessKey = async () => {
    try {
        const success = await updateAccessKey()
        if (success) {
            alert('获取 Access Key 成功')
        } else {
            alert(
                '获取 Access Key 失败。若首次使用,可能会导致账号下线,请刷新后尝试重新登录后刷新重新获取。'
            )
            window.location.reload()
        }
        return success
    } catch (err) {
        logger.error('获取 Access Key 出错', err)
        alert('获取过程出错,请稍后重试')
        return false
    }
}

const accessKey = GM_getValue('aceess_key')
const hasToken = Boolean(accessKey)

const requireAccessKey = async () => {
    if (!unsafeWindow.__BiliUser__.isLogin) {
        logger.error('未登录,无法获取 Access Key')
    } else {
        const confirmMessage =
            `[${GM_info.script.name}] 未获取 Access Key,需要获取才能正常使用\n\n` +
            '首次获取可能会导致账号下线,需要登录后再次获取\n' +
            '是否立即获取?'
        if (confirm(confirmMessage)) {
            acquireAccessKey().then((success) => {
                if (success) window.location.reload()
            })
        }
    }
}

if (hasToken) logger.log('已获取 Access Key', accessKey)

GM.registerMenuCommand(
    `${hasToken ? '【✅ 已获取】' : '【❌ 未获取】'}获取 Access Key`,
    async () => {
        await acquireAccessKey()
    }
)

// 等待 Header 中的信息加载出来
GmExtra.querySelector(document.body, '.upinfo__main').then(
    async (upInfoMainElement) => {
        if (!hasToken) {
            requireAccessKey()
            return
        }
        if (!upInfoMainElement) {
            logger.error('未找到 UP 主信息元素')
            return
        }
        const vmidMatch = window.location.href.match(
            /space\.bilibili\.com\/(\d+)(?:\/|$)/
        )
        if (!vmidMatch) {
            logger.error('未找到 vmid', window.location.href)
            return
        }
        if (!hasToken) return
        const location = await getLocation(vmidMatch[1])
        if (!location) {
            logger.error('获取 IP 属地失败')
            return
        }

        logger.log('获取 IP 属地成功', location)
        injectLocation(
            location,
            /** @type {HTMLDivElement} */ (upInfoMainElement)
        )
    }
)