从动态服务器中删除html列表数据



大家好

对不起,这是我最后的选择。我发誓我试过无数其他的Stackoverflow问题,不同的框架等等,但这些似乎都没有帮助。

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

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

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

现在:我很习惯C#、HTMLAgilityPack和无数其他库,不太喜欢网络相关的东西。我试过头孢夏普、铬等所有这些东西,但不幸的是,它们都不能正常工作。

我想在我的程序中使用一个HTML,它看起来与您在您可以在chrome wenn中打开开发控制台,访问上面提到的网站。HTML解析器在那里工作得很好。

这就是我对代码简化后的样子的想象。

极端C#伪代码:

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

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

非常感谢安迪的帮助。

@SpencerBench在说时很准确

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

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

然而,可以对这个问题给出一个相当通用的答案,这应该是你的出发点。

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

它主要用于测试目的的自动化web应用程序,但肯定不仅限于此。无聊的基于web的管理任务也可以(也应该)实现自动化。

我正在抓取的网站

所以首先我们需要一个网站。我使用ASP.net核心MVC和.net核心3.1创建了一个,尽管网站的技术堆栈并不重要,但重要的是你想要抓取的页面的行为。这个网站有两个页面,被称为Page1和Page2。

页面控制器

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

namespace StackOverflow68925623Website.Controllers
{
using Microsoft.AspNetCore.Mvc;
public class Page1Controller : Controller
{
public IActionResult Index()
{
return View("Page1");
}
}
}
namespace StackOverflow68925623Website.Controllers
{
using Microsoft.AspNetCore.Mvc;
public class Page2Controller : Controller
{
public IActionResult Index()
{
return View("Page2");
}
}
}

API控制器

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

namespace StackOverflow68925623Website.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控制器进行异步调用,请求20000个元素的数组。当呼叫正在进行时;正在加载"显示在屏幕上,当调用返回时,它将被包含接收到的数据的无序列表所取代。这是以一种对自动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个块,此时文本";这就是所有的人"被添加到无序列表的末尾。然而,这更难通过编程进行交互,因为您需要向下滚动页面以显示新数据。

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

刮刀

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

创建了scraper项目后,您需要添加Selenium.WebDriverSelenium.WebDriver.ChromeDrivernuget包。

页面对象模型

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

首先,一个基类,其中包含一些代码,这些代码对于多个页面模型类是通用的。

namespace StackOverflow68925623Scraper
{
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 StackOverflow68925623Scraper
{
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 StackOverflow68925623Scraper
{
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");
}
}
}

刮刀类:

namespace StackOverflow68925623Scraper
{
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();

ChromeDriverSelenium.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元素的存在来确定数据是否已经全部显示,因为该元素从一开始就在页面上,相反,我们需要检查字符串";这就是所有的人"它正好写在最后一个数据块的末尾。此外,数据并不是一次性加载的,我们需要不断向下滚动,以触发下一个数据块的加载。

// 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中的错误,如果我们太快到达页面底部,则不会触发下一块数据的获取,并且当已经位于页面底部时尝试向下滚动不会引发另一个滚动事件。这就是为什么我要滚动到页面的底部,然后再回到顶部——这样我就可以保证引发滚动事件。你永远不会知道,你试图从中抓取数据的网站本身可能有漏洞。

一旦";这就是所有的人"文本已经出现,我们可以继续获取results元素的Text属性,并像以前一样将其转换为字符串数组。

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

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

进一步阅读

硒入门:https://www.guru99.com/selenium-csharp-tutorial.html

(这篇文章中令人好奇的是,它从创建控制台应用程序项目开始,然后将其输出类型更改为类库,并手动添加单元测试包,而该项目本可以使用Visual Studio的一个单元测试项目模板创建。它最终到达了正确的位置,尽管路径相当奇怪。)

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

刮胡子快乐!

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

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

最新更新