云学堂自动化学习脚本

此脚本的默认行为为打开主页,找到“我的任务”,如果有任务,则点击“开始学习”,在任务中心对每个任务进行遍历,直到所有进度完成。如果任务带有考试,那么打开考试网页并等待回答完毕,且正确率合格后继续进行。

如果没有任务,则打开“课程库”并按照“学习人数”排序,遍历所有分页执对没有完成学习的知识进行学习。

在任何学习过程中,每隔 10s 读取学习进度,如果进度完成则视为学习完成,如果弹框提示“不要走开”或者“你已在学习其他知识”,则自动点击确定跳过并继续学习。

在大部分情况下,不需要任何手动干预即可完成学习

此脚本需要 Chrome、对应 Chrome 版本的 Chrome WebDriver,Babashka,登录所需凭证在 config.edn 中配置或暴露为环境变量 EDU_PASS。可配合 clj-runner 实现双击脚本直接运行。

所需的工具除了 Chrome 浏览器外如下所示:

  • chromedriver.exe

    Chrome WebDriver 驱动,需要和 Chrome 版本一致,下载

  • bb.exe

    Babashka SCI 执行环境,需要将其暴露到 PATH,下载

  • config.edn

    内容如下所示,或不提供此文件且直接暴露环境变量 EDU_PASS,格式为 {username}::{password}::{driver-path}。

    {:user ""
     :pass ""
     :path-driver "chromedriver.exe"}
    
  • script.clj

    内容如下所示,直接 clj-runner script.clj 执行或双击选择使用 clj-runner 打开(Windows)

#!/usr/bin/env bb -Sdeps '{:paths ["."] :deps {etaoin/etaoin {:mvn/version,"1.0.39"}}}'
(ns yxt-learn
  "学习自动化,需要暴露变量 EDU_PASS,格式为 {username}::{password}::{driver-path} 或提供 config.edn 文件。"
  (:require [clojure.string :as str]
            [etaoin.api :as e]
            [clojure.edn :as edn]
            [clojure.java.io :as io])
  (:import (java.time Duration LocalTime)
           (java.util Base64)))

(def config (if (.exists (io/file "config.edn"))
              (edn/read-string (slurp "config.edn"))
              (if-let [pass-env (System/getenv "EDU_PASS")]
                  (let [pass (.split (String. (.decode (Base64/getDecoder) pass-env)) "::")]
                    {:user (first pass) 
                     :pass (second pass) 
                     :path-driver (last pass)})
                  {:user "xxx@xxx.com"
                   :pass "YOUR_PASS_HERE"
                   :path-driver (if (= "SCI" (-> *clojure-version* :qualifier))
                                "../resources/chromedriver.exe"
                                "resources/chromedriver.exe")})))

(defn wait-seconds [seconds stop]
  (let [c (atom seconds)]
    (while (and (> @c 0) (not @stop))
      (Thread/sleep 1000)
      (swap! c dec))))

(defn go-home [driver]
  (e/go driver "https://LEARN_WEB_SITE.com")
  (e/wait-visible driver {:id :btnLogin2})
  (e/fill driver {:id :txtUserName2} (:user config))
  (e/fill driver {:id :txtPassword2} (:pass config))
  (e/click driver {:id :chkloginpass})
  (e/click driver {:id :btnLogin2})
  (e/wait-visible driver {:id :panel2})
  (e/wait driver 2)
  (println "task count: " (e/get-element-text driver {:id :panel2})
           ", exam count: " (e/get-element-text driver {:id :panel3})))

(defn goto-learn-page [driver]
  ;随机漫步
  (e/click driver {:css "#divContents > div > div.banner-bg > div > div.remind.fr > div.infor-wrap > div > ul > li:nth-child(2) > a > div > div.text-trim.gray3"})
  (e/wait-visible driver {:id :StudyPersonCount0})
  (e/click driver {:id :StudyPersonCount0}))

(defn learn-doc [driver need-learn-minute block?]
  (let [control (atom false)]
    (let [need-learn
          (fn [] (let [tasks (filterv #(not (str/includes? (or (e/get-element-text-el driver %) "") "已完成"))
                                      (e/query-all driver [{:tag :ul :class "el-kng-img-list clearfix"} {:tag :li}]))]
                   (if (empty? tasks)
                     (do
                       (println "empty tasks this page, go next...")
                       (e/click driver {:css "a[title=\"下一页\"]"})
                       (e/wait driver 1)
                       (recur))
                     tasks)))
          business
          (fn [] (let [start-learn (LocalTime/now)]
                   (while true
                     (e/switch-window-next driver)
                     (e/refresh driver)
                     (let [task (first (need-learn))
                           passed-minutes (.toMinutes (Duration/between start-learn (LocalTime/now)))]
                       (cond (> passed-minutes need-learn-minute)
                             (throw (RuntimeException. "已完成学习"))
                             @control
                             (throw (RuntimeException. "停止学习"))
                             :else
                             (do (println "starting learning "
                                          (str/replace (e/get-element-text-el driver task) "\n" ""))
                                 (e/click-el driver task)
                                 (println "waiting for new window...")
                                 (e/switch-window-next driver)
                                 (while (and (not= "100%" (e/get-element-text driver {:id :ScheduleText}))
                                             (not @control))
                                   (e/wait driver 1)
                                   (println "waiting need time: " (e/get-element-text driver {:id :spanLeavTimes}))
                                   (when (e/exists? driver {:id :reStartStudy})
                                     (println "skipping hint...")
                                     (e/click driver {:id :reStartStudy}))
                                   (when (e/exists? driver {:css "#dvHeartTip > input:nth-child(5)"})
                                     (println "force learn...")
                                     (e/click driver {:css "#dvHeartTip > input:nth-child(5)"}))
                                   (wait-seconds 10 control))
                                 (e/close-window driver)
                                 (println "stop this task learn...")))))))]
      (if block?
        (business)
        (future (business)))
      control)))

(defn week-learn [driver]
  (e/click driver {:id "panel2"})
  ;判断是否有未完成任务,打开新标签学习,学完回来刷新继续判断
  (e/wait driver 1)
  (when (e/exists? driver {:id :contentitem1})
    (e/click driver {:id :contentitem1})
    (e/switch-window-next driver)
    ;学习任务总览页
    (loop []
      (let [all-task (->> (e/query-tree driver {:class :hand})
                          (map #(e/get-element-text-el driver %)))
            all-unfinished-task-el
            (filterv
              (fn [ele]
                (let [process (e/get-element-text-el driver ele)]
                  (and (not (nil? process))
                       (not (.endsWith (.trim process) "100%")))))
              (e/query-all driver {:class :hand}))
            all-unfinished-task-name
            (map #(str/replace (e/get-element-text-el driver %)
                               "\n" "")
                 all-unfinished-task-el)]
        (if-not (first all-unfinished-task-el)
          (e/quit driver)                                   ;如果当前任务已完成,则结束(最终流程)
          (let []
            ;学习任务详情页
            (e/wait driver 2)
            (e/click-el driver (first all-unfinished-task-el))
            (e/wait driver 2)
            (e/switch-window-next driver)
            (cond (e/exists? driver {:css "#btnStartStudy"})
                  (e/click driver {:css "#btnStartStudy"})
                  (e/exists? driver {:css "#btnContinueStudy"})
                  (e/click driver {:css "#btnContinueStudy"})
                  :else :done #_(throw (RuntimeException. "没有找到开始学习或继续学习按钮"))
                  ;不一定,此处可能直接进入了单页面学习界面
                  )
            (if (e/exists? driver {:id :btnTest})           ;如果是测试,停止并告知用户,反之开始学习
              (do (e/click driver {:id :btnTest})
                  ;如果是考试,等待通过并执行如下代码
                  (while (empty? (filterv
                                   (fn [ele]
                                     (let [name (e/get-element-text-el driver ele)]
                                       (= (str/trim name) "通过")))
                                   (e/query-all driver {:class "text-center"})))
                    (Thread/sleep 5000))
                  (recur))
              (let [kill-future (atom false)]
                (while (and (not (= "100%" (e/get-element-text driver {:id :ScheduleText})))
                            (not @kill-future))
                  (println (format "当前视频 [%s] 进度 %s,预计剩余时间 %s"
                                   (e/get-element-text driver {:id :lblTitle})
                                   (e/get-element-text driver {:id :ScheduleText})
                                   (let [remain (e/get-element-text driver {:id :spanLeavTimes})]
                                     (if (str/blank? remain)
                                       "0 分钟" remain))))
                  (when (e/exists? driver {:id :reStartStudy})
                    (println "skipping hint...")
                    (e/click driver {:id :reStartStudy}))
                  (when (e/exists? driver {:css "#dvHeartTip > input:nth-child(5)"})
                    (println "force learn...")
                    (e/click driver {:css "#dvHeartTip > input:nth-child(5)"}))
                  (wait-seconds 20 kill-future))
                (e/close-window driver)
                (e/switch-window-next driver)
                (e/reload driver)
                (recur)))))))))

(defn -main [_]
  (let [learn-minute 60
        driver (e/chrome (merge {:path-driver (:path-driver config) :log-level :warn}
                                (if-let [chrome (:chrome config)] {:path-browser chrome} {})))]
    (try
      (.addShutdownHook (Runtime/getRuntime)
                        (Thread. (fn [] (try (e/quit driver) (catch Exception _)))))
      (go-home driver)

      (if-let [res (e/get-element-text driver {:id :panel2})]
        (if (= "0" res)
          (do (println "task count 0, just walk!")
              ;2. 随机漫步:在文档学习页进行多次学习
              (goto-learn-page driver)
              (learn-doc driver learn-minute true))
          (do (println "finding task count" res)
              ;1. 任务学习
              (week-learn driver)))
        (println "no task found! exit now!"))
      (catch Exception e
        (.printStackTrace e)
        (try (e/quit driver) (catch Exception _))
        (Thread/sleep 5000)
        (-main nil))
      (finally (try (e/quit driver) (catch Exception _))))))

(when (= (-> *clojure-version* :qualifier) "SCI")
  (-main nil))