关于javascript:从零开始快速开发一个-chrome-插件爬取网页内容

31次阅读

共计 12055 个字符,预计需要花费 31 分钟才能阅读完成。

概述

最近在边学边开发一个 chrome 插件,帮人家爬取网站的内容,所以总结下开发时遇到的问题,怎么从零开始开发一个 chrome 插件,实现爬取网站内容、脚本间数据通信、脚本权限和 Tab 的管制等性能。

其实 chrome 的文档有很具体的疾速开始的教程,有趣味的能够看官网教程:Getting started。本篇总结的内容,次要记录我在开发插件时遇到的问题,会比官网的疾速教程波及的内容更多点。

筹备

咱们要做一个 chrome 插件,性能是依据网站的列表,点击对应的详情,获取外面的具体内容,最初在一个自定义页面查看爬取的内容。

我这里曾经把上面的内容整合成一个我的项目了,如果嫌麻烦,能够间接 clone 这个仓库:chrome 插件 demo

首先须要新建一个我的项目,增加对应的文件,文件构造如下:

.
├── data.json # list.html 和 detail.html 应用到的数据
├── demo
│   ├── background.js
│   ├── common.js
│   ├── getDetail.js
│   ├── getList.js
│   ├── jquery.min.js
│   ├── manifest.json
│   ├── popup.html
│   └── popup.js
├── detail.html # 要爬取的详情页
└── list.html # 要爬取的列表页 

整个插件的开发,下面的文件都会用到,前面会一一对其补充内容,用到哪个文件,再解释其作用。

筹备爬取的网页内容

我这里截取了一部分的数据,但也足够应用,批改 data.json

const DATA = {
    "array": [
        {
            "name": "Amy Rodriguez",
            "id": "63000019860511796X"
        },
        {
            "name": "Jose Harris",
            "id": "14000019891212344X"
        },
        {
            "name": "Kenneth Jones",
            "id": "150000200908039343"
        },
        {
            "name": "Elizabeth Wilson",
            "id": "630000197205295754"
        },
        {
            "name": "Elizabeth Hall",
            "id": "32000020040726080X"
        },
        {
            "name": "Frank Garcia",
            "id": "410000199606123660"
        },
        {
            "name": "George White",
            "id": "320000198909170249"
        },
        {
            "name": "Susan Walker",
            "id": "610000198001276878"
        },
        {
            "name": "Deborah Brown",
            "id": "810000200504028347"
        },
        {
            "name": "Angela Hernandez",
            "id": "630000197207149056"
        },
        {
            "name": "Jason Hall",
            "id": "620000198209225078"
        },
        {
            "name": "Robert Clark",
            "id": "350000197212127591"
        },
        {
            "name": "Shirley Thomas",
            "id": "50000020100911686X"
        },
        {
            "name": "Mark Miller",
            "id": "650000200506212412"
        },
        {
            "name": "Dorothy Jones",
            "id": "610000200708203138"
        },
        {
            "name": "Gary Lopez",
            "id": "140000198703280844"
        },
        {
            "name": "Margaret Davis",
            "id": "520000200206107072"
        },
        {
            "name": "Steven Allen",
            "id": "330000201501316327"
        },
        {
            "name": "Brenda Lewis",
            "id": "520000199709270757"
        }
    ]
}

list.html 有一些我本人测试的逻辑,不必关怀这个文件,批改 list.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>
  <style>
    .item {
      display: flex;
      width: 500px;
    }
    .page {display: flex;}
    .prev, .next {
      width: 50px;
      height: 50px;
      display: flex;
      align-items: center;
      justify-content: center;
      margin: 20px;
    }
  </style>
  <script src="./data.json"></script>
</head>
<body>
  <div class="wrap">
    <div class="content"></div>
    <div class="page">
      <button class="prev"> 上 </button>
      <div class="pageNum">1</div>
      <button class="next"> 下 </button>
    </div>
  </div>
  <script>
    const oContent = document.querySelector('.content')
    const oNext = document.querySelector('.next')
    const page = 10
    // const total = Math.ceil(DATA.array / page)
    const total = 10
    let pageNum = 1
    let isPending = false
    function insertItem(pageNum) {const time = (300 - 100) * Math.random() + 100
      console.log(time)
      isPending = true
      setTimeout(() => {const dom = DATA.array.filter((_, i) => i >= ((pageNum - 1) * page) && i < (pageNum * page)).map((v) => {
          return `<div class="item">
            <div class="item-title">${v.name}</div>
            <a class="item-id" href="./detail.html?id=${v.id}" target="_blank">${v.id}</a>
          </div>`
        }).join('')
        oContent.innerHTML = dom
        updateDom()
        isPending = false
      }, time);
    }

    function updateDom() {document.querySelector('.pageNum').innerHTML = pageNum
    }

    insertItem(pageNum)
    document.querySelector('.prev').addEventListener('click', () => {if (isPending) return
      oNext.removeAttribute('disabled')
      pageNum -= 1
      if (pageNum < 1) pageNum = 1
      insertItem(pageNum)
    })
    oNext.addEventListener('click', () => {if (isPending || oNext.getAttribute('disabled')) return
      pageNum += 1
      oNext.removeAttribute('disabled')
      if (pageNum > total) {
        pageNum = total
        oNext.setAttribute('disabled', 'disabled')
      }
      insertItem(pageNum)
    })
  </script>
</body>
</html>

批改 detail.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>
  <script src="./data.json"></script>
</head>
<body>
  <div class="wrap">

  </div>
  <script>
    const {search} = location
    const id = search.replace('?id=', '')
    function getData() {return DATA.array.find(v => v.id === id)
    }
    setTimeout(() => {const data = getData()
      const oWrap = document.querySelector('.wrap')
      oWrap.innerHTML = `<div class="name">${data.name}</div><div class="id">${data.id}</div>`
    }, (1000 - 500) * Math.random() + 1000);
  </script>
</body>
</html>

list.htmldetail.html 为了模仿更实在的状况,我采纳异步渲染数据的模式。

mainfest.json 配置

首先咱们批改 manifest.json

{
  "name": "Test",
  "description": "chrome 插件",
  "version": "1.0",
  "manifest_version": 3,
  "background": {"service_worker": "background.js"},
  "permissions": [
    "storage",
    "tabs"
  ],
  "action": {"default_popup": "popup.html"},
  "content_scripts": [
    {"matches": ["file:///*/chrome-extension-demo/detail.html*"],
      "js": ["getDetail.js"]
    },
    {"matches": ["file:///*/chrome-extension-demo/list.html"],
      "js": ["getList.js"]
    }
  ]
}

namedescriptionversion 这些字段,就是字面意思,对该插件的一些信息申明。

background:相当于整个插件的外围,治理事件。

permissions: 通知浏览器你须要用到什么权限。

popup:弹窗页面,其实都是 html,只不过它的关上模式是通过点击在浏览器右上方的 icon,一个弹窗的模式。

content_scripts:接管一个数组,依据你的 matches 注入对应的 js 到页面内执行。

导入到 chrome

咱们当初能够把插件导入到 chrome,在浏览器输出 chrome://extensions/ 并关上,会看到下图:

咱们关上开发者模式,并点击加载扩大程序,抉择咱们的插件根目录

会看到插件导入胜利,当初是启动状态,而后在浏览器盯住显示:

当初插件导入胜利了。

根本运行原理

咱们的筹备工作实现了,在开发前要明确咱们 chrome 插件根本的运行逻辑:

简略来说:

当咱们将插件导入到 chrome 并启动后,background.js 就会执行。

当咱们点击右上角的 icon 时,就会渲染 popup.html,当弹窗敞开后,popup.html 就会销毁。

当咱们拜访网页时,如果命中 manifest.jsoncontent_scripts 时,就会执行对应的脚本,每当刷新页面,都会从新执行脚本,这个要留神。

来自于 chorme 官网文档:https://developer.chrome.com/…

这图是来自于官网文档的,是对于不同的脚本之间是如何通信的,能够看到整个通信的流程,外围是 background.js

总结一下,popup 是帮忙咱们更不便地应用插件,不是必须;contentscript 是咱们的解决网页内容的外围,background.js 是用于治理整个插件的事件通信和做一些全局性的操作。

开发

应用 content_scripts 获取列表和详情的内容

第一步,咱们要实现上面的三个性能:

  1. 通过 getList.js 查看列表页的内容
  2. 找到详情链接点击
  3. 在详情页应用 getDetail.js 获取详情内容并存储到插件缓存
  4. 详情页发送后,敞开以后页面

列表页的脚本

批改 getList.js

function checkData(executeNum, callback, maxNum = 10) {if (executeNum >= maxNum) {console.log('100 秒过来了,但没有查看到内容')
    return
  }
  setTimeout(() => {if (!!document.querySelector('.item')) {callback()
    } else {checkData(executeNum + 1, callback)
    }
  }, 1000);
}

function getData() {const items = document.querySelectorAll('.item')
  console.log(items)
  items.forEach(item => {item.querySelector('.item-id').click()})
}

checkData(0, getData)

咱们逐行剖析,为什么要这样写。

首先,整个 js 文件的代码是没有被嵌套的,但这些生命的函数是不会被注入到页面的全局对象,chrome 会提供相似沙箱的成果进行隔离的。但如果你想在页面的全局对象增加属性或办法,能够间接 widnow.a = 1 进行增加。

该文件有两个个函数,checkDatagetData

先看下 checkData,它是用于检测页面的内容是否合乎咱们须要,因为咱们解决的网页大部分都是异步的,当浏览器执行你的脚本时,你的数据可能还没渲染进去,所以这时候须要一个轮询的逻辑,一直地检测页面内容,是否符合要求

function checkData(executeNum, callback, maxNum = 10) {if (executeNum >= maxNum) {console.log('100 秒过来了,但没有查看到内容')
    return
  }
  setTimeout(() => {if (!!document.querySelector('.item')) {callback()
    } else {checkData(executeNum + 1, callback)
    }
  }, 1000);
}

每次期待 1s,再次进行检测。咱们检测的根据,就是这个 dom 是否存在,当发现存在时,则只需回调。这里的回调,咱们用的是 getData 函数:

function getData() {document.querySelectorAll('.item').forEach(item => {item.querySelector('.item-id').click()})
}

咱们这里是简略解决,间接关上所有的内容详情页,但我集体比拟偏向于设计一个工作队列,按量去执行工作。

到这里,getList.js 工作曾经实现了,咱们再看下 getDetail.js

详情页的脚本

function checkData(executeNum, callback, maxNum = 10) {if (executeNum >= maxNum) {console.log('100 秒过来了,但没有查看到内容')
    return
  }
  setTimeout(() => {if (!!document.querySelector('.id')) {callback()
    } else {checkData(executeNum + 1, callback)
    }
  }, 1000);
}

function getData() {const name = document.querySelector('.name').innerHTML
  const id = document.querySelector('.id').innerHTML
  chrome.runtime.sendMessage({data: { name, id}, close: true })
}

checkData(0, getData)

和列表有点相似,同样是一个检测函数和一个获取数据函数。咱们次要看下这个:

chrome.runtime.sendMessage({data: { name, id}, close: true })

chrome 这属性,如果没有注入对应的脚本进去,是不会找到的,这行代码的性能是将数据发送给 background

详情页只做一个获取数据并发送给 background 的操作,这里发送了爬取的数据和 closeclose 是通知 background 这次的发送事件,要把对应的 tab 敞开。

存储数据和敞开 Tab

发送事件写好了,当初要增加一个监听事件,这能力获取详情页发送的数据,批改 background.js


chrome.runtime.onMessage.addListener(async (msg, sender) => {if (msg.close) {chrome.tabs.remove(sender.tab.id)
  }
  if (msg.data) {chrome.storage.sync.get(['data'], function(result) {chrome.storage.sync.set({ data: [...result.data, msg.data] }, function() {console.log('设置数据胜利')
      });
    });
  }
})

这里增加了一个监听事件,当任何一个页面应用了 sendMessage 都会在这里接管到。

msg.close 存在时,就会依据 sender.tab.id,应用 chrome.tabs.remove 敞开对应的 tab。

msg.data 存在时,应用 chrome.storage.sync API,进行数据缓存。因为咱们是增量增加,所以每次存储前都须要先获取之前的值。

咱们能够关上 list.html 试试成果。在关上前,要先对插件进行刷新操作,只有你批改了代码,就须要进行刷新。

能够看到当初曾经实现了下面所说的性能,进入列表页,会主动关上详情页,而详情页会爬取内容发送给 backgroundbackground 将数据缓存并敞开对应的 tab。

查看爬取的内容

当初曾经将内容爬取完了,须要查看爬取数据,咱们应用 popup.html 把爬取内容展现进去。

批改 popup.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>
  <style>
    .wrap {
      width: 300px;
      padding: 24px;
    }
  </style>
</head>
<body>
  <div class="wrap">
    <button id="show"> 显示数据 </button>
    <div class="log"></div>
  </div>
  <script src="./popup.js"></script>
</body>
</html>

批改 popup.js

const btn = document.querySelector('#show')

function log(txt) {document.querySelector('.log')
      .appendChild(document.createElement('div'))
      .innerText = ">" + txt;
}

btn.addEventListener('click', () => {document.querySelector('.log').innerHTML = ''chrome.storage.sync.get(['data'], function(result) {(result.data || []).forEach(({name}) => {log(name)
    })
  })  
})

这里的逻辑很简略,popup.html 就是弹窗渲染的内容,popup.js 是对应执行的脚本,点击按钮,把 chrome.storage.sync 的数据展现进去。

要留神一点,在结尾有说过,咱们所用的 chrome API 基本上都要在 manifest.json 进行申明:

{
  "permissions": [
    "storage",
    "tabs"
  ]
}

至此,咱们这个小 demo 曾经开发完了,性能很简略,但曾经笼罩了很多罕用的性能,我这里没有对每个应用到的 API 进行具体解释,只是简略阐明而已,具体的 API 阐明还是要去官网文档查阅。

注意事项

在整个开发插件的过程中,还是遇到不少的问题。

加载第三方库

如果咱们要开发一个略微简单的插件,可能须要引入一些第三方库,这里咱们来演示下,引入 jquery 并在 getDetail.js 应用。首先批改 background.jscontent_scripts

{
  "content_scripts": [
    {"matches": ["file:///*/chrome-extension-demo/detail.html*"],
      "js": ["jquery.min.js", "getDetail.js"]
    },
    {"matches": ["file:///*/chrome-extension-demo/list.html"],
      "js": ["getList.js"]
    }
  ]
}

增加了之后,批改 getDetail.jsgetData

function getData() {const name = $('.name').text()
  const id = $('.id').text()
  chrome.runtime.sendMessage({data: { name, id}, close: true })
}

刷新插件,运行一下,能够看到数据是失常上报的。

"js": ["jquery.min.js", "getDetail.js"]

这段的配置,当命中对应的配置后,会按程序加载你的 js 文件。

优化通用代码

在开发时,多个文件大部分状况都会呈现通用代码或配置的,但 chrome 插件不反对原生的 import,我这里想到的解决办法有两个:

  1. 应用 webpack 等工具,每次批改完代码,先编译,再刷新应用
  2. 通过 contents_script 可加载多个脚本的特点,先加载通用代码文件,这样后加载的文件都能够应用通用代码文件申明的对象、函数。

第一种这里就不多说了,有点麻烦,但这个是最完满的,完满解决通用代码的问题。

第二种,咱们别离批改 common.jsgetList.jsgetDetail.jsmanifest.json 文件。

批改 common.js

function checkData(checkDomTxt, executeNum, callback, maxNum = 10) {if (executeNum >= maxNum) {console.log('100 秒过来了,但没有查看到内容')
    return
  }
  setTimeout(() => {if (!!document.querySelector(checkDomTxt)) {callback()
    } else {checkData(checkDomTxt, executeNum + 1, callback, maxNum)
    }
  }, 1000);
}

getList.js 全笼罩:

function getData() {const items = document.querySelectorAll('.item')
  items.forEach(item => {item.querySelector('.item-id').click()})
}

checkData('.item', 0, getData)

getDetail.js 全笼罩:

function getData() {const name = $('.name').text()
  const id = $('.id').text()
  chrome.runtime.sendMessage({data: { name, id}, close: true })
}

checkData('.id', 0, getData)

manifest.json 批改 content_scripts

{
  "content_scripts": [
    {"matches": ["file:///*/chrome-extension-demo/detail.html*"],
      "js": ["common.js", "jquery.min.js", "getDetail.js"]
    },
    {"matches": ["file:///*/chrome-extension-demo/list.html"],
      "js": ["common.js", "getList.js"]
    }
  ]
}

咱们刷新下插件和 list.html 页面,能够看到插件还是失常运行的,证实了这种加载通用代码的办法是可行的。

当然这种办法尽管相对来说不便,但也有不少问题:

  1. 当代码量多起来后,不晓得哪个变量是来自哪个文件
  2. 命名抵触问题
  3. background.jspopup.js 不能通过这个办法解决

权限与通信

咱们关注三个对象:backgroundpopup 和页面的 content_scripts,这三者之间是如何通信的。

backgroundpopup 通信

backgroundpopup 发送:

chrome.runtime.sendMessage(...)

backgroundpopup 监听:

chrome.runtime.onMessage.addListener(async (msg, sender) => {})

backgroundpopup 的通信是最简略的,间接应用 chrome.runtimesendMessageonMessage 就行了。

backgroundpopupcontent_scripts 通信

backgroundpopup 发送:

// 这里改成了 tabs,不是 runtime
chrome.tabs.sendMessage(tabId, data)

content_scripts 监听:

chrome.runtime.onMessage.addListener(async (msg, sender) => {})

backgroundpopup 不能相似播送那样发送给 content_scripts,只能依据你要发送的那个网页的 tabId,调用 chrome.tabs.sendMessage

你能够在 background.jspopup 增加对应的代码:

chrome.tabs.query({}, function(tabs){const tab = tabs.find(v => v.url.includes('list.html'))
  if (!tab) return
  chrome.tabs.sendMessage(tab.id, 'ok')
});

找到对应的要发送的 tab,进行指定发送。

backgroundpopup 监听:

chrome.runtime.onMessage.addListener(async (msg, sender) => {})

content_scripts 发送:

chrome.runtime.sendMessage(...)

content_scripts 之间通信

这里就略微麻烦点,须要通过 background.js 进行转发,咱们在 getList.js 增加发送的事件,在 getDetail.js 进行监听。

getList.js

chrome.runtime.sendMessage('list send')

background.js

chrome.runtime.onMessage.addListener(async (msg, sender) => {if (msg === 'list send') {chrome.tabs.query({}, function(tabs){const tab = tabs.find(v => v.url.includes('detail.html'))
      if (!tab) return
      chrome.tabs.sendMessage(tab.id, 'to detail')
    });
  }
})

detail.js

chrome.runtime.onMessage.addListener(async (msg, sender) => {console.log(msg)
})

这样就能够了,当 getList.js 发送了 list send 信息后,background.js 会承受到,转发给 detail.js,实现了 content_scripts 之间的通信。

总结

这篇文章,次要总结我在开发时遇到的一些问题,是如何解决的,没有具体的解释为什么这样做,心愿能够给到大家一个参考,解决在开发 chrome 插件时遇到的问题。

正文完
 0