超文本传输协议(HTTP, Hypertext Transfer Protocol)是一种通用机制,Cli使用HTTP向Serv,req文档,而Serv则通过HTTP向Cli提供文档。
HTTP的设计初衷,并非只是将其作为一种用于传输文件的新方法,也不是将其作为旧式文件传输协议(如FTP)的提供缓存功能的替代品。
当然,HTTP能传输书籍、图片、视频这些独立的文件,但尽管如此,HTTP的目的远不止于此。它还允许世界各地的Serv发布文档,并通过相互之间的交叉引用,形成了一张相互连接的信息网。
HTTP就是为万维网设计的。
撰写书籍时,会引用其他书籍的内容。想找到引用源,就必须先找到另一本书,然后不停翻页,找到引用的文字才行。万维网(WWW, World Wide Web,或web)所实现的,就是把寻找引用的任务,交给机器来负责。
如果一段文字"关于cookie的讨论",本来是孤立的,与外界没有联系,但是如果加了下划线,且被点击后,可跳转到所引用的文本,这段文字就成为了一个超链接(hyperlink)。文本中包含内嵌超链接的整个文档,叫做超文本(hypertext)文档。如果文档中加入了图片、声音、视频,该文档就成为了超媒体(hypermedia)。
前缀hyper表示,后面的媒介能理解文档键相互引用的机制,且能为用户生成链接。
在浏览器的地址栏,可看到类似的例子:
# Some sample URLs
https://www.python.org/
http://en.wikidia.org/wiki/Python_(programming_language)
http://localhost:8000/headers
ftp://ssd.jpl.nasa.gov/pub/eph/planets/README.txt
telnet://rainmaker.wunderground.com
第一个标记(https/http)为所使用的机制(scheme),指明了获取文档所使用的协议。后面跟着一个冒号和两个斜杠(://),然后是hostname,接着还有port。URL最后是一个路径(path),用于在可用服务的所有文档中,指明要获取的特定文档。
除了用于描述可从网络获取的资料外。统一资源标识符(URI, Uniform Resource Identifier)可用于标识通过网络访问的物理文档,也可作为通用的统一标识符,为实体指定PC可以识别的名字。这些name叫统一资源名(URN, Uniform Resource Name)。本书所有的内容都可叫做URL。
https://www.google.com/search?q=apod&btnI=yes
此外,还可在URL后,加上一个以#号开始的片段(fragment),后面接上链接引用内容在页面上的具体位置。
http://tools.ietf.org/html/rfc2324#section-2.3.2
片段与URL的其他组成部分有所不同。Web浏览器在寻找片段指定的元素时,需获取路径指定的整个页面,传输的HTTP-req中并不包含关于fra的mes。Serv能从浏览器获取的URL,只包括hostname、路径、查询str。
hostname是以Host-head的形式传输的,路径和查询str则拼接在一起,组成了跟在req首行HTTP方法后的完整路径。
Py标准库内置的urllib.parse模块,提供了解析并构造URL所需的工具。使用urllib.parse时,只需一个函数调用,就能将URL分解成不同的部分。较早版本的Py中,该函数的返回值就是一个元组。使用tuple()来查看元组信息,并使用int索引/在赋值语句中,使用元组拆分,来读取元组中的元素。
>>> from urllib.parse import urlsplit
>>> u = urlsplit('https://www.goole.com/search?q=apod&btnI=yes')
>>> tuple(u)
('https', 'www.google.com', '/search', 'q=apod&btnI=yes', '')
返回的元组同样支持通过属性的名称,来访问属性值,使得解析URL时,编写的代码更具可读性。
>>> u.scheme
'http'
>>> u.netloc
'www.goole.com'
>>> u.path
'/search'
>>> u.query
'q=apod&btnI=yes'
>>> u.fragment
''
表示“网络位置”(network location)的netloc属性,有由若干部分组成,但urlsplit()函数,不会在返回的元组中,将它们分解成不同的部分;相反,urlsplit()还是会在返回值中,将网络位置作为一个单独的属性。
>>> u = urlsplit('https://brandon:atigdng@localhost:8000/')
>>> u.netloc
'brandon:atigdng@localhost:8000'
>>> u.username
'brandon'
>>> u.password
'atigdng'
>>> u.hostname
'localhost'
>>> u.port
8000
如,&和#是URL的分隔符,因此不能直接在URL中使用这两个符号。此外,由于/符号是用来分割路径的,如果要在一个特定的路径中使用/符号,也必须进行转义。
URL的查询str有自己的编码规则。查询str的值,通常会包含空格,使用加号(+)来代替URL中的空格,就是一种编码方案。
如,在Google进行搜索时,如果关键字包含空格,就会用+来代替空格。如果查询str编码时,不使用+,就只能和URL其余部分的编码策略一样,使用十六进制转义码"%20"来表示空格。
假如有一个URL,用于在网站"Q&A"一节中的"TCP/IP"部分中,搜索关于packet loss的信息,如果要正确解析这个URL,就必须遵循下述步骤:
>>> from urllib.parse import parse_qs, parse_qsl, unquote
>>> u = urlsplit('http://example.com/Q&26A/TCP%2FIP?q=packet+loss')
>>> path = [unquote(s) for s in u.path.split('/')]
>>> query = parse_qsl(u.query)
>>> path
['', 'Q&26A', 'TCP/IP']
>>> query
[('q', 'packet loss')]
使用split()对路径进行分割的返回值中,一开始有一个空str。因为该路径是一个绝对路径,且以一个斜杠作为开始。
如果无需在编写的代码中处理这种情况,可将返回的元组列表传递给dict(),最后一次指定的参数值会作为dic中的值。
如果既想返回一个dic,又希望能多次指定同一个查询参数,那么可使用parse_qs()来代替parse_qsl()。此时会返回一个dic,dic中的值是列表。
>>> parse_qs(u.query)
{'q': ['packet loss']}
标准库中提供了反向构造URL所需的所有程序。如果已经有了path和query,Py就能通过斜杠,将路径的不同部分重新组合成完整路径,对查询str进行编码,将结果传递给urlunsplit()函数。是urlsplit()的逆过程。
>>> from urllib.parse import quote, urlencode, urlunsplit
>>> urlunsplit(('http', 'example.com', '/'.join(quote(p, safe='') for p in path),urlencode(query), ''))
'http://example.com/Q%2626A/TCP%2FIP?q=packet+loss'
标准库函数已经将所有HTTP规范都考虑进去了。
大多数网站都会精心设计表示路径的元素,无需在URL中使用不优雅的转义符。DEV将这些路径元素称为slug。
如果某个网站只允许在URL-slug中包含字母、数字、连字符、下划线,就不用再担心slug中,会包含需进行转义的斜杠符
如果确认要处理的路径的各组件中,绝对不包含用于转义的斜杠符,就可直接将该路径传递给quote()和unquote(),无需事先对其进行分割。
>>> quote('Q&A/TCP IP')
'Q%26A/TCP%20IP'
>>> unquote('Q%26A/TCP%20IP')
'Q&A/TCP IP'
quote()函数认为,正常情况下路径组件中,不会包含用于转义的斜杠符,默认参数是safe=‘/‘,表示会直接将斜杠符作为字面值。在之前的版本中,使用safe=‘‘覆盖了该参数值。
标准库的urllib.parse还提供了一些专用方法,如urldefrag(),用于根据#符号,将片段从URL中分离出来。
文件OS的命令行,支持一个用于“更改工作目录”的命令。切换到特定的work-dir后,就可使用相对(relative)路径来搜索文件,相对路径不需以斜杠符开头。如果一个路径以斜杠符开头,就明确表示要从文件OS的根目录开始搜索文件。以斜杠符开头的路径叫做绝对(absolute)路径,绝对路径始终指向同一位置,与用户所处的work-dir无关。
$ wc -l /var/log/dmesg
977 dmesg
$ wc -l dmesg
wc:dmesg: No such file or directory
$ cd /var/log
$wc -l dmesg
977 dmesg
hyper-txt也有类似概念。如果一个文档中的所有链接都是abs-URL,这些链接会指向正确的资源。但,如果文档中包含rel-URL,就需要将文档本身的位置考虑进去了。
假设从一个hyper-txt中提取出一个URL。该URL可能是相对的,也可能是绝对的。此时可将其传递给urljoin(),由urljoin()负责填充剩余信息。
urljoin()的参数顺序,和os.path.join()是一样的。第一个参数是正在乐队的文档的基地址 ,第二个参数是从该文档中提取出的相对URL,有多种方法可以重写基地址的某些部分。
>>> from urllib.parse import urljoin
>>> base = 'http://tools.ietf.org/html/rfc3986'
>>> urljoin(base, 'rfc7320')
'http://tools.ietf.org/html/rfc7320'
>>> urljoin(base, '.')
'http://tools.ietf.org/html/'
>>> urljoin(base, '..')
'http://tools.ietf.org/'
>>> urljoin(base, '/dailydose/')
'http://tools.ietf.org/dailydose/'
>>> urljoin(base, '?version=1.0')
'http://tools.ietf.org/html/rfc3986?version=1.0'
>>> urljoin(base, '#section-5.4')
'http://tools.ietf.org/html/rfc3986#section-5.4'
向urljoin()传入一个绝对地址是绝对安全的。urljoin()会识别出某个地址是否是绝对地址,直接将其返回,不会做任何修改。
>>> urljoin(base, 'https://www.goole.com/search?q=qpod&btnI=yes')
'https://www.goole.com/search?q=qpod&btnI=yes'
由于rel-URL无需指定使用的协议机制,如果编写网页时并不知道要使用HTTP还是HTTPS,使用rel-URL就十分方便了(即使编写网页的静态部分)。这种情况下,urljoin()只会将基地址使用的协议,复制到第二个参数提供的abs-URL中,组成完整的URL,以此作为返回值。
>>> urljoin(base, '//www.google.com/search?q=apod')
'http://www.google.com/search?q=apod'
如果在网站中使用rel-URL,有一点十分重要:一定要注意URL的最后,是否包含一个斜杠。因为,最后包含斜杠与不包含斜杠的rel-URL含义是不同的
>>> urljoin('http://tools.ietf.org/html/rfc3986', 'rfc7320')
'http://tools.ietf.org/html/rfc7320'
>>> urljoin('http://tools.ietf.org/html/rfc3986/','rfc7320')
'http://tools.ietf.org/html/rfc3986/rfc7320'
第一个URL表示,该req是为了显示rfc3986这一文档,而访问该文档的html-dir,此时的“当前work-dir”是html-dir
第二个URL不同。真正的文件OS中,只有dir的结尾会有斜杠,把rfc3986本身看做是正在访问的dir。所以,根据第二个URL构建出来的链接,会直接在"rfc3986/"之后,添加rel-URL参数,而不是直接在html目录下添加。
斜杠对于rel-URL的意义至关重要。
如,要访问上面例子中的第二个URL,那么IETF的web-Serv会检测到最后多加了一个斜杠,它会在res中声明了一个Location-head,给出正确的URL
每个编写过Web-Cli的DEV都会经历:rel-URL不一定相对于HTTP-req中提供的路径,如果web-site选择在res中包含一个Location-head,那么rel-URL必须相对于Location-head中提供的路径。
一些现行的标准,对hyper-txt的格式、使用层级样式表(CSS)确定hyper-txt样式的机制,以及JS等浏览器内嵌语言的API做了描述。其中,JS等浏览器内嵌语言,可在user与页面交互/浏览器从Serv获取更多信息时,对文档进行实时的修改。几个核心标准与资源的链接:
http://www.w3.org/TR/html5/
http://www.w3.org/TR/CSS/
https://developer.mozilla.org/en-US/docs/Web/JavaScript
https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
HTML是一种使用大量尖括号(<...>)来装饰纯文本的机制。每对尖括号都创建了一个标签(tag),如果tag开头没有斜杠,就表示文档中,某个新元素(element)的开始,否则就表示元素的结尾。下面的例子展示了一个简单的段落,该段落中包含了一个加粗的单词,和一个斜体的单词。
<p>This is a paragraph with <b>bold</b> and <i>italic</i> words.</p>
有些DEV会把
写为
,这是从扩展标记语言(XML, Extensible Markup Language)中学习过来的,但HTML中,这并不是必需的。
在HTML中,许多东西都不是必需的。如,不一定要为所有开始标签提供对应的结束标签。当一个用
如果所有元素都使用了相同的<div>标签,该如何使用CSS来合理地设置各元素的样式?如何使用JS来设置用户与各元素的不同交互方式?
为每个元素指定一个class。这样,HTML编写者就可以为各元素提供一个特定的标记,之后就可通过该标记来访问特定的元素了。要使用class,有两种常见的方法。
<div class="weather">
<h5 class="city">Provo</h5>
<p class="temperature">61°F</p>
</div
这样一来,对应的CSS和JS就可以通过.city和.temperature这样的选择器来引用特定的元素了。想要更细粒度一点,可使用h5.city和p.temperature。最简单的形式的CSS选择器,只需要一个标签的名称,后面加上以句点为前缀的class名称即可。两种都不是必须的。
的目的都是唯一的,因此选择只为外层的元素指定class的值。
<div class="weather"><h5>Provo</h5><p>61°F</p></div>
要在CSS/JS中引用该
,就需要使用更复杂的模式了。使用空格来连接外层标签的class值与内层标签的名称。
.weather h5
.weather p
如果从审查元素中找到的某个元素,没有出现在最初的页面源代码中,可能需要进入网络面板,找到JS还获取并使用了哪些资源,来构建这些新增的页面元素。
假设有一个简单的银行app,想要允许账户持有人使用一个Web-app相互发送账单。这个app至少需要一个存储账单的tab、插入新账单的功能,以及获取并显示与当前登录用户账户有关的所有账单的功能。
11-1中展示了一个简单的库,使用了Py标准库内置的SQLite。
# 11-1 用于创建数据库并与数据库进行通信的程序 bank.py
import os, pprint, sqlite3
from collections import namedtuple
def open_database(path='bank.db'):
new = not os.path.exists(path)
db = sqlite3.connect(path)
if new:
c = db.cursor()
c.execute('CREATE TABLE payment (id INTEGER PRIMARY KEY,'
'debit TEXT, credit TEXT, dollars INTEGER, memo TEXT)')
add_payment(db, 'brandon', 'psf', 125, 'Registration for PyCon')
add_payment(db, 'brandon', 'liz', 200, 'Payment for writing that code')
add_payment(db, 'sam', 'brandon', 25, 'Gas money-thanks for the ride!')
db.commit()
return db
def add_payment(db, debit, credit, dollars, memo):
db.cursor().execute('INSERT INTO payment (debit, credit, dollars, memo)'
' VALUES (?, ?, ?, ?)', (debit, credit, dollars, memo))
def get_payments_of(db, account):
c = db.cursor()
c.execute('SELECT * FROM payment WHERE credit = ? or debit = ?'
' ORDER BY id', (account, account))
Row = namedtuple('Row', [tup[0] for tup in c.description])
return [Row(*row) for row in c.fetchall()]
if __name__ == '__main__':
db = open_database()
pprint.pprint(get_payments_of(db, 'brandon'))
O]:
[Row(id=1, debit='brandon', credit='psf', dollars=125, memo='Registration for PyCon'),
Row(id=2, debit='brandon', credit='liz', dollars=200, memo='Payment for writing that code'),
Row(id=3, debit='sam', credit='brandon', dollars=25, memo='Gas money-thanks for the ride!')]
示例中的tab模式极其简单,只是用来满足app运行的最低要求。现实生活中,还需一个user-tab来存储username及pwd的安全散列值、包含官方银行账号的tab。款项最终会从官方银行账号提取,并支付到官方银行账号中。
本例中,一个很重要的操作值得借鉴:SQL调用的所有参数都进行了适当的转义。
程序员在向SQL这样的解释型语言提交一些特殊字符时,有时并没有进行正确的转义。这是现在安全缺陷的主要来源之一。
如果Web前端的一个恶意用户,故意在Memo字段中,包含了一些特殊SQl代码,就会造成很严重的后果。最好的保护方法就是,使用sql自身提供的功能,来正确的引用数据,而不使用自己构建的程序逻辑。
为了完成这一过程,11-1在代码中所有需要插入参数的地方,都向SQLite提供了一个问号(?),而没有自己进行转义/插入参数
(1, 'brandon', 'psf', 125, 'Registration for PyCon')
直接操作这些原始的tuple结果是糟糕的做法。
代码中,“欠款账户”/“已付账款”这样的概念,会以row[2]/row[3]这样的形式来表示,大大降低了可读性。因此,bank.py使用了一个简单的namedtuple类,该类同样支持使用row.credit和row.dollars这样的属性名。
首先应该学习的是app_insecure.py,仔细考虑,该代码是否是糟糕且不可信?会不会导致安全威胁,损害公众的利益?
import bank
from flask import Flask, redirect, request, url_for
from jinja2 import Environment, PackageLoader
app = Flask(__name__)
get = Environment(loader=PackageLoader(__name__, 'templates')).get_template
@app.route('/login', methods=['GET', 'POST'])
def login():
username = request.form.get('username', '')
password = request.form.get('password', '')
if request.method == 'POST':
if (username, password) in [('brandon', 'atigdng'), ('sam', 'xyzzy')]:
response = redirect(url_for('index'))
response.set_cookie('username', username)
return response
return get('login.html').render(username=username)
@app.route('/logout')
def logout():
response = redirect(url_for('login'))
response.set_cookie('username', '')
return response
@app.route('/')
def index():
username = request.cookies.get('username')
if not username:
return redirect(url_for('login'))
payments = bank.get_payments_of(bank.open_database(), username)
return get('index.html').render(payments=payments, username=username,
flash_messages=request.args.getlist('flash'))
def pay():
username = request.cookies.get('username')
if not username:
return redirect(url_for('login'))
account = request.form.get('account', '').strip()
dollars = request.form.get('dollars', '').strip()
memo = request.form.get('memo', '').strip()
complaint = None
if request.method == 'POST':
if account and dollars and dollars.isdigit() and memo:
db = bank.open_database()
bank.add_payment(db, username, account, dollars, memo)
db.commit()
return redirect(url_for('index', flash='Payment successful'))
complaint = ('Dollars must be an integer' if not dollars.isdigit()
else 'Please fill in all three fields')
return get('pay.html').render(complaint=complaint, account=account,
dollars=dollars, memo=memo)
if __name__ == '__main__':
app.debug = True
app.run()
上述代码是危险的,无法抵御现代Web上针对向量的重要攻击。
代码中的弱点,来自于数据处理过程中发生的错误,与网站是否合理进行了TLS防止网络窃听无关。可以假定该网站已经采取了加密保护,如在前端使用了一个rev-prx-Serv。会考虑攻击者在无法获取特定user与app间传递的数据时,所能进行的恶意行为。
1)请求app没有定义的页面时,返回404;
2)从HTML-form中解析数据;
3)使用模板生成HTML文本/重定向到另一个URL,来简化HTTP-res的生成过程。
更多关于Flask的信息,可访问http://flask.pocoo.org/的文档
假设上面的代码是有不熟悉Web的DEV编写的,知道使用模板语言,可以方便向HTML中加入自定义的文本,因此了解加载并运行Jinja2的方法。此外,发现Flask微型框架流行程度仅次于Django,且喜欢Flask能将一个app放在一个单独的文件中这一特性,决定尝试使用Flask。
除了检查user是否已经login外,login后的视图只有两行代码。
但代码中为什么要检查名为'flash'的URL呢(Flask通过request.args-dic来提供URL参数)?
pay()函数中,支付成功后,user会被重定向到index页面,此时user可能需要一些提示,以确认自己提交的form得到了正确的处理。这个功能是通过Web框架的flash-mes来完成的。flash-mes会显示在页面的顶部(这里的flash与Adobe Flash没有任何关系,只是表示user下次访问该页面时,mes会像flash广告一样呈现给user,然后消失)。在该Web-app中的第一个版本中,只是简单地将flash-mes设计为URL中的一个查询str。
http://example.com/?flash=Payment+successful
对于经常阅读Web-app的DEV来说,pay()的剩余部分就很熟悉了:检查form是否成功提交,如果成功,就进行一些操作。
user/浏览器有时可能会提供/漏掉一些form参数,因此很谨慎地在代码中使用request.form-dic的get()进行了处理。如果某个键缺失的话,就返回一个默认值(空字符串‘‘)
如果满足条件,pay()就会将该账单永久添加到DB中;否则,将form返回给user。
如果user已经填写了一些mes,那么上面的代码不会直接将user已经填写的mes丢弃,也不会返回空白的form和err-mes,而是将user已经填写的值传回给模板。这样,在user看到的页面中,就能重新显示他们已经填写过的值了。
11-3所示的模板定义了一个页面框架,其他模板可以向base.html中的几个地方插入页面标题和页面body。标题可使用两次,一次是在<title>元素中,另一次是在<h1>元素中。
11-3 base.html页面的Jinja2模板
<head>
<meta charset="UTF-8">
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet">
<style type="text/css">
</style>
</head>
<body>
<h1>{{ self.title() }}</h1>
{% block body %}{% enblock %}
</body>
</html>
根据Jinja2模板语言的定义,
使用两个大括号在模板中取值,如{{ username }};
使用大括号加上百分号来进行循环,重复生成同样的HTML模式,如 {% for %}
Jinja2的文档:http://jinja.pocoo.org/
11-4展示的登录界面,只包含标题和form两部分。在这段代码中,今后还会遇到很多次的模式---提供了初始value="..."的form元素。屏幕上第一次显示该页面时,初始value的值就会显示在可编辑文本框中。
11-4 login.html的Jinja2模板
{% extends "base.html" %}
{% block title %}Please log in{% endblock %}
{% block body %}
<form method="post">
<label>User: <input name="username" value="{{ username }}"></label>
<label>Password: <input name="password" type="password"></label>
<button type="submit">Log in</button>
</form>
{% endblock %}
如果user输入了错误的密码,该app会重复显示相同的form,让用户重新输入。通过将value="..."的值设置为{{ username }},user重新输入时,可以不用再次输入他们的username。
从11-5中可以看到,URL "/"会映射到index页面,而index.html的模板也比前面几个模板更为复杂。
首先是标题,然后,如果有flash-mes,会直接显示在标题下方。接着是一个标题为Your Payments的无序列表(
11-5 index.html的Jinja2模板
{% extends "base.html" %}
{% block title %}Welcome, {{ username }}{% endblock %}
{% block body %}
{% for message in flash_messages %}
<div class="flash_message">{{ message }}<a href="/">×</a></div>
{% endfor %}
<p>Your Payments</p>
<ul>
{% for p in payments %}
{% set prep = 'from' if (p.credit == username) else 'to' %}
{% set acct = p.debit if (p.credit == username) else p.credit %}
<li class="{{ prep }}">${{ p.dollars }} {{ prep }} <b>{{ acct }}</b>
for: <i>{{ p.memo }}</i></li>
{% endfor %}
</ul>
<a href="/pay">Make payment</a> | <a href="/logout">Log out</a>
{% endblock %}
需注意的是,上面的代码没有在循环显示收入账单和支出账单时,不断显示当前user的username
相反,针对每条账单信息,代码都会根据当前user是credit账户,还是debit账户来输出账单另一方的username
代码使用了正确的动词,来确认该账单是收入账单,还是指出账单。因为Jinja2提供的{% set ...%}命令。有了这条命令,DEV就可在需要时,在模板中进行这种相当简单的计算,来快速动态地决定要显示的信息。
pay.html的Jinja2模板
{% extends "base.html" %}
{% block title %}Make a Payment{% endblock %}
{% block body %}
<form method="post" action="/pay">
{% if complaint %}<span class="complaint">{{ complaint }}</span>{% endif %}
<label>To account: <input name="account" value="{{ account }}"></label>
<label>Dollars: <input name="dollars" value=" {{ dollars }}"></label>
<label>Memo: <input name="memo" value="{{ memo }}"></label>
<button type="submit">Send money</button> | <a href="/">Cancel</a>
</form>
{% endblock %}
在设计网站时,最好每个提交按钮边上,都提供取消功能。实验证明,如果显示取消功能的元素,比默认的表单提交按钮小很多,且没那么显眼,user的操作失误会降到最低---不要把显示取消功能的元素设计成一个按钮。
因此,pay.html将“取消”设计为了一个简单的链接,且使用管道符号(|)将“取消”与提交按钮在视觉上区分开。管道符是现在处理这种情况时,最为流行的方案之一。
运行这个app,进入chapter11-dir,输入:
$ pip install flask
$ python3 app_insecure.py
>python app_insecure.py
* Restarting with stat
* Debugger is active!
* Debugger PIN: 159-992-587
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
如果打开了调试模式,一旦对运行中的代码进行了修改,Flask就会自动重启并载入app,这样就能对代码进行微调时,快速看到修改的效果。
11-3中的base.html用到了style.css,该文件在static/目录下,该目录与app的代码在同一个目录下
HTML-form的默认action是GET,可以只包括一个输入文本框。
<form action="/search">
<label>Search: <input name="q"></label>
<button type="submit">Go</button>
</form>
仅讨论form对于网络的意义。进行GET的form,会把输入的字段直接放到URL中,然后将其作为HTTP-req的路径
GET /search?q=python+network+programming HTTP/1.1
Host: example.com
这意味着,GET的参数是浏览历史的一部分,任何人只要看着浏览器的地址栏,就能看到输入的字段。意味着绝对不能使用GET来传输密码/证书这样的敏感信息。
当填写一个GET-form时,其实就是在指定接下来要访问的地址。最终,浏览器会根据form-mes构造一个URL,指向希望Serv生成的页面。填写前的搜索form中的3个不同的字段,会生成3个独立的页面、浏览器中的3条浏览历史、3个URL。
后期可重新访问这3条浏览历史。如果希望好友也能查看同样的页面,可将这些URL与好友分享。
这与另一种表单(POST、PUT、DELETE的form)大相径庭。对于这些form来说,URL中绝对不会包含任何form-mes,因此form-mes也不会出现在HTTP-req的路径中。
<form method="post" action="/donate">
<label>Charity: <input name="name"></label>
<label>Amount: <input name="dollars"></label>
<button type="submit">Donate</button>
</form>
在提交上面这个HTML-form时,浏览器会把所有数据都放入req-mes-body中,而req路径本身是没有变化的。
POST /donate HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 39
name=PyCon%20scholarships&dollars=35
此例中,并不是因为想要查看一个$35 for PyCon scholarships页面的内容,而req访问该页面。
相反,执行了一个动作。如果进行了两次POST操作,就会造成两倍的执行开销,受到该动作影响的内容,也会被修改两次。因为$35 for PyCon scholarships不是我们想要访问的地址,所以form参数不会被放在URL中。
浏览器在上传大型负载(如某个文件)时,还可使用一种基于MIME标准的form编码multipart/forms。
Web浏览器知道POST-req是一个会造成状态变化的动作,因此在处理POST-req时,是非常小心的。
如果user在浏览一个由POST-req返回的页面时,重新加载网页,浏览器会弹出对话框
11-2访问/pay-form,不填写任何信息就提交表单。浏览器会停留在支付页面,并输出Dollars must be an integer的警告。此时,如果重新加载,会弹出一个对话框
Confirm Form Resubmission(确认重新提交表单)
...
为了防止user在浏览POST返回的页面上,进行重新加载,或在前进、后退操作时,不断收到浏览器弹出的对话框,有两种技术可供网站采用
11-2中的app非常简单,因此user无法从中了解到包含非法mes的POST-form的具体返回细节,但代码也会在/login和/pay-from操作成功时,返回303 See Other。该功能就是由Flask的redirect()提供的。这是所有Web框架中,都应提供的最佳实践。
误用HTTP方法的Web-app会给自动化工具和浏览器带来问题,执行结果也会与user的预期不同
在想要进行“读取”操作时,错用了POST方法,这种错误造成的后果没有那么严重。只会影响可用性,不至于删除所有文件。
如果搜索form错误地使用了POST方法,就无法从浏览器的地址栏中看到真正的查询URL,即通过/search.pl这样的URL来访问我搜索到的那些页面。
这使得每个查询的URL看上去都是相同的,因此无法共享这些搜索,也无法将他们存为书签。此外,当想通过浏览器的前进/后对操作来浏览其他搜索结果时,总是会弹出弹窗,询问是否真的想要重新提交搜索。楼兰器知道POST操作是可能有副作用的。
11-2终端Web-app试图保护user的隐私。user必须先登录,才能通过路径为"/"的GET-req查看账单列表。如果想通过/pay-form的POST-req来进行支付,user必须要先成功登录。
假设一个可以访问该web-site的恶意user所进行的操作。
可先使用自己的账号登录web-site,了解web-site的工作原理。先打开Chrome的调试工具,然后登录web-site,在网络面板中查看req-head与res-headd。
user在login页面提交了username和pwd后,会从res-mes中得到什么内容?
HTTP/1.0 302 FOUND
...
Set-Cookie: username=badguy; Path=/
...
成功登录后,返回给浏览器的res-mes中,会包含一个名为username的cookie,username的值被设置为了badguy。只要后续的req中包含该cookie,那么web-site就一定会认为发送这些req的user已经输入了正确的username和pwd
发送req的Cli可以随意设置这个cookie的值吗?
恶意user可通过设置浏览器的隐私菜单,来尝试伪造cookie,也可使用Py来尝试访问web-site。可使用Requests先看看是否能获取到首页。
没有得到授权的req会被重定向到/login页面
>>> import requests
>>> r = requests.get('http://localhost:5000/')
>>> print(r.url)
http://localhost:5000/login
如果恶意user将cookie的值设置为brandon,而brandon恰好是一个已经登录的user,结果会怎样?
>>> r = requests.get('http://localhost:5000/', cookies={'username': 'brandon'})
>>> print(r.url)
http://localhost:5000/
网站信任它已经设置过的cookie,因此会认为该HTTP-req来自已经登录的user:brandon,进而做出res,返回req的页面。恶意user只需知道账单OS的另一个已登录user的username,就能伪造req,向其他任意user支付了。
>>> r = requests.post('http://localhost:5000/pay',{'account': 'hacker', 'dollars': 100, 'memo': 'Auto-pay'}, cookies={'username': 'brandon'})
>>> print(r.url)
http://localhost:5000/?flash=Payment+successful
伪造成功,已经从brandon的账户中,支付了$100到恶意user控制的账户中。
这个例子中,学到了宝贵的一课:在设计cookie时,一定要保证user不能自己构造cookie。
假设user非常聪明,能了解我们用于混淆user的一些手段:Base64编码、交换字母的顺序/使用常量掩码进行简单的异或操作。
此时,要保证cookie无法被伪造,有3中安全的方法:
这个示例app中,可利用Flask的内置功能,对cookie进行数字签名,这样就没办法伪造cookie了。部署了真实生产环境的Serv上,需将签名密钥和源代码保存在不同的地方。
该例中,直接在源代码文件中的顶部给出了签名密钥。如果直接在生产OS的源代码中包含签名密钥,任何能访问版本控制系统的人,都可得到密钥,在DEV机上和DEVOPS过程中都能获取到证书。
app.secret_key = 'saiGeij8AiS2ahleahMo5dahveixuV3J'
有了签名密钥后,Flask就会通过session对象来使用该密钥,设置cookie
session[‘username‘] = username
session[‘csrf_token‘] uuid.uuid4().hex
收到req并提取出cookie后,Flask会先检查签名密钥,确认密钥正确后,才会信任此次req。
如果cookie的签名不正确,就认为该cookie是伪造的,尽管req中提供了cookie,但该cookie无效
username = session.get('username')
许多web-site在登录时,都会使用HTTPS来安全地传输cookie。登录成功后,浏览器才会直接使用HTTP从同一host处获取所有CSS、JS、图片,cookie只在使用HTTP时是暴露出来的。
为防止暴露出cookie的情况发生,需要了解选择的Web框架在将cookie发送至浏览器时,是如何设置Secure参数的。正确设置了Secure参数后,就绝不会在非加密的req中包含cookie了。
这样一来,即使很多人可以查看非加密req的内容,他们也无法从中得到cookie的内容。
如果恶意user无法获取/伪造cookie,就无法通过浏览器伪装成另一个user来执行操作。
如果他们能控制另一个已登录user的浏览器,他们甚至不需查看cookie,只要通过该浏览器来发送req,req中就自动包含正确的cookie。
要使用这一类型的攻击,至少有3个注明的方法可选。11-2中的Serv无法抵御这3种方法发起的攻击。
假设攻击者想向他们的一个账户支付110美元,他们可能会编写11-7所示的JS脚本
# 11-7 用于支付的attack.js脚本
<script>
var x = new XMLHttpRequest();
x.open('POST', 'http://localhost:5000/pay');
x.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
x.send('account=hacker&dollars=110&memo=Theft');
</script>
user在成功登录账单app后,如果页面中包含这段代码,那么代码汇总描述的POST-req就会自动发送,并以受害者user的身份支付账单。因为user无法在最终生成的网页上看到<script>标记内的代码,所以除非通过Ctrl+U查看了源代码,否则甚至不知道已经被盗用了身份,支付了账单。
攻击者如何将这段包含JS脚本的HTML植入页面中呢?
代码直接将flash参数插入到了/页面的页面模板中,攻击者可以直接通过flash参数来注入这段HTML。
11-2的作者并不知道Jinja2-form没有自动对特殊字符(如<和>)进行转义。
原因在于,只要不明确说明,Jinja2就没有办法知道我们在使用这些特殊字符来构造HTML
攻击者在构造URL时,可在flash参数中包含他们的脚本
>>> from urllib.parse import urlencode
>>> with open('E:\J\2018.11.8_heima\test_web_python3\chapter11\attack.js') as f:
... query = {'flash': f.read().strip().replace('\n', ' ')}
>>> print('http://localhost:5000/?' + urlencode(query))
http://localhost:5000/?flash=%3Cscript%3E+var+x+%3D+new+XMLHttpRequest%28%29%3B+x.open%28%27POST%27%2C+%27http%3A%2F%2Flocalhost%3A5000%2Fpay%27%29%3B+x.setRequestHeader%28%27Content-Type%27%2C+%27application%2Fx-www-form-urlencoded%27%29%3B+x.send%28%27account%3Dhacker%26dollars%3D110%26memo%3DTheft%27%29%3B+%3C%2Fscript%3E
最后,攻击者需要编造出一个出口,诱使user看到并单击指向上述URL链接。
如果只想攻击一个特定的用户,那么这点还是很有难度的。攻击者需要编造一封发自user的某个好友的email,然后将链接隐藏在email中user可能向单击的文本。要完成这一工作,需要进行大量研究。攻击者可登陆到user聊天的某个IRC频道,声称该链接是一篇与user刚发表的观点有关的“文章”。这种情况下,如果user看到了完整的链接,很可能会对链接产生怀疑,因此攻击者经常会分享一个短链接。只有被user点击后,该短链接才会拓展成包含跨站脚本的原始完整链接。
如果并不想攻击某个特定的user,而面向的是一个大型的web-site,该web-site有大量user,那么攻击者就不需为每个user专门设计攻击方案了。成千上万的收到嵌入恶意链接的user中,可能很少一部分登录了支付OS并点击了恶意链接,但这已经足以为攻击者带来收入了。
现代的Chrome已经可以发现并阻止该攻击了。
如果攻击者无法在一个又长又丑的URL中设置flash-mes,就必须通过一些别的方法来注入JS脚本。可能会注意到用于显示账单mes的Memo字段,
可将什么样的字符输入到Memo字段中去呢?
要在页面上显示精心设计的Memo,比直接将Memo嵌入URL中会复杂些。且攻击者可以直接将URL匿名提供给user。要在页面上显示Memo,就必须先使用虚假的个人信息注册网站/盗用另一个user的账户,向受害者进行一次支付,且在Memo字段中,包含<script>元素及11-7中的JS脚本。
点击提交按钮,登出,再以brandon身份登录,重新载入页面,brandon的每次重新访问首页时,都会从自己的账户中支出一笔账单。
这就是持久型(persistent)的跨站脚本攻击。可从上面的脚本中看到,这种攻击的威力是很大的。
在非持久型跨站脚本攻击中,只有user点击了URL才会进行攻击;而持久型跨站脚本攻击中,只要受害者访问了网站,JS脚本就会不断隐式运行,直到Serv上的数据全部被清空位置。当攻击者通过有漏洞的web-site上的公共form-mes发起XSS攻击时,成千上万的user都会收到影响,直到网站漏洞修复为止。
11-2之所以无法抵御这一类型的攻击,是因为使用了Jinja2的DEV没有真正理解Jinja2的使用方法。Jinja2的文档明确说明:它并不会自动进行任何转义。只有打开了转义功能,Jinja2才会对<和>这些HTML的特殊字符进行保护
11-8通过Flask的render_template()函数来调用Jinja2。只要render_template()参数中的模板文件后缀为html,就会自动打开HTML转义功能,就能抵御所有XSS攻击。
使用Web框架的通用模式,而不要自己重新造轮子,就可避免一些因粗心的设计失误而影响app的安全性
攻击者还有一招:既然已经没必要从web-site提交form了,那么就可以尝试从另一个完全不同的web-site提交form。可以事先弄明白所有form字段的意义,从可能访问过的任何web-site发送一个/pay-form-req。
唯一需要做的,就是诱使我们访问一个隐藏了恶意JS的web-site。如果发现了我们使用过的某个web-site论坛,没有对帖子的评论进行合适的转义/没有将评论中的script标记删除,也可将JS脚本嵌入到论坛帖子的评论中。
由于user的浏览器可能启用了JS,攻击者可以直接把11-7中的<script>元素插入到user要载入的页面、论坛帖子、评论中。就可坐等受害者的钱流入他们的账户中了。
也就是说,要抵御这种攻击,我们访问的所有web-site都必须是安全的。
app要如何进行防御呢?
答案就是,增加构造及提交form的难度。除了要完成字符必须填写的字段外,form还需要一个额外的字段,其中包含只对form的合法user/合法user的浏览器可见的私钥,user无法在浏览器中获取该私钥/使用form来获取该私钥。这样一来,由于攻击者并不知道/pay-form的隐藏字段信息,因此也就无法伪造出Serv信任的POST-req。
为了抵御CSRF,11-8页利用了这一功能。这个例子中,假设支付web-site在现实生活中,使用了HTTPS,以保证网页/cookie中的私钥,在传输过程中无法被窃取。
在决定为每个user-session分配一个随机私钥后,支付web-site就可把该私钥添加到所有user都可访问的/pay-form中,且将其隐藏。隐藏的form属性是HTML的一个内置特性,该特性的目的之一就是抵御CSRF攻击。
将下面的字段添加到pay2.html的form中,且在11-8中使用pay2.html来代替11-6中的pay.html
<input name="csrf_token" type="hidden" value="{{ csrf_token }}">
现在,每次提交form时,都会先检查form中的CSRF值,是否与合法user可见的HTML的form中一致。如果两者不一致,web-site就会认为有攻击者正在试图伪装成另一个user,会拒绝form-req,返回403 Forbidden
现实中,应使用Web框架内置的功能/扩展来提供CSRF保护。Flask最流行的Flask-WTF库(一个用于构建于解析HTML-form的库)内置的CSRF保护功能。
11-8的名称是app_improved.py,而不是app_perfect.py/app_secure.py。想要证明一个app完全没有安全漏洞是极其困难的。
# 11-8 改进的支付应用程序app_improved.py
import bank, uuid
from flask import (Flask, abort, flash, get_flashed_messages,
redirect, render_template, request, session, url_for)
app = Flask(__name__)
app.secret_key = 'saiGeij8AiS2ahleahMo5dhveixuV3J'
@app.route('/login', methods=['GET', 'POST'])
def login():
username = request.form.get('username', '')
password = request.form.get('password', '')
if request.method == 'POST':
if (username, password) in [('brandon', 'atigdng'), ('sam', 'xyzzy')]:
session['username'] = username
session['csrf_token'] = uuid.uuid4().hex
return redirect(url_for('index'))
return render_template('login.html', username=username)
@app.route('/logout')
def logout():
session.pop('username', None)
return redirect(url_for('login'))
@app.route('/')
def index():
username = session.get('username')
if not username:
return redirect(url_for('login'))
payments = bank.get_payments_of(bank.open_database(), username)
return render_template('index.html', payments=payments, username=username,
flash_messages=get_flashed_messages())
@app.route('/pay', methods=['GET', 'POST'])
def pay():
username = session.get('username')
if not username:
return redirect(url_for('login'))
account = request.form.get('account', '').strip()
dollars = request.form.get('dollars', '').strip()
memo = request.form.get('memo', '').strip()
complaint = None
if request.method == 'POST':
if request.form.get('csrf_token') != session['csrf_token']:
abort(403)
if account and dollars and dollars.isdigit() and memo:
db = bank.open_database()
bank.add_payment(db, username, account, dollars, memo)
db.commit()
flash('Payment successful')
return redirect(url_for('index'))
complaint = ('Dollars must be an integer' if not dollars.isdigit()
else 'Please fill in all three fields')
return render_template('pay2.html', complaint=complaint, account=account,
dollars=dollars, memo=memo,
csrf_token=session['csrf_token'])
if __name__ == '__main__':
app.debug = True
app.run()
首先,在模板中进行合适的转义。然后用内部存储来存储flash-mes,没有通过user的浏览器来发送flash-mes。在user填写的每个form中都包含一个隐藏的随机UUID,防止form被伪造。
需要注意的是,两个主要的改进都是通过使用Flask内置的标准机制代替自己设计的代码来完成。第一是使用内部存储来flash-mes,第二是启用Jinja2对特殊字符的转义功能。
说明了重要的一点。很多情况下,Web框架提供的这些能不知不觉地解决许多安全问题和性能问题。
现在,这个app在进行网络交互操作时的自动化程度已经很高了。但在处理视图和表单时,还需要进行不少手动操作。
Django是一个“全栈式”的Web框架,提供了一个新手程序员需要的所有功能。有一套自己的模板OS和URL路由框架,提供了与DB的交互功能,且以Py对象的形式来生成DB查询结果。此外,使用Django时,不需使用任何三方库就能构造并解析form。
如果使用的是一些更灵活的框架,程序员就需自己寻找ORM库和form操作库,可能还不太清楚应该如何将这些库与web框架结合使用。
代码中有一些模板文件,不需对其进行详细描述
1)manage.py: 这是一个在chapter11/目录下的可执行文件,可通过该文件运行Django命令,在DEV模式下设置并启动app。
2)djbank/init.py:这是一个空文件,表示djbank是一个Py包,可从中载入模块
3)djbank/settings.py: 文件中包含了app使用的插件,以及关于app加载和运行方式的配置。只对Django1.7的默认settings.py做了一处修改:在最后一行中,将Django的静态文件目录设置为chapter11/static/。这样,Django-app就能和11-2及11-8使用同一个style.css文件了
4)djbank/templates/*.html: 11-3至11-6都使用了Jinja2作为模板语言,Django的模板语言使用起来没有Jinja2那么方便,功能也不如Jinja2强大,因此页面模板的抽象层次要低一些。
5)djbank/wsgi.py: 该文件提供了一个WSGI可调用对象,兼容WSGI的Web-Serv(Gunicorn/Apache),可调用该对象来启动并运行账单app
11-9描述了一个DB-tab,代码在一个声明四的Py类中列出了DB-form的字段。在进行SQL查询时,Django会使用该类来返回tab中的row,除了声明字段的类型外,还可在参数中指定更为复杂的验证逻辑,保证数据符合所设定的限制。
# Django应用程序的/chapter11/djbank/models.py
from django.db import models
from django.forms import ModelForm
class Payment(models.Model):
debit = models.CharField(max_length=200)
credit = models.CharField(max_length=200, verbose_name='To account')
dollars = models.PositiveIntegerField()
memo = models.CharField(max_length=200)
class PaymentForm(ModelForm):
class Meta:
model = Payment
fields = ['credit', 'dollars', 'memo']
下面的类声明表示一个用于创建和编辑DB-row的form。user只需填写列出的3个字段即可。程序会使用当前登录用户的username自动填充debit字段。这个类可以与Web-app的user进行双向交互。可根据form类mes生成HTML的<input>字段,也可反过来在form提交后解析出HTTP POST的数据,然后创建/修改Payment-DB-row。
如果使用的是Flask这样的微框架,就必须自己选择一个外部库来支持这样的操作。如,SQLAlchemy就是一个很有名的ORM。许多程序员不使用Django,就是想使用SQLAcademy这个强大而优雅的ORM
在Flask中,程序员使用flask风格的装饰器将URL路径添加到视图函数中;
在Django中,app-DEV需要创建一个urls.py文件。如11-10所示。虽然这种做法减少了在单独阅读视图函数时,所能获得的语义mes,但这也使得视图和URL分发功能独立,且能集中管理URL空间。
# 11-10 Django应用程序的/djbank/urls.py
from django.conf.urls import include, url
from django.contrib import admin
from django.contrib.auth.views import auth_login
urlpatterns = [url(r'^admin/', include(admin.site.site.urls)),
url(r'^account/login/$', auth_login),
url(r'^$', 'djbank.views.index_view', name='index'),
url(r'^pay/$', 'djbank.views.pay_view', name='pay'),
url(r'^logput/$', 'djbank.views.logput_view'),
]
上面这些模式与之前的Flask-app中表示的URL空间只有一处不同:登录页面的路径遵循了Django认证模块的约定。使用Django时不需自己编写登录页面,标准的Django登录页面已经完成了这一功能,因此不需担心如何正确地去除登录页面的任何安全缺陷。
11-11展示了这个Django-app的视图。比起Flask的app,Django的视图既简单,又复杂。
# Django应用程序的/djbank/views.py
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth import logout
from django.db.models import Q
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods, require_safe
from .models import Payment, PaymentForm
def make_payment_views(payments, username):
for p in payments:
yield {'dollars': p.dollars, 'memo': p.memo,
'prep': 'to' if (p.debit == username) else 'from',
'accout': p.credit if (p.debit == username) else p.debit}
@require_http_methods(['GET'])
@login_required
def index_view(request):
username = request.user.username
payments = Payment.objects.filter(Q(credit=username) | Q(debit=username))
payment_views = make_payment_views(payments, username)
return render(request, 'index.html', {'payments': payment_views})
@require_http_methods(['GET', 'POST'])
@login_required
def pay_view(request):
form = PaymentForm(request.POST or None)
if form.is_valid():
payment = form.save(commit=False)
payment.debit = request.user.username
payment.save()
messages.add_message(request, messages.INFO, 'Payment successful.')
return redirect('/')
return render(request, 'pay.html', {'form': form})
@require_http_methods(['GET'])
def logout_view(request):
logout(request)
return redirect('/')
当运行manage.py startapp名令来构建这个Django-app的框架时,Django就会自动在settings.py中设置跨站脚本攻击保护。
完全不需了解CSRF保护,因为如果没有在form模板中添加{% csrf_token%}的话,是无法成功提交form的,Django会在runserver-DEV模式下输出err-mes,告诉DEV需要在form中添加{% csrf_token %}。这一功能对不太了解这些安全问题的Web-DEV来说很有用:Django在默认配置下,就能防御由常见的form/字段err引起的安全威胁,微框架很少能提供这一功能。
11-11利用Django内置的功能完成了几乎所有的工作,因此这个app的view在概念上要比Flask-app-view简单的多。程序员不需自己去实现登录和会话操作这样的功能,因为urls.py中直接使用了Django的登录页面,所以view中甚至不包含登录页面。
登出页面可以直接调用logout()实现,无需了解具体的工作原理。如果某个view要求user事先登录,那么程序员可以直接使用@login_require进行标记。
在Django-app中与DB进行交互要简单得多。已经完全不需要在bank.py中进行SQL操作了。Django会自动建立一个SQLite-DB(这是settings.py中的默认设置),当代码查询models.py中的模型类时,Django会打开一个DB-session。虽然没有在代码中要求Django打开DB事务,但调用save()来保存一个新账单时,Django也会自动调用COMMIT。
在主页中显示的账单mes现在是在Py代码中编写的,而这本来应该是在模板中编写的。因为使用Django的模板OS来表达这一逻辑并不容易。在Py中处理这一逻辑就简单多了:index()会试图调用一个生成器,该生成器会为每个账单mes生成一个字典,然后把原始对象转换成模板能识别的值。
从长远看,11-11中的代码从长远来看还是最优的。因为,为make_payment_views()这样的函数编写测试用例,要比测试模板内的逻辑简单得多。
要运行Django-app,先下载源码,然后执行下面3条命令:
$ python manage.py syncdb
$ python manage.py loaddata start
$ python manage.py runserver
成功运行后,就可访问http:://localhost:8000/,会发现Django开发出的app与Flask编写的几乎是一样的。
使用了JS的web-site通常需要实时更新网页的内容。
假设在浏览自己的Twitter首页,此时关注的好友发表了一条新的Twitter,那么正在浏览的页面就会实时刷新。
这个过程中,浏览器不会每秒都向Serv进行轮询,检测是否有新Twitter。Websocket协议(RFC 6455)就是用来解决这个“长轮询问题”的最有力解决方案。
HTTP中,Cli首先会发送一个req,然后等Serv进行res。只有Serv完成了res,Cli才能发送下一个req。但,如果将socket切换到WebSocket模式,就可同时向两个方向发送mes了。
Cli可以在user与页面交互时,向Serv发送实时更新,Serv也可在,从其他地方收到更新时,向Cli同步更新mes。
进行WebSocket编程时,一般需要进行大量的前端JS与Serv代码间的交互操作。
tornado.websocket模块中包含了一段Py和JS代码,通过一对对称的回调函数进行交互。
1)查询/操作时直接将数据添加到URL,然后对该URL地址进行GET-req;
2)在操作时向Serv发送POST-req,将数据放在req-body中传输
1)第一类是抓取整个页面。在需要下载大量数据时会这么做。首先可能需要先登录web-site,获取到所需的cookie,然后不断进行GET操作。在使用这些GET操作下载页面时,可能需要通过另一些GET操作来访问页面中的链接。与搜索引擎使用的“爬虫”类似
2)第二种类型是针对一到两个页面的特定部分进行抓取,而不是抓取整个web-site。有时可能只想获取某个特定页面上的某部分数据,
如希望在shell中输出从某个天气预报页面抓取的温度;自动化进行一些本来需要在浏览器中进行的操作,通过客户支付/列出昨天的信用卡交易记录检查账户是否被盗用。
进行此类抓取时,需要在点击量、form和认证的问题上多加小心。web-site可能会使用网页内的JS来阻止尝试非法访问账户mes的自动化脚本,除了Py本身外,还需要一个全功能的浏览器来查看JS。
Py中查看Web页面的内容,可用下面3类方法来获取Web页面:
在得到了要访问的URL后,根据列出的URL,依次发送HTTP-req,然后保存/查看得到的内容。只有在无法事先获取需要访问的URL时,问题才会变得复杂。此时需要在抓取过程中动态获取要访问的URL,且必须记录曾经访问果的URL,以防重复访问已经访问过的URL/发生死循环。
11-12是一个并不复杂,但有特定目标的抓取app。用于登录账单app,然后获取user已经赚取的收入。在运行前,先要在窗口中运行账单app
$ python app_improvied.py
# 登录账单系统并计算收入
import argparse, bs4, lxml.html, requests
from selenium import webdriver
from urllib.parse import urljoin
ROW = '{:>12} {}'
def download_page_with_requests(base):
session = requests.Session()
response = session.post(urljoin(base, '/login'),
{'username': 'brandon', 'password': 'atigdng'})
assert response.url == urljoin(base, '/')
return response.text
def download_page_with_selenium(base):
browser = webdriver.Firefox()
browser.get(base)
assert browser.current_url == urljoin(base, '/login')
css = browser.find_element_by_css_selector
css('input[name="username"]').send_keys('brandon')
css('input[name="password"]').send_keys('atigdng')
css('input[name="password"]').submit()
assert browser.current_url == urljoin(base, '/')
return browser.page_source
def scrape_with_soup(text):
soup = bs4.BeautifulSoup(text)
total = 0
for li in soup.find_all('li', 'to'):
dollars = int(li.get_text().split()[0].lstrip('$'))
memo = li.find('i').get_text()
total += dollars
print(ROW.format(dollars, memo))
print(ROW.format('-' * 8, '-' * 30))
print(ROW.format(total, 'Total payments made'))
def scrape_with_lxml(text):
root = lxml.html.document_fromstring(text)
total = 0
for li in root.cssselect('li.to'):
dollars = int(li.text_content().split()[0].lstrip('$'))
memo = li.cssselect('i')[0].text_content()
total += dollars
print(ROW.format(dollars, memo))
print(ROW.format('-' * 8, '-' * 30))
print(ROW.format(total, 'Total payments made'))
def main():
parser = argparse.ArgumentParser(description='scrape our payments site.')
parser.add_argument('url', help='the URL at which to begin')
parser.add_argument('-l', action='store_true', help='scrape using lxml')
parser.add_argument('-s', action='store_true', help='get with selenium')
args = parser.parse_args()
if args.s:
text = download_page_with_selenium(args.url)
else:
text = download_page_with_requests(args.url)
if args.l:
scrape_with_lxml(text)
else:
scrape_with_soup(text)
if __name__ == '__main__':
main()
$ python mscrape.py http://127.0.0.1:5000/
The code that caused this warning is on line 32 of the file mscrape.py. To get rid of this warning, pass the additional argumen
t 'features="lxml"' to the BeautifulSoup constructor.
soup = bs4.BeautifulSoup(text)
125 Registration for PyCon
200 Payment for writing that code
-------- ------------------------------
325 Total payments made
默认模式下运行后。mscrape.py会先使用Requests库通过form登录网站。然后,Session对象中就会存储抓取页面所需的cookie。上面的脚本会解析页面,获取所有class为to的列表项,使用print()调用打印出账单信息,计算账单之和。
提供了-s选项,mscrape.py就能检测到系统上安装的Firefox,就能运行完整版本的Firefox来访问web-site
$ python mscrape.py -s http:127.0.0.1:5000/
两种方法的不同之处,使用Requests编写代码时,需要自己打开网站,了解登录form的结构,然后根据了解到的内容填写用于登录的post()方法。如果网站的登录form有所变化,代码将会一无所知。代码中硬编码了“username”和“password”,这两个输入名将来是有可能会发生变化的。
Requests并没有打开登录页面,也没有访问form。只是假设已经存在登录页面,但是却绕过了对该页面的访问,直接通过登录页面中的form来发送POST-req
由于Selenium就是通过直接操作Firefox来进行登录,即使form使用了私钥签名/特殊的JS代码发送POST-req,也能成功登录。
当web-site返回CSV、JSON等数据,可以使用标准库模块/三方库来解析数据,并进行相应的处理。
如果返回的mes是原始HTML,该怎么办?
使用实时审查元素功能也存在一个问题,我们看到的文档可能已经被网页内运行的JS修改了,与原始HTML并不相同。
要查看这样的页面,有两种方法:
11-12中使用的解析库内置了这样的功能。成功创建了soup对象后,就可使用如下语句打印HTML的元素了,包含合适的缩进:
print(soup.prettify())
O]
<html>
<head>
<title>
Welcome, brandon
</title>
<link href="/static/style.css" rel="stylesheet" type="text/css"/>
</head>
<body>
<h1>
Welcome, brandon
</h1>
<p>
Your Payments
</p>
<ul>
<li class="to">
$125 to
<b>
psf
</b>
for:
<i>
Registration for PyCon
</i>
</li>
<li class="to">
$200 to
<b>
liz
</b>
for:
<i>
Payment for writing that code
</i>
</li>
<li class="from">
$25 from
<b>
sam
</b>
for:
<i>
Gas money-thanks for the ride!
</i>
</li>
</ul>
<a href="/pay">
Make payment
</a>
|
<a href="/logout">
Log out
</a>
</body>
</html>
如果要显示lxml文档树,步骤会复杂些:
from lxml import etree
O]
<html>
<head>
<title>Welcome, brandon</title>
<link rel="stylesheet" type="text/css" href="/static/style.css"/>
</head>
<body>
<h1>Welcome, brandon</h1>
<p>Your Payments</p>
<ul>
<li class="to">$125 to <b>psf</b>
for: <i>Registration for PyCon</i></li>
<li class="to">$200 to <b>liz</b>
for: <i>Payment for writing that code</i></li>
<li class="from">$25 from <b>sam</b>
for: <i>Gas money-thanks for the ride!</i></li>
</ul>
<a href="/pay">Make payment</a> | <a href="/logout">Log out</a>
</body>
</html>
无论上述那种情况,打印出的结果,阅读起来都会比原始HTML要容易得多。
1)使用所选择的库来解析HTML。许多HTML都存在错误,但浏览器通常会试图修复这些问题。
2)使用选择器(selector)来深入选择文档中的元素。selector提供了一些文本模式,能够自动选择我们需要的元素。尽管可以自己动手选择,花很多时间不停地查看元素的层级结构,但使用选择器就要快得多了。此外,使用selector编写的Py代码可读性会更高
3)获取所需元素的文本及属性值。然后就能使用普通的Py-str及str方法来处理数据。
11-12中两个不同的库就分别执行了上述3个步骤:
python mscrape.py -l http://127.0.0.1:5000/
125 Registration for PyCon
200 Payment for writing that code
-------- ------------------------------
325 Total payments made
基本的操作步骤与使用Beautiful Soup是一样的。从文档顶层开始,使用cssselect()方法搜索需要的元素,进一步搜索获取这些元素/元素包含的文本,最后进行解析并显示。
lxml除了比BS速度更快,还提供了许多选择元素的方法:
1)在cssselect()中支持CSS模式。使用class搜索元素时,可使用class="x"的形式来指定元素属于class x,还可使用class="x y"/class="w x",这一点在使用class来搜索元素时,尤为重要
2)它的xpath()方法支持XPath表达式,受到XML爱好者的喜爱。如,可使用".//p"来获取所有段落。XPath表达式可以以‘.../text()‘结尾,来直接获取元素内的文本,而不是获取一个Py对象,因此就不需要再通过Py对象来获取文本了。
3)在find()和findall()方法中,原生支持了部分高效率版本的XPath操作。
网页在元素内包含了描述账单mes的字段,但每一行开始的$数并没有包含在元素内。无论使用上述哪个库,都需要进行一些手动操作。
我们需要的某些mes就在页面的元素内,因此很容易获取,但另一些信息却在其他文本内,因此需要传统的Py-str方法(如split()和strip())来将它们从上下文中提取出来。
/chapter11/tinysite/中包含了一个小型的静态网站,做了一些设计,使得spyder难以获取该web-site的所有页面。
$ cd py3/chapter11/tinysite
$ python -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
查看页面的源码,然后使用浏览器的Web调试工具,可发现http://127.0.0.1:8000/处的首页中没有显示所有链接。
1)其实,页面的原始HTML中只以href=""标记的形式显示了两个链接(page1、2)
2)还有两个页面的链接,藏在一个搜索form后面,只有点击提交按钮后,才能看到这两个链接。
运行了一小段动态JS代码后,最后两个链接(page5和page6)就会出现在屏幕底部。模拟了web-site的真实行为:
先快速向user展示页面的框架,然后向Serv查询,返回user需要的数据。
这种情况下,需要对整个web-site/一部分包含的URL进行全面的递归搜索。此时可能需要寻找一个网络抓取引擎来完成。
11-13中,会简单学习递归抓取背后的真正原理。该代码中需要使用lxml
# 11-13 进行GET操作的简单递归网络抓取器 rscrape1.py
import argparse, requests
from urllib.parse import urljoin, urlsplit
from lxml import etree
def GET(url):
response = requests.get(url)
if response.headers.get('Content-Type', '').split(';')[0] != 'text/html':
return
text = response.text
try:
html = etree.HTML(text)
except Exception as e:
print('{}: {}'.format(e.__class__.__name__, e))
return
links = html.findall('.//a[@href]')
for link in links:
yield GET, urljoin(url, link.attrib['href'])
def scrape(start, url_filter):
further_work = {start}
already_seen = {start}
while further_work:
call_tuple = further_work.pop()
function, url, *etc = call_tuple
print(function.__name__, url, *etc)
for call_tuple in function(url, *etc):
if call_tuple in already_seen:
continue
already_seen.add(call_tuple)
function, url, *etc = call_tuple
if not url_filter(url):
continue
further_work.add(call_tuple)
def main(GET):
parser = argparse.ArgumentParser(description='Scrape a simple site.')
parser.add_argument('url', help='the URL at which to begin')
start_url = parser.parse_args().url
starting_netloc = urlsplit(start_url).netloc
url_filter = (lambda url: urlsplit(url).netloc == starting_netloc)
scrape((GET, start_url), url_filter)
if __name__ == '__main__':
main(GET)
除了启动和读取命令行参数外,11-13只进行了两个主要的操作。
如果下载的内容是HTML,就尝试进行解析,只有完成了这些步骤后,GET()才会获取<a>标记的href=""属性,来得到当前页面包含的链接所指向的页面。
由于这些链接都使用了相对URL,所以GET()调用了urljoin()来提供URL的基地址。
抓取引擎本身只需记录已经触发过哪些函数和URL的组合,就可防止重复访问页面中多次出现的URL。维护了一个已经访问过的URL集合和一个尚未访问过的URL集合,不断循环,直至后者为空为止。
>python rscrape1.py http://127.0.0.1:8000/
GET http://127.0.0.1:8000/
GET http://127.0.0.1:8000/page2.html
GET http://127.0.0.1:8000/page1.html
如果要获取更多链接,该抓取器还需做另外两件事:
1)需要在一个真正的浏览器中载入HTML,这样才能通过运行JS来载入页面的剩余部分
2)要能通过点击搜索form的提交按钮,来访问隐藏在form后的链接。除了GET()操作外,还需进行POST操作
11-13中的抓取器与其调用的函数间并没有紧耦合,因此11-14可直接重用该抓取器,可调用任何传递给它的方法。
#11-14 使用Selenium递归抓取网站
from urllib.parse import urljoin
from rscrape1 import main
from selenium import webdriver
class WebdriverVisitor:
def __init__(self):
self.browser = webdriver.Firefox()
def GET(self, url):
self.browser.get(url)
yield from self.parse()
if self.browser.find_elements_by_xpath('.//form'):
yield self.submit_form, url
def parse(self):
# (Could alse parse page.source with lxml yourself, as in scraper1.py)
url = self.browser.current_url
links = self.browser.find_elements_by_xpath('.//a[@href]')
for link in links:
yield self.GET, urljoin(url, link.get_attribute('href'))
def submit_form(self, url):
self.browser.get(url)
self.browser.find_element_by_xpath('.//form').submit()
yield from self.parse()
if __name__ == '__main__':
main(WebdriverVisitor().GET)
因为创建Selenium实例的代价是相当昂贵的(需要启动Firefox),所以希望不在每次需要获取URL的时候,都调用Firefox()方法。
submit_form()是11-14与11-13真正的不同之处。当使用GET()方法发现页面上的搜索表单时,会向抓取引擎返回一个元组。除了为每个发现的链接生成元组外,还会生成一个元组,用于载入页面并点击搜索form的提交按钮。这样,11-14能比11-13抓取到更深层的内容。
>python rscrape2.py http://127.0.0.1:8000/
GET http://127.0.0.1:8000/
GET http://127.0.0.1:8000/page1.html
GET http://127.0.0.1:8000/page6.html
GET http://127.0.0.1:8000/page5.html
submit_form http://127.0.0.1:8000/
GET http://127.0.0.1:8000/page2.html
该抓取器能找到包括JS动态加载/form提交后,才能获取的URL在内的所有链接。通过这些强大的技术,完全可以使用Py来实现与web-site间的自动化交互。
user可直接点击超链接来访问它所指向的页面。Py标准库也提供了用于解析、构造URL的方法。此外,还可使用标准库提供的功能,根据页面的基URL地址将相对URL转化为绝对URL
应该使用DB本身提供的功能来引用由Web外部传递来的不可信mes。也可在Py中使用DB-API 2.0和任何ORM来正确地引用不可信mes
如果使用简单的Web框架,就需自己选择模板语言、ORM/其他持久层方案。全栈式的框架则内置了工具来提供这些功能。
无论选择哪种框架,都可在自己的代码中支持静态URL及/person/123/这样包含可变组件的URL。这些框架同样会提供生成与返回模板的方法,以及返回重定向mes/HTTP错误的功能。
在代码中涉及与外部网络的API时,一定要考虑跨站脚本攻击、跨站请求伪造(CSRF)、对user隐私攻击的可能性。在编写会从URL路径、URL查询str、POST-req/文件上传等途径接收数据的代码前,一定要彻底理解这些安全威胁。
像Django这样的全栈式解决方案,鼓励user全部使用它所提供的工具,它会为user提供一个很不错的默认配置(如自动提供form的CSRF保护);
Flask/Bottle这样的轻量级框架要求自己选择其他工具,相互结合,形成最终的解决方案。
此时需要理解所有用到的组件。如,如果选择使用Flask来开发app,但却不知道要提供CSRF保护,那么最后开发出的app就无法抵御CSRF攻击了。
随着asyncio的出现,类似于Tornado的方法变得通用。和WSGI为mutil-thd-Web框架提供的支持是类似的。
在Py中,有很多方法可获取和解析页面。Requests和Selenium是最流行的用来获取页面的库,而Beautiful Soup和lxml是解析页面时最喜欢使用的方案。
原文:https://www.cnblogs.com/wangxue533/p/12237167.html