爬虫静态网页数据提取
为了照顾一些新入门的朋友,本篇的内容html内容解析会用两个库来完成,一个是
BeautifulSoup
另一个是我比较喜欢用的parsel
. 大多数新入门朋友可能学习爬虫的时候,都是从BeautifulSoup这个库开始的。
什么是静态网页
静态网页是指内容固定不变的网页,它的内容是直接写在 HTML 文件中的,不会因为用户的请求或者其他因素而改变。静态网页的内容通常由 HTML、CSS 和 JavaScript 组成,服务器只需要将这些文件发送给浏览器,浏览器就可以直接解析并显示网页内容。
静态网页工作原理
当用户在浏览器中输入一个静态网页的 URL 时,浏览器会向服务器发送一个 HTTP 请求,请求获取该 URL 对应的 HTML 文件。服务器接收到请求后,会在服务器上查找对应的 HTML 文件,并将其内容发送给浏览器。浏览器接收到 HTML 文件后,会解析其中的 HTML、CSS 和 JavaScript 代码,并根据这些代码渲染出网页内容。
爬取静态网页一般需要那些技术
- 会一点点前端的三件套(html、css、js)不会的朋友可以去菜鸟教程上面看一看,只需要简单的入门,知道html标签的一个结构,css选择器的简单用法,js的话暂时不太需要。
- 会使用网络请求库,比如requests、httpx等
- 会使用html解析库,比如BeautifulSoup、parsel等
- 会查找静态网页一个规律
- 存储方面的话看自己需求,如果需要存db这些,就需要自己去了解一些db方面的知识(可选)
实战示例
下面开始爬虫入门教程系列的第一次代码实战,前面7讲都在将一些理论知识,我们来看看如何将这些理论知识用于实践当中。
我的教程都会给大家写两个版本,一个同步请求版本,一个异步请求版本,可能大家在很多别人教程里看见的大多数都是使用requests + BeautifulSoup这一套。
我这边给大家再写一套异步的,我为什么很喜欢写爬虫代码喜欢用异步? 之前大家如果看过MediaCrawler的源码实现的话,可以看到我整个实现全是基于异步,从发请求、操作数据库、操作db,只要能异步化我都异步化了。
- 1、这是一种趋势,python一些流行的web框架现在都在往异步方面靠,我们提前用爬虫代码练练手,为后续你可能从事python方面的后端工程师做一些准备
- 2、性能真的很不错,之前那种多线程爬取的效率有点低,使用异步能在单进程单线内把资源利用发挥到极致。
任务需求描述
由于合规信息要求,我们的案例大多都会选择一些不在国内的站点来作为爬虫目标站点,技术的原理是相通的。
今天我要爬取的是一个BBS论坛网站的股票讨论部分,目标站点地址:https://www.ptt.cc/bbs/Stock/index.html 需要采集前N页的信息,具体采集内容如下:
- 前N页的帖子列表汇总
- 前N页的每一个帖子内容信息信息
- 前N页的每一个帖子的推文信息(可以理解为评论信息)获取
技术可行性分析
1、如何获取前N页中的最新分页Number?
需求中说的是前N页帖子,那么我们是不是要从最新的帖子往前推N页就可以了,理论上我们只需要找出它最新的分页Number就可以了。
我们打开 https://www.ptt.cc/bbs/Stock/index.html
并点击上一页
按钮,从页面URL https://www.ptt.cc/bbs/Stock/index7083.html
可以得出 7083可能是分页Number
我们再点一次上一页
按钮,可以发现URL变为了:https://www.ptt.cc/bbs/Stock/index7082.html
, 那么我们可以初步断定,这个网站的分页模式就是从高到低递减了。
我们如何知道7083这个分页数字?
静态网页一般找这个数字都不难,Chrome浏览器,F12,选中上一页按钮,从html文档中的elements就能看见这个按钮是一个a标签,其中的href属性放着点击该按钮之后要跳转的URL地址。
所以我们只需要使用解析库把这个数字解析出来就可以了
2、html结构分析
我们拿帖子列表来做一个简单分析,帖子详情页的类似
同样F12进入控制台,鼠标选择其中一个帖子,查看右边Chrome调试工具的Eelements,可以看到每一个帖子的一块区域所对应的html代码都是由一个div calss='r-ent'
包裹的.
这种结构化的网页是我们最喜欢看见的,有规律可循,所以下一步就是按需提取信息了。
下面我贴出一个帖子的html代码,然后分别基于两个解析库BeautifulSoup
、parsel
提取我们想要的信息
<div class="r-ent"> <div class="nrec"><span class="hl f3">11</span></div> <div class="title"> <a href="/bbs/Stock/M.1711544298.A.9F8.html">[新聞] 童子賢:用稅收補貼電費非長久之計 應共</a> </div> <div class="meta"> <div class="author">addy7533967</div> <div class="article-menu"> <div class="trigger">⋯</div> <div class="dropdown"> <div class="item"><a href="/bbs/Stock/search?q=thread%3A%5B%E6%96%B0%E8%81%9E%5D+%E7%AB%A5%E5%AD%90%E8%B3%A2%EF%BC%9A%E7%94%A8%E7%A8%85%E6%94%B6%E8%A3%9C%E8%B2%BC%E9%9B%BB%E8%B2%BB%E9%9D%9E%E9%95%B7%E4%B9%85%E4%B9%8B%E8%A8%88+%E6%87%89%E5%85%B1">搜尋同標題文章</a></div> <div class="item"><a href="/bbs/Stock/search?q=author%3Aaddy7533967">搜尋看板內 addy7533967 的文章</a></div> </div> </div> <div class="date"> 3/27</div> <div class="mark"></div> </div> </div>
# -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2024/3/27 22:47 # @Desc : 分别使用两个库演示如何提取html文档结构数据 from bs4 import BeautifulSoup from parsel import Selector class NoteContent: title: str = "" author: str = "" publish_date: str = "" detail_link: str = "" def __str__(self): return f""" Title: {self.title} User: {self.author} Publish Date: {self.publish_date} Detail Link: {self.detail_link} """ def parse_html_use_bs(html_content: str): """ 使用BeautifulSoup提取帖子标题、作者、发布日期,基于css选择器提取 :param html_content: html源代码内容 :return: """ # 初始化一个帖子保存容器 note_content = NoteContent() # 初始化bs查询对象 soup = BeautifulSoup(html_content, "lxml") # 提取标题并去左右除换行空格字符 note_content.title = soup.select("div.r-ent div.title a")[0].text.strip() # 提取作者 note_content.author = soup.select("div.r-ent div.meta div.author")[0].text.strip() # 提取发布日期 note_content.publish_date = soup.select("div.r-ent div.meta div.date")[0].text.strip() # 提取帖子链接 note_content.detail_link = soup.select("div.r-ent div.title a")[0]["href"] print("BeautifulSoup" + "*" * 30) print(note_content) print("BeautifulSoup" + "*" * 30) def parse_html_use_parse(html_content: str): """ 使用parsel提取帖子标题、作者、发布日期,基于xpath选择器提取 :param html_content: html源代码内容 :return: """ # 初始化一个帖子保存容器 note_content = NoteContent() # 使用parsel创建选择器对象 selector = Selector(text=html_content) # 使用XPath提取标题并去除左右空格 note_content.title = selector.xpath("//div[@class='r-ent']/div[@class='title']/a/text()").extract_first().strip() # 使用XPath提取作者 note_content.author = selector.xpath("//div[@class='r-ent']/div[@class='meta']/div[@class='author']/text()").extract_first().strip() # 使用XPath提取发布日期 note_content.publish_date = selector.xpath("//div[@class='r-ent']/div[@class='meta']/div[@class='date']/text()").extract_first().strip() # 使用XPath提取帖子链接 note_content.detail_link = selector.xpath("//div[@class='r-ent']/div[@class='title']/a/@href").extract_first() print("parsel" + "*" * 30) print(note_content) print("parsel" + "*" * 30)
简易流程图
一般像这一类比较简单的爬虫需求,我几乎不化流程图,但是为了让大家更清楚的知道,我在代码编写前都会给大家画一下,养成一个coding前画图的习惯,其实对于自己提升和代码容错都有一定的帮助,画图的过程中就在思考代码流程了。
代码实现
依赖库安装
pip3 install requests pip3 install beautifulsoup4 pip3 install lxml pip3 install httpx pip3 install parsel
requests + BeautifulSoup 同步版本
代码路径:源代码/爬虫入门/08_爬虫入门实战1_静态网页数据提取/002_源码实现_同步版本.py
# -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2024/3/27 23:50 # @Desc : https://www.ptt.cc/bbs/Stock/index.html 前N页帖子数据+推文数据获取 - 同步版本 from typing import List import requests from bs4 import BeautifulSoup from common import NoteContent, NoteContentDetail, NotePushComment FIRST_N_PAGE = 10 # 前N页的论坛帖子数据 BASE_HOST = "https://www.ptt.cc" HEADERS = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" } def parse_note_use_bs(html_content: str) -> NoteContent: """ 使用BeautifulSoup提取帖子标题、作者、发布日期,基于css选择器提取 需要注意的时,我们在提取帖子的时候,可能有些帖子状态不正常,会导致没有link之类的数据,所以我们在取值时最好判断一下元素长度 :param html_content: html源代码内容 :return: """ # 初始化一个帖子保存容器 note_content = NoteContent() soup = BeautifulSoup(html_content, "lxml") # 提取标题并去左右除换行空格字符 note_content.title = soup.select("div.r-ent div.title a")[0].text.strip() if len( soup.select("div.r-ent div.title a")) > 0 else "" # 提取作者 note_content.author = soup.select("div.r-ent div.meta div.author")[0].text.strip() if len( soup.select("div.r-ent div.meta div.author")) > 0 else "" # 提取发布日期 note_content.publish_date = soup.select("div.r-ent div.meta div.date")[0].text.strip() if len( soup.select("div.r-ent div.meta div.date")) > 0 else "" # 提取帖子链接 note_content.detail_link = soup.select("div.r-ent div.title a")[0]["href"] if len( soup.select("div.r-ent div.title a")) > 0 else "" return note_content def get_previos_page_number() -> int: """ 打开首页提取上一页的分页Number :return: """ uri = "/bbs/Stock/index.html" reponse = requests.get(url=BASE_HOST + uri, headers=HEADERS) if reponse.status_code != 200: raise Exception("send request got error status code, reason:", reponse.text) soup = BeautifulSoup(reponse.text, "lxml") # 下面这一串css选择器获取的最好的办法是使用chrom工具,进入F12控制台,选中'上页'按钮, 右键,点击 Copy -> Copy Css Selector就自动生成了。 css_selector = "#action-bar-container > div > div.btn-group.btn-group-paging > a:nth-child(2)" pagination_link = soup.select(css_selector)[0]["href"].strip() # pagination_link: /bbs/Stock/index7084.html 提取数字部分,可以使用正则表达式,也可以使用字符串替换,我这里就使用字符串替换暴力解决了 previos_page_number = int(pagination_link.replace("/bbs/Stock/index", "").replace(".html", "")) return previos_page_number def fetch_bbs_note_list(previos_number: int) -> List[NoteContent]: """ 获取前N页的帖子列表 :return: """ notes_list: List[NoteContent] = [] # 计算分页的其实位置和终止位置,由于我们也是要爬首页的,所以得到上一页的分页Number之后,应该还要加1才是我们的起始位置 start_page_number = previos_number + 1 end_page_number = start_page_number - FIRST_N_PAGE for page_number in range(start_page_number, end_page_number, -1): print(f"开始获取第 {page_number} 页的帖子列表 ...") # 根据分页Number拼接帖子列表的URL uri = f"/bbs/Stock/index{page_number}.html" response = requests.get(url=BASE_HOST + uri, headers=HEADERS) if response.status_code != 200: print(f"第{page_number}页帖子获取异常,原因:{response.text}") continue # 使BeautifulSoup的CSS选择器解析数据,div.r-ent 是帖子列表html页面中每一个帖子都有的css class soup = BeautifulSoup(response.text, "lxml") all_note_elements = soup.select("div.r-ent") for note_element in all_note_elements: # 调用prettify()方法可以获取整个div元素的HTML内容 note_content: NoteContent = parse_note_use_bs(note_element.prettify()) notes_list.append(note_content) print(f"结束获取第 {page_number} 页的帖子列表,本次获取到:{len(all_note_elements)} 篇帖子...") return notes_list def fetch_bbs_note_detail(note_content: NoteContent) -> NoteContentDetail: """ 获取帖子详情页数据 :param note_content: :return: """ print(f"开始获取帖子 {note_content.detail_link} 详情页....") note_content_detail = NoteContentDetail() # note_content有值的, 我们直接赋值,就不要去网页提取了,能偷懒就偷懒(初学者还是要老老实实的都去提取一下数据) note_content_detail.title = note_content.title note_content_detail.author = note_content.author note_content_detail.detail_link = BASE_HOST + note_content.detail_link response = requests.get(url=BASE_HOST + note_content.detail_link, headers=HEADERS) if response.status_code != 200: print(f"帖子:{note_content.title} 获取异常,原因:{response.text}") return note_content_detail soup = BeautifulSoup(response.text, "lxml") note_content_detail.publish_datetime = soup.select("#main-content > div:nth-child(4) > span.article-meta-value")[ 0].text # 处理推文 note_content_detail.push_comment = [] all_push_elements = soup.select("#main-content > div.push") for push_element in all_push_elements: note_push_comment = NotePushComment() if len(push_element.select("span")) < 3: continue note_push_comment.push_user_name = push_element.select("span")[1].text.strip() note_push_comment.push_cotent = push_element.select("span")[2].text.strip().replace(": ", "") note_push_comment.push_time = push_element.select("span")[3].text.strip() note_content_detail.push_comment.append(note_push_comment) print(note_content_detail) return note_content_detail def run_crawler(save_notes: List[NoteContentDetail]): """ 爬虫主程序 :param save_notes: 数据保存容器 :return: """ # step1 获取分页number previos_number: int = get_previos_page_number() # step2 获取前N页帖子集合列表 note_list: List[NoteContent] = fetch_bbs_note_list(previos_number) # step3 获取帖子详情+推文 for note_content in note_list: if not note_content.detail_link: continue note_content_detail = fetch_bbs_note_detail(note_content) save_notes.append(note_content_detail) print("任务爬取完成.......") if __name__ == '__main__': all_note_content_detail: List[NoteContentDetail] = [] run_crawler(all_note_content_detail)
httpx + parsel 异步版本
代码路径:源代码/爬虫入门/08_爬虫入门实战1xxx
# -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2024/3/27 23:50 # @Desc : https://www.ptt.cc/bbs/Stock/index.html 前N页帖子数据获取 - 异步版本 import httpx from parsel import Selector from typing import List from common import NoteContent, NoteContentDetail, NotePushComment FIRST_N_PAGE = 10 # 前N页的论坛帖子数据 BASE_HOST = "https://www.ptt.cc" HEADERS = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" } async def parse_note_use_parsel(html_content: str) -> NoteContent: """ 使用parse提取帖子标题、作者、发布日期,基于css选择器提取 需要注意的时,我们在提取帖子的时候,可能有些帖子状态不正常,会导致没有link之类的数据,所以我们在取值时最好判断一下元素长度 :param html_content: html源代码内容 :return: """ note_content = NoteContent() selector = Selector(text=html_content) title_elements = selector.css("div.r-ent div.title a") author_elements = selector.css("div.r-ent div.meta div.author") date_elements = selector.css("div.r-ent div.meta div.date") note_content.title = title_elements[0].root.text.strip() if title_elements else "" note_content.author = author_elements[0].root.text.strip() if author_elements else "" note_content.publish_date = date_elements[0].root.text.strip() if date_elements else "" note_content.detail_link = title_elements[0].attrib['href'] if title_elements else "" return note_content async def get_previous_page_number() -> int: """ 打开首页提取上一页的分页Number :return: """ uri = "/bbs/Stock/index.html" async with httpx.AsyncClient() as client: response = await client.get(BASE_HOST + uri, headers=HEADERS) if response.status_code != 200: raise Exception("send request got error status code, reason:", response.text) selector = Selector(text=response.text) css_selector = "#action-bar-container > div > div.btn-group.btn-group-paging > a:nth-child(2)" pagination_link = selector.css(css_selector)[0].attrib['href'].strip() previous_page_number = int(pagination_link.replace("/bbs/Stock/index", "").replace(".html", "")) return previous_page_number async def fetch_bbs_note_list(previous_number: int) -> List[NoteContent]: """ 获取前N页的帖子列表 :param previous_number: :return: """ notes_list: List[NoteContent] = [] start_page_number = previous_number + 1 end_page_number = start_page_number - FIRST_N_PAGE async with httpx.AsyncClient() as client: for page_number in range(start_page_number, end_page_number, -1): print(f"开始获取第 {page_number} 页的帖子列表 ...") uri = f"/bbs/Stock/index{page_number}.html" response = await client.get(BASE_HOST + uri, headers=HEADERS) if response.status_code != 200: print(f"第{page_number}页帖子获取异常,原因:{response.text}") continue selector = Selector(text=response.text) all_note_elements = selector.css("div.r-ent") for note_element_html in all_note_elements: note_content: NoteContent = await parse_note_use_parsel(note_element_html.get()) notes_list.append(note_content) print(f"结束获取第 {page_number} 页的帖子列表,本次获取到:{len(all_note_elements)} 篇帖子...") return notes_list async def fetch_bbs_note_detail(note_content: NoteContent) -> NoteContentDetail: """ 获取帖子详情页数据 :param note_content: :return: """ print(f"开始获取帖子 {note_content.detail_link} 详情页....") note_content_detail = NoteContentDetail() note_content_detail.title = note_content.title note_content_detail.author = note_content.author note_content_detail.detail_link = BASE_HOST + note_content.detail_link async with httpx.AsyncClient() as client: response = await client.get(note_content_detail.detail_link, headers=HEADERS) if response.status_code != 200: print(f"帖子:{note_content.title} 获取异常,原因:{response.text}") return note_content_detail selector = Selector(text=response.text) note_content_detail.publish_datetime = \ selector.css("#main-content > div:nth-child(4) > span.article-meta-value")[0].root.text # 解析推文 note_content_detail.push_comment = [] all_push_elements = selector.css("#main-content > div.push") for push_element in all_push_elements: note_push_comment = NotePushComment() spans = push_element.css("span") if len(spans) < 3: continue note_push_comment.push_user_name = spans[1].root.text.strip() note_push_comment.push_cotent = spans[2].root.text.strip().replace(": ", "") note_push_comment.push_time = spans[3].root.text.strip() note_content_detail.push_comment.append(note_push_comment) print(note_content_detail) return note_content_detail async def run_crawler(save_notes: List[NoteContentDetail]): previous_number = await get_previous_page_number() note_list = await fetch_bbs_note_list(previous_number) for note_content in note_list: if not note_content.detail_link: continue note_content_detail = await fetch_bbs_note_detail(note_content) save_notes.append(note_content_detail) print("任务爬取完成.......") if __name__ == '__main__': import asyncio all_note_content_detail: List[NoteContentDetail] = [] asyncio.run(run_crawler(all_note_content_detail))
存储实现
存储实现我们留在第10讲再去实现吧
源代码
其他
不知不觉的这一篇教程从晚上9点开始的,写到了晚上2.27,存储实现还没写玩,之前写前几篇帖子没什么感觉,到了实战帖子之后,感觉花费的时间多了很多很多。
并且这还是一个很简单很简单的爬虫需求,想要把完整的内容思路用图文表达出来,确实不是那么容易。。。。想到后续的进阶和高级爬虫,有点社死,可能时间严重不够。