哔哩哔哩主页 IP 属地

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

// ==UserScript==
// @name         哔哩哔哩主页 IP 属地
// @namespace    https://maxchang.me
// @version      0.0.5
// @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
    }
}

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

const injectLocation = (
    /** @type {string} */ location,
    /** @type {HTMLDivElement} */ upInfoMainElement,
    /** @type {string} */ upInfoSelector,
    /** @type {Record< string, string>} */ overrideStyle = {}
) => {
    const upInfoTopElement = upInfoMainElement.querySelector(upInfoSelector)
    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',
        verticalAlign: 'middle',
        display: 'inline-block',
        ...overrideStyle,
    })

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

if (hasToken)
    logger.log(
        '已获取 Access Key',
        accessKey.substring(0, 4) + '****' + accessKey.substring(accessKey.length - 4)
    )

GM.registerMenuCommand(
    `${hasToken ? '【✅ 已获取】' : '【❌ 未获取】'}获取 Access Key`,
    async () => {
        await acquireAccessKey()
    }
)
const main = async () => {
    const biliMainHeader = await GmExtra.querySelector(document.body, '#biliMainHeader')
    const isFreshSpace = biliMainHeader?.tagName === 'HEADER'

    const appElement = await GmExtra.querySelector(document.body, '#app')

    if (!appElement) {
        logger.error('未找到 #app 元素')
        return
    }

    const upInfoMainSelector = isFreshSpace ? '.upinfo__main' : '.h-inner'
    const upInfoSelector = isFreshSpace ? '.upinfo-detail__top' : '.h-basic div'

    // 等待 Header 中的信息加载出来
    const upInfoMainElement = await GmExtra.querySelector(appElement, upInfoMainSelector)

    if (!hasToken) {
        requireAccessKey()
        return
    }

    if (!upInfoMainElement) {
        logger.error('未找到 UP 主信息元素')
        return
    }

    const URLWithoutQuery = window.location.origin + window.location.pathname
    const vmidMatch = URLWithoutQuery.match(/space\.bilibili\.com\/(\d+)(?:\/|$)/)

    if (!vmidMatch || vmidMatch.length < 2) {
        logger.error('未找到 vmid', window.location.href)
        return
    }

    const vmid = vmidMatch[1]

    const location = await getLocation(vmid)

    if (!location) return

    logger.log(`获取 ${vmid}  IP 属地成功`, location)

    injectLocation(
        location,
        /** @type {HTMLDivElement} */ (upInfoMainElement),
        upInfoSelector,
        isFreshSpace
            ? {}
            : {
                  padding: '0 5px',
                  marginLeft: '5px',
              }
    )
}

main()