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