需求分析

丁香医生小程序中的图片大多通过imgmap引入

目前的图片链接为http,通过静态资源库上传

  1. const host = 'http://special.dxycdn.com/topic/lizy/resource/dxy-doctor-weapp';
  2. const imgMap = {
  3. close2: `${host}/ic_close2@2x.png`
  4. }

需要更换cdn的地址为https,通过管理后台上传

  1. const imgMap = {
  2. close2: 'https://img1.dxycdn.com/2019/0125/899/3325176078032906703-22.png',
  3. }

一共有151张图片需要处理

方案选择

程序的实现思路大致如下,输入是imgMap.js源文件,输出是一份json文件,格式如下

  1. {
  2. noMatchObj: {},
  3. noExistObj: {},
  4. successObj: {},
  5. failObj: {}
  6. }

程序的过程大致如下:

关键在于如何用管理后台实现图片的上传

如果是人工来做这个事情,需要有如下的步骤

  1. 管理后台登录
  2. 使用文件上传工具进行上传
  3. 将cdn地址粘贴到imgMap.js中对应的位置

程序实现的两种思路

  1. 第一种是模拟登录,模拟请求,需要将管理后台上传图片的逻辑使用再node实现一遍
  2. 第二种是本次采用的puppeteer,使用headless Chrome去模拟上传图片的操作

Puppeteer

就跟名字一样,使用Node操作Chrome

这是官方的介绍

Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol. Puppeteer runs headless by default, but can be configured to run full (non-headless) Chrome or Chromium.

官方举了一些🌰,可以使用Puppeteer做下面的事情

  • 生成网页的PDF
  • 爬虫,做服务端渲染
  • 自动化的表单提交,UI测试,键盘输入
  • 提供了一整套测试环境,可以测试最新的Javascript和浏览器特性
  • 测试网页的Performance
  • 测试Chrome插件

总的来说官方文档的引导部分还是不错的,照着简单的例子实现一个demo,后续就可以自己查API实现自己想要的功能了。

关键的API

这里的说明非常不offical!!!,只是用通俗语言便于大家理解

  • puppeteer.launch 启动Chrome
  • browser.newPage 打开一个新的tab页面
  • page.goto 页面跳转
  • page.click(selector[, options]) 这里模拟点击传入选择器匹配的元素
  • page.waitForNavigation([options]) 等待页面跳转
  • page.waitForSelector 等待选择器出现
  • page.waitForResponse 等待请求返回
  • page.evaluate 传入函数,执行一段javascript

参考阅读:

开发历程

将整个开发阶段分为几个步骤

  • 初始数据处理
  • 登录
  • 上传

初始数据处理

简单的编辑器文件替换将imgMap.js做一些简单的处理,改为commonjs的模块规范,直接require引入即可使用。

  1. const imgMap = require('./origin/imgMapTest')

正则校验

一眼扫过去发现大多链接的共性,使用正则表达式将少部分刁民t出来

/^(.+@.+(png|jpg))(?:.*)$/

文件存在性校验

管理后台上传图片的前提是本地要有这张图片,需要做一个文件本地存在性检验

在 puppeteer 中官方都是使用 async await 来实现异步逻辑, node 中的 fs 模块返回的不是 Promise ,还是 err-first 的 callback 形式,个人有两种方式来处理

  • Node8提供了 util.promisify 来将模块包装成 Promise
  • 使用开源的 node-fs-extra 模块来处理文件系统相关操作,提供了更多易用的API

本次选择使用 fs-extra 来做文件存在性检验与写文件等操作

登录

这里是主函数

调用puppeteer.launch({ headless: false })启动一个Chrome,这里的headless参数用来控制以无头方式启动。

  1. const main = async () => {
  2. const browser = await puppeteer.launch({ headless: false })
  3. const page = await browser.newPage()
  4. await page.goto('https://asktest.dxy.net/admin#/devtools/upload')
  5. }

很好,不让我们上传,直接跳转到了登录页面,我这里的登录方案是微信扫码。

使用puppeteer就是要分析页面的dom结构,通过选择器来找到需要触发事件的dom节点。

我们需要模拟的操作是

  • 【点击 —- 返回电脑登录】
  • 【点击 —- 微信图标】

通过分析页面的dom节点,一步一步试,传入一个唯一的选择器

  1. // 模拟登录
  2. const login = async (page) => {
  3. try {
  4. // 点击返回电脑登录
  5. await page.click('[paneid=j_loginTab2]')
  6. // 点击微信登录
  7. await page.click('.third__content a:first-child')
  8. await page.waitForNavigation({
  9. waitUntil: 'load'
  10. });
  11. console.log('登录成功')
  12. } catch (error) {
  13. console.error(error);
  14. console.error('登录失败')
  15. return Promise.reject()
  16. }
  17. }

很好,扫完码我们就进来了,登录大功告成,注意一下这里的page.waitForNavigation调用,等到页面加载完成再进行后续的操作。

上传

找到文件上传入口

上传之前我们需要找到上传的入口,做以下操作的模拟

  • 【点击 —- 内部工具】
  • 【点击 —- 文件上传】

想想简直太简单了有木有!!!结果点开dom结构发现这选择器太难选中了吧。。。。。。。

puppeteer 实现管理后台批量上传 - 图1

好在有个神奇的东西,是一个chrome插件,将页面的操作记录为puppeteer的代码,

puppeteer-record Github

点击 Record,之后在页面依次做内部工具,文件上传操作

点击 Stop

将记录的代码复制下来跑一跑

  1. await page.waitForSelector('.scroll-side-inner > .side-menu-content > ul > .is-active > .el-submenu__title')
  2. await page.click('.scroll-side-inner > .side-menu-content > ul > .is-active > .el-submenu__title')
  3. await page.waitForSelector('ul > .el-submenu > .el-menu > .submenu-wrapper > .is-active')
  4. await page.click('ul > .el-submenu > .el-menu > .submenu-wrapper > .is-active')

竟然跑不通???进到了用户管理里面???咱们的dom结构都是for循环出来的,class名都一样,这个recorder只是记录一层层的结构关系,怪不得进到了错误的菜单里,咱们简单修改一下,加上 nth-child

  1. // 找到上传图片的入口
  2. const findUpload = async (page) => {
  3. try {
  4. // 点击内部工具
  5. await page.waitForSelector('.scroll-side-inner > .side-menu-content > ul > .el-submenu:nth-child(10)')
  6. await page.click('.scroll-side-inner > .side-menu-content > ul > .el-submenu:nth-child(10)')
  7. // 点击文件上传
  8. await page.waitForSelector('ul > .el-submenu.is-opened > .el-menu > .submenu-wrapper > .el-menu-item:nth-child(5)')
  9. await page.click('ul > .el-submenu.is-opened > .el-menu > .submenu-wrapper > .el-menu-item:nth-child(5)')
  10. } catch (error) {
  11. console.error(error)
  12. console.error('上传图片入口寻找失败')
  13. return Promise.reject()
  14. }
  15. }

折腾这么久才进到文件上传菜单!!!

上传核心逻辑

首先去puppeteer的文档查阅上传实现

只有这么一点

管理后台的上传组件基于element UI组件,puppeteer的原理则是直接去拿到input节点

看来又是一波DOM操作

  1. await page.waitForFunction(() => {
  2. return location.href.indexOf('/devtools/upload')
  3. })
  4. // 找到文件上传的节点,并将其设为可见
  5. await page.waitForSelector('input[type=file].el-upload__input')
  6. await page.evaluate(() => {
  7. document.querySelector('input[type=file].el-upload__input').style.display = 'block'
  8. })
  9. const inputElement = await page.$('input[name=uploadFile]')

成功拿到上传的节点,开始上传单个文件的操作,这里使用page.waitForResponse来监听单个请求,也可以使用全局的请求拦截器Response去处理

  1. // 上传单个文件
  2. const uploadSingleFile = async (page, inputElement, key, src, imgObj) => {
  3. try {
  4. await inputElement.uploadFile(src);
  5. await page.waitForResponse(res => ~res.url().indexOf('i/att?center_file_id'))
  6. const url = await page.evaluate(() => {
  7. return Promise.resolve(document.querySelector('input[type=text].el-input__inner[readonly=readonly]').value)
  8. })
  9. return url
  10. } catch (error) {
  11. console.error(`${key}:${src}上传失败`)
  12. console.error(error)
  13. }
  14. }

上传完成之后通过获取反显链接的input框拿到cdn地址,批量上传之前还有一个检验文件存在性的功能要实现,使用之前提到的fs-extra模块,以及最后的写文件操作也一并实现,使用Date.now()命名可以清晰看到文件的生成时间先后顺序。

  1. const fs = require('fs-extra')
  2. async function exist(file) {
  3. try {
  4. const exists = await fs.pathExists(file)
  5. return exists
  6. } catch (error) {
  7. console.error(error);
  8. }
  9. }
  10. // 将JSON写成文件输出
  11. const writeJsonToFile = async (json) => {
  12. try {
  13. const fileName = `./${Date.now()}.json`
  14. await fs.writeJson(fileName, json, { space: 2 })
  15. } catch (error) {
  16. console.error(error)
  17. console.error('写文件失败')
  18. }
  19. }

成果

按照如下数据格式输出:

清晰的看到图片们的状态,successObj中的对象直接拿来使用,放入imgMap.js即可,剩下三种情况的图片手动处理即可。

  1. {
  2. matchObj: {}, // 正则匹配成功
  3. noMatchObj: {}, // 正则匹配成功
  4. existObj: {}, // dropbox存在
  5. noExistObj: {}, // dropbox不存在,
  6. successObj: {}, // 上传成功,
  7. failObj: {} // 上传失败
  8. }