Urllib进度钩子不准确?

3

我正在编写一个程序来下载大文件,并添加了一个功能,该程序确定已下载的百分比并在每次下载另外10%时通知用户及其下载时间(例如,print(str(percent)+“已下载” + str(time)))。但是当我在较小的文件上测试程序时,我发现它的准确性要低得多。以下是我制作的样例程序:

import urllib.request

def printout(a, b, c):
    print(str(a) + ", " + str(b) + ", " + str(c))

urllib.request.urlretrieve("http://downloadcenter.mcafee.com/products/tools/foundstone/fport.zip", r"C:\Users\Username\Downloads\fport.zip", reporthook = printout)

这里是下载Fport的工具,我本来就想要下载。下载完成后,输出结果如下:

0, 8192, 57843
1, 8192, 57843
2, 8192, 57843
3, 8192, 57843
4, 8192, 57843
5, 8192, 57843
6, 8192, 57843
7, 8192, 57843
8, 8192, 57843

我认为这正是我想要的。当我要把它放进去时,我注意到了一个小错误。8192不能被57843整除,不是8倍。我用计算器计算后发现,实际上大约可以整除7次,考虑到这样的误差还是很大的。这种偏差对于更大的文件影响较小,但仍然存在。这是某种元数据或标头吗?如果是,它相当大,不是吗?我该如何解决它(例如,它总是约16000字节)?

顺便提一下,你为什么要首先使用旧版接口? - abarnert
@abarnert 不确定您所说的遗留接口是什么意思... 您是指使用 urllib 而不是 urllib2 吗? - KnightOfNi
不,我的意思是urllib.request.urlretrieve,它只被记录在遗留接口的一部分中,并且“在未来某个时间可能变得过时”。 - abarnert
哦,谢谢你告诉我,那可能会是一个不愉快的惊喜...但现在只是不愉快。我希望他们仍然保留reporthook,它真的可以节省时间。 - KnightOfNi
问题在于reporthook并不能实现你想要的功能——在某些情况下,urlretrieve会将所有内容下载到内存中;它从不通知您看到的字节数;由于它处理的是预解码而不是后解码字节,因此它也无济于事;等等。他们可以修复这个问题,但据我所知,计划是为3.5构建一个新的类似于requests的API,然后逐步淘汰urlretrieve和其他“遗留”接口,最后逐步淘汰当前的“现代”接口。所以,如果您可以等待2-4年来编写此脚本... :) - abarnert
3个回答

1
因此,如果您查看Lib/urllib/request.py(CPython约2.7),就会清楚为什么会出现这种情况:
    with tfp:
        result = filename, headers
        bs = 1024*8  # we read 8KB at a time.
        size = -1
        read = 0
        blocknum = 0
        if "content-length" in headers:
            size = int(headers["Content-Length"])

        if reporthook:
            reporthook(blocknum, bs, size)

        while True:
            block = fp.read(bs)  # here is where we do the read
            if not block:
                break
            read += len(block)
            tfp.write(block)
            blocknum += 1
            if reporthook:
                reporthook(blocknum, bs, size)

在最后一行中,reporthook 告诉我们已经读取了 bs,而不是 len(block)。这可能更加准确,但我不确定为什么会这样,即是否有充分的理由或者这只是库中的一个小错误。当然,你可以在 Python 邮件列表上提问或者报告错误。
注意:我认为按照固定大小块读取数据是相当常见的,例如 fread。在那里,如果遇到 EOF(文件结束),返回值可能与请求读取的字节数不同,这与 Python read API 类似。

1
文档解释了 reporthook 会在每个“块”调用一次,其中包括块编号和总大小。 urllib.request 不会尝试使块大小完全相等;它会尝试使块大小成为像8192这样的美好的2的幂,因为这通常是最快和最简单的。
因此,您想要做的是使用实际字节数来计算百分比,而不是块数。
urlretrieve 接口没有提供获取实际字节的简便方法。只有在假设每个 socket.recv(n)(但最后一个)确实返回 n 个字节时,计算块才有效,但这并不保证。只有在假设 urlretrieve 使用无缓冲文件或在每次调用之前刷新时,os.stat(filename) 才能正常工作(在大多数平台上),但这也不是保证。
这是不使用“遗留接口”的许多原因之一。
高级接口(仅调用 urllib.request.urlopen 并将 Response 用作文件对象)可能看起来比 urlretrieve 提供的信息少,但如果阅读 urllib.request Restrictions,就会很清楚这只是一种错觉。因此,您可以使用 urlopen,这样您只需从一个文件对象复制到另一个文件对象,而不是使用有限的回调接口,因此您可以使用任何文件对象复制函数,或编写自己的函数:
def copy(fin, fout, flen=None):
    sofar = 0
    while True:
        buf = fin.read(8192)
        if not buf:
            break
        sofar += len(buf)
        if flen:
            print('{} / {} bytes'.format(sofar, flen))
        fout.write(buf)
    print('All done')

r = urllib.request.urlopen(url)
with open(path, 'wb') as f:
    copy(r, f, r.headers.get('Content-Length'))

如果你真的想要与urllib的底层进行交互,那么urlretrieve并不能实现;它只是伪装了一下。你需要创建自己的opener子类以及相关的混乱代码。
如果你想要一个接口和urlopen一样简单但提供了像自定义opener一样多的功能……嗯,urllib没有这样的功能,这就是为什么存在第三方模块requests的原因。

在这个问题的两个答案之间,我知道为什么这会导致我的问题,并且通常如何修复它,但您能否更具体地说明如何找到“实际字节”,而不是块号?谢谢。 - KnightOfNi
@user2945577:给我一秒钟,我会写点东西。 - abarnert
哇,这是非常详细的内容。我认为urlopen不适合下载我帖子中提到的“大文件”,所以我猜一旦urlretrieve消失,就需要另一个模块来下载... 唉。 - KnightOfNi

1

urllib.request 的高级接口并不适合你正在尝试做的事情。你可以使用低级别的接口...但实际上,第三方库requests使这个过程简单了一个数量级。(你不一定要使用 requests - 各种 curl 包装器也比 urllib 更容易。但是requests 是最像 urllib 且最简单的第三方替代品.)

requests 可以像 urllib 一样自动下载所有内容,但只需添加 stream=True 即可控制数据的获取。它有几个不同的接口(解码Unicode行、字节行、从套接字读取原始数据等),但 iter_content 可能是您想要的—它按需提供内容块,适当缓冲,将分块传输模式透明地映射到平面传输,处理100继续,...基本上可以应对 HTTP 可能遇到的任何问题。所以:

with open(path, 'wb') as f:
    r = requests.get(url, stream=True)
    for chunk in r.iter_content(8192):
        f.write(chunk)

添加进度仍需手动完成。但由于你是拉取块而不是在背后保存到文件中,你可以精确地知道你已经看到了多少字节。只要服务器提供了Content-Length头部(某些情况下一些服务器不会这样做,但你无法对此做出任何处理),就很容易:

with open(path, 'wb') as f:
    r = requests.get(url, stream=True)
    total = r.headers.get('content-length')
    sofar = 0
    for chunk in r.iter_content(8192):
        f.write(chunk)
        sofar += len(chunk)
        if total:
            print('{} / {}: {}%'.format(sofar, total, sofar*100.0/total))
        else:
            print('{} / ???: ???%'.format(sofar))

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接