我用electron開發了個一鍵批量查詢核酸的桌面應用,為防疫工作貢獻自己的一份力

語言: CN / TW / HK
ead>

背景

為什麼做這個一鍵批量查詢核酸的專案,我簡單介紹下背景。

週六中午(10.26),我大學同學,撥通了我的微信電話,說在他老家那邊做防疫工作,要做查詢人員近期做核酸的情況,然後問我有沒有批量查詢的方法。然後他說他們是在政府的網站上查詢的,每次只能查一個人的資訊,然後又幾萬個人的資訊要查,我說我研究一下,然後自己寫了個批量查詢的指令碼,但是執行指令碼需要在他那邊電腦上安裝執行環境,後來我就用electron開發了個桌面應用給他用,週末兩天開發好了,立即給他用上了。

需求

我自己寫了個簡單的指令碼,可以做到批量查詢,然後跟他溝通了具體的需求,整體如下

  1. 批量查詢核酸結果
  2. 讀取excel表格批量查詢
  3. 根據身份證號查詢,身份證沒有查到再查手機號
  4. 查詢結果去重
  5. 查詢結果篩選,只展示姓名,身份證,手機號和取樣時間
  6. 將查詢結果匯出到excel
  7. 羅列展示未查詢到資訊的身份證,手機

技術方案

網站的核酸介面有跨域的限制,無法通過自己實現的前端發起請求,所以通過node發請求,該介面返回的是html的文字,需解析請求的結果,再將結果寫進excel表格,批量查詢通過讀取excel的資料,迴圈呼叫查詢介面,整個過程類似於爬蟲。 整合electron,electron包含了node和chromium,正好通過node的發起請求,將結果傳給檢視層展示。

專案技術

  • electron
  • wepack
  • react
  • node.js
  • cheerio
  • axios
  • node-xlsx

專案開發

研究核酸查詢介面

進入查詢頁面

首先需要登入:https://hsjc.gdwst.gov.cn/ncov-nat/login image.png 登入成功後,進入綜合查詢->個案查詢 image.png

image.png 可以看到,這裡只能單個查詢,但是我們有幾萬要查詢,手動複製貼上查詢,也很累。

檢視介面資訊

我們先輸入個身份證,查詢看看情況,然後在開發者工具中找到這個介面 image.png 可以看到這個介面返回的資料是html image.png

我們可以自己寫個前端頁面發起請求試試,這裡我試了下,如果前端直接請求這個介面的話,會有跨域問題 image.png ```html

Document

``` 所以,只能通過服務端發起網路請求去獲取資料。

再看介面資訊,在介面中可以找到cookie儲存的登入資訊,這個東西后面也會用到,每次發請求都得帶上這個cookie,如果cookie過期,則需要重新登入。 image.png

右鍵點選介面,把請求複製下來 image.png

匯入到postman中請求試試 image.png image.png image.png image.png 點右邊code,可以複製請求程式碼 image.png 然後選擇Nodejs-Axios,複製請求程式碼,儲存起來,後面electron發請求時直接使用這一段程式碼 image.png

分析請求的資料

通過響應資訊,我們可以看到請求結果是一個html文字 image.png 可以看到資料都放在一個id為reportTable的表格裡 通過解析html表格,就可以取到我們想要的資料

總結

  1. 可得到核酸查詢介面地址:https://hsjc.gdwst.gov.cn/ncov-nat/nat/sample-detection-detail-query/personal-list-query
  2. 需要登入,可以獲取到cookie的相關登入資訊
  3. 介面有跨域限制,只能通過非瀏覽器請求,獲取資料
  4. 介面返回的資料是html,需自己手動解析才能獲取到想要的資料

搭建electron專案

瞭解了核酸查詢介面的資訊,我們就可以進入開發了,目標也很明確

  1. 通過node發請求呼叫查詢介面
  2. 然後解析返回的結果,得到我們想要的資料
  3. 再將結果通過我們自己的前端進行展示,也可以匯出到excel中

搭建過程

搭建過程,不過多介紹,見這些文章

目錄結構

image.png

  • electron入口
  • src/index.js
  • 主程序程式碼
  • src/main/home
    • api/index.js
    • index.js
  • 渲染程序程式碼(react)
  • src/renderer/home
  • index.js

完整程式碼見倉庫:https://github.com/AlanLee97/electron-detection-batch-query

業務開發

編寫electron入口程式碼

src/index.js ```javascript const {mainHome} = require("./main/home"); // 引入主程序業務程式碼

const {app, BrowserWindow, Menu} = require('electron'); const path = require('path');

let win = null; function createWindow(filePath = "./dist/index.html") { win = new BrowserWindow({ width: 800, height: 600, webPreferences: { // 預載入,將electron的API掛載window上 preload: path.join(__dirname, './preload.js') } });

win.loadURL(filePath); }

app.whenReady().then(() => { mainHome.init(); // 引入主程序業務程式碼 const path = (__dirname + '').replace('src', 'dist/index.html') createWindow(path); // 隱藏選單欄 Menu.setApplicationMenu(null) }) ```

主程序業務程式碼

註冊ipcMain監聽 ```javascript function registerEvent() { // 監聽 查詢 訊息 ipcMain.handle('request:search', async () => { const res = await batchQuery() state.excelArr = res.excelArr state.notResultArr = res.notResultArr return res })

// 監聽 讀取excel 訊息 ipcMain.handle('file:readExcel', async () => { const { canceled, filePaths } = await dialog.showOpenDialog() if (canceled) { resetState() return state.excelData } else { const path = filePaths[0] const excelData = readExcel(path) state.excelData = excelData return excelData } })

// 監聽 匯出excel 訊息 ipcMain.on('file:exportExcel', async (event, val) => { return await writeExcel(val) })

// 監聽 登入cookie 訊息 ipcMain.on('login:cookie', async (event, val) => { state.loginCookie = val })

} ```

這裡註冊的監聽程式碼,只要檢視層發起ipc通訊,就會觸發這裡的監聽程式碼,然後呼叫node層相關的程式碼完成相關的邏輯。electron載入的時候通過preload.js定義的一些方法,暴露給前端的window,通過window可以呼叫相關的electron的相關api傳送ipc訊息

比如,通過檢視層(react)檢視中的【查詢】按鈕,檢視層呼叫preload裡定義的search方法,可以呼叫主程序中的node的程式碼,發起網路請求,前端通過回撥函式拿到網路請求的資料。 image.png

前端傳送ipc訊息呼叫主程序程式碼 javascript const { electronAPI } = window // 獲取node查詢的結果 const res = await electronAPI.search() || {}

preload.js ```javascript const { contextBridge, ipcRenderer } = require('electron')

// 將electron的api掛載到window contextBridge.exposeInMainWorld('electronAPI',{ // 查詢結果 search: async () => { return ipcRenderer.invoke('request:search') }, // 讀取excel readExcel: async () => { return ipcRenderer.invoke('file:readExcel') }, // 匯出excel exportExcel: async (val) => { return await ipcRenderer.send('file:exportExcel', val) }, // 更新cookie updateCookie: async (val) => { return ipcRenderer.send('login:cookie', val) } }) ```

為了好理解,我畫了個圖

請求核酸查詢介面 ```javascript // 批量查詢 async function batchQuery() { try { const {idNumArr, phoneArr} = state.excelData const tableHTMLArr = [] // table字串陣列 const notResultArr = [] // 未查詢到結果的資料 // 迴圈查詢結果,先查證件號,再查手機號,手機table字串 for(let i = 0; i < idNumArr.length; i++) { let res = await search({identityNumber: idNumArr[i], loginCookie: state.loginCookie}) let tableHtml = getTableStr(res)

  const $ = cheerio.load(tableHtml);
  const tds = $('#reportTable').find('td')
  console.log('證件號查詢結果為空,查手機號' + phoneArr[i])

  if(tds.length === 0) { // 證件號查詢為空,查手機號
    const phoneNum = state.excelData.phoneArr[i]
    if (phoneNum) {
      res = await search({phoneNumber: phoneNum, loginCookie: state.loginCookie})
      tableHtml = getTableStr(res)
      const $ = cheerio.load(tableHtml);
      const tds = $('#reportTable').find('td')
      // 手機號碼也沒查到資料,將沒有查到資訊的資料儲存起來,提示使用者手動查詢確認
      if(tds.length === 0) {
        notResultArr.push([idNumArr[i], phoneNum])
      }
    }
  }
  tableHTMLArr.push(tableHtml)
}

const excelArr = []
// 遍歷table字串,解析出單元格資料
tableHTMLArr.forEach((tableStr, i) => {
  const arrObj = tableToArr(tableStr) // 解析出單元格資料
  // 拼接表頭資料
  if(i === 0) {
    excelArr.push([...arrObj.head])
  }

  // 拼接表的資料
  let data = arrObj.data || []
  data.forEach(item => {
    excelArr.push(item)
  })
})
// 返回excel二維陣列和未查詢到的資訊
return {excelArr, notResultArr}

} catch (error) { console.error('查詢中失敗,請重試', error) } }

```

這裡的邏輯主要如下

  1. 讀取excel的資料儲存在state.excelData裡,包含證件號idNumArr,手機號phoneArr的資料
  2. 遍歷idNumArr,通過search方法去請求核酸介面,拿到html字串
  3. 通過getTableStr方法,拿到table字串
  4. 判斷table字串中有無td元素
  5. 沒有td,表示該證件號沒有查到相關資訊
    1. 再用對應的手機號查詢是否有資訊
      1. 沒有資訊,儲存當前證件號和手機號
      2. 有結果,返回查詢結果
  6. 有td,有查詢到結果,將table字串放進tableHTMLArr陣列中
  7. 遍歷table字串,解析出單元格資料,拼接表頭資料和表的資料儲存到excelArr中
  8. 返回excel二維陣列excelArr和未查詢到的資訊notResultArr

讀取excel,儲存excel這裡就不介紹了

渲染程序業務程式碼

```jsx import React from "react"; import { Button, Space, Table, message, Input } from 'antd'; import './style.css';

class App extends React.Component { constructor(props) { super(props); this.state = { dataSource: [], // 表格資料來源 columns: [], // 表格表頭 excelData: { // 要匯出的excel資料 phoneArr: [], idNumArr: [] }, loadingTable: false, // table載入狀態 searchOk: false, // 查詢是否成功 // ... } }

// ...

// 查詢 query = async () => { try { const {excelData = {}} = this.state const {phoneArr = [], idNumArr = []} = excelData if(!(phoneArr.length > 0 || idNumArr.length > 0)) { message.warning('請先讀取excel表資料,並且確保excel有資料'); return } this.setState({ loadingTable: true }) const { electronAPI } = window // 獲取node查詢的結果 const res = await electronAPI.search() || {} let {excelArr = [], notResultArr = []} = res

  notResultArr = notResultArr.map(item => {
    return {
      '證件號碼': item[0],
      '電話號碼': item[1]
    }
  })

  const head = excelArr.shift() || []
  const data = excelArr

  // 拼接表頭資訊
  let columns = head.map(item => {
    return {
      title: item,
      dataIndex: item,
      key: item,
    }
  })

  // 拼接資料來源
  let dataSource = data.map((itemArr, i) => {
    const obj = {}
    itemArr.forEach((item, j) => {
      obj[head[j]] = item
      obj['key'] = i
    })
    return obj
  })

  // 去重
  let clearDuplicate = (arr, key) => Array.from(new Set(arr.map(e => e[key]))).map(e => arr.findIndex(x => x[key] == e)).map(e => arr[e])
  dataSource = clearDuplicate(dataSource, '證件號碼')

  // filter column
  const filterArr = ['姓名', '證件號碼', '電話號碼', '取樣時間', '檢測時間', '檢測結果']
  columns = columns.filter(item => filterArr.includes(item.title))

  // 篩選列資料
  dataSource = dataSource.map(item => {
    let obj = {}
    filterArr.forEach(key => {
      obj[key] = item[key]
    })
    return obj
  })
  this.setState({
    columns,
    dataSource,
    notResultArr
  }, () => {
    this.setState({
      loadingTable: false,
      searchOk: true
    })
  })
} catch (error) {
  message.error('查詢失敗,請重試。Error:' + error)
  console.error(error)
  this.setState({
    loadingTable: false,
    searchOk: false
  })
}

}

render() { const {dataSource, columns, excelData, loadingTable, searchOk, updateCookie, notResultArr, notResultArrHead} = this.state

return <div className="page">
  // ...

  {
    <Table loading={loadingTable} dataSource={dataSource} columns={columns} scroll={{ x: 'max-content' }} 
    pagination={
      {
        total: dataSource.length,
        showSizeChanger: true,
        showQuickJumper: true,
        pageSize: 10,
        showTotal: total => `總共 ${total} 條`
      }
    } />
  }

  // ...

</div>

} }

export default App; ``` 這個query函式的業務邏輯主要如下:

  1. 傳送ipc訊息給主程序,通知主程序發起請求獲取資料,並傳回處理好的資料
  2. 拼接好antd的Table元件需要的資料格式
  3. 資料去重,因為這個介面可以查出一個人好幾天的核酸資訊,會有多條資料,所以去重,留著最新的資料
  4. 篩選列資料,因為介面中查詢出來有些資料我們不關心,所以只篩選我們感興趣的列資料
  5. 將處理好的資料儲存打到state中,表格展示資料

image.png

打包electron應用

開發過程中,可以執行npm start進行開發除錯

業務開發完成,打包electron應用,執行npm run package

打包成功後,會在當前專案中生成一個out資料夾 image.png 裡面有我們打包好的應用,進入資料夾,找到electron.exe的檔案,雙擊執行即可 image.png

介面展示

image.png image.png image.png

動圖演示

演示.gif

待優化的地方

  1. 打包出來後的體積太大了

exe檔案130多M image.png 壓縮資料夾之後也還有120M image.png

後記

過了一週,我問了下我那個同學,軟體用得怎麼樣,有沒有什麼地方需要改進的 他說用得挺好的,他那邊的疫情也穩定了。

image.png image.png

其實,這也是我第一次完整的開發了一個electron專案,之前在上一家公司寫過electron的專案,但沒完全自己從0到1做完一個專案,雖然這個專案挺簡單的,也算是一個練手的好專案了。最後能為防疫工作出一點自己的力,也算是一種不錯的體驗了。

有感興趣的朋友可以在這裡看原始碼: https://github.com/AlanLee97/electron-detection-batch-query