爬虫从入门到放弃——抓取前端渲染的页面

抓取前端渲染的页面

随着AJAX技术不断的普及,以及现在AngularJS这种Single-page application框架的出现,现在js渲染出的页面越来越多。对于爬虫来说,这种页面是比较讨厌的:仅仅提取HTML内容,往往无法拿到有效的信息。那么如何处理这种页面呢?总的来说有两种做法:

  1. 在抓取阶段,在爬虫中内置一个浏览器内核,执行js渲染页面后,再抓取。这方面对应的工具有Selenium、HtmlUnit或者PhantomJs。但是这些工具都存在一定的效率问题,同时也不是那么稳定。好处是编写规则同静态页面一样。
  2. 因为js渲染页面的数据也是从后端拿到,而且基本上都是AJAX获取,所以分析AJAX请求,找到对应数据的请求,也是比较可行的做法。而且相对于页面样式,这种接口变化可能性更小。缺点就是找到这个请求,并进行模拟,是一个相对困难的过程,也需要相对多的分析经验。

对比两种方式,我的观点是,对于一次性或者小规模的需求,用第一种方式省时省力。但是对于长期性的、大规模的需求,还是第二种会更靠谱一些。对于一些站点,甚至还有一些js混淆的技术,这个时候,第一种的方式基本是万能的,而第二种就会很复杂了。

对于第一种方法,webmagic-selenium就是这样的一个尝试,它定义了一个Downloader,在下载页面时,就是用浏览器内核进行渲染。selenium的配置比较复杂,而且跟平台和版本有关,没有太稳定的方案。我们先来介绍这个尝试吧。


  • 使用Selenium来抓取动态加载的页面

因为无论怎样动态加载,基础信息总归是包含在初始页面中得,所以我们可以用爬虫代码来模拟js代码,js读取页面元素值,我们也读取页面元素值;js发送ajax,我们就拼凑参数、发送ajax并解析返回的json。这样总归是能做的,但是比较麻烦,有没有比较省力的方法呢?比较好的方法大概是内嵌一个浏览器了。

Selenium是一个模拟浏览器,进行自动化测试的工具,它提供一组API可以与真实的浏览器内核交互。Selenium是跨语言的,有Java、C#、python等版本,并且支持多种浏览器,chrome、firefox以及IE都支持。

在Java项目中使用Selenium,需要做两件事:

  1. 在项目中引入Selenium的Java模块,以Maven为例:
<dependency>
      <groupId>org.seleniumhq.selenium</groupId>
      <artifactId>selenium-java</artifactId>
      <version>2.33.0</version>
  </dependency>
  1. 下载对应的driver,以chrome为例:
    http://code.google.com/p/chromedriver/downloads/list
    下载后,需要将driver的位置写到Java的环境变量里,例如在mac下将其下载到了/Users/yihua/Downloads/chromedriver,则需要在程序里添加以下代码(当然在JVM参数里写-Dxxx=xxx也是可以的):
<!-- lang: java --> 
System.getProperties().setProperty("webdriver.chrome.driver","/Users/yihua/Downloads/chromedriver");

Selenium的API挺简单的,核心是WebDriver,下面是动态渲染页面,并获取最终html的代码:

@Test
    public void testSelenium() {
        System.getProperties().setProperty("webdriver.chrome.driver", "/Users/yihua/Downloads/chromedriver");
        WebDriver webDriver = new ChromeDriver();
        webDriver.get("http://huaban.com/");
        WebElement webElement = webDriver.findElement(By.xpath("/html"));
        System.out.println(webElement.getAttribute("outerHTML"));
        webDriver.close();
    }

值得注意的是,每次new ChromeDriver(),Selenium都会建立一个Chrome进程,并使用一个随机端口在Java中与chrome进程进行通信来交互。由此可见有两个问题:

  • 因此如果直接关闭Java程序,Chrome进程可能是无法关闭的。这里需要显示的调用webDriver.close()来关闭进程。
  • 创建进程的开销还是比较大的,尽量对webDriver进行复用会比较好。可惜根据官方的文档,webDriver不是线程安全的,所以我们需要建立一个webDriver池来保存它们。

最后说说效率问题。嵌入浏览器之后,不但要多花CPU去渲染页面,还要下载页面附加的资源。似乎单个webDriver中的静态资源是有缓存的,初始化之后,访问速度会加快。试用ChromeDriver加载了100次花瓣的首页(http://huaban.com/),共耗时263秒,平均每个页面2.6秒。

/**
 * 花瓣网抽取器。<br>
 * 使用Selenium做页面动态渲染。<br>
 */
public class HuabanProcessor implements PageProcessor {

    private Site site;

    @Override
    public void process(Page page) {
        page.addTargetRequests(page.getHtml().links().regex("http://huaban\\.com/.*").all());
        if (page.getUrl().toString().contains("pins")) {
            page.putField("img", page.getHtml().xpath("//div[@id='pin_img']/img/@src").toString());
        } else {
            page.getResultItems().setSkip(true);
        }
    }

    @Override
    public Site getSite() {
        if (site == null) {
            site = Site.me().setDomain("huaban.com").addStartUrl("http://huaban.com/").setSleepTime(1000);
        }
        return site;
    }

    public static void main(String[] args) {
        Spider.create(new HuabanProcessor()).thread(5)
                .scheduler(new RedisScheduler("localhost"))
                .pipeline(new FilePipeline("/data/webmagic/test/"))
                .downloader(new SeleniumDownloader("/Users/yihua/Downloads/chromedriver"))
                .run();
    }
}
public class SeleniumDownloader implements Downloader, Closeable {

	private volatile WebDriverPool webDriverPool;

	private Logger logger = Logger.getLogger(getClass());

	private int sleepTime = 0;

	private int poolSize = 1;

	private static final String DRIVER_PHANTOMJS = "phantomjs";

	/**
	 * 新建
	 *
	 * @param chromeDriverPath chromeDriverPath
	 */
	public SeleniumDownloader(String chromeDriverPath) {
		System.getProperties().setProperty("webdriver.chrome.driver",
				chromeDriverPath);
	}

	/**
	 * Constructor without any filed. Construct PhantomJS browser
	 * 
	 * @author [email protected]
	 */
	public SeleniumDownloader() {
		// System.setProperty("phantomjs.binary.path",
		// "/Users/Bingo/Downloads/phantomjs-1.9.7-macosx/bin/phantomjs");
	}

	/**
	 * set sleep time to wait until load success
	 *
	 * @param sleepTime sleepTime
	 * @return this
	 */
	public SeleniumDownloader setSleepTime(int sleepTime) {
		this.sleepTime = sleepTime;
		return this;
	}

	@Override
	public Page download(Request request, Task task) {
		checkInit();
		WebDriver webDriver;
		try {
			webDriver = webDriverPool.get();
		} catch (InterruptedException e) {
			logger.warn("interrupted", e);
			return null;
		}
		logger.info("downloading page " + request.getUrl());
		webDriver.get(request.getUrl());
		try {
			Thread.sleep(sleepTime);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		WebDriver.Options manage = webDriver.manage();
		Site site = task.getSite();
		if (site.getCookies() != null) {
			for (Map.Entry<String, String> cookieEntry : site.getCookies()
					.entrySet()) {
				Cookie cookie = new Cookie(cookieEntry.getKey(),
						cookieEntry.getValue());
				manage.addCookie(cookie);
			}
		}

		/*
		 * TODO You can add mouse event or other processes
		 * 
		 * @author: [email protected]
		 */

		WebElement webElement = webDriver.findElement(By.xpath("/html"));
		String content = webElement.getAttribute("outerHTML");
		Page page = new Page();
		page.setRawText(content);
		page.setHtml(new Html(content, request.getUrl()));
		page.setUrl(new PlainText(request.getUrl()));
		page.setRequest(request);
		webDriverPool.returnToPool(webDriver);
		return page;
	}

	private void checkInit() {
		if (webDriverPool == null) {
			synchronized (this) {
				webDriverPool = new WebDriverPool(poolSize);
			}
		}
	}

	@Override
	public void setThread(int thread) {
		this.poolSize = thread;
	}

	@Override
	public void close() throws IOException {
		webDriverPool.closeAll();
	}
}

  • 分析AJAX请求

这里我们以AngularJS中文社区http://angularjs.cn/为例。
在这里插入图片描述

(1) 如何判断前端渲染

扫描二维码关注公众号,回复: 5677363 查看本文章

判断页面是否为js渲染的方式比较简单,在浏览器中直接查看源码(Windows下Ctrl+U,Mac下command+alt+u),如果找不到有效的信息(Ctrl+F),则基本可以肯定为js渲染。
在这里插入图片描述
这个例子中,在页面中的标题“有孚计算机网络-前端攻城师”在源码中无法找到,则可以断定是js渲染,并且这个数据是AJAX得到。

(2)分析请求

下面我们进入最难的一部分:找到这个数据请求。这一步能帮助我们的工具,主要是浏览器中查看网络请求的开发者工具。

以Chome为例,我们打开“开发者工具”(Windows下是F12,Mac下是command+alt+i),然后重新刷新页面(也有可能是下拉页面,总之是所有你认为可能触发新数据的操作),然后记得保留现场,把请求一个个拿来分析吧。

这一步需要一点耐心,但是也并不是无章可循。首先能帮助我们的是上方的分类筛选(All、Document等选项)。如果是正常的AJAX,在XHR标签下会显示,而JSONP请求会在Scripts标签下,这是两个比较常见的数据类型。

然后你可以根据数据大小来判断一下,一般结果体积较大的更有可能是返回数据的接口。剩下的,基本靠经验了,例如这里这个"latest?p=1&s=20"一看就很可疑…
在这里插入图片描述
对于可疑的地址,这时候可以看一下响应体是什么内容了。这里在开发者工具看不清楚,我们把URLhttp://angularjs.cn/api/article/latest?p=1&s=20复制到地址栏,重新请求一次。查看结果,看来我们找到了想要的。
在这里插入图片描述

有些时候,返回的类型不是json格式而是html格式的,这点我们之后再讲解。

(3) 编写程序

回想一下之前列表+目标页的例子,会发现我们这次的需求,跟之前是类似的,只不过换成了AJAX方式-AJAX方式的列表,AJAX方式的数据,而返回数据变成了JSON。那么,我们仍然可以用上次的方式,分为两种页面来进行编写:

1)数据列表:
在这个列表页,我们需要找到有效的信息,来帮助我们构建目标AJAX的URL。这里我们看到,这个_id应该就是我们想要的帖子的id,而帖子的详情请求,就是由一些固定URL加上这个id组成。所以在这一步,我们自己手动构造URL,并加入到待抓取队列中。这里我们使用JsonPath这种选择语言来选择数据(webmagic-extension包中提供了JsonPathSelector来支持它)。

if (page.getUrl().regex(LIST_URL).match()) {
     //这里我们使用JSONPATH这种选择语言来选择数据
     List<String> ids = new JsonPathSelector("$.data[*]._id").selectList(page.getRawText());
     if (CollectionUtils.isNotEmpty(ids)) {
         for (String id : ids) {
             page.addTargetRequest("http://angularjs.cn/api/article/"+id);
         }
     }
 }

2)目标数据
有了URL,实际上解析目标数据就非常简单了,因为JSON数据是完全结构化的,所以省去了我们分析页面,编写XPath的过程。这里我们依然使用JsonPath来获取标题和内容。

page.putField("title", new JsonPathSelector("$.data.title").select(page.getRawText()));
page.putField("content", new JsonPathSelector("$.data.content").select(page.getRawText()));

最后的代码如下:

public class AngularJSProcessor implements PageProcessor {

    private Site site = Site.me();

    private static final String ARITICALE_URL = "http://angularjs\\.cn/api/article/\\w+";

    private static final String LIST_URL = "http://angularjs\\.cn/api/article/latest.*";

    @Override
    public void process(Page page) {
        if (page.getUrl().regex(LIST_URL).match()) {
            List<String> ids = new JsonPathSelector("$.data[*]._id").selectList(page.getRawText());
            if (CollectionUtils.isNotEmpty(ids)) {
                for (String id : ids) {
                    page.addTargetRequest("http://angularjs.cn/api/article/" + id);
                }
            }
        } else {
            page.putField("title", new JsonPathSelector("$.data.title").select(page.getRawText()));
            page.putField("content", new JsonPathSelector("$.data.content").select(page.getRawText()));
        }

    }

    @Override
    public Site getSite() {
        return site;
    }

    public static void main(String[] args) {
        Spider.create(new AngularJSProcessor()).addUrl("http://angularjs.cn/api/article/latest?p=1&s=20").run();
    }
}

在这个例子中,我们分析了一个比较经典的动态页面的抓取过程。实际上,动态页面抓取,最大的区别在于:它提高了链接发现的难度。我们对比一下两种开发模式:

后端渲染的页面

下载辅助页面=>发现链接=>下载并分析目标HTML

前端渲染的页面

发现辅助数据=>构造链接=>下载并分析目标AJAX

对于不同的站点,这个辅助数据可能是在页面HTML中已经预先输出,也可能是通过AJAX去请求,甚至可能是多次数据请求的过程,但是这个模式基本是固定的。

但是这些数据请求的分析比起页面分析来说,仍然是要复杂得多,所以这其实是动态页面抓取的难点。所以,之前说的,如果js请求的结果也是Html,其实就只需要再构造一个http请求而已,把要请求的Url加入待查询的Url即可。

所以对于之前的例子,公告获取不到,怎么办呢?
在这里插入图片描述
查看源码后是这样的:
在这里插入图片描述
断言:是通过ajax来获取的。
然后我们查看请求的Url:
在这里插入图片描述
在这里插入图片描述
返回的是html啊,很简单,把请求的链接放进去,再Process一遍不就好了么?我们来看看url是啥:
https://www.cnblogs.com/mvc/blog/BlogPostInfo.aspx?blogId=368840&postId=10401378&blogApp=hiram-zhang&blogUserGuid=79b817bc-bd91-4e5c-363f-08d49c352df3&_=1550567127429
触到了知识的盲区。
我????????
这id哪来的??
我????
我现在是风沙迷了眼了。。。。

在这里插入图片描述
算了等我弄明白再来搞吧,难搞哦。

public void process(Page page) {
        //判断链接是否符合http://www.cnblogs.com/任意个数字字母-/p/7个数字.html格式
       page.putField("name",page.getHtml().xpath("//*[@id=\"author_profile_detail\"]/a[1]/text()"));
    }

    public static void main(String[] args) {
        long startTime, endTime;
        System.out.println("开始爬取...");
        startTime = System.currentTimeMillis();
        Spider.create(new MyProcessor2()).addUrl("https://www.cnblogs.com/mvc/blog/BlogPostInfo.aspx?blogId=368840&postId=10401378&blogApp=hiram-zhang&blogUserGuid=79b817bc-bd91-4e5c-363f-08d49c352df3&_=1550567127429").addPipeline(new MyPipeline()).thread(5).run();
        endTime = System.currentTimeMillis();
        System.out.println("爬取结束,耗时约" + ((endTime - startTime) / 1000) + "秒,抓取了"+count+"条记录");
    }

猜你喜欢

转载自blog.csdn.net/No_Game_No_Life_/article/details/87713340
今日推荐