/electron-detection-batch-query

Electron应用,核酸检测情况批量查询,读取excel表格的手机号和身份证号,批量查询人员核酸结果,也可将查询结果导出到excel。

Primary LanguageJavaScript

背景

为什么做这个一键批量查询核酸的项目,我简单介绍下背景。

周六中午(10.29),我大学同学,拨通了我的微信电话,说在他老家那边做防疫工作,要做查询人员近期做核酸的情况,然后问我有没有批量查询的方法。然后他说他们是在网站上查询的,每次只能查一个人的信息,然后又几万个人的信息要查,我说我研究一下,然后自己写了个批量查询的脚本,但是运行脚本需要在他那边电脑上安装运行环境,后来我就用electron开发了个桌面应用给他用,周末两天开发好了,立即给他用上了。

需求

我自己写了个简单的脚本,可以做到批量查询,然后跟他沟通了具体的需求,整体如下

  1. 批量查询核酸结果
    1. 读取excel表格批量查询
    2. 根据身份证号查询,身份证没有查到再查手机号
  2. 查询结果去重
  3. 查询结果筛选,只展示姓名,身份证,手机号和采样时间
  4. 将查询结果导出到excel
  5. 罗列展示未查询到信息的身份证,手机

技术方案

网站的核酸接口有跨域的限制,无法通过自己实现的前端发起请求,所以通过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

为保护网站安全,这里我屏蔽真实网站地址

image.png 登录成功后,进入综合查询->个案查询 image.png

image.png 可以看到,这里只能单个查询,但是我们有几万要查询,手动复制粘贴查询,也很累。

查看接口信息

我们先输入个身份证,查询看看情况,然后在开发者工具中找到这个接口 image.png 可以看到这个接口返回的数据是html image.png

我们可以自己写个前端页面发起请求试试,这里我试了下,如果前端直接请求这个接口的话,会有跨域问题 image.png

<!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过期,则需要重新登录。 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://xx.xxx.com/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

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的代码,发起网络请求,前端通过回调函数拿到网络请求的数据。 image.png 前端发送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)
  }
}

这里的逻辑主要如下

  1. 读取excel的数据保存在state.excelData里,包含证件号idNumArr,手机号phoneArr的数据
  2. 遍历idNumArr,通过search方法去请求核酸接口,拿到html字符串
  3. 通过getTableStr方法,拿到table字符串
  4. 判断table字符串中有无td元素
    1. 没有td,表示该证件号没有查到相关信息
      1. 再用对应的手机号查询是否有信息
        1. 没有信息,保存当前证件号和手机号
        2. 有结果,返回查询结果
    2. 有td,有查询到结果,将table字符串放进tableHTMLArr数组中
  5. 遍历table字符串,解析出单元格数据,拼接表头数据和表的数据保存到excelArr中
  6. 返回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函数的业务逻辑主要如下:

  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. 需要手动更新Cookie,还是要手动去粘贴,对于非技术人员使用,还是不太友好
    1. 后期优化可以做个登录界面,直接登录完,再跳到查询页面
  2. 打包出来后的体积太大了

exe文件130多M image.png 压缩文件夹之后也还有120M image.png

后记

过了一周,我问了下我那个同学,软件用得怎么样,有没有什么地方需要改进的 他说用得挺好的,他那边的疫情也稳定了。

image.png image.png

其实,这也是我第一次完整的开发了一个electron项目,之前在上一家公司写过electron的项目,但没完全自己从0到1做完一个项目,虽然这个项目挺简单的,也算是一个练手的好项目了。最后能为防疫工作出一点自己的力,也算是一种不错的体验了。

有感兴趣的朋友可以在这里看源码: https://github.com/AlanLee97/electron-detection-batch-query