资讯专栏INFORMATION COLUMN

爬虫框架WebMagic源码分析之Downloader

104828720 / 2495人阅读

摘要:方法,首先判断是否有这是在中配置的,如果有,直接调用的将相应内容转化成对应编码字符串,否则智能检测响应内容的字符编码。

Downloader是负责请求url获取返回值(html、json、jsonp等)的一个组件。当然会同时处理POST重定向、Https验证、ip代理、判断失败重试等。

接口:Downloader 定义了download方法返回Page,定义了setThread方法来请求的设置线程数。
抽象类:AbstractDownloader。 定义了重载的download方法返回Html,同时定义了onSuccess、onError状态方法,并定义了addToCycleRetry来判断是否需要进行重试。
实现类:HttpClientDownloader。负责通过HttpClient下载页面
辅助类:HttpClientGenerator。负责生成HttpClient实例。

1、AbstractDownloader

public Html download(String url, String charset) {
        Page page = download(new Request(url), Site.me().setCharset(charset).toTask());
        return (Html) page.getHtml();
    }

这里download逻辑很简单,就是调用子类实现的download下载。

protected Page addToCycleRetry(Request request, Site site) {
        Page page = new Page();
        Object cycleTriedTimesObject = request.getExtra(Request.CYCLE_TRIED_TIMES);
        if (cycleTriedTimesObject == null) {
            page.addTargetRequest(request.setPriority(0).putExtra(Request.CYCLE_TRIED_TIMES, 1));
        } else {
            int cycleTriedTimes = (Integer) cycleTriedTimesObject;
            cycleTriedTimes++;
            if (cycleTriedTimes >= site.getCycleRetryTimes()) {
                return null;
            }
            page.addTargetRequest(request.setPriority(0).putExtra(Request.CYCLE_TRIED_TIMES, cycleTriedTimes));
        }
        page.setNeedCycleRetry(true);
        return page;
    }

判断重试逻辑:先判断CYCLE_TRIED_TIMES是否为null,如果不为null,循环重试次数+1,判断是否超过最大允许值(默认为3次),然后设置needCycleRetry标志说明需要被重试。这在我们Spider分析篇提到过这个,我们再来看看Spider中的代码片段加深理解

// for cycle retry
        if (page.isNeedCycleRetry()) {
            extractAndAddRequests(page, true);
            sleep(site.getRetrySleepTime());
            return;
        }

2、HttpClientDownloader
继承了AbstractDownloader.负责通过HttpClient下载页面.
实例变量
httpClients:是一个Map型的变量,用来保存根据站点域名生成的HttpClient实例,以便重用。

httpClientGenerator:HttpClientGenerator实例,用来生成HttpClient

主要方法:
a、获取HttpClient实例。

private CloseableHttpClient getHttpClient(Site site, Proxy proxy) {
        if (site == null) {
            return httpClientGenerator.getClient(null, proxy);
        }
        String domain = site.getDomain();
        CloseableHttpClient httpClient = httpClients.get(domain);
        if (httpClient == null) {
            synchronized (this) {
                httpClient = httpClients.get(domain);
                if (httpClient == null) {
                    httpClient = httpClientGenerator.getClient(site, proxy);
                    httpClients.put(domain, httpClient);
                }
            }
        }
        return httpClient;
    }

主要思路是,通过Site获取域名,然后通过域名判断是否在httpClients这个map中已存在HttpClient实例,如果存在则重用,否则通过httpClientGenerator创建一个新的实例,然后加入到httpClients这个map中,并返回。
注意为了确保线程安全性,这里用到了线程安全的双重判断机制。

b、download方法:

public Page download(Request request, Task task) {
    Site site = null;
    if (task != null) {
        site = task.getSite();
    }
    Set acceptStatCode;
    String charset = null;
    Map headers = null;
    if (site != null) {
        acceptStatCode = site.getAcceptStatCode();
        charset = site.getCharset();
        headers = site.getHeaders();
    } else {
        acceptStatCode = WMCollections.newHashSet(200);
    }
    logger.info("downloading page {}", request.getUrl());
    CloseableHttpResponse httpResponse = null;
    int statusCode=0;
    try {
        HttpHost proxyHost = null;
        Proxy proxy = null; //TODO
        if (site.getHttpProxyPool() != null && site.getHttpProxyPool().isEnable()) {
            proxy = site.getHttpProxyFromPool();
            proxyHost = proxy.getHttpHost();
        } else if(site.getHttpProxy()!= null){
            proxyHost = site.getHttpProxy();
        }
        
        HttpUriRequest httpUriRequest = getHttpUriRequest(request, site, headers, proxyHost);
        httpResponse = getHttpClient(site, proxy).execute(httpUriRequest);
        statusCode = httpResponse.getStatusLine().getStatusCode();
        request.putExtra(Request.STATUS_CODE, statusCode);
        if (statusAccept(acceptStatCode, statusCode)) {
            Page page = handleResponse(request, charset, httpResponse, task);
            onSuccess(request);
            return page;
        } else {
            logger.warn("get page {} error, status code {} ",request.getUrl(),statusCode);
            return null;
        }
    } catch (IOException e) {
        logger.warn("download page {} error", request.getUrl(), e);
        if (site.getCycleRetryTimes() > 0) {
            return addToCycleRetry(request, site);
        }
        onError(request);
        return null;
    } finally {
        request.putExtra(Request.STATUS_CODE, statusCode);
        if (site.getHttpProxyPool()!=null && site.getHttpProxyPool().isEnable()) {
            site.returnHttpProxyToPool((HttpHost) request.getExtra(Request.PROXY), (Integer) request
                    .getExtra(Request.STATUS_CODE));
        }
        try {
            if (httpResponse != null) {
                //ensure the connection is released back to pool
                EntityUtils.consume(httpResponse.getEntity());
            }
        } catch (IOException e) {
            logger.warn("close response fail", e);
        }
    }
}

注意,这里的Task入参,其实就是Spider实例。
首先通过site来设置字符集、请求头、以及允许接收的响应状态码。
之后便是设置代理:首先判断site是否有设置代理池,以及代理池是否可用。可用,则随机从池中获取一个代理主机,否则判断site是否设置过直接代理主机。
然后获取HttpUriRequest(它是HttpGet、HttpPost的接口),执行请求、判断响应码,并将响应转换成Page对象返回。期间还调用了状态方法onSuccess,onError,但是这两个方法都是空实现。(主要原因可能是在Spider中已经通过调用Listener来处理状态了)。
如果发生异常,调用addToCycleRetry判断是否需要进行重试。
如果这里返回的Page为null,在Spider中就不会调用PageProcessor,所以我们在PageProcessor中不用担心Page是否为null
最后的finally块中进行资源回收处理,回收代理入池,回收HttpClient的connection等(EntityUtils.consume(httpResponse.getEntity());)。

c、具体说说怎么获取HttpUriRequest

protected HttpUriRequest getHttpUriRequest(Request request, Site site, Map headers,HttpHost proxy) {
        RequestBuilder requestBuilder = selectRequestMethod(request).setUri(request.getUrl());
        if (headers != null) {
            for (Map.Entry headerEntry : headers.entrySet()) {
                requestBuilder.addHeader(headerEntry.getKey(), headerEntry.getValue());
            }
        }
        RequestConfig.Builder requestConfigBuilder = RequestConfig.custom()
                .setConnectionRequestTimeout(site.getTimeOut())
                .setSocketTimeout(site.getTimeOut())
                .setConnectTimeout(site.getTimeOut())
                .setCookieSpec(CookieSpecs.BEST_MATCH);
        if (proxy !=null) {
            requestConfigBuilder.setProxy(proxy);
            request.putExtra(Request.PROXY, proxy);
        }
        requestBuilder.setConfig(requestConfigBuilder.build());
        return requestBuilder.build();
    }

首先调用selectRequestMethod来获取合适的RequestBuilder,比如是GET还是POST,同时设置请求参数。之后便是调用HttpClient的相关API设置请求头、超时时间、代理等。

关于selectRequestMethod的改动:预计在WebMagic0.6.2(目前还未发布)之后由于作者合并并修改了PR,设置POST请求参数会大大简化。
之前POST请求设置参数需要
request.putExtra("nameValuePair",NameValuePair[]);然后这个NameValuePair[]需要不断add BasicNameValuePair,而且还需要UrlEncodedFormEntity,设置参数过程比较繁琐,整个过程如下:

List formparams = new ArrayList();
formparams.add(new BasicNameValuePair("channelCode", "0008")); 
formparams.add(new BasicNameValuePair("pageIndex", i+""));
formparams.add(new BasicNameValuePair("pageSize", "15"));
formparams.add(new BasicNameValuePair("sitewebName", "广东省"));
request.putExtra("nameValuePair",formparams.toArray());

之后我们只需要如下就可以了:

request.putParam("sitewebName", "广东省");
request.putParam("xxx", "xxx");

d、说说下载的内容如何转换为Page对象:

protected Page handleResponse(Request request, String charset, HttpResponse httpResponse, Task task) throws IOException {
        String content = getContent(charset, httpResponse);
        Page page = new Page();
        page.setRawText(content);
        page.setUrl(new PlainText(request.getUrl()));
        page.setRequest(request);
        page.setStatusCode(httpResponse.getStatusLine().getStatusCode());
        return page;
    }

这个方法没什么好说的,唯一要说的就是它调用getContent方法。

protected String getContent(String charset, HttpResponse httpResponse) throws IOException {
    if (charset == null) {
        byte[] contentBytes = IOUtils.toByteArray(httpResponse.getEntity().getContent());
        String htmlCharset = getHtmlCharset(httpResponse, contentBytes);
        if (htmlCharset != null) {
            return new String(contentBytes, htmlCharset);
        } else {
            logger.warn("Charset autodetect failed, use {} as charset. Please specify charset in Site.setCharset()", Charset.defaultCharset());
            return new String(contentBytes);
        }
    } else {
        return IOUtils.toString(httpResponse.getEntity().getContent(), charset);
    }
}

getContent方法,首先判断是否有charset(这是在Site中配置的),如果有,直接调用ApacheCommons的IOUtils将相应内容转化成对应编码字符串,否则智能检测响应内容的字符编码。

protected String getHtmlCharset(HttpResponse httpResponse, byte[] contentBytes) throws IOException {
    return CharsetUtils.detectCharset(httpResponse.getEntity().getContentType().getValue(), contentBytes);
}

getHtmlCharset是调用CharsetUtils来检测字符编码,其思路就是,首先判断httpResponse.getEntity().getContentType().getValue()是否含有比如charset=utf-8
否则用Jsoup解析内容,判断是提取meta标签,然后判断针对html4中html4.01 和html5中分情况判断出字符编码。
当然,你懂的,如果服务端返回的不是完整的html内容(不包含head的),甚至不是html内容(比如json),那么就会导致判断失败,返回默认jvm编码值.
所以说,如果可以,最好手动给Site设置字符编码。

3、HttpClientGenerator
用于生成HttpClient实例,算是一种工厂模式了。

public HttpClientGenerator() {
        Registry reg = RegistryBuilder.create()
                .register("http", PlainConnectionSocketFactory.INSTANCE)
                .register("https", buildSSLConnectionSocketFactory())
                .build();
        connectionManager = new PoolingHttpClientConnectionManager(reg);
        connectionManager.setDefaultMaxPerRoute(100);
    }

构造函数主要是注册http以及https的socket工厂实例。https下我们需要提供自定义的工厂以忽略不可信证书校验(也就是信任所有证书),在webmagic0.6之前是存在不可信证书校验失败这一问题的,之后webmagic合并了一个关于这一问题的PR,目前的策略是忽略证书校验、信任一切证书(这才是爬虫该采用的嘛,我们爬的不是安全,是寂寞。)

private CloseableHttpClient generateClient(Site site, Proxy proxy) {
    CredentialsProvider credsProvider = null;
    HttpClientBuilder httpClientBuilder = HttpClients.custom();
    
    if(proxy!=null && StringUtils.isNotBlank(proxy.getUser()) && StringUtils.isNotBlank(proxy.getPassword()))
    {
        credsProvider= new BasicCredentialsProvider();
        credsProvider.setCredentials(
                new AuthScope(proxy.getHttpHost().getAddress().getHostAddress(), proxy.getHttpHost().getPort()),
                new UsernamePasswordCredentials(proxy.getUser(), proxy.getPassword()));
        httpClientBuilder.setDefaultCredentialsProvider(credsProvider);
    }

    if(site!=null&&site.getHttpProxy()!=null&&site.getUsernamePasswordCredentials()!=null){
        credsProvider = new BasicCredentialsProvider();
        credsProvider.setCredentials(
                new AuthScope(site.getHttpProxy()),//可以访问的范围
                site.getUsernamePasswordCredentials());//用户名和密码
        httpClientBuilder.setDefaultCredentialsProvider(credsProvider);
    }
    
    httpClientBuilder.setConnectionManager(connectionManager);
    if (site != null && site.getUserAgent() != null) {
        httpClientBuilder.setUserAgent(site.getUserAgent());
    } else {
        httpClientBuilder.setUserAgent("");
    }
    if (site == null || site.isUseGzip()) {
        httpClientBuilder.addInterceptorFirst(new HttpRequestInterceptor() {

            public void process(
                    final HttpRequest request,
                    final HttpContext context) throws HttpException, IOException {
                if (!request.containsHeader("Accept-Encoding")) {
                    request.addHeader("Accept-Encoding", "gzip");
                }
            }
        });
    }
    //解决post/redirect/post 302跳转问题
    httpClientBuilder.setRedirectStrategy(new CustomRedirectStrategy());
    
    SocketConfig socketConfig = SocketConfig.custom().setSoTimeout(site.getTimeOut()).setSoKeepAlive(true).setTcpNoDelay(true).build();
    httpClientBuilder.setDefaultSocketConfig(socketConfig);
    connectionManager.setDefaultSocketConfig(socketConfig);
    if (site != null) {
        httpClientBuilder.setRetryHandler(new DefaultHttpRequestRetryHandler(site.getRetryTimes(), true));
    }
    generateCookie(httpClientBuilder, site);
    return httpClientBuilder.build();
}

前面是设置代理代理及代理的用户名密码
这里主要需要关注的两点是
1、post/redirect/post 302跳转问题:这是是通过设置一个自定义的跳转策略类来实现的。(这在0.6版本之前是存在问题的,0.6版本之后合并了PR)

httpClientBuilder.setRedirectStrategy(new CustomRedirectStrategy());

CustomRedirectStrategy在继承HttpClient自带额LaxRedirectStrategy(支持GET,POST,HEAD,DELETE请求重定向跳转)的基础上,对POST请求做了特殊化处理,如果是POST请求,代码处理如下:

HttpRequestWrapper httpRequestWrapper = (HttpRequestWrapper) request;
httpRequestWrapper.setURI(uri);
httpRequestWrapper.removeHeaders("Content-Length");

可以看到,POST请求时首先会重用原先的request对象,并重新设置uri为新的重定向url,然后移除新请求不需要的头部。重用request对象的好处是,post/redirect/post 302跳转时会携带原有的POST参数,就防止了参数丢失的问题。
否则默认实现是这样的

if (status == HttpStatus.SC_TEMPORARY_REDIRECT) {
                return RequestBuilder.copy(request).setUri(uri).build();
            } else {
                return new HttpGet(uri);
            }

SC_TEMPORARY_REDIRECT是307状态码,也就是说只有在307状态码的时候才会携带参数跳转。

2、HttpClient的重试: 这是是通过设置一个默认处理器来实现的,同时设置了重试次数(也就是Site中配置的retryTimes)。

httpClientBuilder.setRetryHandler(newDefaultHttpRequestRetryHandler(site.getRetryTimes(), true));

之后便是配置Cookie策略。

private void generateCookie(HttpClientBuilder httpClientBuilder, Site site) {
    CookieStore cookieStore = new BasicCookieStore();
    for (Map.Entry cookieEntry : site.getCookies().entrySet()) {
        BasicClientCookie cookie = new BasicClientCookie(cookieEntry.getKey(), cookieEntry.getValue());
        cookie.setDomain(site.getDomain());
        cookieStore.addCookie(cookie);
    }
    for (Map.Entry> domainEntry : site.getAllCookies().entrySet()) {
        for (Map.Entry cookieEntry : domainEntry.getValue().entrySet()) {
            BasicClientCookie cookie = new BasicClientCookie(cookieEntry.getKey(), cookieEntry.getValue());
            cookie.setDomain(domainEntry.getKey());
            cookieStore.addCookie(cookie);
        }
    }
    httpClientBuilder.setDefaultCookieStore(cookieStore);
}

首先创建一个CookieStore实例,然后将Site中的cookie加入到cookieStore中。并配置到httpClientBuilder中。那么在这个HttpClient实例执行的所有请求中都会用到这个cookieStore。比如登录保持就可以通过配置Site中的Cookie来实现。

4、关于Page对象说明:
Page对象代表了一个请求结果,或者说相当于页面(当返回json时这种说法有点勉强)。

public Html getHtml() {
        if (html == null) {
            html = new Html(UrlUtils.fixAllRelativeHrefs(rawText, request.getUrl()));
        }
        return html;
    }

通过它得到的页面,原始页面中的链接是不包含域名的情况下会被自动转换为http[s]开头的完整链接。

关于Downloader就分析到这,后续会进行补充,下篇主题待定。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/66880.html

相关文章

  • 爬虫框架Webmagic源码分析Spider

    摘要:获取正在运行的线程数,用于状态监控。之后初始化组件主要是初始化线程池将到中,初始化开始时间等。如果线程池中运行线程数量为,并且默认,那么就停止退出,结束爬虫。 本系列文章,针对Webmagic 0.6.1版本 一个普通爬虫启动代码 public static void main(String[] args) { Spider.create(new GithubRepoPageP...

    邹立鹏 评论0 收藏0
  • 爬虫框架WebMagic源码分析Selenium

    摘要:有一个模块其中实现了一个。但是感觉灵活性不大。接口如下它会获得一个实例,你可以在里面进行任意的操作。本部分到此结束。 webmagic有一个selenium模块,其中实现了一个SeleniumDownloader。但是感觉灵活性不大。所以我就自己参考实现了一个。 首先是WebDriverPool用来管理WebDriver池: import java.util.ArrayList; im...

    MarvinZhang 评论0 收藏0
  • 爬虫框架WebMagic源码分析系列目录

    摘要:爬虫框架源码分析之爬虫框架源码分析之爬虫框架源码分析之爬虫框架源码分析之爬虫框架源码分析之之进阶 爬虫框架Webmagic源码分析之Spider爬虫框架WebMagic源码分析之Scheduler爬虫框架WebMagic源码分析之Downloader爬虫框架WebMagic源码分析之Selector爬虫框架WebMagic源码分析之SeleniumWebMagic之Spider进阶

    wayneli 评论0 收藏0
  • 【Sasila】一个简单易用的爬虫框架

    摘要:所以我模仿这些爬虫框架的优势,以尽量简单的原则,搭配实际上是开发了这套轻量级爬虫框架。将下载器,解析器,调度器,数据处理器注入核心成为对象。提供对爬虫进行管理监控。每个脚本被认为是一个,确定一个任务。   现在有很多爬虫框架,比如scrapy、webmagic、pyspider都可以在爬虫工作中使用,也可以直接通过requests+beautifulsoup来写一些个性化的小型爬虫脚本...

    yacheng 评论0 收藏0
  • 爬虫框架WebMagic源码分析Selector

    摘要:主要用于选择器抽象类,实现类前面说的两个接口,主要用于选择器继承。多个选择的情形,每个选择器各自独立选择,将所有结果合并。抽象类,定义了一些模板方法。这部分源码就不做分析了。这里需要提到的一点是返回的不支持选择,返回的对象支持选择。 1、Selector部分:接口:Selector:定义了根据字符串选择单个元素和选择多个元素的方法。ElementSelector:定义了根据jsoup ...

    dongxiawu 评论0 收藏0

发表评论

0条评论

104828720

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<