用 Python 实现一个网页下载工具

几个月前,我完成了一次网络综合实验的课设,内容是要设计并实现一个网站下载程序。感觉里面有几个地方挺有意思的,于是在此记录下自己的思路,与大家分享。


实验要求

网站下载程序可以按照要求下载整个网站的网页,其原理是分析每个页面中的所有链接,然后根据该链接下载单个文件,并保存下来,采用递归方式进行扫描下载,直到下载页数达到设定好的最大值或者下载层数达到了设定的最大层数才停止。

主要功能

(1) 设定站点名称; (2) 设定最大下载页; (3) 设定最大下载层; (4) 设定是否下载多媒体文件(图片); (5) 设定是否下载其他站点网页; (6) 图形化显示。

思路分析

实现的思路并不难,首先获取用户输入的网址的 html 文件,然后分析其中的链接和图片并保存到一个列表,接下来对该列表中的链接继续重复这个过程。在下载过程中维持两个计数器,第一个计数器指示着已下载的网页数目,第二个计数器指示着当前下载到了第几层。

这不就是一个树的层次遍历嘛!每个链接相当于一个子树的根节点,图片就相当于一个叶子节点。

树的层次遍历需要队列来实现。首先将用户输入的网址入队,然后将其取出,并把该网页中的链接入队(相当于子节点入队),然后取出队头的第一个子节点,将该节点对应的网页中的链接继续入队(相当于把这个子节点的子节点入队)。这时队头的节点就是根节点的第二个子节点了。将其取出,继续进行之前的操作... ...直到达到了两个计数器中的某个的要求。


接下来就是具体实现的方式了。因为要求图形化显示,所以需要写一个 GUI 界面,这里我在查阅了目前常用的 Python 图形界面库以后,最终选择了自带的 Tkinter 库。虽然之前没写过 Python 的界面,但是参照着官方文档的示例程序和网上的一些例子,也能比较容易地写出来。最终界面如下所示:

image.png

当点击开始下载的按钮以后,会触发判断方法,对用户的输入是否合法、网站是否能访问进行验证,如果验证无误才开始正式下载。这部分判断代码比较简单,就不放出来了,感兴趣的同学可以在这里查看源代码。

然后就是这个树的结构。每个节点都包含 value, children, layer, directory, number 一共五个属性,分别指的是对应的网址、子节点列表、所在的层次、下载后存放的目录,以及当前的序号。这里需要说明存放的目录,因为整个结构是一个树形结构,所以我将下载后的文件也以树形结构保存。定义树节点的具体代码如下:

class TreeNode(object):
    # 通过__slots__来限制成员变量
    __slots__ = ("value", "children", "layer", "directory", "number")
    # 构造函数
    def __init__(self, value, layer, directory, number):
        self.value = value
        self.children = []
        self.layer = layer
        self.directory = directory
        self.number = number

    def get_value(self):
        return self.value

    def get_layer(self):
        return self.layer

    def get_directory(self):
        return self.directory
    # 返回节点的序号
    def get_number(self):
        return self.number
    # 插入子节点
    def insert_child(self, node):
        self.children.append(node)

    def pop_child(self):
        return self.children.pop(0)

接下来是核心代码部分,首先将根节点入队,然后通过while 循环来进行子节点的入队。之所以需要while,是因为可能根节点和它的页面中的子节点的数目之和还没有达到用户输入的数量,所以需要while 循环让子节点依次出队,并将这些子节点的子节点再次入队。还有一些其他需要注意的地方,在代码的注释进行了说明。

# 当队列不为空时一直循环,直到入队的节点数目达到用户输入的数量时return 退出
while treenode_queue:
    # 让队尾的节点出队,下载其 html 文件
    temp = treenode_queue.pop(0)
    re = requests.get(temp.get_value())
    # 注意需要进行编码,否则下载的 html 文件里的中文会乱码
    re.encoding = "utf-8"
    with open(os.path.join(temp.directory, str(temp.number)) + ".html", "w+", encoding="utf-8") as html_file:
        html_file.write(re.text)
    # 判断用户是否勾选下载多媒体文件,主要使用urllib 的urlretrieve()来下载
    if download_img:
        # 用SoupStrainer 类来过滤html 文件里的img 标签
        soup = BeautifulSoup(re.text, "html.parser", parse_only=SoupStrainer('img'))
        count = 1
        print("正在下载", temp.value, "的图片... ...")
        for img in soup:
            if not (img["src"] == ""):
                if str(img["src"][:2]) == r"//":
                    img["src"] = "https:" + img["src"]
                img_dir = os.path.join(temp.directory, str(count))
                if img["src"][-3:] == "png":
                    urllib.request.urlretrieve(img["src"], img_dir + ".png")
                elif img["src"][-3:] == "gif":
                    urllib.request.urlretrieve(img["src"], img_dir + ".gif")
                elif img["src"][-3:] == "jpg":
                    urllib.request.urlretrieve(img["src"], img_dir + ".jpg")
                else:  # 有些图片不带后缀,目前还无法下载下来,比如博客配图
                    print("Failed :", img["src"])
                count = count + 1
    # 层数的判断出口,当下载的网页层数等于用户输入的层数时,退出
    if layer_count >= int(input_max_layers.get()) + 1:
        download_website_of_queue(*treenode_queue)
        with open(r"D:\Download_Website\README.txt", "w+") as readme_file:
            readme_file.write(get_dir_list(r"D:\Download_Website"))
        return
    # 接下来对于出队的节点使用 BeautifulSoup 和 SoupStrainer 来获取链接
    soup = BeautifulSoup(re.text, "html.parser", parse_only=SoupStrainer('a'))
    layer_count = layer_count + 1
    print("----------第" + str(layer_count) + "层----------")
    for each in soup:
        # 对于每个<a>标签中 href 的属性前四个字符是“http”的记录,首先输出其信息,然后构造节点将其入队,并且将其加入上方出队节点的 children 列表
        if each.has_attr("href") and each["href"][:4] == "http":
            website_count = website_count + 1
            print("第" + str(website_count) + "个网站/第" + str(layer_count) +
                  "层:" + each["href"])
            anode = TreeNode(each["href"], layer_count, os.path.join(temp.directory, str(website_count)), website_count)
            temp.insert_child(anode)
            treenode_queue.append(anode)
            if website_count >= int(input_max_pages.get()):
                download_website_of_queue(*treenode_queue)
                # 发现下载数目够了的时候,调用函数下载队列中的所有节点的 html 文件,然后生成README文件,记录输出文件夹中的树形结构
                with open(r"D:\Download_Website\README.txt",
                          "w+") as readme_file:
                    readme_file.write(get_dir_list(r"D:\Download_Website"))
                return

核心代码中有以下几个点需要注意:

  • 下载网页时需要进行编码,以避免中文乱码。这里统一编码成 utf-8。
  • 注意下载图片时的处理,有的图片网址前面只有 \\ ,没有 https 头,需要我们手动添加。
  • 注意函数出口的位置,先判断下载的层数是否已经达到,再进行下一步的操作。
  • 注意对两个计数器的操作和判断。

其中还有两个函数,分别是 download_website_of_queue(),以及 get_dir_list()。前者是用来下载队列中剩下的节点的,内容和核心代码部分比较像。后者用递归的方式产生一个 README 文件,这个文件以树形图的方式呈现了下载后的文件夹结构。这个方法的代码参考了这里。它们的代码如下:

def download_website_of_queue(*args):
    download_img = False
    if (int(btn_Group1.get()) == 1):
        download_img = True
    # 函数的输入是一个列表,对于队列中的每个节点,下载其html 文件
    for temp in args:
        re = requests.get(temp.get_value())
        re.encoding = "utf-8"
        if not os.path.exists(temp.directory):
            os.makedirs(temp.directory)
        with open(
                os.path.join(temp.directory, str(temp.number)) + ".html",
                "w+",
                encoding="utf-8") as html_file:
            html_file.write(re.text)

        if download_img:
            # 以下为下载图片的代码,之前已经给出,此处不再赘述



# 产生README 说明文件的函数,输入为一个文件夹目录的字符串,输出为该目录下所有文件的树形结构
# 以字符串的形式输出。主要是通过递归调用来实现。
BRANCH = '├─'
LAST_BRANCH = '└─'
TAB = '│  '
EMPTY_TAB = '   '

def get_dir_list(path, placeholder=''):
    # 列出输入目录下的文件/文件夹,如果是文件夹,则加入folder_list 列表
    folder_list = [
        folder for folder in os.listdir(path) if os.path.isdir(os.path.join(path, folder))
    ]
    # 列出输入目录下的文件/文件夹,如果是文件,则加入file_list 列表
    file_list = [
        file for file in os.listdir(path) if os.path.isfile(os.path.join(path, file))
    ]
    result = ''
    # 对于文件夹列表中的第一个到倒数第二个元素,添加一个branch,然后递归调用直到
    # 最深的第一个子文件夹,里面只有文件,然后开始加入第一个到倒数第二个文件,
    # 直到添加完该文件夹的最后一个文件,依次类推,直到根目录文件夹的最深的最后
    # 一个子文件夹里的最后一个最后一个文件,递归才结束
    for folder in folder_list[:-1]:
        result += placeholder + BRANCH + folder + '\n'
        result += get_dir_list(os.path.join(path, folder), placeholder + TAB)
    if folder_list:
        result += placeholder + (BRANCH if file_list else
                                 LAST_BRANCH) + folder_list[-1] + '\n'
        result += get_dir_list(
            os.path.join(path, folder_list[-1]),
            placeholder + (TAB if file_list else EMPTY_TAB))
    for file in file_list[:-1]:
        result += placeholder + BRANCH + file + '\n'
    if file_list:
        result += placeholder + LAST_BRANCH + file_list[-1] + '\n'
    return result

接下来用某博客网站进行测试:

存放下载结果的文件夹.png

README 文件和 html 文件.png

主要内容差不多就是这些了。还有一点就是,程序界面上第二个按钮是用来打开对应的文件夹的,这个功能的实现是用 os 模块完成的,只需要一行代码:os.system((r"start explorer D:\Download_Website"))。本质上相当于是调用 Windows 控制台的指令来完成的。从这一点出发,也可以产生许多有意思的应用。

总的来说,这是一个很适合练手的小项目,整个代码加起来还不到 300 行,但可以考察到很多细节方面的东西 ~~~ 虽然我写出来了,但是里面的一些写法、命名之类的还是让人不怎么满意,可能还有一些隐藏的 bug ...但之后我会抽空进行更进一步的完善的!

最后,给出这个小项目的源代码地址,欢迎交流和讨论 ~~~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容

  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,082评论 1 32
  • feisky云计算、虚拟化与Linux技术笔记posts - 1014, comments - 298, trac...
    不排版阅读 3,813评论 0 5
  • 昨天晚上去跑步,还是在那条路上,一圈大概两三公里的样子,因为是晚上,而且那条路很少有人路过,又没有路灯,其...
    时间煮鱼片阅读 399评论 0 2
  • 昨晚发生了一件十分囧的事。 因为之前来岘港我换光了所有人民币和港币。然后买买买太随意,用着用着就发现没有现金了!?...
    向太白阅读 398评论 0 0
  • 3. 怎样学会使用这个词? 1)翻译下面的句子: 要在两个月之内把雅思从 6 分提到 8 分对任何人来说都是一件非...
    江贴心阅读 141评论 0 0