// ==UserScript==
// @name QQ群成员列表导出工具
// @namespace http://tampermonkey.net/
// @version 1.0.1
// @description 将QQ群成员的列表存储为csv格式文件
// @author 御琪幽然
// @match https://qun.qq.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=qq.com
// @grant none
// @run-at document-end
// ==/UserScript==
/**
* 参考文章:https://www.lanol.cn/post/253.html
* 使用教程视频:https://www.bilibili.com/video/BV1QK4y1C7ZU
* 发布于:https://greasyfork.org/zh-CN
* 开源于:https://github.com
* 用于:https://qun.qq.com/#/member-manage/base-manage
*
*/
(function () {
// 0.如果当前的网页链接不为targetURL,则不执行脚本
// let targetURL = "https://qun.qq.com/qun-manage/#/member-manage/base-manage";
// 在不知道什么时候换了新的网址
let targetURL = "https://qun.qq.com/#/member-manage/base-manage";
if (window.location.href != targetURL) {
console.log("当前网页链接不为" + targetURL + ",脚本不执行");
alert("当前网页链接不为" + targetURL + ",脚本不执行");
return;
}
console.log("当前网页链接为" + targetURL + ",脚本开始执行");
// 1.变量定义
let isLogDebug = true;
// 2.函数定义
function consoleLog(message) {
if (isLogDebug == false) {
return;
}
console.log(message);
}
function getSkey() {
let e = "skey";
const t = document.cookie.match(new RegExp(`(^| )${e}=([^;]*)(;|$)`));
// 如果t不为null,则返回t[2],否则返回空字符串
return t ? decodeURIComponent(t[2]) : '';
}
/**
* 生成发送请求需要的bkn参数
*/
function generateBKN() {
let e = getSkey(); // 类似于@xCmnZlnC6
let t = 5381;
for (let n = 0, r = e.length; n < r; ++n) {
t += (t << 5) + e.charAt(n).charCodeAt(0);
}
return String(t & 2147483647)
};
/**
* 将Date对象转换成yyyy-MM-dd HH-mm-ss格式的字符串
* @param {Date} date
* @returns
*/
function convertDateToString(date) {
return date.getFullYear()
+ "-" + (date.getMonth() + 1)
+ "-" + date.getDate() + " " + date.getHours()
+ "-" + date.getMinutes() + "-" + date.getSeconds();
}
/**
* 获取群成员信息
* @param {*} gc QQ群号
* @param {*} st 开始的索引
* @param {*} end 结束的索引(与开始的索引最大值不能相差40以上)
* @param {*} sort 排序方式
* @param {*} bkn bkn参数
* @returns
*/
function get_members(gc, st, end, sort, bkn) {
let url = "https://qun.qq.com/cgi-bin/qun_mgr/search_group_members";
let data = `gc=${gc}&st=${st}&end=${end}&sort=${sort}&bkn=${bkn}`;
let result = fetch(url, {
credentials: "include",
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.0",
Accept: "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
},
referrer: "https://qun.qq.com/member.html",
body: data,
method: "POST",
mode: "cors",
})
.then((response) => response.json())
.then((data) => {
consoleLog("data内容为:")
consoleLog(data)
consoleLog("==========")
if (isLogDebug) {
// consoleLog(data);
data.mems.forEach(function (item) {
consoleLog(combineTextFromItem(item));
});
}
return data;
});
consoleLog("result内容为:")
consoleLog(result)
consoleLog("==========")
return result;
}
/**
* 将item内的信息拼接成文本
* @param {*} item
* @returns
*/
function combineTextFromItem(item) {
// 0为群主,1为管理员,2为普通成员
let role = item.role == 0 ? "群主" : item.role == 1 ? "管理员" : "普通成员";
// 0为男,1为女,未知为-1
let g = item.g == 0 ? "男" : item.g == 1 ? "女" : "未知";
let jt = item.join_time == 0 ? "未知" : new Date(item.join_time * 1000).toLocaleString();
let lst = item.last_speak_time == 0 ? "未曾发言" : new Date(item.last_speak_time * 1000).toLocaleString();
// 将item.nick和item.card中的转义文本修改为正常文本
item.nick = item.nick.replace(/&/g, "&");
item.nick = item.nick.replace(/</g, "<");
item.nick = item.nick.replace(/>/g, ">");
item.nick = item.nick.replace(/"/g, "\"\"");
item.nick = item.nick.replace(/'/g, "'");
item.nick = item.nick.replace(/ /g, " ");
item.card = item.card.replace(/&/g, "&");
item.card = item.card.replace(/</g, "<");
item.card = item.card.replace(/>/g, ">");
item.card = item.card.replace(/"/g, "\"\"");
item.card = item.card.replace(/'/g, "'");
item.card = item.card.replace(/ /g, " ");
//将每个变量用英文引号包裹起来,然后用逗号连接起来,最后加上换行符
return `"${item.nick}","${item.card}","${role}","${item.uin}","${g}","${item.qage}","${jt}","${lst}"\n`;
}
function createButton() {
// 点击按钮,调用get_members函数,把结果以csv格式保存到本地
// 编码使用utf-8 with bom,以防止乱码
// 创建type="button" class="t-button t-button--theme-primary t-button--variant-base"的button
let button = document.createElement("button");
button.type = "button";
button.className = "t-button t-button--theme-primary t-button--variant-base";
button.innerHTML = "将群成员列表存储为csv文件";
button.onclick = async function () {
// 此时禁用按钮并修改按钮文字
button.innerHTML = "正在获取群成员列表中";
button.disabled = true;
let selectedGroup = document.getElementsByClassName("_selectQun_1mksq_1 t-select-option t-is-selected")[0];
if (selectedGroup == null || selectedGroup == undefined) {
alert("请先手动选择一个群后,再点击按钮!");
button.innerHTML = "将群成员列表存储为csv文件";
button.disabled = false;
return;
}
// innerText是群名
let groupName = selectedGroup.innerText
// innerText是带括号的群号,需要去掉括号
let gc = selectedGroup.children[0].children[0].children[1].children[0].innerText.match(/(\d+)/)[1];
// 从class="t-pagination__total"的元素里获取群成员数量,它的格式为“共 xx 条”,需要提取数字
let count = document.getElementsByClassName("t-pagination__total")[0].innerText.match(/(\d+)/)[1];
let bkn = generateBKN();
// csv的标题
let csvTitle = [
"QQ昵称",
"群昵称",
"群身份",
"QQ号",
"性别",
"Q龄",
"入群时间",
"最后发言时间"
];
let csvContent = ""
//将csvTitle数组里的每个元素用制表符连接起来,然后加上换行符,存储到csvContent变量里
csvContent += csvTitle.join(",") + "\n";
/* 旧的方法
let memberList = [];
let memberCount = parseInt(count);
for (let startIndex = 0; startIndex < memberCount; startIndex += 21) {
let endIndex = Math.min(startIndex + 20, memberCount);
let result = get_members(gc, startIndex, endIndex, 0, bkn);
result.then((data) => {
// 把mems数组里所有对象放到result数组里
memberList = memberList.concat(data.mems);
});
saveButton.innerHTML = `正在获取群成员列表(${startIndex}/${memberCount}),请耐心等待`;
await new Promise(resolve => setTimeout(resolve, 200));
}
*/
let memberList = [];
let memberCount = parseInt(count);
let promises = [];
for (let startIndex = 0; startIndex < memberCount; startIndex += 21) {
let endIndex = Math.min(startIndex + 20, memberCount);
let result = get_members(gc, startIndex, endIndex, 0, bkn);
promises.push(result);
button.innerHTML = `正在获取群成员列表(${startIndex}/${memberCount}),请耐心等待`;
await new Promise(resolve => setTimeout(resolve, 200));
}
Promise.all(promises).then((results) => {
results.forEach((data) => {
memberList = memberList.concat(data.mems);
});
//改了这里↓
//遍历memberList数组,把每个对象的属性输出到csv变量里
memberList.forEach((item) => {
csvContent += combineTextFromItem(item);
});
//存储为csv文件
let blob = new Blob(["\ufeff" + csvContent], { type: "text/csv;charset=utf-8" });
let a = document.createElement("a");
a.download = "群成员列表-" + groupName + "-" + convertDateToString(new Date()) + ".csv";
a.href = URL.createObjectURL(blob);
a.click();
//将标题切换回去
button.innerHTML = "将群成员列表存储为csv文件";
button.disabled = false;
//改了这里↑
}).catch((error) => {
console.error(error);
});
};
// 创建class="t-button__text" style="z-index: 1;"的span
let span = document.createElement("span");
span.className = "t-button__text";
span.style.zIndex = "1";
// 将span添加到button里
button.appendChild(span);
return button;
}
/**
* 创建容器元素
*/
function createElement(headerPanel) {
// 创建一个class="t-col t-col-12 t-col-offset-0 t-col-pull-0 t-col-push-0 t-col-order-0" style="padding-left: 4px; padding-right: 4px;"的div
let div1 = document.createElement("div");
div1.className = "t-col t-col-12 t-col-offset-0 t-col-pull-0 t-col-push-0 t-col-order-0";
div1.style.paddingLeft = "4px";
div1.style.paddingRight = "4px";
// 创建一个class="t-form__item t-form-item__groupId" style="width: 100%; max-width: 580px;"的div
let div2 = document.createElement("div");
div2.className = "t-form__item t-form-item__groupId";
div2.style.width = "100%";
div2.style.maxWidth = "580px";
// 创建一个class="t-form__label t-form__label--right" style="width: 100px;"的div
let labelDiv = document.createElement("div");
labelDiv.className = "t-form__label t-form__label--right";
labelDiv.style.width = "100px";
// 创建一个label并添加到labelDiv里
let label = document.createElement("label");
label.innerHTML = "脚本功能";
labelDiv.appendChild(label);
// 创建button
let button = createButton();
// 将labelDiv添加到div2里
div2.appendChild(labelDiv);
// 将button添加到div2里
div2.appendChild(button);
// 将div2添加到div1里
div1.appendChild(div2);
// 将div1添加到headerPanel的最前面
headerPanel.insertBefore(div1, headerPanel.firstChild);
}
// 3.执行功能
consoleLog("脚本开始执行");
// 顶部面板元素
let headerPanel;
// 等到headerPanel的元素加载完毕后,再添加元素
let checkheaderPanelExist = setInterval(function () {
if (headerPanel != null && headerPanel != undefined) {
createElement(headerPanel);
clearInterval(checkheaderPanelExist);
}
else {
headerPanel = document.querySelector("#app > div > div > div.t-layout._sideContainer_199np_6 > main > main > div > div > div._defaultLayout_1866x_1._powerDesignBlock_zj60n_1._powerDesignBlock_zj60n_1 > section > form > div")
}
}, 200);
})();