// ==UserScript==
// @icon http://tns.thss.tsinghua.edu.cn/~yangzheng/images/Tsinghua_University_Logo_Big.png
// @name 网络学堂4202助手
// @namespace [email protected]
// @version 2024年02月25日开学快乐版
// @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
// ==/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;
}
`, // 右上角课程日历显示修复
`
.qtip {
width: max-content;
}
`, // 周数显示部分样式
`
.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;
}
`, // 用于自动刷新的 iframe
`
iframe#refresher {
position: absolute;
left: -10em;
top: -10em;
width: 10em;
height: 10em;
border: 1px solid magenta;
opacity: 0.5;
}
`,
].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}${url.includes('?') ? '&' : '?'}_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((e) => ({ 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} 天<span class="due-time">${dueTime} 截止</span>`;
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 prevMonth = new Date();
prevMonth.setFullYear(year, month - 1, 15);
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=${prevMonth
.toISOString()
.slice(0, 7)
.replaceAll('-', '')}01` +
`&p_end_date=${nextMonth
.toISOString()
.slice(0, 7)
.replaceAll('-', '')}01&jsoncallback=no_such_method` +
`&_csrf=${csrf}`,
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 文件,注意用的是 \r\n
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]) {
var nextStart = new Date(json.resultList[0].kssj);
nextStart.setDate(nextStart.getDate() - 7 * 4);
if (nextStart < Date.now()) {
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 = `第<span>${week}</span>周:`;
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 !== '') {
let container = document.getElementById('nextsuojiaocourse');
if (container && container.innerText.trim() !== '') {
return container;
}
}
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();
}
}
const insideIframe = window.location.hash === '#refresher';
function autoRefresh() {
if (insideIframe) {
return;
}
const iframe = document.createElement('iframe');
const page = '/f/wlxt/index/course/student/#refresher';
iframe.id = 'refresher';
iframe.style.visibility = 'hidden';
iframe.onload = () => {
setTimeout(() => {
iframe.src = '';
}, 5000);
setTimeout(() => {
iframe.src = page;
}, 10 * 60 * 1000);
};
iframe.onerror = iframe.onload;
document.body.append(iframe);
iframe.src = page;
}
function init() {
if (!document.querySelector('.state.stu.clearfix .operations')) {
customize();
}
if (!document.querySelector('iframe#refresher')) {
autoRefresh();
}
}
// 延迟初始化,等到页面加载完、脚本跑完再说
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');
}