现代网站不仅将数据存储在可见的 HTML 页面中,还存储在嵌入式 javascript 代码中。这在页面加载时由 javascript 呈现或由用户交互触发的动态网站元素中尤为常见。
爬取动态数据的最常见方法是使用无头浏览器强制在 HTML 中呈现隐藏数据。然而,在本文中,我们将研究如何在不使用 Web 浏览器的情况下直接提取这些数据,这种方法的速度和效率可以提高一千倍。
我们将了解什么是隐藏数据、一些常见示例以及我们如何使用正则表达式和其他巧妙的解析算法来爬取它。
什么是网页中的隐藏数据?
动态 Web 前端通常将数据存储在 javascript 变量中,然后根据需要(如页面加载或用户操作)将其呈现为 HTML。这意味着数据在页面上不可见,尽管它仍然存在!
例如,一个网站可以这样做:
<html> <head> </head> <body> <div id="product"> <!-- There's no product data in the html --> </div> <script> // but we can see data here var data = {"product": {"name": "some product", "price": 44.33}}; // and it's being put into the HTML on page load: productName = document.createElement("div"); productName.setAttribute("id", "product-name"); productName.innerText = data['product']['name']; product = document.getElementById("product"); product.appendChild(productName); </script> </body> </html>
我们看到初始 HTML 只有一个空的产品<div>
节点,数据本身驻留在一个 javascript 变量中data
。然后,在页面加载时,javascript 用于将该数据转换为可见的 HTML 节点。如果我们在支持 javascript 的浏览器中查看页面源代码,我们会看到:
<div id="product"> <div id="product-name">some product</div> </div>
现代 Web 开发人员喜欢这种技术,因为他们可以隐藏页面中的所有数据并更新前端以他们喜欢的任何方式表示数据。
不幸的是,不执行 javascript(任何不运行浏览器的东西)的网络爬取工具看不到呈现为 HTML 的数据——这意味着,它们必须找到查找和解析这些 Javascript 变量的方法。
如何查找隐藏的 Web 数据
我们可以通过两种方式处理隐藏的网络数据:
Playwright、Puppeteer和Selenium等工具可用于控制真实的无头 Web 浏览器来呈现页面并返回最终呈现的 HTML。虽然这既昂贵又缓慢 – 我们需要运行整个网络浏览器并等待所有内容加载!
或者,我们可以使用 HTML 解析工具、正则表达式和通用解析算法来解析这些隐藏状态/缓存变量的 HTML。我们必须亲力亲为,但我们的过程会明显加快,而且我们可以访问整个数据集,其中可能包含比我们在可见 HTML 中看到的更多的细节。
隐藏的 Web 数据通常还包含网站隐藏 API 使用的各种令牌或用于混淆数据或用于阻止 Web 爬取工具的详细信息。
让我们来看看隐藏数据的一些常见存储方式以及我们如何找到它。
查找隐藏的 JSON 数据
为了确认该网站是否包含隐藏的网络数据,我们可以使用一个简单的测试:
- 在我们的网络浏览器中加载页面并找到唯一的数据标识符(例如产品名称、ID 或部分描述)。
- 在我们的浏览器中禁用 javascript 并重新加载页面。
- 检查页面源代码(右键单击页面)并查找我们的唯一标识符(例如 ctrl+f)
几乎所有形式的隐藏数据都存储在 HTML 节点中,例如<script>
. 可以是 JSON 对象或变量。因此,我们可以做的第一件事是捕获包含此数据的脚本文本。
我们可以使用常见的 HTML 解析包来做到这一点,比如parsel或beautifulsoup:
import json html = """ <html> <head> </head> <body> <script id="__NEXT_DATA__" type="application/json"> {"product": {"id": 1, "name": "first product"}} </script> </body> </html """ # using parsel from parsel import Selector selector = Selector(html) data = selector.css("#__NEXT_DATA__::text").get() data = json.loads(data) print(data['product']) # {"id": 1, "name": "first product"} # using beautifulsoup from bs4 import BeautifulSoup soup = BeautifulSoup(html) data = soup.select_one("#__NEXT_DATA__").text data = json.loads(data) print(data['product']) # {"id": 1, "name": "first product"}
在上述两种情况下,我们加载 HTML 并在<script>
具有特定id
属性的节点中查找文本。然后将找到的JSON数据加载为Python字典,我们就可以随意解析了!
type=application-json
如果按照我们示例中的原样存储,这通常足以检索隐藏数据。然而,情况并非总是如此,脚本中的数据可以位于 javascript 变量下。
使用正则表达式
正则表达式非常适合查找结构化文本数据,例如 JSON。例如,如果我们的隐藏数据在源代码中是这样的:
<script id="__NEXT_DATA__"> // javascript data: var product = {"product": {"id": "1", "name": "first product"}}; var _meta = ... </script>
Python 的 JSON 模块不够智能,无法提取它。相反,我们可以用正则表达式来帮助它:
html = """ <html> <head> </head> <body> <script id="__NEXT_DATA__"> // javascript data: var product = {"product": {"id": "1", "name": "first product"}}; var _meta = ... </script> </body> </html> """ # find script text using parsel: from parsel import Selector selector = Selector(html) script_text = selector.css("#__NEXT_DATA__::text").get() # find json using regular expressions: import re import json data = re.findall(r"product = ({.*?});", script_text) data = json.loads(data[0]) print(data["product"])
product =
在上面的示例中,我们使用正则表达式模式来选择和标记之间的文本};
,这是隐藏的 JSON Web 数据。
正则表达式很好用,但会变得非常复杂并且很容易损坏。另一种提取此数据的方法是使用常见的数据解析算法——让我们接下来看一下。
使用 JSON 查找算法
Python 自带一个很棒的 JSON 数据解码器,可以用来在任何文本中查找 JSON 文档!
例如,这是一个流行的函数,可以在文本字符串中找到所有有效的 JSON 对象:
import json def find_json_objects(text: str, decoder=json.JSONDecoder()): """Find JSON objects in text, and generate decoded JSON data""" pos = 0 while True: match = text.find("{", pos) if match == -1: break try: result, index = decoder.raw_decode(text[match:]) yield result pos = match + index except ValueError: pos = match + 1 text = """ This text contains some {"json": "objects"} and some json products like product = {"product": {"id": 1, "name": "first product"}}; console.log("more javascript"); """ found = list(find_json_objects(text)) print(found) # [{'json': 'objects'}, {'product': {'id': 1, 'name': 'first product'}}]
该函数在任何文本字符串中查找所有 JSON 对象,这比我们的正则表达式示例方便得多。此外,由于我们知道我们的产品数据对象的外观(例如,它包含一个product
键),我们可以不费吹灰之力地专门选择它:
product = next(data for data in found if data.get('product')) print(product) # {'product': {'id': 1, 'name': 'first product'}}
查找 Javascript 数据
javascript 中的 JSON 对象是本机的,这意味着它们本身可以包含 javascript 代码,这就是事情变得复杂的地方。有效的 javascript 代码对象不是有效的 JSON 数据对象。让我们看一下这个例子:
text = """ var product = { // some comment: "element": document.createElement("div"), "url": "http://foo.com", // some trailing comment "price": 44.23,"discount": 22.11, "features": ["warm", "cold"], "product": {"id": 1, "name": "first product"} } """ print(list(find_json_objects(text)))
我们基于正则表达式和 JSON 查找器的解决方案都无法成功解析整个对象。那是因为这是一个有效的 javascript 对象而不是一个有效的 JSON 数据对象。它包含评论和代码块,如果没有网络浏览器,我们的爬取工具将无法理解。
我们有几种方法可以解决这个问题:
- 删除注释和任何非基本数据类型(字符串、数字、布尔值等)的内容,然后使用我们的 JSON 查找器。
- 使用 javascript 语言解析器解析 javascript 代码,然后提取该数据。
根据您的项目规模和复杂性,这些方法中的任何一种都可能更合适。例如,对于一些小项目,我们可以破解我们的 JSON 查找器以删除垃圾数据,但对于更大的项目,我们可能需要投入更多时间来采用更具弹性的基于语言解析的方法。
让我们来看看两者吧!
从 JSON 中删除 Javascript
要将 javascript 对象转换为 JSON 对象,我们所要做的就是删除任何不是原始值的值,如字符串、布尔值或数字,并删除注释。
要清除我们可以使用正则表达式和评论的对象,我们可以利用现有的包,如pyparsing:
import re import pyparsing import json comment_remover = pyparsing.cpp_style_comment.suppress() comment_remover.ignore(pyparsing.QuotedString('"') | pyparsing.QuotedString("'")) def remove_objects(text): """ replaces all `"key": object` ocurrances in text with `"key": {}` """ text = comment_remover.transform_string(text) def _rm(match: re.Match): key, value, trail = match.groups() return key + "{}" + trail return re.sub(r'("[^"]+?"\s*:\s*)([^"\s[{\d(?:true|false)].+?)(,|$|})', _rm, text) cleaned = remove_objects(text) # let's try it with our text: text = """ var product = { // some comment: "element": document.createElement("div"), "url": "http://foo.com", // some trailing comment "price": 44.23,"discount": 22, "features": ["warm", "cold"], "product": {"id": 1, "name": "first product"} } """ clean_text = remove_objects(comment_remover.transform_string(text)) print(list(find_json_objects(clean_text))) # will print: [ { "element": {}, "url": "http://foo.com", "price": 44.23, "discount": 22.11, "features": ["warm", "cold"], "product": {"id": 1, "name": "first product"}, } ]
通过这种快速破解,我们可以轻松地爬取更复杂的嵌入式 JSON 结构。但是,我们正在丢失所有的 javascript 数据——如果那里有有价值的东西怎么办?此外,正则表达式模式虽然速度很快,但很复杂,并且在网站更改时很容易崩溃。
让我们来看看另一种方法——解析 javascript 代码本身。
使用 js2xml 解析 Javascript
就像 javascript 解释器需要解析代码以理解它一样,我们也可以解析它以获取可变数据。
使用js2xml我们可以将 javascript 代码(包括 JSON)转换为我们可以使用CSS或XPath选择器解析的 XML 文档。让我们再看看我们的例子:
import js2xml from js2xml.utils.vars import get_vars, make_obj text = """ var product = { // some comment: "element": document.createElement("div"), "url": "http://fo,o.com", // some trailing comment "price": 44.23,"discount": document.deleteElement(foo), "features": ["warm", "cold"], "product": {"id": 1, "name": "first, product"} } """ # first convert javascript code to XML tree (return lxml.Element) parsed_tree = js2xml.parse(text) # we can see generated XML tree: print(js2xml.pretty_print(parsed_tree)) """ <program> <var name="product"> <object> <property name="element"> <functioncall> <function> <dotaccessor> <object> <identifier name="document"/> </object> <property> <identifier name="createElement"/> </property> </dotaccessor> </function> <arguments> <string>div</string> </arguments> </functioncall> </property> <property name="url"> <string>http://fo,o.com</string> </property> <property name="price"> <number value="44.23"/> </property> <property name="discount"> <functioncall> <function> <dotaccessor> <object> <identifier name="document"/> </object> <property> <identifier name="deleteElement"/> </property> </dotaccessor> </function> <arguments> <identifier name="foo"/> </arguments> </functioncall> </property> <property name="features"> <array> <string>warm</string> <string>cold</string> </array> </property> <property name="product"> <object> <property name="id"> <number value="1"/> </property> <property name="name"> <string>first, product</string> </property> </object> </property> </object> </var> </program> """ # we can also extract this tree as json print(get_vars(parsed_tree)) { "product": { "element": None, "url": "http://fo,o.com", "price": 44.23, "discount": None, "features": ["warm", "cold"], "product": {"id": 1, "name": "first, product"}, } } # or if the json is deep in the code we can find it with xpath and then convert it print(make_obj(parsed_tree.xpath('//property[@name="product"]/object')[0])) {"id": 1, "name": "first, product"}
在上面的示例中,我们使用 js2xml 将 javascript 代码转换为 XML,然后我们可以使用 css/xpath 选择器解析它或将数据转换为 python 字典。
一些真实的例子
我们经常在我们的网络爬取博客系列中遇到隐藏的网络数据,该系列涵盖了有关如何爬取流行的网络爬取目标的教程。
例如,我们在如何爬取 Glassdoorhttps://www.glassdoor.com/index.htm
文章中爬取时使用简单的正则表达式模式:
import re import httpx import json def extract_apollo_state(html): """Extract apollo graphql state data from HTML source""" # here we use regex pattern to find first json object after apolloState keyword: data = re.findall('apolloState":\s*({.+})};', html)[0] return json.loads(data) def scrape_overview(company_id: int): short_url = f"https://www.glassdoor.com/Overview/-IE_EI{company_id}.htm" response = httpx.get(short_url) apollo_state = extract_apollo_state(response.text) return next(v for k, v in state.items() if k.startswith("Employer:")) # Ebay's glassdoor profile page: print(json.dumps(scrape_overview("7671"), indent=2))
我们在此博客中介绍的其他一些隐藏的 Web 数据示例:
使用无头浏览器
我们介绍了爬取隐藏的 Web 数据如何成为使用无头浏览器完全呈现动态数据的替代方法。以同样的方式,我们可以使用无头浏览器来检索页面中存在的 javascript 变量,该页面返回完全呈现的隐藏 web 数据集。
例如,假设我们有这个隐藏的网络数据片段:
html = """ <html> <head> </head> <body> <script id="__NEXT_DATA__"> var product = { "product": { "id": "1", "name": "first product", "secret": create_secret() }}; var _meta = ... </script> </body> </html
在这里,我们可以看到该secret
字段是由 javascript 函数动态生成的。如果我们按原样爬取它,我们只会在数据中获取函数名称。
相反,我们可以通过Playwrigt、Puppeteer或Selenium启动一个真正的无头 Web 浏览器,并评估自定义 javascript 以捕获此数据。
作为一个真实的例子,让我们回到 Glassdoor 看看我们如何在 Playwright 和 Python 中做到这一点:
from playwright.sync_api import sync_playwright with sync_playwright() as pw: browser = pw.chromium.launch(headless=True) context = browser.new_context(viewport={"width": 1920, "height": 1080}) page = context.new_page() # got to glassdoor url page.goto("https://www.glassdoor.com/Overview/Working-at-eBay-EI_IE7853.11,15.htm") # extract apolloState data, Eployer:7853 contains company overview data # of ebay which is ID 7853: data = page.evaluate("window.appCache.apolloState['Employer:7853']") print(data) # will print { '__typename': 'Employer', 'id': 7853, 'shortName': 'eBay', 'website': 'www.ebayinc.com', 'type': 'Company - Public', 'revenue': '$10+ billion (USD)', 'headquarters': 'San Jose, CA', 'size': '10000+ Employees', 'stock': 'EBAY', ... }
在上面的例子中,我们启动了一个 Chrome 浏览器的无头实例,告诉它去 glassdoor.com 上的 Ebay 的个人资料页面,并通过 javascript 评估函数提取隐藏的网络数据。
常问问题
为了总结这篇文章,让我们看一下有关爬取隐藏网络数据的一些常见问题:
爬取隐藏的网络数据是否合法?
是的,隐藏的 Web 数据与可见的 HTML 是相同的公共数据。请注意,由于欧盟地区的 GDRP,应清除隐藏的网络数据中的用户识别信息。
隐藏数据爬取总结
随着网站越来越依赖 javascript 动态生成网页内容,隐藏的网页数据正变得越来越流行。因此,在这个广泛的教程中,我们了解了如何爬取这些数据、如何解析它以及这些领域中的常见挑战。
js2xml
我们探索了常见的正则表达式模式、JSON 解析算法和诸如词法数据解析之类的工具pyparsing
——所有这些都是在网络上查找公共隐藏数据集的好工具。