禅道日报自动化脚本

下面展示了一种一键将 Microsoft TODO 待办事项导入到禅道日志并作为日报的脚本,在完成日报填写后执行了 WebHook 回调。可根据自己需求决定日报数据的来源和执行后的动作(比如发送邮件或发送信息到钉钉、Slack 等)。如果当日有修复 BUG 等任务,会自动将其清空并使用我们自己的数据源。

在绝大部分情况下,不需要任何手动干预即可完成数据导入

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

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

  • chromedriver.exe

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

  • bb.exe

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

  • config.edn

    内容如下所示,或不提供此文件且直接暴露环境变量 ZENDAO_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 zendao-auto
  "日报自动化,需要暴露变量 ZENDAO_PASS,格式为 {username}::{password}::{driver-path} 或提供 config.edn 文件。"
  (:require
    [clojure.string :as str]
    [org.httpkit.client :as http]
    [etaoin.api :as e]
    [cheshire.core :as json])
  (:import (java.util Base64)))

(def config (merge
              (if-let [pass-env (System/getenv "ZENDAO_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")})
              {:cyberToken           (System/getenv "CYBER_TOKEN")
               :cyberUrl             "日报的数据源(这里是我自己的 Miscrosoft TODO 接口)"
               :callbackPostCyberUrl "执行后的回调接口"}))

(defn go-to-log-page [driver]
  (println "try login now...")
  (e/go driver "https://ZENDAO_URL")
  (e/wait-visible driver {:id :account})
  (e/fill driver {:id :account} (:user config))
  (e/fill driver {:type :password} (:pass config))
  (e/click driver {:id :submit})
  (e/wait-visible driver "//*[@id=\"menuMainNav\"]/li[1]/a/span")
  (println "login success, go to log page...")
  (e/switch-frame driver {:id :appIframe-my})
  (e/click driver {:xpath "/html/body/header/div/div/nav/ul/li[3]/a"})
  (e/wait-visible driver {:css ".current"}))

(defn go-to-new-log-page
  "进入新增日志模态框"
  [driver]
  (println "go to new log page...")
  (e/click driver {:css ".current"})
  (e/switch-frame driver {:id :iframe-triggerModal})
  (try
    (e/wait-visible driver {:id :submit})
    (catch Exception _
      (println "today have work log, please delete them first!")
      (e/js-execute driver "alert('日志不为空,请删除日志后关闭程序并重试!')"))))

(defn fill-tasks-on-log-page [driver tasks]
  (if (empty? tasks)
    (do (println "no task today, exist now..."))
    (do (e/click driver {:css ".modal-actions > button:nth-child(1)"})
        (e/wait driver 1)
        (let [contents (e/query-all driver {:id "work[]"})
              objs (e/query-all driver {:id "objectType_chosen"})
              lefts (filterv #(let [name (e/get-element-attr-el driver % :name)]
                                (and name (str/starts-with? name "left[")))
                             (e/query-all driver {:tag :input :class "form-control"}))]
          (doseq [[{:keys [title hour] :as item} index]
                  (zipmap tasks [0 1 2 3 4 5 6 7 8])]
            (println "now handling" item)
            (e/click-el driver (nth contents index))
            (e/fill-el driver (nth contents index) title)
            (e/click-el driver (nth objs index))
            (e/wait driver 0.5)
            (let [suggest-obj-selects
                  (first (filter
                           #(let [title (e/get-element-attr-el driver % :title)]
                              (and title (str/includes? title "任务")))
                           (e/query-all driver {:css (format "#objectTable > tbody > tr:nth-child(%s) li"
                                                             (+ index 1))})))]
              (println "choose task: " (e/get-element-attr-el driver suggest-obj-selects :title))
              (e/click-el driver suggest-obj-selects)
              (e/click-el driver (nth lefts index))
              (e/fill-el driver (nth lefts index) "1")
              (let [costs (e/query-all driver {:id "consumed[]"})]
                (e/click-el driver (nth costs index))
                (e/fill-el driver (nth costs index) (str hour))
                (e/wait driver 0.5))))
          (e/click driver {:id "submit"})))))

(defn -main []
  (let [tasks (-> (http/request {:url     (:cyberUrl config)
                                 :headers {"authorization" (:cyberToken config)}})
                  deref
                  :body
                  (json/parse-string true)
                  :data)]
    (if-not (empty? tasks)
      (let [driver (e/chrome (merge {:path-driver (:path-driver config)
                                     :log-level   :warn}
                                    (if-let [chrome (:chrome config)]
                                      {:path-browser chrome}
                                      {})))]
        (go-to-log-page driver)
        (go-to-new-log-page driver)
        (Thread/sleep 2000)
        (fill-tasks-on-log-page driver tasks)
        (-> (http/request {:url     (:callbackPostCyberUrl config)
                           :method  :post
                           :headers {"authorization" (:cyberToken config)
                                     "content-type" "application/json"}
                           :body    (json/generate-string {:status "已完成日报"} true)})
            deref
            :body
            (json/parse-string true)
            println)
        ;等待手动检查确认无误
        (Thread/sleep 5000)
        (e/quit driver))
      (println "no task in Microsoft TODO, exist now..."))))

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