// ==UserScript==
// @name TronClass Copilot
// @namespace Anong0u0
// @version 0.0.11
// @description Your best copilot for TronClass
// @author Anong0u0
// @match https://eclass.yuntech.edu.tw/*
// @match https://elearning.aeust.edu.tw/*
// @match https://elearn.ntuspecs.ntu.edu.tw/*
// @match https://elearn2.fju.edu.tw/*
// @match https://iclass.tku.edu.tw/*
// @match https://tronclass.ntou.edu.tw/*
// @match https://tronclass.hk.edu.tw/*
// @match https://tronclass.kh.usc.edu.tw/*
// @match https://tronclass.usc.edu.tw/*
// @match https://tronclass.cyut.edu.tw/*
// @match https://tronclass.ypu.edu.tw/*
// @match https://tccas.thu.edu.tw/*
// @match https://ilearn.thu.edu.tw:8080/*
// @match https://tronclass.cjcu.edu.tw/*
// @match https://tronclass.asia.edu.tw/*
// @match https://tronclass.mdu.edu.tw/*
// @match https://ulearn.nfu.edu.tw/*
// @match https://tc.nutc.edu.tw/*
// @match https://tronclass.au.edu.tw/*
// @match https://tronclass.cgust.edu.tw/*
// @match https://tronclass.ocu.edu.tw/*
// @match https://tc.stu.edu.tw/*
// @match https://tronclass.ctust.edu.tw/*
// @match https://tronclass.pu.edu.tw/*
// @match https://nou.tronclass.com.tw/*
// @match https://tronclass.must.edu.tw/*
// @match https://tronclass.scu.edu.tw/*
// @match https://ilearn.ttu.edu.tw/*
// @match https://iclass.hwu.edu.tw/*
// @icon https://tronclass.com.tw/static/assets/images/favicon-b420ac72.ico
// @grant GM_xmlhttpRequest
// @run-at document-start
// @license Beerware
// ==/UserScript==
const _parse = JSON.parse
const parseSet = {allow_download: true, allow_forward_seeking: true, pause_when_leaving_window: false}
JSON.parse = (text, reviver) => _parse(text, (k, v) => {
if(reviver) v = reviver(k, v)
return parseSet[k] || v
})
const _addEventListener = Window.prototype.addEventListener;
Window.prototype.addEventListener = (eventName, fn, options) => {
if (eventName === "blur" && String(fn).match(/pause/)) return;
_addEventListener(eventName, fn, options);
};
const delay = (ms = 0) => {return new Promise((r)=>{setTimeout(r, ms)})}
const waitElementLoad = (elementSelector, selectCount = 1, tryTimes = 1, interval = 0) =>
{
return new Promise(async (resolve, reject)=>
{
let t = 1, result;
while(true)
{
if(selectCount != 1) {if((result = document.querySelectorAll(elementSelector)).length >= selectCount) break;}
else {if(result = document.querySelector(elementSelector)) break;}
if(tryTimes>0 && ++t>tryTimes) return reject(new Error("Wait Timeout"));
await delay(interval);
}
resolve(result);
})
}
const requests = ({method, url, type = "", data = null, headers = {}}) => {
return new Promise(async (resolve, reject) => {
GM_xmlhttpRequest({
method: method,
url: url,
headers: headers,
responseType: type,
data: data,
onload: resolve,
onerror: reject,
onabort: reject
});
});
};
Node.prototype.catch = function ()
{
const a = document.createElement("a")
a.target = "_blank"
this.insertAdjacentElement("beforebegin", a)
a.appendChild(this)
return a
}
const css = document.createElement("style")
css.innerHTML = `
.title,
.forum-category-title,
.group-set > span
{color:var(--primary-text-color)}
`
const embedLink = async () =>
{
const courseID = location.href.match(/(?<=course\/)\d+/)
document.body.appendChild(css)
if (location.href.match(/learning-activity\/full-screen/))
{
const dict = {};
(await requests({method:"get", url:`/api/courses/${courseID}/activities`,type:"json"}))
.response.activities.forEach((e)=>{dict[e.title] = e.type=="questionnaire"?`questionnaire/${e.id}`:e.id});
(await requests({method:"get", url:`/api/courses/${courseID}/exams`,type:"json"}))
.response.exams.forEach((e)=>{dict[e.title] = `exam/${e.id}`});
document.querySelectorAll(".activity a[ng-click]").forEach((e) =>
{
if(!(e.textContent.trim() in dict)) return;
e.addEventListener("click", (e) => e.stopImmediatePropagation(), true)
e.href = `/course/${courseID}/learning-activity/full-screen#/${dict[e.textContent.trim()]}`
})
}
else if (location.href.match(/homework/))
{
const ids = (await requests({method:"get", url:`/api/courses/${courseID}/homework-activities?conditions=%7B%22itemsSortBy%22:%7B%22predicate%22:%22module%22,%22reverse%22:false%7D%7D&page_size=20`,type:"json"}))
.response.homework_activities.map((e)=>e.id)
document.querySelectorAll(".list-item").forEach((e, idx)=>
{
e.querySelectorAll("[ng-click]").forEach((e)=>{
if(!e.closest(".activity-operations-container")) e.addEventListener("click", (e) => e.stopImmediatePropagation(), true)
})
e.catch().href = `/course/${courseID}/learning-activity#/${ids[idx]}`
})
}
else if (location.href.match(/(?<!learning-activity#\/)exam/))
{
const ids = (await requests({method:"get", url:`/api/courses/${courseID}/exam-list?page_size=20`,type:"json"}))
.response.exams.map((e)=>e.id).reverse()
document.querySelectorAll(".sub-title").forEach((e, idx)=>
{
e.querySelector("[ng-click]").addEventListener("click", (e) => e.stopImmediatePropagation(), true)
e.catch().href = `/course/${courseID}/learning-activity#/exam/${ids[idx]}`
})
}
else if(location.href.match(/forum(?!#\/topic-category)/))
{
const ids = (await requests({method:"get", url:`/api/courses/${courseID}/topic-categories?page_size=20`,type:"json"}))
.response.topic_categories.map((e)=>e.id).reverse()
ids.unshift(ids.pop())
document.querySelectorAll(".list-item").forEach((e, idx)=>
{
e.addEventListener("click", (e) => e.stopImmediatePropagation(), true);
e.catch().href = `/course/${courseID}/forum#/topic-category/${ids[idx]}`
})
}
else if(location.href.match(/forum#\/topic-category/))
{
const sort = document.querySelector(".sort-operation .sort-active"),
isReversed = sort.className.match(/down/) ? true : false,
reversed = isReversed ? "&reverse" : "",
sortTypeName = sort.parentElement.parentElement.className,
sortType = {"last-updated-time":"lastUpdatedDate", "replies-number":"reply_count", "like-count":"like_count"}[
["last-updated-time", "replies-number", "like-count"].filter((e)=>sortTypeName.match(e))[0]],
page = document.querySelector(".pager-button.active").innerText,
count = document.querySelector(".last-page").innerText.trim(),
ids = (await requests({method:"get", url:`/api/forum/categories/${location.href.match(/(?<=category\/)\d+/)}?page=${page}&conditions=${
encodeURIComponent(`{"topic_sort_by":{"predicate":"${sortType}","reverse":${String(isReversed)}}}`)}`,type:"json"}))
.response.result.topics.map((e)=>e.id),
idList = ids.join(",")
document.querySelectorAll(".topic-summary a[ng-click]").forEach((e, idx)=>
{
e.addEventListener("click", (e) => e.stopImmediatePropagation(), true);
e.href = `/course/${courseID}/forum#/topics/${ids[idx]}?topicIds=${idList}&pageIndex=${page}&pageCount=${count}&predicate=${sortType}${reversed}`
})
}
else if (location.href.match(/\/user\/index/))
{
const li = (await requests({method:"get", url:`/api/todos`,type:"json"}))
.response.todo_list.sort((a,b)=>new Date(a.end_time)-new Date(b.end_time)).map((e)=>{return {cid:e.course_id, aid:e.id, type:e.type}})
document.querySelectorAll(".todo-list > a").forEach((e, idx)=>
{
switch(li[idx].type)
{
case "homework":
e.href = `/course/${li[idx].cid}/learning-activity#/${li[idx].aid}`
break
case "exam":
e.href = `/course/${li[idx].cid}/learning-activity#/exam/${li[idx].aid}`
break
case "questionnaire":
e.href = `/course/${li[idx].cid}/learning-activity/full-screen#/questionnaire/${li[idx].aid}`
break
}
e.addEventListener("click", (e) => e.stopImmediatePropagation(), true)
})
}
document.querySelectorAll("[id^=learning-activity]").forEach((e) =>
{
const actID = e.id.match(/\d+/),
collapse = e.querySelector(".expand-collapse-attachments")
if(collapse) collapse.addEventListener("click", (e) => e.preventDefault())
e.querySelectorAll(".attachment-row").forEach((e)=>e.addEventListener("click", (e) => e.preventDefault()))
e.querySelector("[ng-click]").addEventListener("click", (e) => {
if(!(e.target == collapse || e.target.closest(".attachment-row"))) e.stopImmediatePropagation();
}, true);
const type = e.querySelector("[ng-switch-when]").getAttribute("ng-switch-when"),
a = e.catch()
switch(type)
{
case "exam":
a.href = `/course/${courseID}/learning-activity#/exam/${actID}`
break
case "web_link":
requests({method:"get",url:`/api/activities/${actID}`,type:"json"}).then((r)=>{a.href = r.response.data.link})
break
case "homework":
a.href = `/course/${courseID}/learning-activity#/${actID}`
break
case "questionnaire":
a.href = `/course/${courseID}/learning-activity/full-screen#/questionnaire/${actID}`
break
case "material":
case "online_video":
case "forum":
default:
a.href = `/course/${courseID}/learning-activity/full-screen#/${actID}`
break
/* TODO:
'slide': '微課程',
'lesson': '錄影教材',
'lesson_replay': '教室录播',
'chatroom': 'iSlide 直播',
'classroom': '隨堂測驗',
'page': '頁面',
'scorm': '第三方教材',
'interaction': '互動教材',
'feedback': '教學回饋',
'virtual_classroom': 'Connect 直播',
'zoom': 'Zoom直播',
'microsoft_teams_meeting': 'Teams 直播',
'google_meeting': 'Google Live',
'webex_meeting': 'Webex 直播',
'welink': 'Welink',
'classin': 'ClassIn 直播',
'live_record': '直播',
'select_student': '選人',
'race_answer': '搶答',
'number_rollcall': '数字点名',
'qr_rollcall': '二维码点名',
'virtual_experiment': '模擬實驗',
'wecom_meeting': 'WeCom會議',
'vocabulary': '詞彙表',
*/
}
})
}
const videoSpeedrun = async (element) =>
{
const [userID, orgID] = st ? [st.userId, st.orgId] : (await requests({method:"get",url:"/api/profile",type:"json"}).then((e)=>[e.response.id, e.response.org.id])),
courseID = st?.tags.course_id || location.href.match(/(?<=course\/)\d+/),
actID = st?.tags.activity_id || location.href.split('/').pop(),
endTime = element.innerText.split(":").map((e)=> Number(e)).reduce((acc, curr, index) => acc + curr * [3600,60,1][index], 0),
need = Number(document.querySelector(".completion-criterion > .attribute-value").innerText.match(/\d+(?=%)/)),
now = (await requests({method:"POST", url:`/api/course/activities-read/${actID}`, type:"json"})).response.data?.completeness || 0
await requests({ // increase student stat times for watch video
method:"post",
url:"/statistics/api/online-videos",
data:`{"user_id":${userID},"org_id":${orgID},"course_id":${courseID},"activity_id":${actID},"action_type":"view","ts":${Date.now()}}`
})
if(now >= need)
{
console.log("速刷已完成");
return;
}
const title = document.querySelector("span.title"),
origText = title.innerText
let res;
for(let nowTime=Math.floor(endTime*0.01*Math.max(now-10,0)); nowTime!=endTime;)
{
const dur = endTime-nowTime<120 ? endTime-nowTime : Math.floor(120-Math.random()*66),
newTime = nowTime + dur
res = await requests({ // watch video api
method: "POST",
url: `/api/course/activities-read/${actID}`,
data: `{"start":${nowTime},"end":${newTime}}`,
headers: {"Content-Type": "application/json"},
type: "json"
}).catch(()=>alert("速刷失敗,請聯絡作者"))
await requests({ // increase student stat video watching time
method:"post",
url:"/statistics/api/online-videos",
data:`{"user_id":${userID},"org_id":${orgID},"course_id":${courseID},"activity_id":${actID},"action_type":"play","ts":${Date.now()},"start_at":${nowTime},"end_at":${newTime},"duration":${dur}}`
})
await requests({ // increase student stat times for access course
method:"post",
url:"/statistics/api/user-visits",
data:`{"user_id":"${userID}","org_id":${orgID},"course_id":"${courseID}","visit_duration":${dur}}`
})
console.log(`${res.response.data.completeness}% ${nowTime}-${newTime} (${endTime})`)
title.innerHTML = `<b>[速刷中] (${res.response.data.completeness}%) ${newTime}/${endTime}s</b> ${origText}`
nowTime = newTime
}
await delay(100)
if(res.response.data.completeness < need) alert("速刷失敗,請聯絡作者")
location.reload()
}
const myStat = async () =>
{
const courseID = location.href.match(/(?<=course\/)\d+/)
}
let lock = false;
waitElementLoad("#ngProgress",1,0,50).then((e)=>{
new MutationObserver(()=>{
if(lock==false && e.style.width=="100%")
{
lock = true
embedLink()
waitElementLoad("span[ng-bind='ui.duration|formatTime']").then(videoSpeedrun).catch(()=>{})
if(location.href.match(/course\/\d+/)) myStat()
}
else if (e.style.width=="0%") lock = false;
}).observe(e, {attributes:true})
})
waitElementLoad("[data-category=tronclass-footer]",1,10,200).then((e)=>e.remove())