我用electron開發了個一鍵批量查詢核酸的桌面應用,為防疫工作貢獻自己的一份力
ead>背景
為什麼做這個一鍵批量查詢核酸的專案,我簡單介紹下背景。
週六中午(10.26),我大學同學,撥通了我的微信電話,說在他老家那邊做防疫工作,要做查詢人員近期做核酸的情況,然後問我有沒有批量查詢的方法。然後他說他們是在政府的網站上查詢的,每次只能查一個人的資訊,然後又幾萬個人的資訊要查,我說我研究一下,然後自己寫了個批量查詢的指令碼,但是執行指令碼需要在他那邊電腦上安裝執行環境,後來我就用electron開發了個桌面應用給他用,週末兩天開發好了,立即給他用上了。
需求
我自己寫了個簡單的指令碼,可以做到批量查詢,然後跟他溝通了具體的需求,整體如下
- 批量查詢核酸結果
- 讀取excel表格批量查詢
- 根據身份證號查詢,身份證沒有查到再查手機號
- 查詢結果去重
- 查詢結果篩選,只展示姓名,身份證,手機號和取樣時間
- 將查詢結果匯出到excel
- 羅列展示未查詢到資訊的身份證,手機
技術方案
網站的核酸介面有跨域的限制,無法通過自己實現的前端發起請求,所以通過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
登入成功後,進入綜合查詢->個案查詢
可以看到,這裡只能單個查詢,但是我們有幾萬要查詢,手動複製貼上查詢,也很累。
檢視介面資訊
我們先輸入個身份證,查詢看看情況,然後在開發者工具中找到這個介面
可以看到這個介面返回的資料是html
我們可以自己寫個前端頁面發起請求試試,這裡我試了下,如果前端直接請求這個介面的話,會有跨域問題
```html
``` 所以,只能通過服務端發起網路請求去獲取資料。
再看介面資訊,在介面中可以找到cookie儲存的登入資訊,這個東西后面也會用到,每次發請求都得帶上這個cookie,如果cookie過期,則需要重新登入。
右鍵點選介面,把請求複製下來
匯入到postman中請求試試
點右邊code,可以複製請求程式碼
然後選擇Nodejs-Axios,複製請求程式碼,儲存起來,後面electron發請求時直接使用這一段程式碼
分析請求的資料
通過響應資訊,我們可以看到請求結果是一個html文字
可以看到資料都放在一個id為reportTable的表格裡
通過解析html表格,就可以取到我們想要的資料
總結
- 可得到核酸查詢介面地址:https://hsjc.gdwst.gov.cn/ncov-nat/nat/sample-detection-detail-query/personal-list-query
- 需要登入,可以獲取到cookie的相關登入資訊
- 介面有跨域限制,只能通過非瀏覽器請求,獲取資料
- 介面返回的資料是html,需自己手動解析才能獲取到想要的資料
搭建electron專案
瞭解了核酸查詢介面的資訊,我們就可以進入開發了,目標也很明確
- 通過node發請求呼叫查詢介面
- 然後解析返回的結果,得到我們想要的資料
- 再將結果通過我們自己的前端進行展示,也可以匯出到excel中
搭建過程
搭建過程,不過多介紹,見這些文章
目錄結構
- 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的程式碼,發起網路請求,前端通過回撥函式拿到網路請求的資料。
前端傳送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) } }
```
這裡的邏輯主要如下
- 讀取excel的資料儲存在state.excelData裡,包含證件號idNumArr,手機號phoneArr的資料
- 遍歷idNumArr,通過search方法去請求核酸介面,拿到html字串
- 通過getTableStr方法,拿到table字串
- 判斷table字串中有無td元素
- 沒有td,表示該證件號沒有查到相關資訊
- 再用對應的手機號查詢是否有資訊
- 沒有資訊,儲存當前證件號和手機號
- 有結果,返回查詢結果
- 再用對應的手機號查詢是否有資訊
- 有td,有查詢到結果,將table字串放進tableHTMLArr陣列中
- 遍歷table字串,解析出單元格資料,拼接表頭資料和表的資料儲存到excelArr中
- 返回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函式的業務邏輯主要如下:
- 傳送ipc訊息給主程序,通知主程序發起請求獲取資料,並傳回處理好的資料
- 拼接好antd的Table元件需要的資料格式
- 資料去重,因為這個介面可以查出一個人好幾天的核酸資訊,會有多條資料,所以去重,留著最新的資料
- 篩選列資料,因為介面中查詢出來有些資料我們不關心,所以只篩選我們感興趣的列資料
- 將處理好的資料儲存打到state中,表格展示資料
打包electron應用
開發過程中,可以執行npm start
進行開發除錯
業務開發完成,打包electron應用,執行npm run package
打包成功後,會在當前專案中生成一個out資料夾
裡面有我們打包好的應用,進入資料夾,找到electron.exe的檔案,雙擊執行即可
介面展示
動圖演示
待優化的地方
- 打包出來後的體積太大了
exe檔案130多M
壓縮資料夾之後也還有120M
後記
過了一週,我問了下我那個同學,軟體用得怎麼樣,有沒有什麼地方需要改進的 他說用得挺好的,他那邊的疫情也穩定了。
其實,這也是我第一次完整的開發了一個electron專案,之前在上一家公司寫過electron的專案,但沒完全自己從0到1做完一個專案,雖然這個專案挺簡單的,也算是一個練手的好專案了。最後能為防疫工作出一點自己的力,也算是一種不錯的體驗了。
有感興趣的朋友可以在這裡看原始碼: https://github.com/AlanLee97/electron-detection-batch-query