TronClass Copilot

Your best copilot for TronClass

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name          TronClass Copilot
// @namespace     Anong0u0
// @version       0.0.11.1
// @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())