// ==UserScript==
// @name 剧本杀活动通知生成器
// @namespace https://github.com/heiyexing
// @version 2024-03-12
// @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;
}
function chineseToArabic(chineseNum) {
let num = chineseNum
.replace(/零/g, '0')
.replace(/一/g, '1')
.replace(/二/g, '2')
.replace(/三/g, '3')
.replace(/四/g, '4')
.replace(/五/g, '5')
.replace(/六/g, '6')
.replace(/七/g, '7')
.replace(/八/g, '8')
.replace(/九/g, '9');
num = num
.replace(/十/g, '10')
.replace(/百/g, '100')
.replace(/千/g, '1000')
.replace(/万/g, '10000');
return num;
}
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) {
requestAnimationFrame(() => {
document
.querySelector('#murder-activity-btn')
?.addEventListener('click', () => {
if (list.every((item) => item.isFull)) {
window.layui?.layer?.msg('所有活动已满人,无需生成 Markdown');
return;
}
const text = `
# 📢 剧本杀活动通知
---
${list
.filter((item) => !item.isFull)
.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.inputValue ?? ''}·${
item.duration
}】[报名](${item.url})
`;
})
.join('')}
---
📎 本周其他剧本活动信息
${list
.filter((item) => item.isFull)
.map((item) => {
return `
${item.month}月${item.date}日《${item.name}》【满】
`;
})
.join('')}
---
🙋 [玩家报名须知](https://yuque.antfin.com/yuhmb7/pksdw8/igri3gwp127v3v32?singleDoc#),防跳车押金以报名页面为准!
🔜 加入钉群:14575023754,获取更多活动信息!
`;
exeCommandCopyText(text);
window.layui?.layer?.msg('已复制到剪贴板');
});
});
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: 12px; height: 400px; overflow: auto;">
${list
.map((item) => {
let manCount = 0;
let womanCount = 0;
let unknownCount = 0;
item.comments.forEach((comment) => {
const content = chineseToArabic(
getInnerText(comment.body) ?? '',
);
comment.checked = true;
if (/(\d+)\s?男/.test(content)) {
manCount += +/(\d+)\s?男/.exec(content)[1];
} else if (/男[\s+]*(\d+)/.test(content)) {
manCount += +/男[\s+]*(\d+)/.exec(content)[1];
} else if (/^\+?男$/.test(content)) {
manCount += 1;
} else if (/(\d+)\s?女/.test(content)) {
womanCount += +/(\d+)\s?女/.exec(content)[1];
} else if (/女[\s+]*(\d+)/.test(content)) {
womanCount += +/女[\s+]*(\d+)/.exec(content)[1];
} else if (/^\+?女$/.test(content)) {
womanCount += 1;
} else if (/\+(\d+)/.test(content)) {
unknownCount += +/\+(\d+)/.exec(content)[1];
} else if (content === '+') {
unknownCount += 1;
} else if (/\d+/.test(content)) {
unknownCount += +/\d+/.exec(content)[0];
} else {
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 personCount = manCount + womanCount + unknownCount;
const status = (() => {
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 '';
})();
item.isFull = status.indexOf('已满人') > -1;
item.inputValue = (() => {
if (
item.personCount &&
personCount < item.personCount
) {
return `=${item.personCount - personCount}`;
}
if (
item.manCount &&
item.womanCount &&
!item.reversable
) {
let result = '=';
if (manCount < item.manCount) {
result += `${item.manCount - manCount}男`;
}
if (womanCount < item.womanCount) {
result += `${item.womanCount - womanCount}女`;
}
if (result.length > 1) {
return result;
}
}
return '';
})();
const operation = document.createElement('div');
operation.style.width = '120px';
const operationId = `murder-operation-${item.uuid}`;
operation.id = operationId;
operation.style =
'display: flex; align-items: center;text-wrap: nowrap;';
const updateOperation = () => {
const checkboxId = `murder-checkbox-${item.uuid}`;
const inputId = `murder-input-${item.uuid}`;
let innerHTML = '';
if (!item.isFull) {
innerHTML += `<input value="${item.inputValue}" type="text" id="${inputId}" class="layui-input" style="margin-right: 6px; width: 80px;" />`;
}
innerHTML += `<input type="checkbox" id="${checkboxId}" ${
item.isFull ? 'checked' : ''
} /> 满人`;
const target =
document.querySelector(`#${operationId}`) ??
operation;
target.innerHTML = innerHTML;
requestAnimationFrame(() => {
document
.querySelector(`#${checkboxId}`)
?.addEventListener(
'change',
(e) => {
item.isFull = !!e.target.checked;
updateOperation();
},
{
once: true,
},
);
document
.querySelector(`#${inputId}`)
?.addEventListener('change', (e) => {
item.inputValue = e.target.value;
console.log('chagne', item.inputValue);
});
});
};
updateOperation();
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 + unknownCount}人</span>
${operation.outerHTML}
</div>
</div>
</div>
`;
})
.join('')}
</div>
<div style="padding: 4px 12px; position: absolute; width: 100%; bottom: 0; left: 0; text-align: right;">
<button type="button" class="layui-btn" id="murder-activity-btn">生成 Markdown</button>
</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);
}
},
});
})();