我用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