这个小项目的主要(唯一)的业务就是一个爬虫。这个爬虫的功能就是爬取一个图片网站的图片。爬虫相对是独立的,如果只想做一个简单的爬虫,也可以参考。
做爬虫之前,先分析一下要爬的网站的结构。不要一上来就乱爬。由于爬虫的单位最大是一个图集(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项目,添加依赖
首先实现一些基础方法:(注:关于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;
}
下面是如何利用这两个方法,爬取上述结构的网站所需要的其他方法的方法列表(没有实现,需要具体情况,具体实现):
/**
* 获得标题(作为爬出来的图集的目录名)
* @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
以上就是一些主要方法的声明。也许有些没用,还有的没有的,可以添加、删除、修改。然后其实可以将一些信息比如图片地址,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 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,供以后参考吧。