凯文·库 ( Kevin Ku)在Unsplash上的照片
最近在Facebook上, David Smooke ( 哈克农恩 (Hackernoon)的首席执行官)发表了一篇文章,其中列出了2018年的Top Tech Stories 。 他还提到,如果有人希望列出类似的清单,例如说JavaScript, 他很乐意将其放在Hackernoon 的首页上。
在不断争取更多人阅读我的作品的努力中,我不能错过这个机会,因此我立即开始计划如何制作这样的清单。
由于这一年快要结束了,我的时间有限,所以我决定不手工搜索帖子,而是使用网络抓取技巧。
我相信学习如何制作这样的刮板是一个有用的练习,并且可以作为一个有趣的案例研究。
如果您已阅读有关如何创建instagram机器人的文章,那么您就会知道,使用Node.js 与网站进行交互的最佳方法是使用控制铬 实例的puppeteer 库。 这样,我们可以做潜在用户可以在网站上做的所有事情。
这是存储库的链接 。
创建刮板
让我们用这个简单的助手来抽象地创建一个伪造 者的浏览器和页面:
const createBrowser = async () => { const browser = await puppeteer.launch({ headless: true }) return async function getPage(url: string, callback: (page: puppeteer.Page) => Promise) { const page = await browser.newPage() try { await page.goto(url, { waitUntil: 'domcontentloaded' }) page.on('console', (msg) => console.log(msg.text())) const result = await callback(page) await page.close() return result } catch (e) { await page.close() throw e } } }
我们在回调中使用该页面,这样我们可以避免一遍又一遍地重复相同的代码。 由于这个帮手,我们不需要去看场定的url,从里面听console.logs 担心page.evaluate
和关闭页万事俱备后。 函数的结果将在promise中返回,因此我们可以稍后await
,而不必在回调中使用结果。
让我们谈谈数据
在一个网站上 ,我们可以找到所有由Hackernoon 发布的带有JavaScript 标签的文章。 它们是按日期排序的,但是有时会像以前那样无处不在地发表文章,例如2016年,所以我们必须提防这一点。
我们可以仅从此帖子预览中提取所有需要的信息,而无需实际在新标签中打开该帖子,这使我们的工作更加轻松。
在上面显示的框中,我们看到了所需的所有数据:
作者的姓名和个人简介网址
文章标题和网址
拍手数
阅读时间
日期
这是文章的界面:
interface Article { articleUrl: string date: string claps: number articleTitle: string authorName: string authorUrl: string minRead: string }
在“ 中”上, 存在无限滚动,这意味着当我们向下滚动时,将加载更多文章。 如果我们使用GET 请求获取静态HTML 并用诸如JSDOM之类 的库进行解析,那么获取这些文章将是不可能的,因为我们不能在静态HTML中 使用滚动。 这就是当与网站进行任何形式的交互时, 伪娘 可以挽救 生命的原因。
要获取所有已加载的帖子,我们可以使用:
Array.from(document.querySelectorAll('.postArticle')) .slice(offset) .map((post) => {})
现在,我们可以将每个帖子用作选择器的上下文-而不是编写document.querySelector
我们现在要编写post.querySelector
。 这样,我们可以将搜索仅限于给定的post元素。
另外,请注意.slice(offset)
片段-由于我们向下滚动而不是打开新页面,因此已经解析的文章仍然存在。 当然,我们可以再次解析它们,但这并不能真正有效。 偏移量从0开始,每次我们刮掉一些文章时,我们都会将集合的长度添加到偏移量中。
offset += scrapedArticles.length
收集帖子数据
抓取数据时最常见的错误是“无法读取null的'textContent'属性”。 我们将创建一个简单的辅助函数,以防止我们试图获取不存在的元素的属性。
function safeGet( element: T, callback: (element: T) => K, fallbackValue = null, ): K { if (!element) { return fallbackValue } return callback(element) }
safeGet
仅在element
存在时才执行回调。 现在,让我们使用它来访问包含我们感兴趣的数据的元素的属性。
发表文章的日期
const dateElement = post.querySelector('time') const date = safeGet( dateElement, (el) => new Date(el.dateTime).toUTCString(), '', )
如果dateElement
发生了某些事情, dateElement
找到,我们的safeGet
将防止发生错误。
元素具有一个名为dateTime
的属性,该属性保存文章发布日期的字符串表示形式。
const authorDataElement = post.querySelector(
'.postMetaInline-authorLockup a[data-action="show-user-card"]', ) const { authorUrl, authorName } = safeGet( authorDataElement, (el) => { return { authorUrl: removeQueryFromURL(el.href), authorName: el.textContent, } }, {}, )
在此
元素内,我们可以找到用户的个人资料URL和他/她的名字。
同样,在这里我们使用removeQueryFromURL
因为在我们要删除的查询中,作者的个人资料URL和帖子的URL都有此奇怪的源参数:
https://hackernoon.com/javascript-2018-top-20-hackernoon-articles-of-the-year-9975563216d1? 源=---------
? URL中的字符表示查询参数的开头,因此让我们简单地删除它后面的所有内容。
const removeQueryFromURL = (url: string) => url.split('?').shift()
我们将字符串拆分为? 并只返回第一部分。
拍手
在上面的示例帖子中,我们看到“拍手”的数量为204,这是准确的。 但是,一旦数字超过1000,它们就会显示为1K,2K,2.5K。 如果我们需要拍手的确切数目,这可能是个问题。 在我们的用例中,此舍入效果很好。
const clapsElement = post.querySelector('span > button') const claps = safeGet( clapsElement, (el) => { const clapsString = el.textContent if (clapsString.endsWith('K')) { return Number(clapsString.slice(0, -1)) * 1000 } return Number(clapsString) }, 0, )
如果拍手的字符串表示形式以K结尾,我们只需删除K字母,然后将其乘以1000,就可以了。
文章的网址和标题
const articleTitleElement = post.querySelector('h3') const articleTitle = safeGet( articleTitleElement, (el) => el.textContent )
const articleUrlElement = post.querySelector( '.postArticle-readMore a', )
const articleUrl = safeGet( articleUrlElement, (el) => removeQueryFromURL(el.href) )
同样,由于选择器是在post
上下文中使用的,因此我们不需要对其结构过分具体。
“最小阅读量”
const minReadElement = post.querySelector('span[title]') const minRead = safeGet(minReadElement, (el) => el.title)
在这里,我们使用一个稍微不同的选择器:我们寻找一个包含data-title
属性的
。
注意:稍后我们将使用.title
属性,因此区分它们很重要。
好的,我们现在已经抓取了当前显示在页面上的所有文章,但是如何滚动以加载更多文章?
滚动以加载更多文章
// scroll to the bottom of the page await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight) }) // wait to fetch the new articles await page.waitFor(7500)
我们将页面滚动到底部并等待7.5秒。 这是一个“安全”的时间-文章可以在2秒钟内加载,但我们宁愿确保所有帖子都已加载,而不是错过一些帖子。 如果时间是一个重要因素,我们可能会在请求处设置一些拦截器,该拦截器将获取帖子并在完成后继续前进。
什么时候结束刮
如果按日期对帖子进行排序,我们会在我们遇到2017年以来的文章时停止抓取。但是,由于在2018年以来的文章之间出现了一些奇怪的旧文章,我们无法做到这一点。 我们可以做的是为2018年或以后发布的文章筛选已删除的文章。 如果结果数组是空的,我们可以有把握地认为有没有更多的文章中,我们感兴趣的是,在matchingArticles
我们一直被张贴在2018或更高版本的文章和parsedArticles
我们只有被张贴在2018年的文章。
const matchingArticles = scrapedArticles.filter((article) => { return article && new Date(article.date).getFullYear() >= 2018 }) if (!matchingArticles.length) { return articles } const parsedArticles = matchingArticles.filter((article) => { return new Date(article.date).getFullYear() === 2018 }) articles = [...articles, ...parsedArticles]
如果matchingArticles
为空,我们将返回所有文章,从而结束抓取。
放在一起
这是获取文章所需的全部代码:
const scrapArticles = async () => { const createPage = await createBrowser() return createPage('https: //hackernoon.com/tagged/javascript', async (page) => { let articles: Article[] = [] let offset = 0 while (true) { console.log({ offset }) const scrapedArticles: Article[] = await page.evaluate((offset) => { function safeGet( element: T, callback: (element: T) => K, fallbackValue = null, ): K { if (!element) { return fallbackValue } return callback(element) } const removeQueryFromURL = (url: string) => url.split('?').shift() return Array.from(document.querySelectorAll('.postArticle')) .slice(offset) .map((post) => { try { const dateElement = post.querySelector('time') const date = safeGet(dateElement, (el) => new Date(el.dateTime).toUTCString(), '') const authorDataElement = post.querySelector( '.postMetaInline-authorLockup a[data-action="show-user-card"]', ) const { authorUrl, authorName } = safeGet( authorDataElement, (el) => { return { authorUrl: removeQueryFromURL(el.href), authorName: el.textContent, } }, {}, ) const clapsElement = post.querySelector('span > button') const claps = safeGet( clapsElement, (el) => { const clapsString = el.textContent if (clapsString.endsWith('K')) { return Number(clapsString.slice(0, -1)) * 1000 } return Number(clapsString) }, 0, ) const articleTitleElement = post.querySelector('h3') const articleTitle = safeGet(articleTitleElement, (el) => el.textContent) const articleUrlElement = post.querySelector( '.postArticle-readMore a', ) const articleUrl = safeGet(articleUrlElement, (el) => removeQueryFromURL(el.href)) const minReadElement = post.querySelector('span[title]') const minRead = safeGet(minReadElement, (el) => el.title) return { claps, articleTitle, articleUrl, date, authorUrl, authorName, minRead, } as Article } catch (e) { console.log(e.message) return null } }) }, offset) offset += scrapedArticles.length // scroll to the bottom of the page await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight) }) // wait to fetch the new articles await page.waitFor(7500) const matchingArticles = scrapedArticles.filter((article) => { return article && new Date(article.date).getFullYear() >= 2018 }) if (!matchingArticles.length) { return articles } const parsedArticles = matchingArticles.filter((article) => { return new Date(article.date).getFullYear() === 2018 }) articles = [...articles, ...parsedArticles] console.log(articles[articles.length - 1]) } }) }
在以适当的格式保存数据之前,让我们按拍子降序对文章进行排序:
const sortArticlesByClaps = (articles: Article[]) => { return articles.sort( (fArticle, sArticle) => sArticle.claps - fArticle.claps ) }
现在让我们将文章输出为可读格式,因为到目前为止它们仅存在于我们计算机的内存中。
输出格式
JSON格式
我们可以使用JSON 格式将所有数据转储到单个文件中。 以这种方式存储所有文章可能会在将来的某个时候派上用场。
转换为JSON 格式归结为键入:
const jsonRepresentation = JSON.stringify(articles)
我们现在可以停止使用文章的JSON 表示,然后将我们认为属于该文章的文章复制并粘贴到列表中。 但是,您可以想象,这也可以自动化。
的HTML
与从JSON 格式手动复制所有内容相比, HTML 格式肯定会使从列表中复制和粘贴项目更加容易。
大卫在他的文章中列出以下方式的文章:
大卫的名单格式
我们希望我们的列表采用这种格式。 我们可以再次使用puppeteer 来创建HTML 元素并对其进行操作,但是,由于我们正在使用HTML ,因此我们可以将值嵌入字符串中-浏览器将始终解析它们。
const createHTMLRepresentation = async (articles: Article[]) => { const list = articles .map((article) => { return ` ${article.articleTitle} by ${article.authorName} [${article.minRead}] (${article.claps}) `
})
.join('')
return `
Articles ${list} `
}
如您所见,我们只是在文章上使用.map()
并返回一个字符串,其中包含我们喜欢的格式化格式的数据。 现在,我们有了一个包含
元素的数组-每个元素代表一篇文章。 现在我们只需要.join()
来创建一个字符串并将其嵌入到简单的HTML5 模板中。
保存文件
剩下要做的最后一件事是将表示形式保存在单独的文件中。
const scrapedArticles = await scrapArticles() const articles = sortArticlesByClaps(scrapedArticles) console.log(`Scrapped ${articles.length} articles.`) const jsonRepresentation = JSON.stringify(articles) const htmlRepresentation = createHTMLRepresentation(articles)
await Promise.all([ fs.writeFileAsync(jsonFilepath, jsonRepresentation), fs.writeFileAsync(htmlFilepath, htmlRepresentation), ])
结果
根据抓取工具 ,今年在Hackernoon 上发布了894篇带有JavaScript 标签的文章,平均每天有2.45篇文章。
HTML 文件如下所示:
现在是JSON 文件:
[ { "claps": 222000, "articleTitle": "I'm harvesting credit card numbers and passwords from your site. Here's how.", "articleUrl": "https://hackernoon.com/im-harvesting-credit-card-numbers-and-passwords-from-your-site-here-s-how-9a8cb347c5b5", "date": "Sat, 06 Jan 2018 08:48:50 GMT", "authorUrl": "https://hackernoon.com/@david.gilbertson", "authorName": "David Gilbertson", "minRead": "10 min read" }, { "claps": 18300, "articleTitle": "Part 2: How to stop me harvesting credit card numbers and passwords from your site", "articleUrl": "https://hackernoon.com/part-2-how-to-stop-me-harvesting-credit-card-numbers-and-passwords-from-your-site-844f739659b9", "date": "Sat, 27 Jan 2018 08:38:33 GMT", "authorUrl": "https://hackernoon.com/@david.gilbertson", "authorName": "David Gilbertson", "minRead": "16 min read" }, { "claps": 218, "articleTitle": "JAVASCRIPT 2018 -- TOP 20 HACKERNOON ARTICLES OF THE YEAR", "articleUrl": "https://hackernoon.com/javascript-2018-top-20-hackernoon-articles-of-the-year-9975563216d1", "date": "Sat, 29 Dec 2018 16:26:36 GMT", "authorUrl": "https://hackernoon.com/@maciejcieslar", "authorName": "Maciej Cieślar", "minRead": "2 min read" } ]
通过创建一个为我完成所有繁琐,令人麻木的工作的刮板,我可能节省了7至8个小时。 完成后,剩下要做的就是查看热门文章并选择要添加在文章中的内容。 创建代码大约需要一个小时,而手工复制和粘贴所有数据(更不用说以HTML 和JSON 格式保存)将容易地花费更多。
如果您有兴趣查看我选择放入列表的内容 ,请参阅本文 。
最初于 2019 年1月7日 发布在 www.mcieslar.com 上。
From: https://hackernoon.com/how-i-used-my-programming-skills-to-save-over-8-hours-of-writing-work-7aba154d4232