通过仅下载网页的相关部分来刮取标题

Posted

技术标签:

【中文标题】通过仅下载网页的相关部分来刮取标题【英文标题】:Scrape title by only downloading relevant part of webpage 【发布时间】:2017-10-22 17:48:43 【问题描述】:

我想使用 Python 只抓取网页的标题。我需要为数千个站点执行此操作,因此必须快速。我见过像retrieving just the title of a webpage in python 这样的以前的问题,但我发现的所有问题都在检索标题之前下载了整个页面,这似乎效率很低,因为标题通常包含在 html 的前几行中。

在找到标题之前是否可以只下载网页的部分内容?

我尝试了以下方法,但page.readline() 下载了整个页面。

import urllib2
print("Looking up ".format(link))
hdr = 'User-Agent': 'Mozilla/5.0',
       'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
       'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.3',
       'Accept-Encoding': 'none',
       'Accept-Language': 'en-US,en;q=0.8',
       'Connection': 'keep-alive'
req = urllib2.Request(link, headers=hdr)
page = urllib2.urlopen(req, timeout=10)
content = ''
while '</title>' not in content:
    content = content + page.readline()

-- 编辑--

请注意,我当前的解决方案使用 BeautifulSoup 仅限于处理标题,因此我唯一可以优化的地方可能不会在整个页面中阅读。

title_selector = SoupStrainer('title')
soup = BeautifulSoup(page, "lxml", parse_only=title_selector)
title = soup.title.string.strip()

-- 编辑 2--

我发现 BeautifulSoup 本身将内容拆分为 self.current_data 中的多个字符串 变量(see this function in bs4),但我不确定如何修改代码以在找到标题后基本上停止读取所有剩余内容。一个问题可能是重定向应该仍然有效。

-- 编辑 3--

所以这里有一个例子。我有一个链接 www.xyz.com/abc,我必须通过任何重定向来跟踪它(我的几乎所有链接都使用 bit.ly 类型的链接缩短)。我对任何重定向后出现的标题和域都感兴趣。

-- 编辑 4--

非常感谢您的所有帮助! Kul-Tigin 的回答效果很好并且已被接受。我会保留赏金,直到用完为止,看看是否有更好的答案出现(如时间测量比较所示)。

-- 编辑 5--

对于任何感兴趣的人:我已将接受的答案计时,大约是我使用 BeautifulSoup4 的现有解决方案的两倍。

【问题讨论】:

【参考方案1】:

使用urllib可以设置Range头来请求一定范围的字节,但是有一些后果:

这取决于服务器是否接受请求 您假设您要查找的数据在所需范围内(但是您可以使用不同的范围标头发出另一个请求以获取下一个字节 - 即下载前 300 个字节并仅在您无法在其中找到标题时再获取 300 个第一个结果 - 2 个 300 字节的请求仍然比整个文档便宜得多)

(编辑)- 为避免标题标签在两个范围请求之间拆分的情况,使您的范围重叠,请参阅我的 example code 中的“range_header_overlapped”函数

导入 urllib

req = urllib.request.Request('http://www.python.org/')

req.headers['Range']='bytes=%s-%s' % (0, 300)

f = urllib.request.urlopen(req)

只是为了验证服务器是否接受了我们的范围:

content_range=f.headers.get('Content-Range')

打印(内容范围)

【讨论】:

【参考方案2】:

我的代码还解决了标题标签在块之间拆分的情况。

#!/usr/bin/env python2
# -*- coding: utf-8 -*-
"""
Created on Tue May 30 04:21:26 2017
====================
@author: s
"""

import requests
from string import lower
from html.parser import HTMLParser

#proxies =  'http': 'http://127.0.0.1:8080' 
urls = ['http://opencvexamples.blogspot.com/p/learning-opencv-functions-step-by-step.html',
        'http://www.robindavid.fr/opencv-tutorial/chapter2-filters-and-arithmetic.html',
        'http://blog.iank.org/playing-capitals-with-opencv-and-python.html',
        'http://docs.opencv.org/3.2.0/df/d9d/tutorial_py_colorspaces.html',
        'http://scikit-image.org/docs/dev/api/skimage.exposure.html',
        'http://apprize.info/programming/opencv/8.html',
        'http://opencvexamples.blogspot.com/2013/09/find-contour.html',
        'http://docs.opencv.org/2.4/modules/imgproc/doc/geometric_transformations.html',
        'https://github.com/ArunJayan/OpenCV-Python/blob/master/resize.py']

class TitleParser(HTMLParser):
    def __init__(self):
        HTMLParser.__init__(self)
        self.match = False
        self.title = ''
    def handle_starttag(self, tag, attributes):
        self.match = True if tag == 'title' else False
    def handle_data(self, data):
        if self.match:
            self.title = data
            self.match = False

def valid_content( url, proxies=None ):
    valid = [ 'text/html; charset=utf-8',
              'text/html',
              'application/xhtml+xml',
              'application/xhtml',
              'application/xml',
              'text/xml' ]
    r = requests.head(url, proxies=proxies)
    our_type = lower(r.headers.get('Content-Type'))
    if not our_type in valid:
        print('unknown content-type:  at URL:'.format(our_type, url))
        return False
    return our_type in valid

def range_header_overlapped( chunksize, seg_num=0, overlap=50 ):
    """
    generate overlapping ranges
    (to solve cases when title tag splits between them)

    seg_num: segment number we want, 0 based
    overlap: number of overlaping bytes, defaults to 50
    """
    start = chunksize * seg_num
    end = chunksize * (seg_num + 1)
    if seg_num:
        overlap = overlap * seg_num
        start -= overlap
        end -= overlap
    return 'Range': 'bytes=-'.format( start, end )

def get_title_from_url(url, proxies=None, chunksize=300, max_chunks=5):
    if not valid_content(url, proxies=proxies):
        return False
    current_chunk = 0
    myparser = TitleParser()
    while current_chunk <= max_chunks:
        headers = range_header_overlapped( chunksize, current_chunk )
        headers['Accept-Encoding'] = 'deflate'
        # quick fix, as my locally hosted Apache/2.4.25 kept raising
        # ContentDecodingError when using "Content-Encoding: gzip"
        # ContentDecodingError: ('Received response with content-encoding: gzip, but failed to decode it.', 
        #                  error('Error -3 while decompressing: incorrect header check',))
        r = requests.get( url, headers=headers, proxies=proxies )
        myparser.feed(r.content)
        if myparser.title:
            return myparser.title
        current_chunk += 1
    print('title tag not found within  chunks (b each) at '.format(current_chunk-1, chunksize, url))
    return False

【讨论】:

【参考方案3】:

您可以通过启用requests 的流模式来延迟下载整个响应正文。

Requests 2.14.2 documentation - Advanced Usage

默认情况下,当您发出请求时,响应的正文是 立即下载。您可以覆盖此行为并推迟 下载响应正文,直到您访问 Response.content 带有stream 参数的属性:

...

如果您在发出请求时将stream 设置为True,则除非您消耗所有数据或调用Response.close,否则Requests 无法将连接释放回池中。 这可能导致连接效率低下。如果您发现自己在使用 stream=True 时部分阅读了请求正文(或根本不阅读它们),您应该考虑使用 contextlib.closing (documented here)

因此,使用此方法,您可以逐块读取响应块,直到遇到标题标签。由于重定向将由库处理,您就可以开始了。

这是一个使用 Python 2.7.103.6.0 测试的容易出错的代码:

try:
    from HTMLParser import HTMLParser
except ImportError:
    from html.parser import HTMLParser

import requests, re
from contextlib import closing

CHUNKSIZE = 1024
retitle = re.compile("<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
buffer = ""
htmlp = HTMLParser()
with closing(requests.get("http://example.com/abc", stream=True)) as res:
    for chunk in res.iter_content(chunk_size=CHUNKSIZE, decode_unicode=True):
        buffer = "".join([buffer, chunk])
        match = retitle.search(buffer)
        if match:
            print(htmlp.unescape(match.group(1)))
            break

【讨论】:

这似乎工作得很好!你说它容易出错是什么意思?也许一个可行的解决方案就是在所有链接上运行它。对于这种方法失败的那些,运行标准的慢方法。但是,您预计会出现什么错误,我应该如何捕捉它们? 对于我测试过的链接(New York Times 和 Stack Overflow),它会在 resp 之后找到标题。 7 和 2 个大小为 64 的块。分别加载整个页面。 417 和 872 块。太棒了! 流媒体是如何工作的?我应该注意不要设置太低的块大小吗? @pir 没什么特别的。偶然异常、请求超时、ssl 问题等。如您所见,没有错误处理,这就是我的意思。 啊,没关系!这是由于打印标题。再次感谢!【参考方案4】:

问题:……我唯一可以优化的地方可能是没有读完整个页面。

这不会读取整个页面。

注意:如果您在中间剪切一个 Unicode 序列,则 Unicode .decode() 将变为 raise Exception。使用.decode(errors='ignore') 删除这些序列。

例如:

import re
try:
    # PY3
    from urllib import request
except:
    import urllib2 as request

for url in ['http://www.python.org/', 'http://www.google.com', 'http://www.bit.ly']:
    f = request.urlopen(url)
    re_obj = re.compile(r'.*(<head.*<title.*?>(.*)</title>.*</head>)',re.DOTALL)
    Found = False
    data = ''
    while True:
        b_data = f.read(4096)
        if not b_data: break

        data += b_data.decode(errors='ignore')
        match = re_obj.match(data)
        if match:
            Found = True
            title = match.groups()[1]
            print('title='.format(title))
            break

    f.close()

输出: title=欢迎来到 Python.org 标题=谷歌 标题=比特利 |网址缩短器和链接管理平台

用 Python 测试:3.4.2 和 2.7.9

【讨论】:

非常有趣,谢谢!测试重定向的一种简单方法是使用 bit.ly 缩短任何链接。在 Python 2.7 中使用 from six.moves import urllib 运行它会给我一堆解码异常。只有http://www.python.org/ 适合我。有什么办法可以使这更健壮? @pir:添加了 Python 2.7 支持并删除了解码异常。【参考方案5】:

你想要的那种事情我认为是做不到的,因为网络的设置方式,你在解析任何东西之前就得到了请求的响应。通常没有流式传输“如果遇到&lt;title&gt; 然后停止给我数据”标志。如果有我喜欢看它,但有一些东西可以帮助你。请记住,并非所有网站都尊重这一点。因此,有些网站会强制您下载整个页面源代码,然后才能对其采取行动。但其中很多都允许您指定范围标题。所以在请求示例中:

import requests

targeturl = "http://www.urbandictionary.com/define.php?term=Blarg&page=2"
rangeheader = "Range": "bytes=0-150"

response = requests.get(targeturl, headers=rangeheader)

response.text

你得到

'<!DOCTYPE html>\n<html lang="en-US" prefix="og: http://ogp.me/ns#'

当然,这就是问题所在 如果您指定的范围太短而无法获取页面标题怎么办? 瞄准的好范围是什么? (速度和准确性保证的结合) 如果页面不尊重 Range 会发生什么? (大多数情况下,如果没有它,您只会得到完整的响应。)

我不知道这是否对你有帮助?但愿如此。但我做了类似的事情,只获取文件头进行下载检查。

EDIT4:

所以我想到了另一种可能会有所帮助的 hacky 东西。几乎每个页面都有一个 404 page not found 页面。我们也许可以利用这一点来发挥我们的优势。而不是请求常规页面。请求这样的东西。

http://www.urbandictionary.com/nothing.php

一般页面将包含大量信息、链接和数据。但是 404 页面只不过是一条消息,并且(在这种情况下)是一个视频。而且通常没有视频。只是一些文字。

但您也注意到标题仍然出现在此处。所以也许我们可以请求一些我们知道在任何页面上不存在的东西。

X5ijsuUJSoisjHJFk948.php

并为每个页面获取 404。这样你只下载一个非常小和简约的页面。而已。这将大大减少您下载的信息量。从而提高速度和效率。

此方法的问题是:您需要以某种方式检查页面是否不提供其自己的 404 版本。大多数页面都有它,因为它在网站上看起来不错。及其标准做法包括一个。但并非所有人都这样做。一定要处理好这个案子。

但我认为这可能是值得尝试的事情。在数千个站点的过程中,它可以为每个 html 节省数毫秒的下载时间。

EDIT5:

正如我们所谈到的,因为您对重定向的 url 感兴趣。我们可能会使用 http 头请求。这不会得到网站内容。只是标题。所以在这种情况下:

response = requests.head('http://myshortenedurl.com/5b2su2')

用 tunyurl 替换我的 shortedurl 以跟随。

>>>response
<Response [301]>

很好,所以我们知道这会重定向到某些东西。

>>>response.headers['Location']
'http://***.com'

现在我们知道 url 重定向到哪里,而无需实际关注它或下载任何页面源。现在我们可以应用之前讨论的任何其他技术了。

这是一个例子,使用 requests 和 lxml 模块并使用 404 页面的想法。 (请注意,我必须用 bit'ly 替换 bit.ly,这样堆栈溢出才不会生气。)

#!/usr/bin/python3

import requests
from lxml.html import fromstring

links = ['http://bit'ly/MW2qgH',
         'http://bit'ly/1x0885j',
         'http://bit'ly/IFHzvO',
         'http://bit'ly/1PwR9xM']

for link in links:

    response = '<Response [301]>'
    redirect = ''

    while response == '<Response [301]>':
        response = requests.head(link)
        try:
            redirect = response.headers['Location']
        except Exception as e:
            pass

    fakepage = redirect + 'X5ijsuUJSoisjHJFk948.php'

    scrapetarget = requests.get(fakepage)
    tree = fromstring(scrapetarget.text)
    print(tree.findtext('.//title'))

所以在这里我们得到了 404 页面,它会跟随任意数量的重定向。现在继承人的输出:

Urban Dictionary error
Page Not Found - Stack Overflow
Error 404 (Not Found)!!1
Kijiji: Page Not Found

如您所见,我们确实获得了标题。但我们看到该方法存在一些问题。即有些标题添加了一些东西,而有些则根本没有一个好的标题。这就是该方法的问题。但是,我们也可以尝试 range 方法。这样做的好处是标题是正确的,但有时我们可能会错过它,有时我们必须下载整个页面源才能获得它。增加所需时间。

也感谢 alecxe 我快速而肮脏的脚本的这一部分

tree = fromstring(scrapetarget.text)
print(tree.findtext('.//title'))

以 range 方法为例。在链接中的链接循环中:将try catch语句之后的代码更改为:

rangeheader = "Range": "bytes=0-500"

scrapetargetsection = requests.get(redirect, headers=rangeheader)
tree = fromstring(scrapetargetsection.text)
print(tree.findtext('.//title'))

输出是:

None
Stack Overflow
Google
Kijiji: Free Classifieds in...

这里我们看到城市字典没有标题,或者我在返回的字节中错过了它。在任何这些方法中都有权衡。接近完全准确度的唯一方法是下载我认为的每个页面的完整源代码。

【讨论】:

谢谢!如果我将问题简化为仅获取域(例如 www.cnn.com)怎么办?我仍然必须允许重定向。 我不确定你的意思,你所追求的东西,即&lt;title&gt;,只有当你已经拥有你想要抓取的域时才能实现。我不确定你只想用域名做什么?但是我确实想到了一个可以帮助您更好的技巧。查看答案编辑。 抱歉,我不确定您是否正确理解了我的问题。我有一个链接 www.xyz.com/abc,我必须通过任何重定向来跟踪它(几乎我所有的链接都使用了 bit.ly 类型的链接缩短)。因此,我将无法修改指向 404 页面的链接,因为这样我就不会被重定向。我对重定向后发生的标题和重定向都感兴趣,但也许 domain 会这样做。 啊,在这种情况下,您可能想使用 http 头请求。请参阅编辑 5。【参考方案6】:

您正在使用标准 REST 请求抓取网页,我不知道有任何请求只返回标题,所以我认为这是不可能的。

我知道这不一定只有助于获得标题,但我通常使用BeautifulSoup 进行任何网络抓取。这要容易得多。这是一个例子。

代码:

import requests
from bs4 import BeautifulSoup

urls = ["http://www.google.com", "http://www.msn.com"]

for url in urls:
    r = requests.get(url)
    soup = BeautifulSoup(r.text, "html.parser")

    print "Title with tags: %s" % soup.title
    print "Title: %s" % soup.title.text
    print

输出:

Title with tags: <title>Google</title>
Title: Google

Title with tags: <title>MSN.com - Hotmail, Outlook, Skype, Bing, Latest News, Photos &amp; Videos</title>
Title: MSN.com - Hotmail, Outlook, Skype, Bing, Latest News, Photos & Videos

【讨论】:

谢谢。我目前也在使用 BeautifulSoup,但它太慢了:/ 假设 REST 请求接口以流的方式发回数据,难道不能在收到前几行后直接切断连接吗?是否以某种方式编码的信息会使这变得困难? @pir 理论上你可以。您必须深入到套接字级别并编写代码以从套接字读取数据并在您认为合适时断开连接。尽管您可能需要处理错误、DNS、代理、重定向等,但您最终可能会花费更多时间编写此代码,而不仅仅是使用更高级别的库读取整个页面。另一件要考虑的事情是 标记不是必需的,是可选的。认为它通常在响应的开头,我相信它也可以在更接近结尾。

以上是关于通过仅下载网页的相关部分来刮取标题的主要内容,如果未能解决你的问题,请参考以下文章

无法使用scrapy框架307重定向错误来刮取myntra API数据

爬虫之网页下载

Scrapy:如何通过AJAX调用刮取第二个HTML页面

如何更好地循环进入我的网络爬虫?

WinInet 只下载网页的一部分

是否为网页上下载的每个文件加载 cookie?