// ==UserScript== // @name 剧本杀活动通知生成器 // @namespace https://github.com/heiyexing // @version 2024-03-14 // @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 // @downloadURL none // ==/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('')} 🔺 入门:新手友好,10推理本以内经验的玩家 🔺 进阶:中等难度,20推理本以内经验的玩家 🔺 烧脑:积极推理、全程在线、20推理本以上 🔍 务必结合自身经验和剧本难度充分评估后报名 🙋‍ [【活动须知】](https://yuque.antfin.com/yuhmb7/pksdw8/hyv3ir5v5gplvvgl?singleDoc#)[【报名规则】](https://yuque.antfin.com/yuhmb7/pksdw8/igri3gwp127v3v32?singleDoc#)[【情感本注意事项】](https://yuque.antfin.com/yuhmb7/pksdw8/sxs3yz5y5b00f65w?singleDoc#) `; 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', () => { const fullList = list.filter((item) => item.isFull); const unFullList = list.filter((item) => !item.isFull); if (fullList.length === list.length) { window.layui?.layer?.msg('所有活动已满人,无需生成 Markdown'); return; } const text = ` # 📢 剧本杀活动通知 --- ${unFullList .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('')} ${ fullList.length ? ` 📎 本周其他剧本活动信息 ${list .filter((item) => item.isFull) .map((item) => { return ` ${item.month}月${item.date}日《${item.name}》【满】 `; }) .join('')} --- ` : '' } 🔺 入门:新手友好,10推理本以内经验的玩家 🔺 进阶:中等难度,20推理本以内经验的玩家 🔺 烧脑:积极推理、全程在线、20推理本以上 🔍 务必结合自身经验和剧本难度充分评估后报名 🙋‍ [【活动须知】](https://yuque.antfin.com/yuhmb7/pksdw8/hyv3ir5v5gplvvgl?singleDoc#)[【报名规则】](https://yuque.antfin.com/yuhmb7/pksdw8/igri3gwp127v3v32?singleDoc#)[【情感本注意事项】](https://yuque.antfin.com/yuhmb7/pksdw8/sxs3yz5y5b00f65w?singleDoc#) `; 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: `
${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 `
${comment.user.name}
${content}
`; }) .join(''); const personCount = manCount + womanCount + unknownCount; const status = (() => { if ( item.manCount && item.womanCount && !item.reversable ) { if ( manCount >= item.manCount && womanCount >= item.womanCount ) { return `已满人`; } if (personCount >= item.manCount + item.womanCount) { return `满人,但男女未满`; } return `未满人`; } if (item.personCount) { if (personCount >= item.personCount) { return `已满人`; } return `未满人`; } 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 += ``; } innerHTML += ` 满人`; 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 `
${listHTML}
`; }) .join('')}
`, }, 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: `
`, }, 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); } }, }); })();