// ==UserScript==
// @name 剧本杀活动通知生成器
// @namespace https://github.com/heiyexing
// @version 2024-02-18
// @description 用于获取本周剧本杀活动信息并生成 Markdown 代码
// @author 炎熊
// @match https://yuque.antfin-inc.com/yuhmb7/pksdw8/**
// @match https://yuque.antfin.com/yuhmb7/pksdw8/**
// @icon https://www.google.com/s2/favicons?sz=64&domain=antfin-inc.com
// @require https://cdn.bootcdn.net/ajax/libs/dayjs/1.11.9/dayjs.min.js
// @require https://cdn.bootcdn.net/ajax/libs/dayjs/1.11.9/plugin/isSameOrAfter.js
// @require https://cdn.bootcdn.net/ajax/libs/dayjs/1.11.9/plugin/isSameOrBefore.js
// @require https://cdn.bootcdn.net/ajax/libs/dayjs/1.11.9/locale/zh-cn.min.js
// @require https://cdn.bootcdn.net/ajax/libs/layui/2.8.17/layui.min.js
// @run-at document-end
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
dayjs.locale(dayjs_locale_zh_cn);
dayjs.extend(dayjs_plugin_isSameOrAfter);
dayjs.extend(dayjs_plugin_isSameOrBefore);
const BTN_ID = 'murder-mystery-btn';
const USER_LIST_CLASS_NAME = 'murder-user-list';
const USER_ITEM_CLASS_NAME = 'murder-user-item';
let timeRange = [dayjs().startOf('week'), dayjs().endOf('week')];
function initStyle() {
const style = document.createElement('style');
style.innerHTML = `
#${BTN_ID} {
position: fixed;
bottom: 25px;
right: 80px;
width: 40px;
height: 40px;
background-color: #fff;
border-radius: 50%;
box-shadow: 0 0 10px rgba(0, 0, 0, .2);
cursor: pointer;
display: inline-flex;
justify-content: center;
align-items: center;
z-index: 2;
}
#${BTN_ID} img {
width: 20px;
}
.${USER_LIST_CLASS_NAME} {
display: flex;
flex-wrap: wrap;
}
.${USER_ITEM_CLASS_NAME} {
margin-right: 12px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
line-height: 14px;
border-radius: 6px;
padding: 6px;
border: 1px solid #E7E9E8;
}
.${USER_ITEM_CLASS_NAME}.unchecked {
border-color: #ff0000;
}
.${USER_ITEM_CLASS_NAME} span {
white-space: nowrap;
}
.${USER_ITEM_CLASS_NAME} img {
width: 30px;
height: 30px;
border-radius: 30px;
margin-right: 6px;
}
.layui-card-body {
width: 100%;
}
.layui-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
`;
const link = document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
link.href =
'https://cdn.bootcdn.net/ajax/libs/layui/2.8.17/css/layui.min.css';
document.head.appendChild(style);
document.head.appendChild(link);
return style;
}
function initBtn() {
const btn = document.createElement('div');
btn.id = BTN_ID;
const logo = document.createElement('img');
logo.src =
'https://mdn.alipayobjects.com/huamei_baaa7a/afts/img/A*f8MvQYdbHPoAAAAAAAAAAAAADqSCAQ/original';
btn.appendChild(logo);
document.body.appendChild(btn);
return btn;
}
function getTitleInfo(title) {
const month = title.match(/\d+(?=\s*月)/)?.[0];
const date = title.match(/\d+(?=\s*日)/)?.[0];
const name = title.match(/(?<=《).*?(?=》)/)?.[0];
if (!month || !date || !name) {
return null;
}
return {
month: +month,
date: +date,
name,
};
}
function getRegExpStr(strList, regexp) {
for (const str of strList) {
const result = str.match(regexp);
if (result) {
return result[0].trim();
}
}
return '';
}
function exeCommandCopyText(text) {
try {
const t = document.createElement('textarea');
t.nodeValue = text;
t.value = text;
document.body.appendChild(t);
t.select();
document.execCommand('copy');
document.body.removeChild(t);
return true;
} catch (e) {
console.log(e);
return false;
}
}
function getInnerText(content) {
const div = document.createElement('div');
div.style = 'height: 0px; overflow: hidden;';
div.innerHTML = content;
document.body.appendChild(div);
return div.innerText;
}
async function getActivesInfo(start, end) {
if (!window.appData || !Array.isArray(window.appData?.book.toc)) {
return;
}
const tocList = window.appData?.book.toc;
const pathList = location.pathname.split('/');
if (pathList.length <= 0) {
return;
}
const docUrl = pathList[pathList.length - 1];
const currentToc = tocList.find((item) => item.url === docUrl);
if (!currentToc) {
return;
}
const parentToc = tocList.find(
(item) => item.uuid === currentToc.parent_uuid,
);
if (!parentToc) {
return;
}
const targetTocList = tocList.filter(
(item) => item.parent_uuid === parentToc.uuid,
);
const targetTimeRangeList = targetTocList
.map((item) => {
const titleInfo = getTitleInfo(item.title);
if (!titleInfo) {
return item;
}
return {
...item,
...titleInfo,
dayjs: dayjs()
.set('month', titleInfo.month - 1)
.set('date', titleInfo.date),
};
})
.filter((item) => {
return (
item.dayjs.isSameOrAfter(start, 'date') &&
item.dayjs.isSameOrBefore(end, 'date')
);
})
.sort((a, b) => a.dayjs - b.dayjs);
return await Promise.all(
targetTimeRangeList.map((item) => {
return fetch(
`${location.origin}/api/docs/${item.url}?book_id=${window.appData?.book.id}&include_contributors=true&include_like=true&include_hits=true&merge_dynamic_data=false`,
)
.then((res) => res.json())
.then((res) => {
const rowList = getInnerText(res.data.content).split('\n');
const tag = getRegExpStr(rowList, /(?<=类型\s*[::]\s*).+/)
?.split(/[/||]/)
.join('/');
const level = getRegExpStr(
rowList,
/(?<=(难度|适合)\s*[::\s*]).+/,
);
const dm = getRegExpStr(rowList, /(?<=(dm|DM)\s*[::]\s*).+/);
let place = getRegExpStr(rowList, /(?<=(地点|场地)\s*[::]\s*).+/);
if (/[Aa]\s?空间/.test(place)) {
place = 'A空间';
}
if (/元空间/.test(place)) {
place = '元空间';
}
const persons = getRegExpStr(rowList, /(?<=(人数)\s*[::]\s*).+/)
.split(/[,,\(\)()「」]/)
.map((item) => item.replace(/(回复报名|注明男女|及人数)/, ''))
.filter((item) => item.trim())
.join('·');
const manCount = +persons.match(/(\d+)\s?男/)?.[1] || undefined;
const womanCount = +persons.match(/(\d+)\s?女/)?.[1] || undefined;
const personCount = (() => {
if (manCount && womanCount) {
return manCount + womanCount;
}
if (/(\d+)[~~到-](\d+)/.test(persons.replace(/\s/g, ''))) {
return +/(\d+)[~~到-](\d+)/.exec(
persons.replaceAll(' ', ''),
)[1];
}
if (/(\d+)人/.test(persons.replaceAll(/\s/g, ''))) {
return +/(\d+)人/.exec(persons.replaceAll(' ', ''))[1];
}
return undefined;
})();
const reversable = !/不[^反]*反串/.test(persons);
const week =
getRegExpStr(rowList, /周[一二三四五六日]/) ||
`周${
['日', '一', '二', '三', '四', '五', '六'][item.dayjs.day()]
}`;
const time = getRegExpStr(rowList, /\d{1,2}[::]\d{2}/);
const [hour = '', minute = ''] = time.split(/[::]/);
const duration = getRegExpStr(
rowList,
/(?<=(预计时.|时长)\s*[::]\s*).+/,
).replace(/(h|小时)/, 'H');
const url = `https://yuque.antfin.com/yuhmb7/pksdw8/${item.url}?singleDoc#`;
return {
...item,
tag,
level,
dm,
week,
hour,
minute,
place,
persons,
duration,
url,
manCount,
womanCount,
personCount,
reversable,
};
});
}),
);
}
async function copyMarkdownInfo(list) {
const text = `
# 📢 剧本杀活动通知
---
${list
.map((item) => {
return `
🎬 《${item.name}》${item.tag}${item.level ? `/${item.level}` : ''}
🕙 ${item.month}.${item.date} ${item.week} ${item.hour}:${item.minute} 📍${
item.place
}
💎 DM ${item.dm}【${item.persons}·${item.duration}】[报名](${item.url})
---
`;
})
.join('')}
🙋 [玩家报名须知](https://yuque.antfin.com/yuhmb7/pksdw8/igri3gwp127v3v32?singleDoc#),防跳车押金以报名页面为准!
🔜 加入钉群:14575023754,获取更多活动信息!
`;
exeCommandCopyText(text);
window.layui?.layer?.msg('已复制到剪贴板');
}
async function getCommentsList(list) {
return Promise.all(
list.map((item) => {
return fetch(
`https://yuque.antfin-inc.com/api/comments/floor?commentable_type=Doc&commentable_id=${item.id}&include_section=true&include_to_user=true&include_reactions=true`,
{
headers: {
accept: 'application/json',
'accept-language': 'zh-CN,zh;q=0.9',
'content-type': 'application/json',
'sec-ch-ua':
'"Not A(Brand";v="99", "Google Chrome";v="121", "Chromium";v="121"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"macOS"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'x-csrf-token': '7g3LVrMMDcljwFdl3GBLLIRy',
'x-requested-with': 'XMLHttpRequest',
},
referrerPolicy: 'strict-origin-when-cross-origin',
body: null,
method: 'GET',
mode: 'cors',
credentials: 'include',
},
)
.then((res) => res.json())
.then((res) => {
return {
...item,
comments: res.data.comments,
};
});
}),
);
}
function openActivityModal(list) {
layui.layer.open(
{
type: 1, // page 层类型
area: ['800px', '500px'],
title: '活动报名情况',
shade: 0.6, // 遮罩透明度
shadeClose: true, // 点击遮罩区域,关闭弹层
maxmin: true, // 允许全屏最小化
anim: 0, // 0-6 的动画形式,-1 不开启
content: `
<div style="padding: 24px">
${list.map((item) => {
let manCount = 0;
let womanCount = 0;
let unknownCount = 0;
item.comments.forEach((comment) => {
const content = getInnerText(comment.body);
comment.checked = true;
const MAN_REG = /(\d+)\s?男/;
const WOMAN_REG = /(\d+)\s?女/;
const UNKNOWN_REG = /\+(\d+)/;
if (MAN_REG.test(content)) {
manCount += +MAN_REG.exec(content)[1];
return;
}
if (WOMAN_REG.test(content)) {
womanCount += +WOMAN_REG.exec(content)[1];
return;
}
if (UNKNOWN_REG.test(content)) {
unknownCount += +UNKNOWN_REG.exec(content)[1];
return;
}
comment.checked = false;
});
const listHTML = item.comments
.map((comment) => {
const content = getInnerText(comment.body);
return `<a class="${USER_ITEM_CLASS_NAME} ${
!comment.checked ? 'unchecked' : ''
}" href="https://yuque.antfin-inc.com/${
comment.user.login
}" target="_blank">
<img src="${comment.user.avatar_url}"/>
<div>
<div>${comment.user.name}</div>
<div style="font-size: 12px; color: gray; margin-top: 4px;">${content}</div>
</div>
</a>`;
})
.join('');
const status = (() => {
const personCount = manCount + womanCount + unknownCount;
if (item.manCount && item.womanCount && !item.reversable) {
if (
manCount >= item.manCount &&
womanCount >= item.womanCount
) {
return `<span class="layui-badge layui-bg-green">已满人</span>`;
}
if (personCount >= item.manCount + item.womanCount) {
return `<span class="layui-badge layui-bg-orange">满人,但男女未满</span>`;
}
return `<span class="layui-badge layui-bg-red">未满人</span>`;
}
if (item.personCount) {
if (personCount >= item.personCount) {
return `<span class="layui-badge layui-bg-green">已满人</span>`;
}
return `<span class="layui-badge layui-bg-red">未满人</span>`;
}
})();
return `
<div class="layui-card">
<div class="layui-card-header" style="display: flex; justify-content: space-between;">
<a href="${item.url}" target="_blank">🔗 ${item.title}</a>
</div>
<div class="layui-card-body">
<div class="${USER_LIST_CLASS_NAME}">
${listHTML}
</div>
<div class="layui-card-footer">
<span>要求:${item.persons}</span>
<span>当前:${manCount}男${womanCount}女${
unknownCount ? `${unknownCount}未知` : ''
},共${manCount + womanCount}人</span>
${status}
</div>
</div>
</div>
`;
})}
</div>
`,
},
2000,
);
}
function openDatePickerModal([start, end]) {
const modalIndex = layui.layer.open(
{
type: 1, // page 层类型
title: '请选择日期范围',
shade: 0.6, // 遮罩透明度
area: ['655px', '400px'],
shadeClose: true, // 点击遮罩区域,关闭弹层
maxmin: true, // 允许全屏最小化
anim: 0, // 0-6 的动画形式,-1 不开启
content: `
<div style="padding: 12px">
<div id="date"></div>
</div>
`,
},
2000,
);
layui.laydate.render({
elem: '#date',
range: true,
type: 'date',
rangeLinked: true,
weekStart: 1,
show: true,
theme: '#0271BD',
position: 'static',
value: `${start.format('YYYY-MM-DD')} - ${end.format('YYYY-MM-DD')}`,
mark: {
[dayjs().format('YYYY-MM-DD')]: '今天',
},
shortcuts: [
{
text: '本周',
value: [
new Date(+dayjs().startOf('week')),
new Date(+dayjs().endOf('week')),
],
},
{
text: '上周',
value: [
new Date(+dayjs().startOf('week').subtract(1, 'week')),
new Date(+dayjs().endOf('week').subtract(1, 'week')),
],
},
{
text: '下周',
value: [
new Date(+dayjs().startOf('week').add(1, 'week')),
new Date(+dayjs().endOf('week').add(1, 'week')),
],
},
{
text: '本月',
value: [
new Date(+dayjs().startOf('month')),
new Date(+dayjs().endOf('month')),
],
},
// 更多选项 …
],
done: function (value, startDate, endDate) {
const [startStr, endStr] = value.split(' - ');
timeRange = [
dayjs(startStr, 'YYYY-MM-DD'),
dayjs(endStr, 'YYYY-MM-DD'),
];
layui.dropdown.reload(BTN_ID, {
data: getDropdownItems(),
});
layui.layer.close(modalIndex);
},
});
}
initStyle();
initBtn();
function getDropdownItems() {
return [
{
title: `日期范围:${timeRange[0].format('M-D')} - ${timeRange[1].format(
'M-D',
)}`,
disabled: true,
},
{
title: `更改日期范围`,
id: 'edit date range',
},
{
title: '复制活动信息 Markdown',
id: 'copy week markdown',
},
{
title: '查看活动报名情况',
id: 'check sign up',
},
];
}
layui.dropdown.render({
elem: `#${BTN_ID}`,
data: getDropdownItems(),
click: async function ({ id }) {
let list = await getActivesInfo(...timeRange);
if (id === 'edit date range') {
openDatePickerModal(timeRange);
}
if (id === 'copy week markdown') {
copyMarkdownInfo(list);
}
if (id === 'check sign up') {
list = await getCommentsList(list);
openActivityModal(list);
}
},
});
})();