前言: 最近简单学习了一下一个比较知名的Java爬虫框架——webmagic。虽然还是不太理解它的整体结构,但是用来爬取数据,应该是没有问题了。而且,我发现它和我使用HttpClient和Jsoup这两个框架(类库)的时候,思考不太一样。使用上面两种工具,对网络数据进行爬取,还是比较原始的。因为爬虫其实涉及了到了很多知识,并不是简单的发起请求和接收响应,例如:URL调度、URL去重。这些东西以前都没有考虑到,对于URL调度来说,基本上没有调度,看到就爬了。这种对于小型网站或者单独的页面似乎是没有问题,但是对于大量数据的处理就显得力不从心了。不过,我现在也还是处理这些简单的页面(这样也导致了爬虫技术没有什么进步,基本上就是图片、视频、音频、文本爬一爬,应该去学习学习别人的经验了),但是使用webmagic框架之后,可以主要去思考爬取的逻辑,而不用去处理哪些细节性的问题了。

目的:我来抓取一些idol的图片,现在写爬虫的绅士太多了,都是千篇一律的抓美女图片,把那些网站搞得都弄起来反爬措施了。所以,为了表示我和他们不一样,我来换一个方向,来抓取idol的图片!

目标:TFBoys的若干张图片采集
起始地址:http://www.win4000/meinvtag22781_1.html

目标网站结构分析

由于这个网站没有反爬措施,我这里就不去分析数据包了。直接来分析一下网站的结构就行了 ,确定采集的逻辑。

首先是图片标题列表页:
它含有若干个分类标题,然后分为五页。但是这里只是一个学习而已,没必要采集那么多数据,所以我只采集一个页面的数据就行了。

然后是图片的标题页:
图片的标题页是一个标题下的若干张图片,但是每一个标题页下面的图片数量不等,**这里有一个小的分页,我们可以通过它来获取到下一页,但是比较坑的是它的下一页可以一直下去,导致我的爬虫停不下来了了。**因为它的最后一张图片的下一页是其它明星的图片了。不知道为什么这样设计,可能是增加浏览量吧。


简单的网站结构图示(简化)
这个processon的图片,怎么是透明背景的?算了,凑合着看吧。

爬取思路:
首先从起始页面开始,抓取起始页包含的所有的标题页链接,并将它们加入URL调度器中,然后对每一个标题页进行抓取,抓取一张图片后,把它的下一页链接添加到URL调度器中,知道没有下一页。重复此步骤,直到所有图片全部被抓取到,整个采集流程就结束了。

使用WebMagic进行图片采集

导入依赖

<dependencies>
    <!-- webmagic框架 -->
  	<dependency>
	    <groupId>us.codecraft</groupId>
	    <artifactId>webmagic-core</artifactId>
	    <version>0.7.3</version>
	</dependency>
	
	<dependency>
	    <groupId>us.codecraft</groupId>
	    <artifactId>webmagic-extension</artifactId>
	    <version>0.7.3</version>
	</dependency>
  </dependencies>

在resource文件夹下添加日志文件的配置

log4j.rootLogger=debug, stdout, R

log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout

log4j.appender.stdout.layout.ConversionPattern=%5p - %m%n

log4j.appender.R=org.apache.log4j.RollingFileAppender
log4j.appender.R.File=firestorm.log

log4j.appender.R.MaxFileSize=100KB
log4j.appender.R.MaxBackupIndex=1

log4j.appender.R.layout=org.apache.log4j.PatternLayout
log4j.appender.R.layout.ConversionPattern=%p %t %c - %m%n

log4j.logger.com.codefutures=DEBUG

注:这是网上找的代码,因为缺少日志配置文件,程序无法启动。但是我还没有真正使用过日志框架,所以对于它不是很了解。但是这里它是可以使用的,只是日志太多了。

主要代码逻辑

package com.spider;

import java.util.List;

import us.codecraft.webmagic.Page;
import us.codecraft.webmagic.Site;
import us.codecraft.webmagic.Spider;
import us.codecraft.webmagic.processor.PageProcessor;

public class TFBoysSpider implements PageProcessor {
	private static final int TIME_OUT = 10*1000;
	private static final int SLEEP_TIME = 5*1000;
	private static final String USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_2) "
			+ "AppleWebKit/537.31 (KHTML, like Gecko) Chrome/26.0.1410.65 Safari/537.31";
	
	private Site site = Site.me()
			.setTimeOut(TIME_OUT)
			.setRetryTimes(3)
			.setRetrySleepTime(SLEEP_TIME)
			.setUserAgent(USER_AGENT);
	
	@Override
	public void process(Page page) {
		// 我这里只是获取一页的数据,五页太多了。
		if (page.getUrl().regex("http://www.win4000/meinvtag22781_").match()) {	
			List<String> urlList = page.getHtml().css("div.list_cont.Left_list_cont.Left_list_cont2 ul li a", "href").all();
			// 将当前页的所有相关详情页添加到接下来的任务中
			page.addTargetRequests(urlList);
			page.setSkip(true);   // 跳过该页
		} else if (page.getUrl().regex("http://www.win4000/meinv+").match()) {
			// 爬取图片页的图片
			String name = page.getHtml().css("div.ptitle > h1", "text").toString();
			String order = page.getHtml().css("div.ptitle > span", "text").toString();
			// key 即作为文件名,然后 value 是链接地址
			page.putField(name+order, page.getHtml().css("div.paper-down > a", "href").toString());
			// 获取分页的数据详情
			String nextUrl = page.getHtml().css("div.pic-next-img > a", "href").toString();
			// String regex = "_\\d.html$" 以此结尾,就认为是分页链接,否则跳过。  
			String regex = "http://www.win4000/meinv\\d+_\\d\\.html";
			if (nextUrl.matches(regex)) {
				page.addTargetRequest(nextUrl);
			}
		}
	}
	
	@Override
	public Site getSite() {
		return site;
	}
	
	public static void main(String[] args) {
		Spider.create(new TFBoysSpider())
			  .addUrl("http://www.win4000/meinvtag22781_1.html")
		      .addPipeline(new ImgPipeline())
		      .thread(3)
		      .run();
	}
}

数据持久化是自定义了一个Pipeline

package com.spider;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

import org.apache.http.HttpEntity;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;

import us.codecraft.webmagic.ResultItems;
import us.codecraft.webmagic.Task;
import us.codecraft.webmagic.pipeline.Pipeline;

public class ImgPipeline implements Pipeline {
	
	// 保存图片的文件夹
	private static final String SAVE_DIR = "D:/DragonFile/img";
	
	@Override
	public void process(ResultItems resultItems, Task task) {
		resultItems.getAll().forEach(this::download);
	}
	
	// 这里本来打算按照标题来分文件夹保存的,但是我初学此框架,
	// 对此不是太熟悉,就一并保存了吧!
	private void download(String imgName, Object imgUrl) {
		// 这里使用连接池来管理,就不需要手动关闭连接了!不要再使用 try-with-resource 语句了!
		CloseableHttpClient httpClient = HttpClientUtil.getHttpClient();
		HttpGet get = new HttpGet(imgUrl.toString());   // 这里imgUrl是一个 Object 类型,但是它确实是一个String,这里是没问题的。
		try (CloseableHttpResponse response = httpClient.execute(get)) {
			if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
				HttpEntity entity = response.getEntity();  
				entity.writeTo(new BufferedOutputStream(new FileOutputStream(new File(SAVE_DIR, imgName+".jpg"))));
				EntityUtils.consume(entity);
			} else {
				System.out.println("下载失败:" + imgUrl);
			}
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

自定义了一个HttpClientUtil工具类,使用连接词来管理下载连接

package com.spider;

import org.apache.http.client.config.RequestConfig;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;

public class HttpClientUtil {
	private static final int TIME_OUT = 10 * 1000;
	private static PoolingHttpClientConnectionManager pcm;   //HttpClient 连接池管理类
	private static RequestConfig requestConfig;
	
	static {
		requestConfig = RequestConfig.custom()
				.setConnectionRequestTimeout(TIME_OUT)
				.setConnectTimeout(TIME_OUT)
				.setSocketTimeout(TIME_OUT).build();
		
		pcm = new PoolingHttpClientConnectionManager();
		pcm.setMaxTotal(50);
		pcm.setDefaultMaxPerRoute(10);  //这里可能用不到这个东西。
	}
	
	public static CloseableHttpClient getHttpClient() {
		return HttpClients.custom()
				.setConnectionManager(pcm)
				.setDefaultRequestConfig(requestConfig)
				.build();
	}
}

运行结果
控制台的日志,这里人能阅读的信息很好,大概就最后总共145页内容被下载了。

这里是下载的图片,但是这里只有144张图片,为什么呢?答案很简单,因为第一页是非图片页,它是没有图片可下载的,所以跳过去了。

page.setSkip(true);  // 它应该作用就是跳过持久化,但是我还是不太确定。

说明
这里的抓取应该是广度优先遍历的,因为它是通过一个队列来进行URL调度的。不过,我也是刚接触这个爬虫框架,还有很多不是很熟悉的地方。

使用HTTP Client 和 Jsoup采集

这里最好换一个工程,虽然webmagic里面已经添加了它们的依赖,但是我在控制台输出的日志无法看到了,都被日志框架掩盖了。

添加依赖

<!-- https://mvnrepository/artifact/org.apache.httpcomponents/httpclient -->
	<dependency>
	    <groupId>org.apache.httpcomponents</groupId>
	    <artifactId>httpclient</artifactId>
	    <version>4.5.6</version>
	</dependency>
	
	<dependency>
     	<groupId>org.jsoup</groupId>
     	<artifactId>jsoup</artifactId>
     	<version>1.11.3</version>
     </dependency>
	

主要逻辑代码

package com.tf;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import org.apache.http.HttpEntity;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;

import com.tf.HttpClientUtil;

public class TFBSpider {
	public static String savePath = "D:/DragonFile/img2";
	private ScheduledExecutorService service = Executors.newScheduledThreadPool(3);
	
	// 爬虫初始爬取网页的链接地址
	private String root;
	
	public TFBSpider(String root) {
		this.root = root;
	}
	
	public void spider() {
		// 获取第一页的html,总共是分为5页,我只获取第一页的信息
		String html = this.getHtml(root);
		// 获取第一页所有的详情页,对每一个详情页的图片进行递归爬取
		this.getList(html).forEach(url -> {
			this.getImg(html, url);
		});
		
		// 关闭线程池,否则会导致程序无法正常停止
		service.shutdown();
	}
	
	/**
	 * 通用方法,用于获取html
	 * */
	public String getHtml(String url) {
		CloseableHttpClient httpClient = HttpClientUtil.getHttpClient();
		HttpGet get = new HttpGet(url);
		get.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
				+ " (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36 Edg/84.0.522.52");   // 设置一个最基础的 User-Agent
		try (CloseableHttpResponse response = httpClient.execute(get)) {
			int statusCode = response.getStatusLine().getStatusCode();
			if (statusCode == HttpStatus.SC_OK) {
				HttpEntity entity = response.getEntity();
				if (entity != null) {
					return EntityUtils.toString(entity, StandardCharsets.UTF_8);
				}
			} else {
				throw new IOException("抓取网页发生异常!状态码为:" + statusCode);
			}
		} catch (IOException e) {
			e.printStackTrace();
		}
		return null;
	}
	
	// 获取详情页里所有的列表链接
	public List<String> getList(String html) {
		return Jsoup.parse(html)
				.select("div.list_cont.Left_list_cont.Left_list_cont2 ul li a")
				.eachAttr("href");
	}
	
	/**
	 * 递归爬取图片
	 * */
	public void getImg(String savePath, String url) {
		// 爬取图片页的图片
		String html = this.getHtml(url);
		Document doc = Jsoup.parse(html);
		// 获取当前页图片的链接地址
		String imgUrl = doc.select("div.paper-down > a").first().attr("href");
		// name 为标题加序号
		String name = doc.select("div.ptitle > h1").text() + doc.select("div.ptitle > span").text();
		// 下一页的链接地址
		String nextUrl = doc.select("div.pic-next-img > a").first().attr("href");
		// 提交线程池,每 4 秒执行一次
		service.schedule(new Downloader(name, imgUrl), 4, TimeUnit.SECONDS);
		String regex = "http://www.win4000/meinv\\d+_\\d\\.html";
		if (nextUrl.matches(regex)) {
			getImg(savePath, nextUrl);
		}
	}
	
	private class Downloader implements Runnable {
		private String name;
		private String url;
		
		public Downloader(String name, String url) {
			this.name = name;
			this.url = url;
		}
		
		public void download() {
			CloseableHttpClient httpClient = HttpClientUtil.getHttpClient();
			HttpGet get = new HttpGet(url);
			try (CloseableHttpResponse response = httpClient.execute(get)) {
				int statusCode = response.getStatusLine().getStatusCode();
				if (statusCode == HttpStatus.SC_OK) {
					HttpEntity entity = response.getEntity();
					if (entity != null) {
						entity.writeTo(new BufferedOutputStream(new FileOutputStream(new File(savePath, name+".jpg"))));
					} else {
						throw new IOException("HttpEntity 为 null!" + url);
					}
				} else {
					throw new IOException("请求图片发生异常!状态码为:" + statusCode + " " + url);
				}
			} catch (IOException e) {
				e.printStackTrace();
			}
			System.out.println("下载完成了:" + name + " " + url);
		}
		
		@Override
		public void run() {
			this.download();
		}
	}
	
	public static void main(String[] args) {
		// 爬取第二页,和使用框架不一样的页
		TFBSpider tfboysSpider = new TFBSpider("http://www.win4000/meinvtag22781_2.html");
		tfboysSpider.spider();
	}
}

注意:import com.tf.HttpClientUtil; 它就是上面的那个HttpClientUtil

运行结果:


注意:
我这里抓取的是第二页的图片,所以图片是165张。

说明:
从上面可以看出来,我这里抓取是按照标题来抓取得,就是一次性把当前标题下所有的图片全部抓取完,再去抓取其它标题下的。有点像深度遍历,但是好像也不完全是。

总结

使用爬虫的框架之后,对于数据采集来说确实是方便了很多。这样就不用取考虑很多细节方面的东西了,只需要考虑具体的逻辑是如何实现的即可。但是,同时也是失去了一定的自由度,你得按照爬虫框架的思路去处理,我以前写多了那种自由的代码(不正规),导致我接触这个爬虫框架有些不太适应,不知所措了。这是因为我以前的爬虫学习可能是走了点歪路了。不过,还是作者有先见之明!

PS:我使用HttpClient和Jsoup的爬虫,抓取图片的操作就是写成了递归的形式,不过我觉得我在五台机器上运行,分别抓取五页似乎也行。不过,这只是非常少量的数据,看不出框架的效果,还是得继续探索!

更多推荐

webmagic初次尝试——写给女生看的爬虫