Вводное руководство по обходчику узлов, интеграция статического и динамического обхода, простая для понимания

Node.js

предисловие

В этой статье представлен проект сканера nodejs. Целевая аудитория – друзья, которые плохо знакомы со сканированием. С помощью этого проекта вы сможете получить общее представление о сканере узлов, а также сможете сами написать какой-нибудь простой сканер.

адрес проекта:

github

запустить службу коа

🐯 Ожидается, что окончательные данные будут использоваться для веб-разработки, поэтому я запустил здесь веб-сервис, также основанный на koa.koaЭто среда веб-разработки нового поколения, основанная на платформе nodejs.Также очень просто использовать koa для запуска службы узла.Три строки кода могут запустить службу http

const Koa = require('koa')
const app = new Koa()

app.listen(8080)

Как насчет этого, вы можете увидеть его с первого взгляда? Вы можете узнать больше о коаофициальная документация, если вы можете гибко использовать nodejs, koa также может начать работу за считанные минуты.

Поисковый анализ

🕷️Каково предназначение рептилий? На самом деле цель краулера очень проста, то есть нам нужно получить данные, которые мы хотим на сайте. Независимо от того, какой метод или язык используется, пока данные могут быть собраны, мы достигнем нашей цели. Однако, проанализировав сайт, мы обнаружили, что некоторые веб-сайты являются статическими, и интерфейс не может просматривать запросы API на веб-сайте, поэтому мы можем извлекать данные только путем анализа страницы, что называется статическим сканированием. Некоторые страницы используют внешний интерфейс запроса для рендеринга данных. В этом случае мы можем напрямую получить адрес API и имитировать запрос в краулер. Это называется динамическим краулером. На основе этого я просто разработал общий краулер.

Глобальная конфигурация

Для удобства некоторые методы параметров я настроил глобально

const path = require('path')
const base = require('app-root-dir')

// 全局的 require 方式
global.r = (p = base.get(), m = '') => require(path.join(p, m))

// 全局的路径配置
global.APP = {
  R: base.get(),
  C: path.resolve(base.get(), 'config.js'),
  P: path.resolve(base.get(), 'package.json'),
  A: path.resolve(base.get(), 'apis'),
  L: path.resolve(base.get(), 'lib'),
  S: path.resolve(base.get(), 'src'),
  D: path.resolve(base.get(), 'data'),
  M: path.resolve(base.get(), 'model')
}

Для унифицированного управления я записываю все адреса страниц для обхода в конфигурационный файл:

// 所有抓取目标
const targets = {
  // 掘金前端相关的文章
  juejinFront: {
    url: 'https://web-api.juejin.im/query',
    method: 'POST',
    options: {
      headers: {
        'X-Agent': 'Juejin/Web',
        'X-Legacy-Device-Id': '1559199715822',
        'X-Legacy-Token': 'eyJhY2Nlc3NfdG9rZW4iOiJoZ01va0dVNnhLV1U0VGtqIiwicmVmcmVzaF90b2tlbiI6IkczSk81TU9QRjd3WFozY2IiLCJ0b2tlbl90eXBlIjoibWFjIiwiZXhwaXJlX2luIjoyNTkyMDAwfQ==',
        'X-Legacy-Uid': '5c9449c15188252d9179ce68'
      }
    }
  },
  // 电影天堂的所所有类型的电影
  movie: {
    url: 'https://www.dy2018.com'
  },
  // pixabay 图片网站
  pixabay:  {
    url: 'https://pixabay.com'
  },
  // 豆瓣高分电影
  douban: {
    url: 'https://movie.douban.com/j/search_subjects?type=movie&tag=%E8%B1%86%E7%93%A3%E9%AB%98%E5%88%86&sort=recommend&page_limit=20&page_start=0'
  }
}

Как показано выше, некоторые сканируют статические страницы, некоторые сканируют динамический API, и при имитации последнего запроса вам необходимо установить дополнительные заголовки запроса, а запросы на публикацию также должны передавать json, которые все настраиваются здесь.

Общая библиотека классов

Для анализа статических страниц я использую библиотеку cheerio.

Cheerio похож на jquery в среде узла, он может анализировать страницу и извлекать соответствующую информацию на странице, предоставляемый им API похож на jquery, его можно понимать как jq на стороне сервера, и он просто инкапсулируется следующим образом.

const cheerio = require('cheerio')

const $ = html => cheerio.load(html, {
  ignoreWhitespace: true,
  xmlMode: true
})

const $select = (html, selector) => $(html)(selector)

// 节点属性
const $attr = (html, attr) => $(html).attr(attr)


module.exports = {
  $,
  $select,
  $attr
}

superagent — это полнофункциональная серверная http-библиотека, которая может захватывать статические страницы и предоставлять их в Cheerio для анализа, а также захватывать динамические возвращаемые данные API, на основе этого я сделал простую инкапсуляцию.

// 封装 superagent 库
const superagent = require('superagent')
const { isEmpty } = require('lodash')

// 页面需要转码 例如 utf-8
const charset = require('superagent-charset')
const debug = require('debug')('superAgent')

charset(superagent)

const allowMethods = ['GET', 'POST']

const errPromise = new Promise((resolve, reject) => {
  return reject('no url or method is not supported')
}).catch(err => err)


 /*
  * options 包含 post 数据 和 headers, 如
  * {
  *    json: { a: 1 },
  *    headers: { accept: 'json' }
  * }
  */

// mode 区分动态还是静态抓取, unicode 为页面编码方式,静态页面中使用
const superAgent = (url, {method = 'GET', options = {}} = {}, mode = 'dynamic', unicode = 'gbk') => {
  if(!url || !allowMethods.includes(method)) return errPromise
  const {headers} = options

  let postPromise 

  if(method === 'GET') {
    postPromise = superagent.get(url)
    if(mode === 'static') {
      // 抓取的静态页面需要根据编码模式解码
      postPromise = postPromise.charset(unicode)
    }
  }

  if(method === 'POST') {
    const {json} = options
// post 请求要求发送一个 json
    postPromise = superagent.post(url).send(json)
  }

// 需要请求头的话这里设置请求头
  if(headers && !isEmpty(headers)) {
    postPromise = postPromise.set(headers)
  }

  return new Promise(resolve => {
    return postPromise
      .end((err, res) => {
        if(err) {
          console.log('err', err)
          // 不抛错
          return resolve(`There is a ${err.status} error has not been resolved`)
        }
        // 静态页面,返回 text 页面内容
        if(mode === 'static') {
          debug('output html in static mode')
          return resolve(res.text)
        }
        // api 返回 body 的内容
        return resolve(res.body)
      })
  })
}

module.exports = superAgent

Кроме того, нам нужно прочитать и записать полученные данные:

const fs = require('fs')
const path = require('path')
const debug = require('debug')('readFile')

// 默认读取 data 文件夹下的文件
module.exports = (filename, filepath = APP.D) => {
  const file = path.join(filepath, filename)
  if(fs.existsSync(file)) {
    return fs.readFileSync(file, 'utf8')
  } else {
    debug(`Error: the file is not exist`)
  }
}
const fs = require('fs')
const path = require('path')
const debug = require('debug')('writeFile')

// 默认都写入 data 文件夹下的对应文件
module.exports = (filename, data, filepath) => {
  const writeData = JSON.stringify(data, '', '\t')
  const lastPath = path.join(filepath || APP.D, filename)
  if(!fs.existsSync(path.join(filepath || APP.D))) {
    fs.mkdirSync(path.join(filepath || APP.D))
  }
  fs.writeFileSync(lastPath, writeData, function(err) {
    if(err) {
      debug(`Error: some error occured, the status is ${err.status}`)
    }
  })
}

Когда все будет готово, начните сканировать страницу

Сканирование динамического API

На примере Nuggets необходимо анализировать и моделировать запросы

Лента потока статьи Nuggets реализована так, в возвращаемых данных на предыдущей странице есть тегafter, при запросе следующей страницы нужно поставить значение after в json поста, остальные параметры статические, можно прописать до смерти первым при захвате

const { get } = require('lodash')
const superAgent = r(APP.L, 'superagent')
const { targets } = r(APP.C)
const writeFile = r(APP.L, 'writeFile')
const { juejinFront } = targets

let totalPage = 10 // 只抓取十页

const getPostJson = ({after = ''}) => {
  return {
    extensions: {query: {id: '653b587c5c7c8a00ddf67fc66f989d42'}},
    operationName: '',
    query: '',
    variables: {limit: 10, category: '5562b415e4b00c57d9b94ac8', after, order: 'POPULAR', first: 20}
  }
}

// 保存所有文章数据
let data = []
let paging = {}

const fetchData = async (params = {}) => {
  const {method, options: {headers}} = juejinFront
  const options = {method, options: {headers, json: getPostJson(params)}}
  // 发起请求
  const res = await superAgent(juejinFront.url, options)
  const resItems = get(res, 'data.articleFeed.items', {})
  data = data.concat(resItems.edges)
  paging = {
    total: data.length,
    ...resItems.pageInfo
  }
  pageInfo = resItems.pageInfo
  if(resItems.pageInfo.hasNextPage && totalPage > 1) {
    fetchData({after: resItems.pageInfo.endCursor})
    totalPage--
  } else {
  // 请求玩之后写入 data 文件夹
    writeFile('juejinFront.json', {paging, data})
  }
}

module.exports = fetchData

Сканировать статический HTML

Возьмем, к примеру, Movie Heaven.

Чтобы проанализировать страницу Movie Paradise, есть страница списка и страница сведений.Чтобы получить магнитную ссылку, вам нужно войти на страницу сведений, а ссылку на страницу сведений нужно ввести со страницы списка, поэтому мы сначала запросите страницу списка, получите URL-адрес страницы сведений, а затем введите сведения. Страница анализирует страницу, чтобы получить магнитную ссылку.

Вы можете видеть, что URL-адрес на странице списка может быть проанализирован.co_content8 ul table 下的 a 标签, узел dom, полученный с помощью cheerio, представляет собой массив классов, и его API-интерфейс each() эквивалентен методу forEach массива, и таким образом мы захватываем ссылку. Захват магнитных ссылок после входа на страницу сведений аналогичен этому. Это включает в себя синтаксис асинхронного ожидания es7, который является эффективным способом асинхронного получения данных.

const path = require('path')
const debug = require('debug')('fetchMovie')
const superAgent = r(APP.L, 'superagent')
const { targets } = r(APP.C)
const writeFile = r(APP.L, 'writeFile')
const {$, $select} = r(APP.L, 'cheerio')

const { movie } = targets

// 各种电影类型,分析网站得到的
const movieTypes = {
  0: 'drama', 
  1: 'comedy', 
  2: 'action', 
  3: 'love', 
  4: 'sciFi', 
  5: 'cartoon', 
  7: 'thriller',
  8: 'horror', 
  14: 'war',
  15: 'crime',
}

const typeIndex = Object.keys(movieTypes)

// 分析页面,得到页面节点选择器,'.co_content8 ul table'
const fetchMovieList = async (type = 0) => {
  debug(`fetch ${movieTypes[type]} movie`)
  // 存电影数据,title,磁力链接
  let data = []
  let paging = {}
  let currentPage = 1
  const totalPage = 30 // 抓取页
  while(currentPage <= totalPage) {
    const url = movie.url + `/${type}/index${currentPage > 1 ? '_' + currentPage : ''}.html`
    const res = await superAgent(url, {}, 'static')
    // 拿到一个节点的数组
    const $ele = $select(res, '.co_content8 ul table')
    // 遍历
    $ele.each((index, ele) => {
      const li = $(ele).html()
      $select(li, 'td b .ulink').last().each(async (idx, e) => {
        const link = movie.url + e.attribs.href
        // 这里去请求详情页
        const { magneto, score } = await fetchMoreInfo(link)
        const info = {title: $(e).text(), link, magneto, score}
        data.push(info)
        // 按评分倒序
        data.sort((a, b) => b.score - a.score)
        paging = { total: data.length }
      })
    })
    writeFile(`${movieTypes[type]}Movie.json`, { paging, data }, path.join(APP.D, `movie`))
    currentPage++
  }
}

// 获取磁力链接 '.bd2 #Zoom table a'
const fetchMoreInfo = async link => {
  if(!link) return null
  let magneto = []
  let score = 0
  const res = await superAgent(link, {}, 'static')
  $select(res, '.bd2 #Zoom table a').each((index, ele) => {
    // 不做这个限制了,有些电影没有 magnet 链接
    // if(/^magnet/.test(ele.attribs.href)) {}
    magneto.push(ele.attribs.href)
  })
  $select(res, '.position .rank').each((index, ele) => {
    score = Math.min(Number($(ele).text()), 10).toFixed(1)
  })
  return { magneto, score }
}

// 获取所有类型电影,并发
const fetchAllMovies = () => {
  typeIndex.map(index => {
    fetchMovieList(index)
  })
}

module.exports = fetchAllMovies

обработка данных

Захваченные данные могут быть сохранены в базе данных.В настоящее время я пишу их локально, и локальные данные также могут использоваться в качестве источника данных API.Например, я могу написать локальное API как локально разработанный сервер для данных Фильм Рай.

const path = require('path')
const router = require('koa-router')()
const readFile = r(APP.L, 'readFile')
const formatPaging = r(APP.M, 'formatPaging')

// router.prefix('/api');
router.get('/movie/:type', async ctx => {
  const {type} = ctx.params
  const totalData = readFile(`${type}Movie.json`, path.join(APP.D, 'movie'))
  const formatData = await formatPaging(ctx, totalData)
  ctx.body = formatData
})

module.exports = router.routes()

Среди них я вручную поддерживаю список с разбивкой на страницы, чтобы облегчить поток подачи, когда данные отправляются во внешний интерфейс:

// 手动生成分页数据
const {getQuery, addQuery} = r(APP.L, 'url')
const {isEmpty} = require('lodash')

module.exports = (ctx, originData) => {
  return new Promise((resolve) => {
    const {url, header: {host}} = ctx
    if(!url || isEmpty(originData)) {
      return resolve({
        data: [],
        paging: {}
      })
    }
    const {data, paging} = JSON.parse(originData)
    const query = getQuery(url)
    const limit = parseInt(query.limit) || 10
    const offset = parseInt(query.offset) || 0
    const isEnd = offset + limit >= data.length
    const prev = addQuery(`http://${host}${url}`, {limit, offset: Math.max(offset - limit, 0)})
    const next = addQuery(`http://${host}${url}`, {limit, offset: Math.max(offset + limit, 0)})
    const formatData = {
      data: data.slice(offset, offset + limit),
      paging: Object.assign({}, paging, {prev, next, isEnd})
    }
    return resolve(formatData)
  })
}

Если это удобно, вы можете записать данные в базу данных, чтобы вы могли реализовать единую остановку сканера-внутренней-внешней-внешней части, ха-ха.

Последний API, пейджинг управляется параметрами лимита и смещения, которые можно настроить, а следующую страницу можно запросить для реализации потока подачи, запросив следующую.

✨✨✨

Конечно, о краулерах можно говорить слишком много.На некоторых сайтах есть ограничения на краулеры.Они должны создавать пул ip и время от времени менять ip.Некоторым нужно имитировать вход в систему.Еще многому нужно научиться. , учиться вместе