[雪喵空友列] QQ 空间一键获取自己的好友列表

[雪喵空友列] 一键导出下载 QQ 好友列表到 JSON、TSV、CSV Excel 进行管理,或作为 .url 链接放到桌面或使用 Everything、Listary 等以快速批量打开好友的聊天窗口。本项目仅为学习研究使用,请保管好自己的个人数据,注意隐私安全。使用方法:登录 https://user.qzone.qq.com/ ,在顶栏获取好友列表。

// ==UserScript==
// @name         [雪喵空友列] QQ 空间一键获取自己的好友列表
// @namespace    https://userscript.snomiao.com/
// @version      1.2.0-(20200714)
// @description  [雪喵空友列] 一键导出下载 QQ 好友列表到 JSON、TSV、CSV Excel 进行管理,或作为 .url 链接放到桌面或使用 Everything、Listary 等以快速批量打开好友的聊天窗口。本项目仅为学习研究使用,请保管好自己的个人数据,注意隐私安全。使用方法:登录 https://user.qzone.qq.com/ ,在顶栏获取好友列表。
// @supportURL   https://github.com/snomiao/SNOMIAO-QZFriends.user.js
// @author       snomiao@gmail.com
// @match        *://user.qzone.qq.com/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.5.0/jszip.min.js
// @grant        none
// @noframes
// ==/UserScript==
// v1.1 (20200714) 修复 uin 匹配问题
// v1.0 (20200713) 完成 .URL 下载功能
; (() => {
    // 常规函数定义
    const 下载URL到文件 = (url, filename = '') => {
        var a = document.createElement('a');
        a.style.display = 'none';
        a.href = url
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        a.remove()
    }
    // 进度显示
    const 自动恢复标题函数 = (函数) => async (...参数) => {
        const 原标题 = document.title;
        const 返 = await 函数(...参数)
        document.title = 原标题;
        return 返;
    }
    const 进度显示 = (正在) => {
        var 串 = `[雪喵友列] ${正在}`
        document.title = 串
        console.log(串)
    }
    // 计算 Token
    const getCookieByRegex = (regex) => (e => e && e[1] || "")(document.cookie.match(regex))
    // 用户常量
    const uin = getCookieByRegex(/\buin=o(.*?)(?=;|$)/)
    const g_tk = (() => {
        var p_skey = getCookieByRegex(/\bp_skey=(.*?)(?=;|$)/)
        var skey = getCookieByRegex(/\bskey=(.*?)(?=;|$)/)
        var rv2 = getCookieByRegex(/\brv2=(.*?)(?=;|$)/)
        var b = p_skey || skey || rv2
        var a = 5381;
        for (var c = 0, d = b.length; c < d; ++c)
            a += (a << 5) + b.charAt(c).charCodeAt();
        return a & 2147483647
    })();
    // 好友列表解析
    const 好友列表解析 = (json) => {
        进度显示("正在解析好友列表...")
        const 元 = json.data;
        const 分组名表 = Object.fromEntries(元.gpnames.map(({ gpid, gpname }) => [gpid, gpname]))
        const 好友列表 = 元.items.map(
            ({ name, remark, uin, groupid }) => {
                const [Q号, 分组, 昵称, 备注] = [uin, ...[分组名表[groupid], name, remark].map(unescape)];
                return { Q号, 分组, 昵称, 备注 }
            })
        return { 分组名表, 好友列表 }
    }
    const 好友列表向TSV转换 = (json) => {
        const { 好友列表 } = 好友列表解析(json)
        进度显示("正在制作TSV表格...")
        const 输出TSV = ['Q号', '分组', '昵称', '备注'].join('\t') + '\n' +
            好友列表.map(({ Q号, 分组, 昵称, 备注 }) => [Q号, 分组, 昵称, 备注]
                // TSV 没有转义的定义,不兼容的字符只能直接删掉
                .map(e => e.toString().replace(/\t|\r|\n/g, '_')).join('\t')
            ).join('\n')
        return 输出TSV
    }
    const 好友列表向CSV转换 = (json) => {
        const { 好友列表 } = 好友列表解析(json)
        进度显示("正在制作CSV表格...")
        const 输出CSV = ['Q号', '分组', '昵称', '备注'].join(',') + '\n' +
            好友列表.map(({ Q号, 分组, 昵称, 备注 }) => [Q号, 分组, 昵称, 备注]
                .map(e => e.toString()
                    // CSV转义见 [CSV格式特殊字符转义处理_icycode的专栏-CSDN博客_csv 转义]( https://blog.csdn.net/icycode/article/details/80043956 )
                    .replace(/(.*?)("|,|\r|\n)(.*)/, (s) => '"' + s.replace(/"/g, '""') + '"')
                ).join(',')
            ).join('\n')
        return 输出CSV
    }
    // URL 文件打包下载
    const URL文件生成 = (url) => `[InternetShortcut]\nURL=${url}`
    const 好友列表向URL文件转换并作为ZIP打包并下载 = async (json) => {
        alert(
            `点击确定后,开始下载你的好友列表,一般来说在 Windows 系统的浏览器中下载的文件,解压后点击url文件出现会安全警告,请看解决方法:\n`+
            `方法1:请在解压前,对压缩包点一下右键属性,在属性下方,把安全警告勾掉,点确定,再解压即可。\n`
            `方法2:对解压后的文件夹重新压缩再解压一遍。`)
        const { 好友列表 } = 好友列表解析(json)
        // 替换掉文件名里不允许出现的特殊字符
        const 文件名安全化 = (s) => s.toString().replace(/[\<\>\:\?\$\%\^\&\*\\\/\'\"\;\|\~\t\r\n-]+/g, "-")

        const 打开QQ的URL获取 = Q号 => `tencent://message?uin=${Q号}`
        const 时间戳 = '(' + new Date().toISOString().slice(0, -4).replace(/[^\dT]/g, '').replace('T', '.') + ')'
        const 文件名 = 时间戳 + `-好友列表@${uin}.zip`

        const zip = new JSZip()
        let n = 0;
        await Promise.all(好友列表.map(async ({ Q号, 分组, 昵称, 备注 }) => {
            [Q号, 分组, 昵称, 备注] = [Q号, 分组, 昵称, 备注].map(文件名安全化)
            zip.file(`${分组}/[${备注 || ''}@${昵称}](${Q号}@qq.com).url`, URL文件生成(打开QQ的URL获取(Q号)))
            const 完成率 = n++ / 好友列表.length;
            await 进度显示(`正在打包 ${Math.ceil(完成率 * 100)}%`);
        }))
        进度显示(`正在压缩...`)
        await zip.generateAsync({ type: "blob" }).then(function (content) {
            进度显示(`压缩完成,准备下载...`)
            进度显示(`正在下载...`)
            let url = window.URL.createObjectURL(content);
            下载URL到文件(url, 文件名)
            window.URL.revokeObjectURL(url);
        })
        return ''
    }
    const 复制文本 = (content) => {
        const input = document.createElement('textarea');
        input.setAttribute('readonly', 'readonly');
        input.setAttribute('value', content);
        input.innerHTML = (content);
        input.setAttribute('style', 'position: fixed; top:0; left:0;z-index: 9999');
        document.body.appendChild(input);
        input.select();
        input.setSelectionRange(0, input.value.length);
        if (document.execCommand('copy')) {
            document.execCommand('copy');
            console.log(`长度为${content.length}的内容已复制`);
        }
        document.body.removeChild(input);
    };
    const 下载并复制文本 = (文本, 格式后缀) => {
        const 数据URL = "data:text/plain;base64," + btoa(unescape(encodeURIComponent(文本)));
        const 时间戳 = '(' + new Date().toISOString().slice(0, -4).replace(/[^\dT]/g, '').replace('T', '.') + ')'
        const 文件名 = 时间戳 + `-好友列表@${uin}` + 格式后缀
        复制文本(文本)
        if (confirm(文件名 + " 内容已复制,是否下载为文件?")) {
            下载URL到文件(数据URL, 文件名)
        }
    }
    // 从 TX 服务器获取好友列表
    const 好友列表JSON获取 = async (g_tk, uin) => {
        进度显示("正在获取好友列表...")
        const API地址 = `https://user.qzone.qq.com/proxy/domain/r.qzone.qq.com/cgi-bin/tfriend/friend_show_qqfriends.cgi?follow_flag=1&groupface_flag=0&fupdate=1&g_tk=${g_tk}&uin=${uin}`
        return await fetch(API地址).then(async (res) => {
            const jsonp = await res.text()
            const _Callback = (json) => json
            const json = eval(jsonp)
            return json
        })
    }
    // 下面这个函数启发自: [csv 文件打开乱码,有哪些方法可以解决? - 知乎]( https://www.zhihu.com/question/21869078/answer/350728339 )
    const 加UTF8文件BOM头 = (str) => '\uFEFF' + str
    // 输出函数
    const 友列取 = async () => await 好友列表JSON获取(g_tk, uin)
    const 好友列表JSON输出 = 自动恢复标题函数(async () => 下载并复制文本(JSON.stringify(await 友列取()), ".json"))
    const 好友列表TSV输出 = 自动恢复标题函数(async () => 下载并复制文本(好友列表向TSV转换(await 友列取()), ".tsv"))
    const 好友列表CSV输出 = 自动恢复标题函数(async () => 下载并复制文本(加UTF8文件BOM头(好友列表向CSV转换(await 友列取())), ".csv"))
    const 好友列表EXCELCSV输出 = 自动恢复标题函数(async () => 下载并复制文本(加UTF8文件BOM头(好友列表向CSV转换(await 友列取())), ".csv"))
    const 好友列表ZIP输出 = 自动恢复标题函数(async () => 好友列表向URL文件转换并作为ZIP打包并下载(await 友列取()))
    // UI 定义
    const 新元素 = (innerHTML, attributes = {}) => {
        const e = document.createElement("div");
        e.innerHTML = innerHTML;
        return Object.assign(e.children[0], attributes)
    }
    const 按钮向页面插入 = () => {
        // 在“个人中心”按钮前插入一个按钮
        const 获取好友列表控件 = 新元素(`
            <li class="nav-list" id="tb_friendlist_li">
                <div class="nav-list-inner">
                    ☑👫📜好友列表
                    :<a onclick="好友列表JSON输出()" href="javascript:" style="padding: 0rem" title="复制并下载JSON格式">.JSON</a>
                    /<a onclick="好友列表TSV输出()" href="javascript:" style="padding: 0rem" title="复制并下载TSV格式">.TSV</a>
                    /<a onclick="好友列表CSV输出()" href="javascript:" style="padding: 0rem" title="复制并下载CSV格式(utf-8)">.CSV</a>
                    /<a onclick="好友列表EXCELCSV输出()" href="javascript:" style="padding: 0rem" title="复制并下载 Excel 可直接打开的CSV格式" >.CSV(Excel)</a>
                    /<a onclick="好友列表ZIP输出()" href="javascript:" style="padding: 0rem" title="下载按目录划分的 .url 文件为 .zip包" >.URL.ZIP</a>
                </div>
            </li>`)
        const 好友菜单按钮 = document.querySelector('#tb_friend_li')
        好友菜单按钮 && 好友菜单按钮.parentNode.insertBefore(获取好友列表控件, 好友菜单按钮)
        window.好友列表JSON输出 = () => 好友列表JSON输出()
        window.好友列表TSV输出 = () => 好友列表TSV输出()
        window.好友列表CSV输出 = () => 好友列表CSV输出()
        window.好友列表EXCELCSV输出 = () => 好友列表EXCELCSV输出()
        window.好友列表ZIP输出 = () => 好友列表ZIP输出()
    }

    // 生成界面
    if (location.hostname == 'user.qzone.qq.com') {
        按钮向页面插入()
    }
})()