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