为什么做这个一键批量查询核酸的项目,我简单介绍下背景。
周六中午(10.29),我大学同学,拨通了我的微信电话,说在他老家那边做防疫工作,要做查询人员近期做核酸的情况,然后问我有没有批量查询的方法。然后他说他们是在网站上查询的,每次只能查一个人的信息,然后又几万个人的信息要查,我说我研究一下,然后自己写了个批量查询的脚本,但是运行脚本需要在他那边电脑上安装运行环境,后来我就用electron开发了个桌面应用给他用,周末两天开发好了,立即给他用上了。
我自己写了个简单的脚本,可以做到批量查询,然后跟他沟通了具体的需求,整体如下
- 批量查询核酸结果
- 读取excel表格批量查询
- 根据身份证号查询,身份证没有查到再查手机号
- 查询结果去重
- 查询结果筛选,只展示姓名,身份证,手机号和采样时间
- 将查询结果导出到excel
- 罗列展示未查询到信息的身份证,手机
网站的核酸接口有跨域的限制,无法通过自己实现的前端发起请求,所以通过node发请求,该接口返回的是html的文本,需解析请求的结果,再将结果写进excel表格,批量查询通过读取excel的数据,循环调用查询接口,整个过程类似于爬虫。
整合electron,electron包含了node和chromium,正好通过node的发起请求,将结果传给视图层展示。
- electron
- wepack
- react
- node.js
- cheerio
- axios
- node-xlsx
首先需要登录:https://xx.xxx.com/ncov-nat/login
为保护网站安全,这里我屏蔽真实网站地址
可以看到,这里只能单个查询,但是我们有几万要查询,手动复制粘贴查询,也很累。
我们先输入个身份证,查询看看情况,然后在开发者工具中找到这个接口
可以看到这个接口返回的数据是html
我们可以自己写个前端页面发起请求试试,这里我试了下,如果前端直接请求这个接口的话,会有跨域问题
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
fetch("https://xx.xxx.com/ncov-nat/nat/sample-detection-detail-query/personal-list-query", {
"headers": {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
"cache-control": "max-age=0",
"content-type": "application/x-www-form-urlencoded",
"sec-ch-ua": "\" Not;A Brand\";v=\"99\", \"Google Chrome\";v=\"91\", \"Chromium\";v=\"91\"",
"sec-ch-ua-mobile": "?0",
"sec-fetch-dest": "iframe",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"cookie": "xxx"
},
"referrer": "https://xx.xxx.com/ncov-nat/nat/sample-detection-detail-query/personal-list",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "selectScope=2&sampleBarcode=&personName=&identityNumber=441702xxxxxxxx1765&phoneNumber=&sampleDateBegin=&sampleDateEnd=&detectionDateBegin=&detectionDateEnd=",
"method": "POST",
"mode": "cors"
}).then(res => {
console.log(res)
}).catch(err => {
console.error(err)
})
</script>
</body>
</html>
上面代码已做网站信息屏蔽处理
所以,只能通过服务端发起网络请求去获取数据。
再看接口信息,在接口中可以找到cookie保存的登录信息,这个东西后面也会用到,每次发请求都得带上这个cookie,如果cookie过期,则需要重新登录。
导入到postman中请求试试
点右边code,可以复制请求代码
然后选择Nodejs-Axios,复制请求代码,保存起来,后面electron发请求时直接使用这一段代码
通过响应信息,我们可以看到请求结果是一个html文本
可以看到数据都放在一个id为reportTable的表格里
通过解析html表格,就可以取到我们想要的数据
- 可得到核酸查询接口地址:https://xx.xxx.com/ncov-nat/nat/sample-detection-detail-query/personal-list-query (为保护网站安全,已隐藏真实网址)
- 需要登录,可以获取到cookie的相关登录信息
- 接口有跨域限制,只能通过非浏览器请求,获取数据
- 接口返回的数据是html,需自己手动解析才能获取到想要的数据
了解了核酸查询接口的信息,我们就可以进入开发了,目标也很明确
- 通过node发请求调用查询接口
- 然后解析返回的结果,得到我们想要的数据
- 再将结果通过我们自己的前端进行展示,也可以导出到excel中
搭建过程,不过多介绍,见这些文章
- electron入口
- src/index.js
- 主进程代码
- src/main/home
- api/index.js
- index.js
- src/main/home
- 渲染进程代码(react)
- src/renderer/home
- index.js
完整代码见仓库:https://github.com/AlanLee97/electron-detection-batch-query
src/index.js
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监听
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消息调用主进程代码
const { electronAPI } = window
// 获取node查询的结果
const res = await electronAPI.search() || {}
preload.js
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)
}
})
请求核酸查询接口
// 批量查询
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数组中
- 没有td,表示该证件号没有查到相关信息
- 遍历table字符串,解析出单元格数据,拼接表头数据和表的数据保存到excelArr中
- 返回excel二维数组excelArr和未查询到的信息notResultArr
读取excel,保存excel这里就不介绍了
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中,表格展示数据
开发过程中,可以执行npm start
进行开发调试
业务开发完成,打包electron应用,执行npm run package
打包成功后,会在当前项目中生成一个out文件夹
里面有我们打包好的应用,进入文件夹,找到electron.exe的文件,双击执行即可
- 需要手动更新Cookie,还是要手动去粘贴,对于非技术人员使用,还是不太友好
- 后期优化可以做个登录界面,直接登录完,再跳到查询页面
- 打包出来后的体积太大了
过了一周,我问了下我那个同学,软件用得怎么样,有没有什么地方需要改进的 他说用得挺好的,他那边的疫情也稳定了。
其实,这也是我第一次完整的开发了一个electron项目,之前在上一家公司写过electron的项目,但没完全自己从0到1做完一个项目,虽然这个项目挺简单的,也算是一个练手的好项目了。最后能为防疫工作出一点自己的力,也算是一种不错的体验了。
有感兴趣的朋友可以在这里看源码: https://github.com/AlanLee97/electron-detection-batch-query