Spring Boot + Java爬虫 + 部署到Linux (二、Java爬虫)

    这个小项目的主要(唯一)的业务就是一个爬虫。这个爬虫的功能就是爬取一个图片网站的图片。爬虫相对是独立的,如果只想做一个简单的爬虫,也可以参考。

    做爬虫之前,先分析一下要爬的网站的结构。不要一上来就乱爬。由于爬虫的单位最大是一个图集(image set),所以爬虫的入口就设置为图集的地址。如果需要爬取更大的范围,爬图集也可以作为基础的子程序。

    一般图集的首地址,会展示一些图集的基本信息,如标题、长度、各个图片的缩略图等(如果图片数量多,还会分页显示)我叫这个页面为“outer”敲打。而点击这些缩略图之后,会进入图片页面。图片页面主要就是图片的url、还有下一个图片页面的地址。我叫这个页面“inner”敲打。结构如下图:


    为了方便,我的爬虫的输入只接收三个参数,分别是图集的地址(outerURL),开始位置(从第几张图开始),结束位置(也可以设置为下载数量=结束位置-开始位置+1)。爬取的目标是【开始,结束】的一个闭集。主要的整体流程思想就是:

1. 爬取图集地址对应的页面,收集标题、长度等信息。

2. 找到开始位置所在的页数。一般来说,网站都有默认的分页的单页元素数量,比如是50。那么如果开始位置是1-50,就在第一页;51-100就在第二页,以此类推。

3. 在开始位置所在的outer页面上,找到开始位置的对应的图片页面的地址(InnerURL),然后通过访问进入到这个页面。

4. 通过分析InnerHtml,获取到图片的地址信息,将其保存。然后还能通过下一个获取到下一张图片页面的InnerURL。

5. 重复步骤4 ,直至到达结束位置或者到达了最后一张。如果中途出现失败,则直接停止。这个是因为刚开始我想的是如果在这一步中间出错了,由于采取的是链表式的遍历,会导致后面的都无法获取,所以干脆就直接终止、抛异常了。后来想想,其实可以重新将失败位置作为开始位置进行递归调用,但是一定要注意控制递归的深度和其他一些问题,嫌麻烦就没做。而且重要的一点是由于这个步骤只爬取网页(文本内容)一般不会在这个步骤出现问题,做了收益也不大,得不偿失。

6. 遍历收集到图片地址,进行爬取下载保存。如果中途出现失败的情况,可以保存起来,等下载完毕之后,再重新下载这些失败的图片。如果下载失败的过程中出现失败,可以重复这个过程,收集然后继续下载。这个过程可以重复若干次。

    可能会有疑问,“为什么步骤5的失败,不能通过循环解决?”这是因为步骤5在遍历过程中,中间一环如果断了,那后面的都不可达了,也就是说没法跳过失败。而步骤6中,所有的信息都已经知道了,如果失败的话,可以跳过进而执行下一个。

7. 将下载的图片,进行打包,提供下载。(只爬虫可以省略这一步)

    既然流程已经清楚了,下面就开始实现了。爬虫的原理十分简单,组装请求并发送,然后解析响应获得信息。或者对信息进行进一步的分析和提取等。Java有一个很好用的包叫httpclient,可以百度进官网下载,目前最新版本是4.5.5(2018.6.27)。如果是spring boot项目,添加依赖<groupId>org.apache.httpcomponents</groupId><artifactId>httpclient</artifactId>即可。

首先实现一些基础方法:(注:关于sendMessgae(String s )方法,可以先直接写成System.out.println(s);输出在控制台上即可。这个方法是以后websocket用的,到时候再改可以。CloseUtil.close(Closeale c)方法就是关闭对象的,可以写成判断是不是空,不是空就关闭,然后捕获一下异常就行了)。

首先是一些静态常量,一般都是浏览器的一些参数或者是自己设定的一些参数。可以按自己的需求修改


private static  String COOKIE = "your cookie";  //你的cookie,可以通过浏览器查看或者模拟登录获取。如果不需要cookie,则这一项可以不设置
private static final String CODING = "gzip, deflate";  //编码格式,httpclient会自动解压,所以不用关心
private static final String LANGUAGE = "zh-CN,zh;q=0.9";
private static final String CONNECTION = "Keep-Alive";
//这个是chrome的Agent,也可以换其他浏览器的。这个很重要,很多网站没有这个,直接就403拒绝服务了
private static final String AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36";
private static final String ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8";
private static final int DEFAULT_CONNECT_TIMEOUT = 10000;//10s建立连接的超时时间
private static final int DEFAULT_SOCKET_TIMEOUT = 60000;//60s的传输超时时间
private static final String ROOT_PATH = "images/";  //放图片的总目录
private static final String ZIP_PATH = ROOT_PATH + "zips/"; //放zip的目录,不打包就不要了

方法一:HttpGet getRequest(String url),功能就是根据url,组装一个Get请求。由于只有一个url参数,对于一些其它的网站(需要Cookie验证的),可能这方法就不适用了,根据具体情况修改。如果需要Post请求,一般多是在登录的时候,这个后面说吧


/**
 * 获得request对象
 * @param url
 * @return
 * @
 */
private  HttpGet getRequest(String url) {
	String host = null;
	try {
		URL uri = new URL(url);
		int port = uri.getPort();
		if(port == 80 || port == -1){  //-1表示url里不带端口号,就是默认的80
			host = uri.getHost();
		}else{
			host = uri.getHost()+":"+port; //我看chrome在访问端口不是80的服务器时,host带端口号
		}
		
	} catch (MalformedURLException e) {  //url格式不正确
		System.out.println(url);
		e.printStackTrace();
	}
	HttpGet httpGet = new HttpGet(url);
	httpGet.addHeader("Accept", ACCEPT);
	httpGet.addHeader("Accept-Encoding", CODING);
	httpGet.addHeader("Accept-Language", LANGUAGE);
	httpGet.addHeader("Connection", CONNECTION);
	httpGet.addHeader("Cookie", COOKIE);
	httpGet.addHeader("Host", host);
	httpGet.addHeader("User-Agent", AGENT);
	return httpGet;
}

    方法二:CloseableHttpResponse getResponse(String url, int refreshTime, int connectTimeout, int socketTimeout, int sleep); //其中refreshTime表示失败之后,重新访问的次数;connectTimeout即建立连接超时时间,socketTimeout即连接时间(传输时间),sleep表示等待的秒数,因为快速一直发请求,会让服务器认出来,可能直接就gg了。

        这个方法参数可能比较多,用起来不方便,可以用常用的一些参数直接封装一下。

        至于为啥不直接返回输入流,而是返回response。这是因为官方文档有这么一句话:

//the user MUST call CloseableHttpResponse#close() from a finally clause.

也就说response必须关闭。而如果返回输入流,我不太清楚是不是把输入流关闭了,response就关闭了,为求保险,就这么写了。


@SuppressWarnings("static-access")
public  CloseableHttpResponse getResponse(String url, int refreshTime, int connectTimeout, int socketTimeout, int sleep) {
	int sleepMills = (int)(Math.random()*sleep+1000);
	try {
		Thread.currentThread().sleep(sleepMills);
	} catch (InterruptedException e1) {
		e1.printStackTrace();
	}


	int total = refreshTime;
	CloseableHttpClient client = HttpClients.createDefault(); 
	HttpGet httpGet  = getRequest(url); //获得请求


	//ConnectTimeout为建立连接 的超时时间,SocketTimeout为传输数据的超时时间
	/**关于这两个timeout的官方文档
	 * getConnectTimeout() 
	*  Determines the timeout in milliseconds until a connection is established.
	*	getSocketTimeout() 
	*	Defines the socket timeout (SO_TIMEOUT) in milliseconds, 
	*	which is the timeout for waiting for data or, put differently, 
	*	a maximum period inactivity between two consecutive data packets).
	 */
	RequestConfig requestConfig = RequestConfig.custom().setSocketTimeout(socketTimeout).setConnectTimeout(connectTimeout).build();
	httpGet.setConfig(requestConfig); //设置超时
	CloseableHttpResponse response = null;
	while(refreshTime>0 && response==null){
		try {
			response = client.execute(httpGet);	//执行请求
			break;  //没有异常说明成功了,就直接退出了
		} catch (ClientProtocolException e) {   
			e.printStackTrace();
		} catch (IOException e) {
			//超时等情况
			sendMessage("网络连接超时,开始尝试第 "+ (total + 1 - refreshTime)+"次重连");
			//e.printStackTrace();
			CloseUtil.close(response);
		}
		refreshTime--;
	}
	return response;
}

方法三:String getHtml(String url);获取html,也就是你判断返回的是文本数据的时候,调用这个获取文本字符串。需要注意的是,因为图省事,且要爬的网站都是utf-8编码的,所以就写死了。其实可以通过分析response的头Content-Type: text/html;charset=utf-8来判断是什么编码的。上面的例子就是说,这次的数据是文本,编码是utf-8。不过很多中文网站使用gbk,gbk2312等。

    当发生403的时候,说明问题很严重,很可能已经识别出来是爬虫了。404则说明资源根本不存在,检查一下输入需要。

    
/**
 * 默认utf8了
 * @param url
 * @return
 * @
 */
public  String getHtml(String url) {
	CloseableHttpResponse response = getResponse(url);
	int statusCode = response.getStatusLine().getStatusCode();
	if(statusCode==403){
		sendMessage("严重错误!服务器禁止访问!");
		return null;
	}
	if(statusCode==404){
		sendMessage("不存在的错误的地址!");
		return null;
	}
	HttpEntity entity = response.getEntity();
	InputStream in = null;
	BufferedReader br = null;
	StringBuffer sb = new StringBuffer();
	try {
		in = entity.getContent();
		br = new BufferedReader(new InputStreamReader(in, "utf8")); //不一定是utf8
		String line = null;
		while(null!=(line = br.readLine())){
			sb.append(line+"\n");
		}
	} catch (IOException e) {
		e.printStackTrace(); //一般来说,是到达了超时时间(socketTimeout),还没下载完,直接关闭连接了。可能是资源或者网速有问题。
	}finally{
		CloseUtil.close(br);
		CloseUtil.close(response);  //必须关闭response
	}
	return sb.toString();
}

方法四:boolean downloadImage(String url, String file);下载图片的方法,其实可以推广到所有文件,包括文本。不过由于文本太多了,存起来很麻烦,而且没有实际的意义(因为直接请求也很快)。一般用来保存二进制文件(非文本文件,如一些多媒体文件图片、视频,或者是程序、压缩文件等等等等)。其中的url就是这个文件的资源路径,file就是你要保存的文件名。文件名一般可以通过分析url得到,也可以自定义。之所以叫downloadImage,是因为这个爬虫就是专门爬图片的。

返回true就代表成功了,返回false就失败了。


/**
 * 下载图片
 * @param url
 * @param file
 * @
 */
public  boolean downloadImage(String url, String file) {
	CloseableHttpResponse response = getResponse(url);
	if(null == response){
		return false;
	}
	HttpEntity entity = response.getEntity();
	InputStream in = null;
	FileOutputStream fos = null;
	try {
		in = entity.getContent();
		fos = new FileOutputStream(file);
		byte[] buffer = new byte[10240];  //缓存,可以自由设置
		int lenth = 0;
		while(-1!=(lenth=in.read(buffer))){
			fos.write(buffer, 0, lenth);
		}
		fos.flush();
	} catch (FileNotFoundException e) {
		e.printStackTrace();
		return false;
	} catch (IOException e) {
		//e.printStackTrace(); //超时,读图片的时间超过了指定的时间阈值
		sendMessage("网速慢或者图片资源问题导致的超时!");
		return false;
	}finally{
		CloseUtil.close(fos);
		CloseUtil.close(in);
		CloseUtil.close(response);
	}
	return true;
}

以上四个方法就是爬虫的基础方法,实际用的就是getHtml和imageDownload,另外两个是为这两个服务的。通过getHtml爬取网页,然后使用正则表达式进行分析,得到自己想要的信息。最后通过imageDownload下载自己想要爬取的文件(任何文件)。

下面是如何利用这两个方法,爬取上述结构的网站所需要的其他方法的方法列表(没有实现,需要具体情况,具体实现):

 
/**
 * 获得标题(作为爬出来的图集的目录名)
 * @param html
 * @return Originaltitle
 */
public  String getOriginalTitle(String html);

/**
 * 根据原始标题,获得windows下的标题(默认windows了,其他系统可能有所不同)
*       9种字符不能出现在windows文件命名中,而且长度要小于200(好像是)
*String[] limit = {"<", ">", "/", "\\", "|", "\"", "*", "?", ":"};
   * @param title 
 */
public String getWindowsTitle(String title);

/**
 * 从outerHtml里获得长度(图集总图片数量)
 * @param outerHtml 就是上述的图集的地址,其中outer一律是图片外部,inner一律指图片所在的位置(相对内部),参考上面的结构图,下不赘述
 * @return Length
 */
public  int getLenth(String outerHtml);

/**
 * 从innerHtml里获得图片Url(图片的资源地址)
 * @param imageHtml @return imageUrl
 */
public  String getImageUrl(String innerHtml);

/**
 * 根据图片uri,获得图片的类型(后缀)
 * @param imageUrl
 * @return suffix
 */
public  String getSuffix(String imageUrl); 

/**
 * 获得当前innerHtml的后继innerUrl,如果没有,返回空串,也可以返回null(最后判断有所区别)
 * @param innerHtml
 * @return NextInnerUrl
 */
public  String getNextUrl(String innerHtml); 

/**
 * 获得outerHtml的第focus的innerUrl。比如说开始位置是focus=48,那么返回第48张图片的innerUrl。这个48是相对于图集的第48,不是这个页面的。        *注意:这个outerHtml,一定要包含focus。所以这个方法是被下个方法用的
 * @param outerHtml
 * @return FocusInnerUrl
 */
private  String getFocusInner(String outerHtml, int focus );

/**
 * 获取图片第start位置的innerUrl,其中outerUrl就是图集的第一页即主界面,所以我们要先找到这个start在第几页,然后调用上面的方法。        *至于如何获得这个start所在的页面编号,则需要下一个方法。
 * @param outerUrl
 * @param start
 */
private String getStartUrl(String outerUrl, int start);

/**
 * 获得start所在的页数(从1计数),也可以从0计,保持统一即可
 * 0<start<=lenth
 * @param start 我们的目标
 * @param lenth 总长度 pageLength 每页的长度
 * @return
 */
private int getLocationPage(int start, int lenth, int pageLength);

/**       
*从内部开始下载,其中url就是开始的innerUrl。可以通过getStartUrl获得。num是要下载的数量。这个方法一般被下面的方法调用 */ 
public void DownloadInner(String url, int num); 

/**          
*这个方法可以作为这个爬虫的总入口,这三个参数就是爬虫的输入,分别为图集的地址,开始位置和结束位置。要对参数进行判断,比如说开始小于等于结束          
*开始要大于等于1,结束要大于等于0。其中结束0代表下载全部,因为用户很多时候不知道有多长。然后计算出num,通过getStartUrl获得第一个innerUrl           
*然后调上一个方法就行了       
*/
public void DownloadOuter(String url, int begin, int end);

以上就是一些主要方法的声明。也许有些没用,还有的没有的,可以添加、删除、修改。然后其实可以将一些信息比如图片地址,innerUrl,outerUrl,长度,标题放到数据库里,下一次直接从数据库里取就行了。

    但我发现,这个网站反爬虫还是很厉害的,它的图片的源url是不断的在变化的,一般只能维持几分钟。这使得数据库意义可能不大了,因为每次还要重新的去爬图片的url。不过也实现一下了,使用spring data jpa,这个以后再写吧。

  然后放一个Java打包Zip的代码,从网上抄的:


import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
 * ZipUtil
 */
public class ZipUtil {
	public static void main(String[] args) throws FileNotFoundException {
		File file = new File("C:/Users/Administrator/Desktop/screenshoot.zip");
		OutputStream os = new FileOutputStream(file);
		String src = "C:/Users/Administrator/Desktop/screenshoot";
		ZipUtil.toZip(src, os, true);
	
	}
	private static final int  BUFFER_SIZE = 2 * 1024;
	
	/**
	 * 压缩成ZIP 方法1
	 * @param srcDir 压缩文件夹路径 
	 * @param out    压缩文件输出流
	 * @param KeepDirStructure  是否保留原来的目录结构,true:保留目录结构; 
	 * 							false:所有文件跑到压缩包根目录下(注意:不保留目录结构可能会出现同名文件,会压缩失败)
	 * @throws RuntimeException 压缩失败会抛出运行时异常
	 */
	public static void toZip(String srcDir, OutputStream out, boolean KeepDirStructure)
			throws RuntimeException{
		
		long start = System.currentTimeMillis();
		ZipOutputStream zos = null ;
		try {
			zos = new ZipOutputStream(out);
			File sourceFile = new File(srcDir);
			compress(sourceFile,zos,sourceFile.getName(),KeepDirStructure);
			long end = System.currentTimeMillis();
			System.out.println("压缩完成,耗时:" + (end - start) +" ms");
		} catch (Exception e) {
			throw new RuntimeException("zip error from ZipUtils",e);
		}finally{
			if(zos != null){
				try {
					zos.close();
				} catch (IOException e) {
					e.printStackTrace();
				}
			}
		}
		
	}
	
	/**
	 * 压缩成ZIP 方法2
	 * @param srcFiles 需要压缩的文件列表
	 * @param out 	        压缩文件输出流
	 * @throws RuntimeException 压缩失败会抛出运行时异常
	 */
	public static void toZip(List<File> srcFiles , OutputStream out)throws RuntimeException {
		long start = System.currentTimeMillis();
		ZipOutputStream zos = null ;
		try {
			zos = new ZipOutputStream(out);
			for (File srcFile : srcFiles) {
				byte[] buf = new byte[BUFFER_SIZE];
				zos.putNextEntry(new ZipEntry(srcFile.getName()));
				int len;
				FileInputStream in = new FileInputStream(srcFile);
				while ((len = in.read(buf)) != -1){
					zos.write(buf, 0, len);
				}
				zos.closeEntry();
				in.close();
			}
			long end = System.currentTimeMillis();
			System.out.println("压缩完成,耗时:" + (end - start) +" ms");
		} catch (Exception e) {
			throw new RuntimeException("zip error from ZipUtils",e);
		}finally{
			if(zos != null){
				try {
					zos.close();
				} catch (IOException e) {
					e.printStackTrace();
				}
			}
		}
	}
	
	
	/**
	 * 递归压缩方法
	 * @param sourceFile 源文件
	 * @param zos		 zip输出流
	 * @param name		 压缩后的名称
	 * @param KeepDirStructure  是否保留原来的目录结构,true:保留目录结构; 
	 * 							false:所有文件跑到压缩包根目录下(注意:不保留目录结构可能会出现同名文件,会压缩失败)
	 * @throws Exception
	 */
	private static void compress(File sourceFile, ZipOutputStream zos, String name,
			boolean KeepDirStructure) throws Exception{
		byte[] buf = new byte[BUFFER_SIZE];
		if(sourceFile.isFile()){
			// 向zip输出流中添加一个zip实体,构造器中name为zip实体的文件的名字
			zos.putNextEntry(new ZipEntry(name));
			// copy文件到zip输出流中
			int len;
			FileInputStream in = new FileInputStream(sourceFile);
			while ((len = in.read(buf)) != -1){
				zos.write(buf, 0, len);
			}
			// Complete the entry
			zos.closeEntry();
			in.close();
		} else {
			File[] listFiles = sourceFile.listFiles();
			if(listFiles == null || listFiles.length == 0){
				// 需要保留原来的文件结构时,需要对空文件夹进行处理
				if(KeepDirStructure){
					// 空文件夹的处理
					zos.putNextEntry(new ZipEntry(name + "/"));
					// 没有文件,不需要文件的copy
					zos.closeEntry();
				}
				
			}else {
				for (File file : listFiles) {
					// 判断是否需要保留原来的文件结构
					if (KeepDirStructure) {
						// 注意:file.getName()前面需要带上父文件夹的名字加一斜杠,
						// 不然最后压缩包中就不能保留原来的文件结构,即:所有文件都跑到压缩包根目录下了
						compress(file, zos, name + "/" + file.getName(),KeepDirStructure);
					} else {
						compress(file, zos, file.getName(),KeepDirStructure);
					}
					
				}
			}
		}
	
	}
}

由于爬虫根据网站的不同,具体实现千差万别,所以没有放具体的实现。结束之后,我就直接贴上整个eclipse项目的git,供以后参考吧。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值