// ==UserScript==
// @icon http://tns.thss.tsinghua.edu.cn/~yangzheng/images/Tsinghua_University_Logo_Big.png
// @name 网络学堂3202助手
// @namespace exhen32@live.com
// @version 2023年9月20日00版
// @license AGPL-3.0-or-later
// @description 直观展现死线情况,点击即可跳转;导出所有课程至日历;一键标记公告已读。
// @require https://cdn.bootcdn.net/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @grant GM.xmlHttpRequest
// @connect zhjw.cic.tsinghua.edu.cn
// @author thuyesh
// @match http*://learn.tsinghua.edu.cn/f/wlxt/index/course/student/
// @match http*://learn.tsinghua.edu.cn/f/wlxt/*
// @connect learn.tsinghua.edu.cn
// @run-at document-end
// @downloadURL none
// ==/UserScript==
document.head.appendChild(document.createElement('style'))
const sheet = document.styleSheets[document.styleSheets.length - 1];
[
// 左上角图标
`
#banner .left a img[src="/res/app/wlxt/img/netcourse_logo.svg"] {
cursor: pointer;
margin-top: 13px;
padding-top: 0px;
background: linear-gradient(90deg, rgb(67, 159, 226) 0%, rgb(75, 168, 234) 55.29%, rgb(0, 114, 198) 55.29%);
border-radius: 10px;
}
`, // 周数显示部分样式
`
.nav #myTabs {
margin-bottom: 0;
}
`,`
.nav #myTabContent .boxdetail {
margin-top: 0.3em;
}
`, // 鼠标加载动画
`
body.loading * {
cursor: progress !important;
}
`, // 不显示课程的第一个框里显示的默认图片,我们把它替换为 DDL 提醒
`
.p_img {
display: none;
}
`, // 下面是 DDL 提醒的样式
`
ul.stu.clearfix li.clearfix:first-child {
text-align: center;
display: flex;
flex-flow: column;
justify-content: center;
}
`,`
ul.stu.clearfix li.clearfix:first-child a {
text-decoration: underline;
font-size: 1.5em;
color: black;
}
`,`
ul.stu.clearfix li.clearfix:first-child a span {
display: block;
margin-top: 1em;
font-size: 0.6em;
}
`,`
ul.stu.clearfix.clean li.clearfix:first-child {
background-color: lightgreen;
color: white;
font-size: 2em;
font-weight: bolder;
}
`,`
ul.stu.clearfix.hw-yellow li.clearfix:first-child {
background-color: gold;
}
`,`
ul.stu.clearfix.hw-orange li.clearfix:first-child {
background-color: orange;
}
`,`
ul.stu.clearfix.hw-red li.clearfix:first-child {
background-color: red;
}
`,`
ul.stu.clearfix.hw-red li.clearfix:first-child a {
color: yellow;
}
`,`
ul.stu.clearfix.hw-overdue li.clearfix:first-child {
background-color: lightgrey;
}
`,`
ul.stu.clearfix.clean li.clearfix:first-child::after {
content: "✓";
}
`, // 周数显示
`
.week-count {
display: inline-block;
font-size: 1em;
}
`,`
.week-count span {
color: brown;
font-size: 1.2em;
margin: 0 0.2em 0 0.2em;
}
`,`
.state.stu.clearfix + .operations {
margin-top: 0.5em;
}
`,`
button.operation {
background-color: white;
padding: 0.3em;
border: 1px grey solid;
cursor: pointer;
border-radius: 3px;
transition: background-color 0.1s;
}
`,`
button.operation:not(:first-child) {
margin-left: 1em;
}
`,`
button.operation:hover, button.operation:active {
background-color: lightgrey;
}
`,`
.boxdetail dd.clearfix.stu {
padding-bottom: 0;
}
`,`
.boxdetail dd.clearfix.stu:last-child {
padding-bottom: 16px;
}
`].forEach((rule) => sheet.insertRule(rule, 0))
// 鼠标加载动画开启关闭
function setLoading() {
document.body.classList.add('loading')
}
function unsetLoading() {
document.body.classList.remove('loading')
}
// 仿造请求
var csrf = ''
const dummy = {
'sEcho': 1,
'iColumns': 8,
'sColumns': ',,,,,,,',
'iDisplayStart': 0,
'iDisplayLength': '30',
'mDataProp_0': 'wz',
'bSortable_0': false,
'mDataProp_1': 'bt',
'bSortable_1': true,
'mDataProp_2': 'mxdxmc',
'bSortable_2': true,
'mDataProp_3': 'zywcfs',
'bSortable_3': true,
'mDataProp_4': 'kssj',
'bSortable_4': true,
'mDataProp_5': 'jzsj',
'bSortable_5': true,
'mDataProp_6': 'jzsj',
'bSortable_6': true,
'mDataProp_7': 'function',
'bSortable_7': false,
'iSortCol_0': 5,
'sSortDir_0': 'desc',
'iSortCol_1': 6,
'sSortDir_1': 'desc',
'iSortingCols': 2,
'wlkcid': ''
}
function shallowCopyObject(obj) {
return Object.fromEntries(Object.entries(obj))
}
function fetchResponse(url, method, data) {
return fetch(url.includes('?') ? (url + '&_csrf=' + csrf) : (url + '?_csrf=' + csrf), {
method: method,
headers: {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Cache-Control': 'max-age=0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'include',
referrer: 'https://learn.tsinghua.edu.cn/',
referrerPolicy: 'strict-origin-when-cross-origin',
body: method.toUpperCase() === 'POST' ? new URLSearchParams({
'aoData': JSON.stringify(Object.entries(Object.assign(shallowCopyObject(dummy), data)).map(function (e) {
return {
'name': e[0],
'value': e[1]
}
}))
}) : undefined,
})
}
function fetchJSON(url, method, data) {
return fetchResponse(url, method, data).then(function (response) {
if (response.ok) {
return response.json()
} else {
return Promise.resolve()
}
})
}
function getJSON(url) {
return fetchJSON(url, 'GET')
}
// 抓取作业剩余时间信息并显示
function displayDDL(e, wlkcid) {
var parent = e
if (parseInt(e.querySelector('li.clearfix:nth-child(4) .rt').innerText) > 0) {
fetchJSON('https://learn.tsinghua.edu.cn/b/wlxt/kczy/zy/student/zyListWj', 'POST', { 'wlkcid': wlkcid }).then(
function (json) {
if(json) {
var now = Date.now()
var deadlines = json.object.aaData.filter((hw) => hw.jzsj - now > 0)
var hwLink = document.createElement('a')
if(deadlines.length === 0) {
hwLink.innerText = '已过期'
parent.classList.add('hw-overdue')
} else {
var deadline = deadlines.reduce((hw1, hw2) => (hw1.jzsj > hw2.jzsj ? hw2 : hw1))
var days = Math.floor((deadline.jzsj - now) / 1000 / 60 / 60 / 24)
var dueTime = new Date(deadline.jzsj + 8 * 60 * 60 * 1000).toISOString().substring(5, 16).replace('T', ' ')
hwLink.innerHTML = `剩余 ${days} 天${dueTime} 截止`
hwLink.href = `https://learn.tsinghua.edu.cn/f/wlxt/kczy/zy/student/viewZy?wlkcid=${deadline.wlkcid}&sfgq=0&zyid=${deadline.zyid}&xszyid=${deadline.xszyid}`
const bgColors = ['hw-red', 'hw-red', 'hw-orange', 'hw-orange', 'hw-orange', 'hw-orange', 'hw-orange']
parent.classList.add(bgColors[days] ? bgColors[days] : 'hw-yellow')
}
parent.querySelector('li.clearfix:first-child').appendChild(hwLink)
}
}
)
} else {
parent.classList.add('clean')
}
}
// 保存新课件
function saveNewFiles(e, wlkcid) {
getJSON(`http://learn.tsinghua.edu.cn/b/wlxt/kj/wlkc_kjxxb/student/kjxxbByWlkcidAndSizeForStudent?size=999&wlkcid=${wlkcid}`).then((json) => {
if(json) {
var files = json.object.filter(f => f.isNew )
if(files.length === 0) {
alert('无新课件')
} else {
var size = files.reduce((i, file) => i + file.wjdx, 0)
const sizeSuffix = ['kB', 'MB', 'GB', 'TB']
var humanReadable = sizeSuffix.reduce((size, name) => {
if(size[0] >= 1024) {
return [size[0] / 1024, name]
} else {
return size
}
}, [size, 'B'])
if(confirm(`下载以下 ${humanReadable[0].toFixed(2)} ${humanReadable[1]} 的新课件?\n→ ${files.map(f => `${f.bt} - ${f.fileSize}`).join('\n→ ')}`)) {
files.forEach(f => window.open(`http://learn.tsinghua.edu.cn/b/wlxt/kj/wlkc_kjxxb/student/downloadFile?sfgk=0&wjid=${f.wjid}`))
}
}
}
})
}
// 公告已读
function markRead(e, wlkcid) {
setLoading()
getJSON(`http://learn.tsinghua.edu.cn/b/wlxt/kcgg/wlkc_ggb/student/kcggListXs?size=999&wlkcid=${wlkcid}`).then(json => {
if(json) {
var unreadItems = json.object.aaData.filter(e => e.sfyd === '否')
if (unreadItems.length === 0) {
alert('无公告')
unsetLoading()
} else if (confirm(`按确认键将以下公告设为已读:\n${unreadItems.map(function(e) { return '→ ' + e.bt + (e.fjmc ? `(有附件 ${e.fjmc})` : '') }).join('\n')}`)) {
let total = unreadItems.length
let count = 0
var handleResponse = response => {
count++
if (total === count) {
alert('已读完成')
location.reload()
}
}
unreadItems.forEach(e =>
fetchResponse(`http://learn.tsinghua.edu.cn/f/wlxt/kcgg/wlkc_ggb/student/beforeViewXs?wlkcid=${wlkcid}&id=${e.ggid}`, 'GET')
.then(handleResponse)
)
}
} else {
alert('网络错误')
unsetLoading()
}
})
}
// 把按钮加到界面
function displayOperations(e, wlkcid) {
const operations = {
'所有公告标为已读': markRead,
'批量保存新课件': saveNewFiles,
}
var buttonContainer = document.createElement('div')
buttonContainer.classList.add('operations')
for(var i in operations) {
var button = document.createElement('button')
button.innerText = i
button.classList.add('operation')
button.onclick = (operation => (() => operation(e, wlkcid)))(operations[i])
buttonContainer.appendChild(button)
}
e.parentElement.parentElement.appendChild(buttonContainer)
}
// 抓取课程日历信息
function fetchEvents(year, month, events) {
var thisMonth = new Date()
thisMonth.setFullYear(year, month, 15)
var nextMonth = new Date()
nextMonth.setFullYear(year, month + 1, 15)
return new Promise((resolve, reject) => {
const graduate = unsafeWindow.role === 'yjs'
GM.xmlHttpRequest({
method: "GET",
url: `https://zhjw.cic.tsinghua.edu.cn/jxmh_out.do?m=${graduate ? 'yjs' : 'bks'}_jxrl_all&p_start_date=${thisMonth.toISOString().slice(0, 7).replaceAll('-', '')}01`
+ `&p_end_date=${nextMonth.toISOString().slice(0, 7).replaceAll('-', '')}01&jsoncallback=no_such_method`,
onload: response => {
if(response.status < 400) {
var text = response.responseText
var monthCalendar = JSON.parse(text.slice(15, -1))
if(monthCalendar.length === 0) {
resolve(events)
} else {
resolve(fetchEvents(year, month + 1, events.concat(
monthCalendar.filter(event => (new Date(event.nq)).getMonth() === thisMonth.getMonth())
)))
}
} else {
resolve(events)
}
},
onerror: reject,
})
})
}
function getTZ(date, time) {
return `${date.replaceAll('-', '')}T${time.replaceAll(':', '')}00`
}
// 生成 ics 文件
function makeIEvent(event, prior) {
return `BEGIN:VEVENT\r
UID:${getTZ(event.nq, event.kssj)}-${Math.floor(Math.random() * 10000)}@tsinghua.edu.cn\r
DTSTAMP;TZID=Asia/Shanghai:${getTZ(event.nq, event.kssj)}\r
DTSTART;TZID=Asia/Shanghai:${getTZ(event.nq, event.kssj)}\r
DTEND;TZID=Asia/Shanghai:${getTZ(event.nq, event.jssj)}\r
SUMMARY:${event.nr}\r
LOCATION:${event.dd}\r
BEGIN:VALARM\r
TRIGGER:-PT${prior}M\r
ACTION:DISPLAY\r
DESCRIPTION:${event.nr}前 ${prior} 分钟提醒\r
END:VALARM\r
END:VEVENT`
}
function makeICalendar(events, prior) {
return `BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//Web THU Helper//iCalender//EN\r
BEGIN:VTIMEZONE\r
TZID:Asia/Shanghai\r
BEGIN:STANDARD\r
TZOFFSETFROM:+0800\r
TZOFFSETTO:+0800\r
TZNAME:CST\r
DTSTART:19700101T000000\r
END:STANDARD\r
END:VTIMEZONE\r
${events.map(e => makeIEvent(e, prior)).join('\r\n')}\r
END:VCALENDAR\r
`
}
// 两个学期临界的一周的时候,result 是上一学期,而 resultList[0] 是下一学期
function getLatestResult(json) {
if (json.resultList && json.resultList[0]) {
return json.resultList[0];
}
return json.result;
}
// 下载日程为日历
const semesterUrl = 'https://learn.tsinghua.edu.cn/b/kc/zhjw_v_code_xnxq/getCurrentAndNextSemester'
function calendarizeAll() {
setLoading()
getJSON(semesterUrl).then(json => {
if(json) {
var now = new Date(getLatestResult(json).kssj)
var month = now.getMonth()
var year = now.getFullYear()
var alarmPrior = parseInt(prompt('日历文件可设置日程提醒,每节课提前多少分钟提醒?', 30))
if(alarmPrior > 0) {
fetchEvents(year, month, []).then(events => {
unsetLoading()
var blob = new Blob([makeICalendar(events, alarmPrior)], {type: "text/plain"})
// eslint-disable-next-line no-undef
saveAs(blob, `${getLatestResult(json).xnxqmc}.ics`)
})
} else {
unsetLoading()
}
} else {
unsetLoading()
}
})
}
// 在界面里加入周数
function addWeekCount(container) {
getJSON(semesterUrl).then(json => {
if(json) {
var start = new Date(getLatestResult(json).kssj)
var day = start.getDay()
day = (day == 0 ? 7 : day)
var nextMonday = new Date(start.setDate(start.getDate() + 7 - day + 1))
var week = Math.ceil((Date.now() - nextMonday) / 1000 / 60 / 60 / 24 / 7)
var weekDiv = document.createElement('div')
weekDiv.innerHTML = `第${week}周:`
weekDiv.classList.add('week-count')
container.insertBefore(weekDiv, container.childNodes[0])
}
})
}
// 从图片上获取 csrf token
function initCsrf() {
for(var imgNode of document.querySelectorAll('img')) {
csrf = new URL(imgNode.src).searchParams.get('_csrf')
if(csrf) { break }
}
}
// 两学期交界处的特殊处理
function getCourseContainer() {
if (document.getElementById('nextSemester').value !== '') {
return document.getElementById('nextsuojiaocourse');
}
return document.getElementById('suoxuecourse');
}
// 初始化
function customize() {
initCsrf()
// 课程 DDL 显示、课件下载按钮
document.querySelectorAll('.state.stu .name a[href^="/f/wlxt/kczy/zy/"]').forEach(e => {
var wlkcid = new URL(e.href).searchParams.get('wlkcid')
const parent = e.closest('ul')
displayDDL(parent, wlkcid)
displayOperations(parent, wlkcid)
})
// 周数显示、导出日历按钮
if(!document.getElementById('calendarizer')) {
var container = getCourseContainer().parentElement.querySelector('.title')
var calendarButton = document.createElement('button')
calendarButton.classList.add('operation')
calendarButton.id = 'calendarizer'
calendarButton.style.marginLeft = '1em'
calendarButton.innerText = '导出所有课程至日历文件'
calendarButton.onclick = calendarizeAll
container.appendChild(calendarButton)
addWeekCount(container)
}
// 切换到网络学堂旧样式
const tabSwitch = document.querySelector('.in .tab2');
if (tabSwitch) {
tabSwitch.click()
}
}
function init() {
if(!document.querySelector('.state.stu.clearfix .operations')) {
customize()
}
}
// 延迟初始化,等到页面加载完、脚本跑完再说
const homePath = '/f/wlxt/index/course/student/'
if (window.location.pathname === homePath) {
if (document.querySelector('dd.stu') === null) {
var container = getCourseContainer()
var observer = new MutationObserver(function () {
if (document.querySelector('dd.stu') !== null) {
setTimeout(function () {
init()
}, 50)
}
})
observer.observe(container, {
attributes: false,
childList: true,
subtree: false
})
} else {
init()
}
}
// 左上角的图标分割
var logo = document.querySelector('#banner .left a>img')
if (logo) {
logo.parentElement.href = '#'
var iconMap = document.createElement('map')
iconMap.name = 'iconmap'
var iconMapArea1 = document.createElement('area')
var iconMapArea2 = document.createElement('area')
Object.entries({
shape: 'rect',
coords: '0,0,115,47',
href: homePath + 'index',
title: '登录界面',
}).forEach(entry => iconMapArea1.setAttribute(entry[0], entry[1]))
Object.entries({
shape: 'rect',
coords: '115,0,202,47',
href: homePath,
title: '我的课程',
}).forEach(entry => iconMapArea2.setAttribute(entry[0], entry[1]))
iconMap.appendChild(iconMapArea1)
iconMap.appendChild(iconMapArea2)
logo.parentElement.appendChild(iconMap)
logo.setAttribute('usemap', '#iconmap')
}