"A user interface is like a joke. If you have to explain it, it's not that good." – Martin LeBlanc
在本章中,我们将一起做一个项目。我们将编写一个简单的刮板,用于查找和保存网页中的图像。我们将重点讨论三个部分:
- Python 中的一个简单 HTTP Web 服务器
- 刮取给定 URL 的脚本
- 获取给定 URL 的 GUI 应用程序
A graphical user interface (GUI) is a type of interface that allows the user to interact with an electronic device through graphical icons, buttons, and widgets, as opposed to text-based or command-line interfaces, which require commands or text to be typed on the keyboard. In a nutshell, any browser, any office suite such as LibreOffice, and, in general, anything that pops up when you click on an icon, is a GUI application.
因此,如果您还没有这样做,这将是启动控制台并将自己放置在本书项目根目录中名为ch12
的文件夹中的最佳时机。在该文件夹中,我们将创建两个 Python 模块(scrape.py
和guiscrape.py
以及一个文件夹(simple_server
。在simple_server
中,我们将编写我们的 HTML 页面:index.html
。图像将存储在simple_server/img
中。
ch12
中的结构应该是这样的:
$ tree -A
.
├── guiscrape.py
├── scrape.py
└── simple_server
├── img
│ ├── owl-alcohol.png
│ ├── owl-book.png
│ ├── owl-books.png
│ ├── owl-ebook.jpg
│ └── owl-rose.jpeg
├── index.html
└── serve.sh
如果您使用的是 Linux 或 macOS,您可以像我一样,将启动 HTTP 服务器的代码放入一个serve.sh
文件中。在 Windows 上,您可能需要使用批处理文件。
我们将要刮取的 HTML 页面具有以下结构:
# simple_server/index.html
<!DOCTYPE html>
<html lang="en">
<head><title>Cool Owls!</title></head>
<body>
<h1>Welcome to my owl gallery</h1>
<div>
<img src="img/owl-alcohol.png" height="128" />
<img src="img/owl-book.png" height="128" />
<img src="img/owl-books.png" height="128" />
<img src="img/owl-ebook.jpg" height="128" />
<img src="img/owl-rose.jpeg" height="128" />
</div>
<p>Do you like my owls?</p>
</body>
</html>
这是一个非常简单的页面,所以我们只需注意,我们有五个图像,其中三个是 PNG,两个是 JPG(请注意,即使它们都是 JPG,一个以.jpg
结尾,另一个以.jpeg
结尾,这两个都是此格式的有效扩展)。
因此,Python 免费为您提供了一个非常简单的 HTTP 服务器,您可以从以下命令开始(在simple_server
文件夹中):
$ python -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
127.0.0.1 - - [06/May/2018 16:54:30] "GET / HTTP/1.1" 200 -
...
最后一行是您访问http://localhost:8000
时获得的日志,这里将提供我们美丽的页面。或者,您可以将该命令放在名为serve.sh
的文件中,然后使用该命令运行该命令(确保它是可执行的):
$ ./serve.sh
这将产生同样的效果。如果你有这本书的代码,你的页面应该是这样的:
请随意使用任何其他图像集,只要您使用至少一个 PNG 和一个 JPG,并且在src
标记中使用相对路径,而不是绝对路径。我从那里得到了这些可爱的猫头鹰 https://openclipart.org/ 。
现在,让我们开始编写脚本。我将分三步介绍源代码:导入、参数解析和业务逻辑。
以下是脚本的启动方式:
# scrape.py
import argparse
import base64
import json
import os
from bs4 import BeautifulSoup
import requests
从上面看,您可以看到我们需要解析参数,我们将把这些参数提供给脚本本身(argparse
。我们需要base64
库将图像保存在 JSON 文件(json
中,并且我们需要打开文件进行写入(os
。最后,我们需要BeautifulSoup
来轻松地抓取网页,并requests
来获取其内容。我假设您熟悉requests
,正如我们在前面章节中使用的一样。
We will explore the HTTP protocol and the requests
mechanism in Chapter 14, Web Development, so for now, let's just (simplistically) say that we perform an HTTP request to fetch the content of a web page. We can do it programmatically using a library, such as requests
, and it's more or less the equivalent of typing a URL in your browser and pressing Enter (the browser then fetches the content of a web page and displays it to you).
在所有这些导入中,只有最后两个不属于 Python 标准库,因此请确保已安装它们:
$ pip freeze | egrep -i "soup|requests"
beautifulsoup4==4.6.0
requests==2.18.4
当然,版本号对您可能不同。如果未安装,请使用以下命令:
$ pip install beautifulsoup4==4.6.0 requests==2.18.4
在这一点上,我认为唯一可能让你困惑的是base64/json
夫妇,所以请允许我花几句话来说明这一点。
正如我们在前一章中所看到的,JSON 是应用程序之间最流行的数据交换格式之一。它还广泛用于其他用途,例如,将数据保存到文件中。在我们的脚本中,我们将向用户提供将图像保存为图像文件或 JSON 单个文件的功能。在 JSON 中,我们将放置一个字典,其中键作为图像名称,值作为其内容。唯一的问题是以二进制格式保存图像很棘手,而这正是base64
库的作用所在。
base64
图书馆其实很有用。例如,每次您发送一封附有图像的电子邮件时,图像都会在发送电子邮件之前用base64
编码。在收件人端,图像自动解码为原始二进制格式,以便电子邮件客户端可以显示它们。
现在技术问题已经解决了,让我们看看脚本的第二部分(应该在scrape.py
模块的末尾):
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description='Scrape a webpage.')
parser.add_argument(
'-t',
'--type',
choices=['all', 'png', 'jpg'],
default='all',
help='The image type we want to scrape.')
parser.add_argument(
'-f',
'--format',
choices=['img', 'json'],
default='img',
help='The format images are _saved to.')
parser.add_argument(
'url',
help='The URL we want to scrape for images.')
args = parser.parse_args()
scrape(args.url, args.format, args.type)
看看第一行;在编写脚本时,这是一个非常常见的习惯用法。根据官方 Python 文档,'__main__'
字符串是顶级代码执行的作用域的名称。从标准输入、脚本或交互提示读取时,模块的__name__
设置为'__main__'
。
因此,如果您将执行逻辑放在该if
下,则仅当您直接运行脚本时才会运行,因为它的__name__
将是'__main__'
。另一方面,如果您从这个模块导入,那么它的名称将被设置为其他名称,因此,if
下的逻辑将不会运行。
我们要做的第一件事是定义解析器。我建议使用标准库模块argparse
,它足够简单,功能也相当强大。还有其他选择,但在这种情况下,argparse
将为我们提供所需的一切。
我们希望为脚本提供三种不同的数据:要保存的图像类型、要保存图像的格式以及要刮取的页面的 URL。
类型可以是 PNGs、JPGs 或两者(默认),而格式可以是 image 或 JSON,image 是默认格式。URL 是唯一的强制参数。
因此,我们添加了-t
选项,也允许长版本--type
。这些选项是'all'
、'png'
和'jpg'
。我们将默认设置为'all'
并添加help
消息。
我们对format
参数执行类似的过程,允许使用短语法和长语法(-f
和--format
,最后我们添加url
参数,这是唯一一个指定不同的参数,因此它不会被视为选项,而是作为位置参数。
为了解析所有参数,我们只需要parser.parse_args()
。很简单,不是吗?
最后一行是我们触发实际逻辑的地方,通过调用scrape
函数,传递我们刚刚解析的所有参数。我们将很快看到它的定义。argparse
的好处是,如果您通过传递-h
调用脚本,它会自动为您打印一个好的用法文本。让我们试一下:
$ python scrape.py -h
usage: scrape.py [-h] [-t {all,png,jpg}] [-f {img,json}] url
Scrape a webpage.
positional arguments:
url The URL we want to scrape for images.
optional arguments:
-h, --help show this help message and exit
-t {all,png,jpg}, --type {all,png,jpg}
The image type we want to scrape.
-f {img,json}, --format {img,json}
The format images are _saved to.
仔细想想,这样做的一个真正好处是,我们只需要指定参数,而不必担心使用文本,这意味着我们不必在每次更改某些内容时都将其与参数的定义保持同步。这是珍贵的。
这里有几种不同的方法来调用我们的scrape.py
脚本,它们演示了type
和format
是可选的,以及如何使用短语法和长语法来使用它们:
$ python scrape.py http://localhost:8000
$ python scrape.py -t png http://localhost:8000
$ python scrape.py --type=jpg -f json http://localhost:8000
第一个是使用type
和format
的默认值。第二个将只保存 PNG 图像,第三个将只保存 JPG,但为 JSON 格式。
现在我们已经看到了脚手架,让我们深入了解实际逻辑(如果它看起来很吓人,不要担心;我们将一起讨论)。在脚本中,该逻辑位于导入之后和解析之前(在if __name__
子句之前):
def scrape(url, format_, type_):
try:
page = requests.get(url)
except requests.RequestException as err:
print(str(err))
else:
soup = BeautifulSoup(page.content, 'html.parser')
images = _fetch_images(soup, url)
images = _filter_images(images, type_)
_save(images, format_)
让我们从scrape
函数开始。它所做的第一件事是在给定的url
参数处获取页面。无论在执行此操作时发生什么错误,我们都会将其捕获在RequestException
(err
中)并打印出来。RequestException
是requests
库中所有异常的基本异常类。
但是,如果事情进展顺利,并且我们从GET
请求返回了一个页面,那么我们可以继续(else
分支)并将其内容提供给BeautifulSoup
解析器。BeautifulSoup
库允许我们在任何时候解析网页,而无需编写查找页面中所有图像所需的所有逻辑,这是我们真正不想做的。这并不像看上去那么容易,而且重新发明轮子从来都不是件好事。为了获取图像,我们使用_fetch_images
函数,并使用_filter_images
进行过滤。最后,我们调用_save
并给出结果。
将代码拆分为具有有意义名称的不同函数可以让我们更容易地阅读它。即使你还没有看到_fetch_images
、_filter_images
和_save
函数的逻辑,也不难预测它们的功能,对吧?请查看以下内容:
def _fetch_images(soup, base_url):
images = []
for img in soup.findAll('img'):
src = img.get('src')
img_url = f'{base_url}/{src}'
name = img_url.split('/')[-1]
images.append(dict(name=name, url=img_url))
return images
_fetch_images
接受一个BeautifulSoup
对象和一个基本 URL。它所做的只是循环浏览页面上找到的所有图像,并在字典中填写关于它们的name
和url
信息(每张图像一个)。所有字典都添加到images
列表中,并在末尾返回。
当我们得到一张图片的名字时,会有一些诡计。我们使用'/'
作为分隔符拆分img_url
(http://localhost:80img/my_image_name.png
)字符串,并将最后一项作为图像名称。有一种更可靠的方法可以做到这一点,但对于这个例子来说,这是一种过激的做法。如果您想查看每个步骤的详细信息,请尝试将此逻辑分解为更小的步骤,并打印每个步骤的结果以帮助自己理解。在本书的最后,我将向您展示另一种更有效的调试技术。
总之,只要在_fetch_images
函数的末尾添加print(images)
,我们就可以得到:
[{'url': 'http://localhost:80img/owl-alcohol.png', 'name': 'owl-alcohol.png'}, {'url': 'http://localhost:80img/owl-book.png', 'name': 'owl-book.png'}, ...]
为了简洁起见,我截断了结果。您可以看到每个字典都有一个url
和name
键/值对,我们可以使用它来获取、识别和保存图像。在这一点上,我听到你问如果页面上的图像是用绝对路径而不是相对路径指定的,会发生什么,对吗?好问题!
答案是脚本将无法下载它们,因为此逻辑需要相对路径。我正要添加一些逻辑来解决这个问题,这时我想,在这个阶段,这对你来说是一个很好的练习,所以我将让你来解决它。
Hint: Inspect the start of that src
variable. If it starts with 'http'
, it's probably an absolute path. You might also want to checkout urllib.parse
to do that.
我希望_filter_images
函数的主体对您感兴趣。我想向您展示如何使用映射技术检查多个扩展:
def _filter_images(images, type_):
if type_ == 'all':
return images
ext_map = {
'png': ['.png'],
'jpg': ['.jpg', '.jpeg'],
}
return [
img for img in images
if _matches_extension(img['name'], ext_map[type_])
]
def _matches_extension(filename, extension_list):
name, extension = os.path.splitext(filename.lower())
return extension in extension_list
在这个函数中,如果type_
是all
,则不需要过滤,所以我们只返回所有图像。另一方面,当type_
不是all
时,我们从ext_map
字典中获取允许的扩展名,并使用它过滤结束函数体的列表理解中的图像。您可以看到,通过使用另一个助手函数_matches_extension
,我使列表理解更简单、更可读。
_matches_extension
所做的只是分割得到其扩展名的图像的名称,并检查其是否在允许的扩展名列表中。你能找到一个可以对该功能进行的微小改进(速度方面)吗?
我想你一定想知道为什么我收集了列表中的所有图像,然后将它们删除,而不是在将它们添加到列表之前检查是否要保存它们。第一个原因是我现在需要在 GUI 应用程序中使用_fetch_images
。第二个原因是,组合、获取和过滤将产生一个更长、更复杂的函数,我正在努力降低复杂性。第三个原因是,这可能是一个很好的练习:
def _save(images, format_):
if images:
if format_ == 'img':
_save_images(images)
else:
_save_json(images)
print('Done')
else:
print('No images to save.')
def _save_images(images):
for img in images:
img_data = requests.get(img['url']).content
with open(img['name'], 'wb') as f:
f.write(img_data)
def _save_json(images):
data = {}
for img in images:
img_data = requests.get(img['url']).content
b64_img_data = base64.b64encode(img_data)
str_img_data = b64_img_data.decode('utf-8')
data[img['name']] = str_img_data
with open('images.json', 'w') as ijson:
ijson.write(json.dumps(data))
让我们继续浏览代码并检查_save
函数。您可以看到,当images
不为空时,它基本上充当调度器。根据format_
变量中存储的信息,我们可以调用_save_images
或_save_json
。
我们差不多完成了。让我们跳到_save_images
。我们在images
列表上循环,对于在那里找到的每个字典,我们对图像 URL 执行GET
请求,并将其内容保存在一个文件中,我们称之为图像本身。
最后,让我们进入_save_json
函数。它与前一个非常相似。我们基本上填写了data
字典。图像名称为键,其二进制内容的 Base64 表示为值。填充完字典后,我们使用json
库将其转储到images.json
文件中。我会给你一个小预览:
# images.json (truncated)
{
"owl-alcohol.png": "iVBORw0KGgoAAAANSUhEUgAAASwAAAEICA...
"owl-book.png": "iVBORw0KGgoAAAANSUhEUgAAASwAAAEbCAYAA...
"owl-books.png": "iVBORw0KGgoAAAANSUhEUgAAASwAAAElCAYA...
"owl-ebook.jpg": "/9j/4AAQSkZJRgABAQEAMQAxAAD/2wBDAAEB...
"owl-rose.jpeg": "/9j/4AAQSkZJRgABAQEANAA0AAD/2wBDAAEB...
}
就这样!现在,在继续下一节之前,请确保使用此脚本并了解其工作原理。尝试修改某些内容,打印中间结果,添加新的参数或功能,或扰乱逻辑。我们现在要将其迁移到 GUI 应用程序中,这将增加一层复杂性,因为我们必须构建 GUI 界面,因此您必须熟悉业务逻辑,这将使您能够专注于代码的其余部分。
有几个库用 Python 编写 GUI 应用程序。最著名的是Tkinter、wxPython、PyGTK和PyQt。它们都提供了广泛的工具和小部件,您可以使用它们来编写 GUI 应用程序。
在本章的其余部分,我将使用 Tkinter。Tkinter代表Tk 接口,是 Tk GUI 工具包的标准 Python 接口。Tk 和 Tkinter 在大多数 Unix 平台、macOS X 以及 Windows 系统上都可用。
让我们通过运行以下命令来确保tkinter
已正确安装在您的系统上:
$ python -m tkinter
它应该打开一个对话框窗口,展示一个简单的Tk
界面。如果你能看到,我们可以走了。但是,如果不起作用,请在 Python 官方文档(中搜索tkinter
https://docs.python.org/3.7/library/tkinter.html )。您将找到几个指向资源的链接,这些链接将帮助您启动并运行它。
我们将制作一个非常简单的 GUI 应用程序,它基本上模仿了我们在本章第一部分看到的脚本的行为。我们不会单独添加保存 JPG 或 PNG 的功能,但是在您读完本章之后,您应该能够自己使用代码并将该功能放回原处。
这就是我们的目标:
好极了,不是吗?正如您所看到的,它是一个非常简单的界面(在 mac 上应该是这样)。URL 字段和 Fetch info 按钮有一个框架(即容器),另一个框架用于列表框(内容)保存图像名称,单选按钮控制我们保存它们的方式,最后是一个刮擦!按钮在底部。我们还有一个状态栏,它向我们显示一些信息。
为了得到这个布局,我们可以将所有的小部件放在一个根窗口上,但这会使布局逻辑变得非常混乱和不必要的复杂。因此,我们将使用框架划分空间,并将小部件放置在这些框架中。这样我们将取得更好的结果。这是布局图的草稿:
我们有一个根窗口,它是应用程序的主窗口。我们将其分为两行,第一行放置主框架,第二行放置状态框架(将保存状态栏文本)。随后将主机架分为三行。在第一个示例中,我们放置了URL 框架,其中包含URL小部件。在第二个框架中,我们放置Img 框架,它将容纳列表框和单选框,后者将承载标签和单选按钮小部件。最后是第三个按钮,它只会按住刮按钮。
为了布局框架和小部件,我们将使用一个名为网格的布局管理器,它将空间简单地划分为行和列,就像在矩阵中一样。
现在,我将要编写的所有代码都来自guiscrape.py
模块,因此为了节省空间,我不会对每个代码段重复它的名称。该模块在逻辑上分为三个部分,与脚本版本类似:导入、布局逻辑和业务逻辑。我们将一行一行地分析它们,分为三个部分。
导入与脚本版本类似,只是我们丢失了不再需要的argparse
,并且我们添加了两行:
# guiscrape.py
from tkinter import *
from tkinter import ttk, filedialog, messagebox
...
在处理tkinter
时,第一行是非常常见的做法,尽管通常使用*
语法*导入是不好的做法。*您可能会发生名称冲突,如果模块太大,导入所有内容都会很昂贵。
之后,我们按照此库使用的常规方法显式导入ttk
、filedialog
和messagebox
。ttk
是一组新的样式化小部件。它们的行为基本上与旧的一样,但是能够根据操作系统设置的样式正确地绘制它们自己,这很好。
其余的进口(省略)是我们需要的,以便执行您现在很清楚的任务。请注意,在第二部分中,我们不需要使用pip
进行安装;我们需要的东西都已经准备好了。
我将一块一块地粘贴它,这样我可以很容易地向你们解释。您将看到我们在布局草稿中讨论的所有部件是如何排列和粘合在一起的。我将要粘贴的内容,正如我们之前在脚本中所做的一样,guiscrape.py
模块的最后一部分。我们将把中间部分业务逻辑留到最后:
if __name__ == "__main__":
_root = Tk()
_root.title('Scrape app')
正如您现在所知道的,我们只希望在模块直接运行时执行逻辑,所以第一行不会让您感到惊讶。
在最后两行中,我们设置了主窗口,它是Tk
类的一个实例。我们实例化它并给它一个标题。请注意,我对tkinter
对象的所有名称使用了前置下划线技术,以避免与业务逻辑中的名称发生潜在冲突。我只是觉得这样比较干净,但你可以不同意:
_mainframe = ttk.Frame(_root, padding='5 5 5 5')
_mainframe.grid(row=0, column=0, sticky=(E, W, N, S))
在这里,我们设置了主框架。这是一个例子。我们将_root
设置为它的父对象,并给它一些padding
。padding
以像素为单位衡量内部内容和边框之间应该插入多少空间,以便让我们的布局有一点呼吸,否则我们会产生沙丁鱼效应,小部件被包装得太紧。
第二行更有趣。我们将此_mainframe
放置在父对象(_root
的第一个row
(0
)和第一个column
(0
)上。我们还说,这个框架需要通过使用sticky
参数和所有四个基本方向在每个方向上扩展自己。如果你想知道它们是从哪里来的,是from tkinter import *
魔法把它们带给了我们:
_url_frame = ttk.LabelFrame(
_mainframe, text='URL', padding='5 5 5 5')
_url_frame.grid(row=0, column=0, sticky=(E, W))
_url_frame.columnconfigure(0, weight=1)
_url_frame.rowconfigure(0, weight=1)
接下来,我们将开始放置URL 框架。这一次,父对象是_mainframe
,您可以从我们的草稿中回忆起。这不仅仅是一个简单的Frame
,它实际上是一个LabelFrame
,这意味着我们可以设置文本参数,并期望在其周围绘制一个矩形,文本参数的内容写在其左上角(如果有帮助,请查看前一张图片)。我们将此帧定位在(0
、0
),并说它应该向左和向右扩展。我们不需要另外两个方向。
最后,如果需要调整大小,我们使用rowconfigure
和columnconfigure
来确保其行为正确。这只是我们目前布局中的一种形式:
_url = StringVar()
_url.set('http://localhost:8000')
_url_entry = ttk.Entry(
_url_frame, width=40, textvariable=_url)
_url_entry.grid(row=0, column=0, sticky=(E, W, S, N), padx=5)
_fetch_btn = ttk.Button(
_url_frame, text='Fetch info', command=fetch_url)
_fetch_btn.grid(row=0, column=1, sticky=W, padx=5)
这里,我们有代码来展示 URL 文本框和_fetch
按钮。此环境中的文本框称为Entry
。我们像往常一样实例化它,将_url_frame
设置为其父对象并给它一个宽度。还有,这是最有趣的部分,我们将textvariable
参数设置为_url
。_url
是一个StringVar
,它是一个现在连接到Entry
的对象,将用于操纵其内容。因此,我们不直接修改_url_entry
实例中的文本,而是通过访问_url
来修改。在本例中,我们对其调用set
方法,将初始值设置为本地网页的 URL。
我们将_url_entry
定位在(0
、0
),设置所有四个基本方向以使其保持不变,并且我们还使用padx
在左右边缘设置了一点额外的填充,这在x轴(水平)上添加了填充。另一方面,pady
负责垂直方向。
现在,您应该知道,每次调用对象上的.grid
方法时,我们基本上是告诉网格布局管理器将该对象放置在某个位置,根据我们在grid()
调用中指定为参数的规则。
同样,我们设置并放置_fetch
按钮。唯一有趣的参数是command=fetch_url
。这意味着当我们点击这个按钮时,我们调用fetch_url
函数。这种技术称为回调:
_img_frame = ttk.LabelFrame(
_mainframe, text='Content', padding='9 0 0 0')
_img_frame.grid(row=1, column=0, sticky=(N, S, E, W))
这就是我们在布局草图中所称的Img 框架。它位于其父级_mainframe
的第二行。它将容纳列表框和无线电帧:
_images = StringVar()
_img_listbox = Listbox(
_img_frame, listvariable=_images, height=6, width=25)
_img_listbox.grid(row=0, column=0, sticky=(E, W), pady=5)
_scrollbar = ttk.Scrollbar(
_img_frame, orient=VERTICAL, command=_img_listbox.yview)
_scrollbar.grid(row=0, column=1, sticky=(S, N), pady=6)
_img_listbox.configure(yscrollcommand=_scrollbar.set)
这可能是整个布局逻辑中最有趣的部分。正如我们对_url_entry
所做的那样,我们需要通过将Listbox
的内容绑定到_images
变量来驱动它。我们设置了Listbox
以便_img_frame
是它的父项,_images
是它所绑定的变量。我们也传递一些维度。
有趣的部分来自_scrollbar
实例。注意,当我们实例化它时,我们将其命令设置为_img_listbox.yview
。这是Listbox
和Scrollbar
之间的合同的上半部分。另一半由_img_listbox.configure
方法提供,设置yscrollcommand=_scrollbar.set
。
通过提供此互惠键,当我们在Listbox
上滚动时,Scrollbar
将相应移动,反之亦然,当我们操作Scrollbar
时,Listbox
将相应滚动:
_radio_frame = ttk.Frame(_img_frame)
_radio_frame.grid(row=0, column=2, sticky=(N, S, W, E))
我们放置无线电帧,准备填充。注意Listbox
在_img_frame
、Scrollbar
(0
、1
)上占用0
、0
),因此_radio_frame
将进入0
、2
:
_choice_lbl = ttk.Label(
_radio_frame, text="Choose how to save images")
_choice_lbl.grid(row=0, column=0, padx=5, pady=5)
_save_method = StringVar()
_save_method.set('img')
_img_only_radio = ttk.Radiobutton(
_radio_frame, text='As Images', variable=_save_method,
value='img')
_img_only_radio.grid(
row=1, column=0, padx=5, pady=2, sticky=W)
_img_only_radio.configure(state='normal')
_json_radio = ttk.Radiobutton(
_radio_frame, text='As JSON', variable=_save_method,
value='json')
_json_radio.grid(row=2, column=0, padx=5, pady=2, sticky=W)
首先,我们放置标签,并给它一些填充。请注意,标签和单选按钮是_radio_frame
的子项。
至于Entry
和Listbox
对象,Radiobutton
也是由一个外部变量的键驱动的,我称之为_save_method
。每个Radiobutton
实例设置一个 value 参数,通过检查_save_method
上的值,我们知道
选择了哪个按钮:
_scrape_btn = ttk.Button(
_mainframe, text='Scrape!', command=save)
_scrape_btn.grid(row=2, column=0, sticky=E, pady=5)
在_mainframe
的第三排,我们放置刮按钮。其command
为save
,成功解析网页后,保存Listbox
中列出的图片:
_status_frame = ttk.Frame(
_root, relief='sunken', padding='2 2 2 2')
_status_frame.grid(row=1, column=0, sticky=(E, W, S))
_status_msg = StringVar()
_status_msg.set('Type a URL to start scraping...')
_status = ttk.Label(
_status_frame, textvariable=_status_msg, anchor=W)
_status.grid(row=0, column=0, sticky=(E, W))
我们通过放置状态框来结束布局部分,状态框是一个简单的ttk.Frame
。为了给它一点状态栏效果,我们将它的relief
属性设置为'sunken'
,并给它两个像素的均匀填充。它需要粘贴到_root
窗口的左侧、右侧和底部,因此我们将其sticky
属性设置为(E, W, S)
。
然后我们在其中放置一个标签,这一次,我们将它绑定到一个StringVar
对象,因为每次我们想要更新状态栏文本时,我们都必须修改它。你现在应该已经熟悉了这项技术。
最后,在最后一行,我们通过在Tk
实例上调用mainloop
方法来运行应用程序:
_root.mainloop()
请记住,所有这些说明都放在原始脚本的if __name__ == "__main__":
子句下。
如您所见,设计 GUI 应用程序的代码并不难。诚然,一开始,你必须玩一点。并不是每件事都能在第一次尝试时完美完成,但我向你保证这很容易,你可以在网上找到很多教程。现在让我们进入有趣的部分,业务逻辑。
我们将分三个部分分析 GUI 应用程序的业务逻辑。有获取逻辑、保存逻辑和警报逻辑。
让我们从获取页面和图像的代码开始:
config = {}
def fetch_url():
url = _url.get()
config['images'] = []
_images.set(()) # initialised as an empty tuple
try:
page = requests.get(url)
except requests.RequestException as err:
_sb(str(err))
else:
soup = BeautifulSoup(page.content, 'html.parser')
images = fetch_images(soup, url)
if images:
_images.set(tuple(img['name'] for img in images))
_sb('Images found: {}'.format(len(images)))
else:
_sb('No images found')
config['images'] = images
def fetch_images(soup, base_url):
images = []
for img in soup.findAll('img'):
src = img.get('src')
img_url = f'{base_url}/{src}'
name = img_url.split('/')[-1]
images.append(dict(name=name, url=img_url))
return images
首先,让我解释一下那本字典。我们需要某种方式在 GUI 应用程序和业务逻辑之间传递数据。现在,与其用许多不同的变量来污染全局名称空间,我个人的偏好是使用一个字典来保存我们需要来回传递的所有对象,这样全局名称空间就不会被所有这些名称阻塞,而且我们有一个单一的、干净的,了解应用程序所需的所有对象的位置的简单方法。
在这个简单的示例中,我们将使用从页面获取的图像填充config
字典,但我想向您展示该技术,以便您至少有一个示例。这项技术来自我使用 JavaScript 的经验。编写网页代码时,通常会导入几个不同的库。如果这些变量中的每一个都在全局名称空间中塞满了各种各样的变量,那么由于名称冲突和变量重写,使一切正常工作可能会出现问题。
因此,最好尽可能保持全局名称空间干净。在这种情况下,我发现使用一个config
变量是完全可以接受的。
fetch_url
函数与我们在脚本中所做的非常相似。首先调用_url.get()
得到url
值。记住,_url
对象是一个StringVar
实例,它绑定到_url_entry
对象,这是一个Entry
。您在 GUI 上看到的文本字段是Entry
,但幕后的文本是StringVar
对象的值。
通过在_url
上调用get()
,我们得到文本的值,显示在_url_entry
中。
下一步是将config['images']
准备为空列表,并清空绑定到_img_listbox
的_images
变量。当然,这具有清理_img_listbox
中所有项目的效果。
在这个准备步骤之后,我们可以尝试获取页面,使用我们在本章开头的脚本中采用的相同的try
/except
逻辑。唯一的区别是,如果出现问题,我们会采取什么行动。我们称之为_sb(str(err))
。_sb
是一个助手函数,我们将很快看到它的代码。基本上,它为我们设置状态栏中的文本。不是个好名字,对吧?我必须向你解释它的行为——值得思考。
如果我们可以获取页面,那么我们将创建soup
实例,并从中获取图像。fetch_images
的逻辑与前面解释的完全相同,因此我在此不再重复。
如果我们有图像,使用快速的元组理解(实际上是一个提供给元组构造函数的生成器表达式),我们将_images
作为StringVar
提供,这就产生了用所有图像名称填充我们的_img_listbox
的效果。最后,我们更新状态栏。
如果没有图像,我们仍然会更新状态栏,在函数结束时,无论找到多少图像,我们都会更新config['images']
以保留images
列表。这样,我们就可以通过检查config['images']
来访问来自其他函数的图像,而无需传递该列表。
保存图像的逻辑非常简单。这是:
def save():
if not config.get('images'):
_alert('No images to save')
return
if _save_method.get() == 'img':
dirname = filedialog.askdirectory(mustexist=True)
_save_images(dirname)
else:
filename = filedialog.asksaveasfilename(
initialfile='images.json',
filetypes=[('JSON', '.json')])
_save_json(filename)
def _save_images(dirname):
if dirname and config.get('images'):
for img in config['images']:
img_data = requests.get(img['url']).content
filename = os.path.join(dirname, img['name'])
with open(filename, 'wb') as f:
f.write(img_data)
_alert('Done')
def _save_json(filename):
if filename and config.get('images'):
data = {}
for img in config['images']:
img_data = requests.get(img['url']).content
b64_img_data = base64.b64encode(img_data)
str_img_data = b64_img_data.decode('utf-8')
data[img['name']] = str_img_data
with open(filename, 'w') as ijson:
ijson.write(json.dumps(data))
_alert('Done')
当用户单击“刮”按钮时!按钮,使用回调机制调用save
函数。
此函数要做的第一件事是检查是否确实有任何要保存的图像。如果没有,它会使用另一个助手函数_alert
提醒用户,我们将很快看到其代码。如果没有图像,则不会执行进一步的操作。
另一方面,如果config['images']
列表不为空,save
充当调度器,调用_save_images
或_save_json
,根据该值由_same_method
持有。请记住,此变量与单选按钮相关,因此我们希望其值为'img'
或'json'
。
此调度程序与脚本中的调度程序略有不同。根据我们选择的方法,必须采取不同的行动。
如果我们想将图像保存为图像,我们需要让用户选择一个目录。我们通过调用filedialog.askdirectory
并将调用结果分配给dirname
变量来实现这一点。这将打开一个很好的对话框窗口,要求我们选择一个目录。我们选择的目录必须存在,正如我们调用方法的方式所指定的那样。这样做是为了在保存文件时不必编写代码来处理可能丢失的目录。
下面是此对话框在 mac 上的显示方式:
如果我们取消操作,dirname
将设置为None
。
在分析完save
中的逻辑之前,让我们快速浏览_save_images
。
它与我们在脚本中的版本非常相似,所以请注意,在开始时,为了确保我们确实有事情要做,我们检查了dirname
和config['images']
中至少有一个图像的存在。
如果是这样的话,这意味着我们至少要保存一个图像和它的路径,所以我们可以继续。已经解释了保存图像的逻辑。这次我们做的一件不同的事情是通过os.path.join
将目录(即完整路径)连接到图像名称
在_save_images
的末尾,如果我们至少保存了一张图像,我们会提醒用户我们已经完成了。
现在我们回到save
中的另一个分支。当用户在按下 Scrape 按钮之前选择 As JSON 单选按钮时,执行该分支。在这种情况下,我们要保存一个文件;因此,我们不能只要求一个目录。我们想让用户能够选择一个文件名以及。因此,我们启动了一个不同的对话框:filedialog.asksaveasfilename
。
我们传递一个初始文件名,它是建议给用户的——如果用户不喜欢,他们可以更改它。此外,因为我们正在保存一个 JSON 文件,所以我们通过传递filetypes
参数来强制用户使用正确的扩展名。它是一个列表,包含任意数量的两个元组*(描述,扩展),*,用于运行对话框的逻辑。
以下是此对话框在 macOS 上的外观:
一旦我们选择了一个位置和一个文件名,我们就可以继续执行保存逻辑,这与上一个脚本中的逻辑相同。我们从 Python 字典(data
中创建了一个 JSON 对象,我们使用images
名称和 Base64 编码内容组成的键/值对填充该对象。
同样在_save_json
中,我们在开始时进行了一些检查,以确保我们不会继续,除非我们有一个文件名和至少一个要保存的图像。这确保了如果用户按下“取消”按钮,不会发生任何不好的情况。
最后,让我们看看警报逻辑。这非常简单:
def _sb(msg):
_status_msg.set(msg)
def _alert(msg):
messagebox.showinfo(message=msg)
就这样!要更改状态栏消息,我们只需访问_status_msg``StringVar
,因为它与_status
标签绑定。
另一方面,如果我们想向用户显示一条更明显的消息,我们可以启动一个消息框。以下是它在 mac 电脑上的外观:
messagebox
对象还可用于警告用户(messagebox.showwarning
或发出错误信号(messagebox.showerror
。但它也可以用来提供对话框,询问我们是否确定要继续,或者是否真的要删除该文件,等等。
如果您通过简单打印dir(messagebox)
返回的内容来检查messagebox
,您会发现askokcancel
、askquestion
、askretrycancel
、askyesno
、askyesnocancel
等方法,以及一组验证用户响应的常量,如CANCEL
、NO
、OK
、OKCANCEL
、YES
、YESNOCANCEL
。您可以将它们与用户的选择进行比较,以便在对话框关闭时知道要执行的下一个操作。
既然您已经习惯了设计 GUI 应用程序的基本原理,我想给您一些如何使我们的应用程序更好的建议。
我们可以从代码质量开始。你认为这段代码足够好吗,或者你会改进它吗?如果是,怎么做?我会对它进行测试,确保它的健壮性,并满足用户通过点击应用程序可能创建的各种场景。我还要确保当我们正在清理的网站因为任何原因关闭时,我的行为是我所期望的。
另一件我们可以改进的事情是命名。我谨慎地用一个前导下划线命名了所有组件,以突出它们的某种私有性质,并避免与它们链接到的底层对象发生名称冲突。但现在回想起来,这些组件中的许多都可以使用更好的名称,因此,在找到最适合您的表单之前,您真的需要进行重构。你可以先给_sb
函数起个更好的名字!
对于与用户界面有关的内容,您可以尝试调整主应用程序的大小。看看会发生什么?整个内容都保持原样。如果展开,则会添加空白,如果缩小,则整个小部件集会逐渐消失。这种行为并不是很好,因此一个快速的解决方案是修复根窗口(即,无法调整大小)。
要改进应用程序,您可以做的另一件事是添加与脚本中相同的功能,只保存 PNG 或 JPG。为此,您可以在某处放置一个组合框,其中包含三个值:All、PNGs、JPGs 或类似值。在保存文件之前,用户应该能够选择这些选项之一。
更好的是,您可以更改Listbox
的声明,以便可以同时选择多个图像,并且只保存所选的图像。如果你设法做到这一点(它并不像看上去的那么难,相信我),那么你应该考虑更好地表现出 T1 T1,也许为行提供交替的背景颜色。
您可以添加的另一个好东西是一个按钮,它打开一个对话框来选择文件。该文件必须是应用程序可以生成的 JSON 文件之一。选择后,您可以运行一些逻辑来从 Base64 编码版本重建图像。这样做的逻辑非常简单,下面是一个示例:
with open('images.json', 'r') as f:
data = json.loads(f.read())
for (name, b64val) in data.items():
with open(name, 'wb') as f:
f.write(base64.b64decode(b64val))
如您所见,我们需要在阅读模式下打开images.json
,然后抓取data
字典。一旦有了它,我们就可以循环浏览它的项目,并用 Base64 解码的内容保存每个图像。我将把这个逻辑与应用程序中的一个按钮联系起来。
您可以添加的另一个很酷的功能是能够打开预览窗格,其中显示您从Listbox
中选择的任何图像,以便用户在决定保存图像之前可以查看这些图像。
最后,这个应用程序的最后一个建议是添加一个菜单。甚至可能是一个简单的菜单与文件和?提供通常的帮助或帮助。只是为了好玩。添加菜单并没有那么复杂;您可以添加文本、键盘快捷键、图像等。
如果您对深入研究 GUI 世界感兴趣,那么我将向您提供以下建议。
turtle
模块是从 Python 标准发行版到 Python 2.5 版的同名模块的扩展重新实现。这是一种非常流行的向孩子们介绍编程的方式。
它是基于一个假想的海龟在笛卡尔平面上从(0,0)开始的想法。您可以通过编程命令海龟前后移动、旋转等等;通过组合所有可能的动作,可以绘制各种复杂的形状和图像。
如果只是为了看一些不同的东西,它绝对值得一看。
在您探索了tkinter
领域的广阔之后,我建议您探索其他 GUI 库:wxPython(https://www.wxpython.org/ 、PyQt(https://riverbankcomputing.com/software/pyqt/intro 、PyGTK(https://pygobject.readthedocs.io/en/latest/ )。您可能会发现其中一个更适合您,或者它使您更容易编写所需的应用程序。
我相信只有当程序员意识到他们有什么工具可用时,他们才能实现他们的想法。如果你的工具集太窄,你的想法可能看起来不可能实现,或者很难实现,而且它们可能会保持原样,仅仅是想法。
当然,今天的技术范围是巨大的,所以了解一切是不可能的;因此,当你即将学习一项新技术或一门新学科时,我的建议是通过探索广度优先来增长你的知识。
调查几件事情,然后深入研究其中一件或几件看起来最有希望的事情。通过这种方式,您至少可以使用一种工具进行生产,并且当该工具不再适合您的需要时,您将知道在哪里可以进行更深入的挖掘,这要感谢您以前的探索。
在设计界面时,需要记住许多不同的事情。其中,对我来说最重要的一条,就是最小惊奇法则。它基本上说明,如果在您的设计中,一个必要的特性具有很高的惊人因素,那么可能有必要重新设计您的应用程序。举一个例子,当您习惯于使用 Windows 时,最小化、最大化和关闭窗口的按钮位于右上角,而 Linux 位于左上角,因此很难使用这些按钮。你会发现自己不断地走到右上角,却再次发现按钮在另一边。
如果某个按钮在应用程序中变得如此重要,以至于设计师现在将其放置在一个精确的位置,请不要创新。按照惯例去做就行了。只有当用户不得不浪费时间寻找一个按钮而不是它应该在的地方时,他们才会感到沮丧。
无视这一规则是我无法使用 Jira 等产品的原因。我花了几分钟去做一些简单的事情,这些事情需要几秒钟的时间。
这个话题超出了本书的范围,但我确实想提一下
如果您正在编写一个 GUI 应用程序,在单击按钮时需要执行长时间运行的操作,您将看到您的应用程序可能会冻结,直到操作执行完毕。为了避免这种情况,并保持应用程序的响应性,您可能需要在不同的线程(甚至是不同的进程)中运行耗时昂贵的操作,以便操作系统能够不时为 GUI 留出一点时间,以保持其响应性。
首先要掌握好基本原理,然后在探索中享受乐趣!
在本章中,我们一起做了一个项目。我们已经编写了一个脚本,它可以抓取一个非常简单的网页,并接受可选的命令来改变其行为。我们还编写了一个 GUI 应用程序,通过单击按钮而不是在控制台上键入来执行相同的操作。我希望你喜欢读这本书,并像我喜欢写这本书一样跟着读。
我们看到了许多不同的概念,例如处理文件和执行 HTTP 请求,我们还讨论了可用性和设计准则。
我只能够触及表面,但希望你们有一个良好的起点,从中扩大你们的探索。
在本章中,我指出了几种可以改进应用程序的不同方法,并通过一些练习和问题向您提出了挑战。我希望你已经花时间考虑这些想法。通过使用有趣的应用程序,比如我们一起编写的应用程序,您可以学到很多东西。
在下一章中,我们将讨论数据科学,或者至少讨论 Python 程序员在面对这个主题时所拥有的工具。