从动态服务器抓取 html 列表数据

Posted

技术标签:

【中文标题】从动态服务器抓取 html 列表数据【英文标题】:Scraping html list data from a dynamic server 【发布时间】:2021-10-25 18:04:45 【问题描述】:

大家好!

抱歉转储问题,这是我最后的选择。我发誓我尝试了无数其他 *** 问题、不同的框架等,但这些似乎没有帮助。

我有以下问题: 一个网站显示一个数据列表(前面有一大堆 div、li、span 等标签,它是一个很大的 html。)

我正在编写一个工具,它可以从大量其他 div 标签内的特定列表中获取数据,下载它并输出一个 excel 文件。

我试图访问的网站是动态的。所以你打开网站,它加载一点,然后出现列表(可能是一些 JS 和东西)。 当我尝试通过 C# 中的 webRequest 下载网站时,我得到的 html 几乎是空的,有大量空白、大量非 html 内容,还有一些垃圾数据。

现在:我已经习惯了 C#、HTMLAgillityPack 和无数其他库,但在 Web 相关的东西中并不多。我尝试了 CefSharp、Chromium 等所有这些东西,但不幸的是无法让它们正常工作。

我想在我的程序中使用一个 HTML,它看起来与您在使用时看到的 HTML 完全一样 您访问上面提到的网站,在 chrome wenn 中打开开发控制台。 HTML 解析器在那里毫无问题地工作。

这就是我想象的代码看起来像简化的样子。

Extreme C# 伪代码:

WebBrowserEngine web = new WebBrowserEngine()
web.LoadURLuntilFinished(url); // with all the JS executed and stuff
String html = web.getHTML();
web.close();

我的目标是伪代码中的字符串 html 看起来与 Chrome 开发选项卡中的字符串完全相同。 也许在其他地方发布了一个解决方案,但我发誓我找不到它,一直在寻找。

非常感谢 Andy 的帮助。

【问题讨论】:

我认为该网站不受您的控制?听起来它正在加载一个框架页面,以便用户可以快速查看某些内容,然后它会发出一些异步 Web 请求以获取要插入该页面的数据。当您在浏览器中查看页面时,是什么告诉您该页面“已完成”?是否存在具有特定 ID 的元素?一个特定的表有超过 0 行?这些条件可能不止一种。如果您可以将此特定页面的“已完成”定义添加到您的问题中,那将非常有帮助。 感谢您的快速回答!该页面仅动态加载 HTML,我真的不知道。我在浏览器中打开页面,获取数据列表(需要一段时间才能加载),然后我打开 chrome 开发工具以查看 HTML,并且有我需要的数据。我知道我需要 webengine 加载一点来获取数据,但我有时间。我只需要完成 HTML 中的一些数据 暂时不用担心页面如何加载其内容。我们真正需要知道的是,第一次加载时页面中不存在什么,但一旦“完成”就存在。有多种方法可以检测该内容何时添加到页面中,但如果不知道该内容是什么样子,就很难描述如何检测它。 【参考方案1】:

如果您需要完全执行网页,那么像 CefSharp 这样的完整浏览器是您唯一的选择。

可能是页面正在使用滚动状态、元素可见性或元素位置的某种组合来触发内容加载。如果是这种情况,那么您需要弄清楚它是什么并以编程方式触发它。我知道 CefSharp 可以模拟点击、滚动等用户操作。

【讨论】:

感谢您的快速回答!该页面仅动态加载 HTML,我真的不知道。我在浏览器中打开页面,获取数据列表(需要一段时间才能加载),然后我打开 chrome 开发工具以查看 HTML,并且有我需要的数据。我知道我需要 webengine 加载一点来获取数据,但我有时间。我只需要完成 HTML 中的一些数据【参考方案2】:

@SpencerBench 说的很到位

可能是页面正在使用滚动状态、元素可见性或元素位置的某种组合来触发内容加载。如果是这种情况,那么您需要弄清楚它是什么并以编程方式触发它。

要回答您的特定用例的问题,我们需要了解您要从中抓取数据的页面的行为,或者正如我在 cmets 中所问的,您如何知道该页面已“完成”?

但是,可以对这个问题给出一个相当笼统的答案,这应该作为你的起点。

此答案使用Selenium,这是一个通常用于自动测试 Web UI 的包,但正如他们在主页上所说,这不是唯一可以使用的东西。

它主要用于自动化 Web 应用程序以进行测试,但当然不仅限于此。无聊的基于 Web 的管理任务也可以(而且应该)实现自动化。

我正在抓取的网站

所以首先我们需要一个网站。我已经创建了一个使用 .net core 3.1 的 ASP.net core MVC,虽然网站的技术栈并不重要,但重要的是你想要抓取的页面的行为。这个网站有 2 个页面,想象不到的叫做 Page1 和 Page2。

页面控制器

这些控制器没有什么特别之处:

namespace ***68925623Website.Controllers

    using Microsoft.AspNetCore.Mvc;

    public class Page1Controller : Controller
    
        public IActionResult Index()
        
            return View("Page1");
        
    

namespace ***68925623Website.Controllers

    using Microsoft.AspNetCore.Mvc;

    public class Page2Controller : Controller
    
        public IActionResult Index()
        
            return View("Page2");
        
    

API 控制器

还有一个 API 控制器(即它返回数据而不是视图),视图可以异步调用该控制器以获取一些要显示的数据。这只是创建了一个由请求数量的随机字符串组成的数组。

namespace ***68925623Website.Controllers

    using Microsoft.AspNetCore.Mvc;
    using System;
    using System.Collections.Generic;
    using System.Text;

    [Route("api/[controller]")]
    [ApiController]
    public class DataController : ControllerBase
    
        [HttpGet("Create")]
        public IActionResult Create(int numberOfElements)
        
            var response = new List<string>();
            for (var i = 0; i < numberOfElements; i++)
            
                response.Add(RandomString(10));
            

            return Ok(response);
        

        private string RandomString(int length)
        
            var sb = new StringBuilder();
            var random = new Random();
            for (var i = 0; i < length; i++)
            
                var characterCode = random.Next(65, 90); // A-Z
                sb.Append((char)characterCode);
            

            return sb.ToString();
        
    

观看次数

Page1 的视图如下所示:

@
    ViewData["Title"] = "Page 1";


<div class="text-center">
    <div id="list" />

    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script>
        var apiUrl = 'https://localhost:44394/api/Data/Create';

        $(document).ready(function () 
            $('#list').append('<li id="loading">Loading...</li>');
            $.ajax(
                url: apiUrl + '?numberOfElements=20000',
                datatype: 'json',
                success: function (data) 
                    $('#loading').remove();
                    var insert = ''
                    for (var item of data) 
                        insert += '<li>' + item + '</li>';
                    
                    insert = '<ul id="results">' + insert + '</ul>';
                    $('#list').html(insert);
                ,
                error: function (xht, status) 
                    alert('Error: ' + status);
                
            );
        );
    </script>
</div>

所以当页面第一次加载时,它只包含一个名为list的空div,但是页面加载触发器的函数传递给jQuery的$(document).ready函数,它对API控制器进行异步调用,请求一个包含 20,000 个元素的数组。在调用过程中,屏幕上会显示“Loading...”,当调用返回时,将替换为包含接收数据的无序列表。这是以一种对自动化 UI 测试或屏幕抓取工具的开发人员友好的方式编写的,因为我们可以通过测试页面是否包含 ID 为 results 的元素来判断所有数据是否已加载。

Page2 的视图如下所示:

@
    ViewData["Title"] = "Page 2";


<div class="text-center">
    <div id="list">
        <ul id="results" />
    </div>

    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script>
        var apiUrl = 'https://localhost:44394/api/Data/Create';
        var requestCount = 0;
        var maxRequests = 20;

        $(document).ready(function () 
            getData();
        );

        function getDataIfAtBottomOfPage() 
            console.log("scroll - " + requestCount + " requests");
            if (requestCount < maxRequests) 
                console.log("scrollTop " + document.documentElement.scrollTop + " scrollHeight " + document.documentElement.scrollHeight);
                if (document.documentElement.scrollTop > (document.documentElement.scrollHeight - window.innerHeight - 100)) 
                    getData();
                
            
        

        function getData() 
            window.onscroll = undefined;
            requestCount++;
            $('results2').append('<li id="loading">Loading...</li>');
            $.ajax(
                url: apiUrl + '?numberOfElements=50',
                datatype: 'json',
                success: function (data) 
                    var insert = ''
                    for (var item of data) 
                        insert += '<li>' + item + '</li>';
                    
                    $('#loading').remove();
                    $('#results').append(insert);
                    if (requestCount < maxRequests) 
                        window.setTimeout(function ()  window.onscroll = getDataIfAtBottomOfPage , 1000);
                     else 
                        $('#results').append('<li>That\'s all folks');
                    
                ,
                error: function (xht, status) 
                    alert('Error: ' + status);
                
            );
        
    </script>
</div>

这提供了更好的用户体验,因为它以多个较小的块从 API 控制器请求数据,因此第一个数据块显示得相当快,一旦用户向下滚动到页面底部附近的某个位置,下一个数据块请求数据块,直到请求并显示 20 个数据块,此时将文本“That's all people”添加到无序列表的末尾。但是,这更难以以编程方式进行交互,因为您需要向下滚动页面才能显示新数据。

(是的,这个实现有点错误 - 如果用户太快到达页面底部,那么在他们向上滚动一点之前不会请求下一个数据块。但问题不在于如何在网页中实现此行为,但关于如何抓取显示的数据,请原谅我的错误。)

刮刀

我已经将刮板实现为一个 xUnit 单元测试项目,只是因为我没有对从网站上刮取的数据做任何事情,除了 Asserting 它的长度正确,并且因此证明我没有过早地认为我正在抓取的网页是“完成的”。您可以将大部分代码(Asserts 除外)放入任何类型的项目中。

创建了您的爬虫项目后,您需要添加 Selenium.WebDriverSelenium.WebDriver.ChromeDriver nuget 包。

页面对象模型

我正在使用Page Object Model 模式在与页面的功能交互和如何编写交互代码的实现细节之间提供一个抽象层。网站中的每个页面都有一个相应的页面模型类,用于与该页面进行交互。

首先,一个基类,其中包含多个页面模型类共有的一些代码。

namespace ***68925623Scraper

    using System;
    using OpenQA.Selenium;
    using OpenQA.Selenium.Support.UI;

    public class PageModel
    
        protected PageModel(IWebDriver driver)
        
            this.Driver = driver;
        

        protected IWebDriver Driver  get; 

        public void ScrollToTop()
        
            var js = (IjavascriptExecutor)this.Driver;
            js.ExecuteScript("window.scrollTo(0, 0)");
        

        public void ScrollToBottom()
        
            var js = (IJavaScriptExecutor)this.Driver;
            js.ExecuteScript("window.scrollTo(0, document.body.scrollHeight)");
        

        protected IWebElement GetById(string id)
        
            try
            
                return this.Driver.FindElement(By.Id(id));
            
            catch (NoSuchElementException)
            
                return null;
            
        

        protected IWebElement AwaitGetById(string id)
        
            var wait = new WebDriverWait(Driver, TimeSpan.FromSeconds(10));
            return wait.Until(e => e.FindElement(By.Id(id)));
        
    

这个基类为我们提供了 4 个方便的方法:

滚动到页面顶部 滚动到页面底部 使用提供的 ID 获取元素,如果不存在则返回 null 获取具有提供的 ID 的元素,或者等待最多 10 秒以使其出现(如果尚不存在)

网站中的每个页面都有自己的模型类,从该基类派生而来。

namespace ***68925623Scraper

    using OpenQA.Selenium;

    public class Page1Model : PageModel
    
        public Page1Model(IWebDriver driver) : base(driver)
        
        

        public IWebElement AwaitResults => this.AwaitGetById("results");

        public void Navigate()
        
            this.Driver.Navigate().GoToUrl("https://localhost:44394/Page1");
        
    

namespace ***68925623Scraper

    using OpenQA.Selenium;

    public class Page2Model : PageModel
    
        public Page2Model(IWebDriver driver) : base(driver)
        
        

        public IWebElement Results => this.GetById("results");

        public void Navigate()
        
            this.Driver.Navigate().GoToUrl("https://localhost:44394/Page2");
        
    

还有 Scraper 类:

namespace ***68925623Scraper

    using OpenQA.Selenium.Chrome;
    using System;
    using System.Threading;
    using Xunit;

    public class Scraper
    
        [Fact]
        public void TestPage1()
        
            // Arrange
            var driver = new ChromeDriver();
            var page = new Page1Model(driver);
            page.Navigate();
            try
            
                // Act
                var actualResults = page.AwaitResults.Text.Split(Environment.NewLine);

                // Assert
                Assert.Equal(20000, actualResults.Length);
            
            finally
            
                // Ensure the browser window closes even if things go pear-shaped
                driver.Quit();
            
        

        [Fact]
        public void TestPage2()
        
            // Arrange
            var driver = new ChromeDriver();
            var page = new Page2Model(driver);
            page.Navigate();
            try
            
                // Act
                while (!page.Results.Text.Contains("That's all folks"))
                
                    Thread.Sleep(1000);
                    page.ScrollToBottom();
                    page.ScrollToTop();
                

                var actualResults = page.Results.Text.Split(Environment.NewLine);

                // Assert - we expect 1001 because of the extra "that's all folks"
                Assert.Equal(1001, actualResults.Length);
            
            finally
            
                // Ensure the browser window closes even if things go pear-shaped
                driver.Quit();
            
        
    

那么,这里发生了什么?

// Arrange
var driver = new ChromeDriver();
var page = new Page1Model(driver);
page.Navigate();

ChromeDriver 位于Selenium.WebDriver.ChromeDriver 包中,并使用代码实现Selenium.WebDriver 包中的IWebDriver 接口与Chrome 浏览器交互。其他包可用,其中包含所有流行浏览器的实现。实例化驱动程序对象会打开一个浏览器窗口,并调用其Navigate 方法将浏览器定向到我们要测试/抓取的页面。

// Act
var actualResults = page.AwaitResults.Text.Split(Environment.NewLine);

因为在Page1 上,results 元素直到所有数据都显示后才存在,并且不需要用户交互就可以显示它,我们使用页面模型的AwaitResults 属性来只需等待该元素出现并在它出现后返回它。

AwaitResults 返回一个代表元素的IWebElement 实例,它又具有我们可以用来与元素交互的各种方法和属性。在这种情况下,我们使用它的 Text 属性,它将元素的内容作为字符串返回,没有任何标记。因为数据显示为无序列表,列表中的每个元素都用换行符分隔,所以我们可以使用StringSplit方法将其转换为字符串数组。

Page2 需要不同的方法 - 我们不能使用 results 元素的存在来确定数据是否已全部显示,因为该元素从一开始就在页面上,相反我们需要检查字符串“That's all peoples”,它写在最后一个数据块的末尾。此外,数据并没有一次全部加载,我们需要继续向下滚动以触发下一个数据块的加载。

// Act
while (!page.Results.Text.Contains("That's all folks"))

    Thread.Sleep(1000);
    page.ScrollToBottom();
    page.ScrollToTop();


var actualResults = page.Results.Text.Split(Environment.NewLine);

由于我之前提到的 UI 中的错误,如果我们太快到达页面底部,则不会触发下一个数据块的获取,并且在已经在底部时尝试向下滚动的页面不会引发另一个滚动事件。这就是我滚动到页面底部然后返回顶部的原因 - 这样我可以保证引发滚动事件。您永远不会知道,您尝试从中抓取数据的网站本身可能有问题。

一旦出现“That's all peoples”文本,我们就可以继续获取results 元素的Text 属性,并像以前一样将其转换为字符串数组。

// Assert - we expect 1001 because of the extra "that's all folks"
Assert.Equal(1001, actualResults.Length);

这部分不会出现在您的代码中。因为我正在抓取一个由我控制的网站,所以我确切地知道它应该显示多少数据,因此我可以检查我是否已获得所有数据,因此我的抓取代码是否正常工作。

进一步阅读

Selenium 绝对初学者入门:https://www.guru99.com/selenium-csharp-tutorial.html

(那篇文章中的一个奇怪之处在于它首先创建一个控制台应用程序项目,然后将其输出类型更改为类库并手动添加单元测试包,而该项目可以使用 Visual Studio 的其中一个创建单元测试项目模板。它最终到达了正确的位置,尽管是通过一条相当奇怪的路线。)

Selenium 文档:https://www.selenium.dev/documentation/

祝你刮得愉快!

【讨论】:

以上是关于从动态服务器抓取 html 列表数据的主要内容,如果未能解决你的问题,请参考以下文章

如何解决python xpath爬取页面得到空列表(语法都对的情况下)

c#抓取动态网页中的数据

Python爬虫实战:爬取京东商品列表

使用 Python 抓取网页动态内容(动态 HTML/Javascript 表格)

如何抓取网页中的动态数据

如何使用jquery从url中抓取数据列表