读书笔记_python网络编程3_(11)

11. 万维网

超文本传输协议(HTTP, Hypertext Transfer Protocol)是一种通用机制,Cli使用HTTP向Serv,req文档,而Serv则通过HTTP向Cli提供文档。

11.0. 协议的名称为什么要以超文本(hypertext)开头?

HTTP的设计初衷,并非只是将其作为一种用于传输文件的新方法,也不是将其作为旧式文件传输协议(如FTP)的提供缓存功能的替代品。
当然,HTTP能传输书籍、图片、视频这些独立的文件,但尽管如此,HTTP的目的远不止于此。它还允许世界各地的Serv发布文档,并通过相互之间的交叉引用,形成了一张相互连接的信息网。
HTTP就是为万维网设计的。

11.1 超媒体与URL

撰写书籍时,会引用其他书籍的内容。想找到引用源,就必须先找到另一本书,然后不停翻页,找到引用的文字才行。万维网(WWW, World Wide Web,或web)所实现的,就是把寻找引用的任务,交给机器来负责。
如果一段文字"关于cookie的讨论",本来是孤立的,与外界没有联系,但是如果加了下划线,且被点击后,可跳转到所引用的文本,这段文字就成为了一个超链接(hyperlink)。文本中包含内嵌超链接的整个文档,叫做超文本(hypertext)文档。如果文档中加入了图片、声音、视频,该文档就成为了超媒体(hypermedia)。
前缀hyper表示,后面的媒介能理解文档键相互引用的机制,且能为用户生成链接。

11.1.0. 为了操作超媒体,人们发明了统一资源定位符(URL, Uniform Resource Locator)。不仅为现代的hytxt提供了一个统一的机制,还能供以前的FTP文件和Telnet-Serv使用。

在浏览器的地址栏,可看到类似的例子:
# 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。

11.1.0.1. 当Serv需要根据user提供的参数来自动生成文档时,就需要再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方法后的完整路径。

11.1.1. 解析与构造URL

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

11.1.1.1. 对URL进行分解,只是整个解析过程中的第一步。在构建URL的路径和查询字符串时,需要对一些字符进行转义。

如,&和#是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。因为该路径是一个绝对路径,且以一个斜杠作为开始。

11.1.1.2. URL的查询str,允许多次指定同一个查询参数,因此解析查询str后,会返回一个元组列表,而不是简单的dir。

如果无需在编写的代码中处理这种情况,可将返回的元组列表传递给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中分离出来。

11.1.2. 相对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,就需要将文档本身的位置考虑进去了。

11.1.2.1. Py提供了一个urljoin()函数,用于处理标准中的所有相关细节。

假设从一个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的意义至关重要。

11.1.2.2. 设计web-site时,一定要确保当user提供错误的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中提供的路径。

11.2. 超文本标记语言(HTML)

一些现行的标准,对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>

11.2.1. 某些标签是自包含的,不需要之后再使用对应的结束标记。如
标签,创建了段落中的一个空行。

有些DEV会把
写为
,这是从扩展标记语言(XML, Extensible Markup Language)中学习过来的,但HTML中,这并不是必需的。
在HTML中,许多东西都不是必需的。如,不一定要为所有开始标签提供对应的结束标签。当一个用

    表示的无序列表结束时,无论
      内部用
    • 表示的列表元素,是否通过
    • 标签表示元素结束,HTML解释器都会认为该无序列表包含的所有列表元素都已经结束。
      HTML的标签是可以层层嵌套的。DEV在构建完整的Web页面时,可以不断地在HTML元素内部嵌入其他HTML元素。在构建页面的过程中,DEV大多会不断重复使用HTML定义的有限元素集合中的元素。这些元素用于表示页面上不同类型的内容。尽管HTML5标准允许DEV直接在页面上创建新元素,但DEV还是会倾向于使用标准元素。

      11.2.2. 一个大型的页面可能会出现各种原因而使用
      (最通用的分块形式)/ (最通用的标记连续文本的方式)这样的通用标签。

      如果所有元素都使用了相同的<div>标签,该如何使用CSS来合理地设置各元素的样式?如何使用JS来设置用户与各元素的不同交互方式?

      为每个元素指定一个class。这样,HTML编写者就可以为各元素提供一个特定的标记,之后就可通过该标记来访问特定的元素了。要使用class,有两种常见的方法。

      11.2.2.1. 第一种方法,设计时,为所有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名称即可。两种都不是必须的。

      11.2.2.2. 有时,在class为weather的
      内,DEV认为他们使用

      的目的都是唯一的,因此选择只为外层的元素指定class的值。

      <div class="weather"><h5>Provo</h5><p>61°F</p></div>

      要在CSS/JS中引用该

      内部的

      ,就需要使用更复杂的模式了。使用空格来连接外层标签的class值与内层标签的名称。
      .weather h5
      .weather p

      11.2.2.3. 在浏览器审查元素面板里,查看到的实时文档,与最初载入页面的HTML源代码可能有所不同。取决于JS是否已经向最初载入的页面添加/删除了元素。

      如果从审查元素中找到的某个元素,没有出现在最初的页面源代码中,可能需要进入网络面板,找到JS还获取并使用了哪些资源,来构建这些新增的页面元素。

      11.3. 读写数据库

      假设有一个简单的银行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!')]
      

      11.3.1. SQLite引擎将每个sql存储为磁盘上的一个独立文件,因此open_database()函数可通过检查文件是否存在来确认sql是否已经创建。如果sql已经存在,只需重新连接该sql即可。

      11.3.2. 创建sql时,open_database()函数创建了一张账单表,并向tab中添加了3条示例账单信息,以便app进行展示

      示例中的tab模式极其简单,只是用来满足app运行的最低要求。现实生活中,还需一个user-tab来存储username及pwd的安全散列值、包含官方银行账号的tab。款项最终会从官方银行账号提取,并支付到官方银行账号中。
      本例中,一个很重要的操作值得借鉴:SQL调用的所有参数都进行了适当的转义。
      程序员在向SQL这样的解释型语言提交一些特殊字符时,有时并没有进行正确的转义。这是现在安全缺陷的主要来源之一。
      如果Web前端的一个恶意用户,故意在Memo字段中,包含了一些特殊SQl代码,就会造成很严重的后果。最好的保护方法就是,使用sql自身提供的功能,来正确的引用数据,而不使用自己构建的程序逻辑。
      为了完成这一过程,11-1在代码中所有需要插入参数的地方,都向SQLite提供了一个问号(?),而没有自己进行转义/插入参数

      11.3.3. 本例中另一个重要操作就是为原始的sql-row赋予了更丰富的语义。fetchall()方法并不是sqlite3独有的,它是DB-API2.0的一部分。为支持互操作性,所有现代Py-DB连接接口,都支持DB-API 2.0。此外,fetchall()没有为sql查询返回的每一行结果返回一个对象,甚至没有返回一个dic,而是为每一行返回了一个tuple

      (1, 'brandon', 'psf', 125, 'Registration for PyCon')

      直接操作这些原始的tuple结果是糟糕的做法。
      代码中,“欠款账户”/“已付账款”这样的概念,会以row[2]/row[3]这样的形式来表示,大大降低了可读性。因此,bank.py使用了一个简单的namedtuple类,该类同样支持使用row.credit和row.dollars这样的属性名。

      11.3.4. 尽管每次调用SELECT时,都需要新建一个类,这在效率上并不是最优的,但却能只用一两行简单的代码,就提供了Web-app所需的语义,使得能把精力集中在Web-app本身的编写上。

      11.4. 一个糟糕的Web应一共程序(使用Flask)

      首先应该学习的是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间传递的数据时,所能进行的恶意行为。

      11.4.1. 该app使用了Flask框架,来处理Py-Web-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。

      11.4.1.1. 从上往下阅读代码,可依次看到login()和logout()。这个app没有真正的user-DB,因此login()中直接硬编码了两个虚拟的username和pwd。从login()中可以看出,登录和登出会导致cookie的创建和删除。如果后续的req中提供了cookie,那么Serv会认为cookie标记的user是授权user。

      11.4.1.2. 另外两个页面都不允许非授权user查看。index()和pay()都会先查询cookie,如果没有找到cookie值,就会重定向到登录页面。

      除了检查user是否已经login外,login后的视图只有两行代码。

      11.4.1.3. 首先从数据拉取当前user的账单信息,然后与其他信息组合起来一起传递给HTML页面模板。需要向即将生成的页面提供username。

      但代码中为什么要检查名为'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.4.1.4. 把一些通用的HTML设计元素提取到了一个基本模板中,因此实际上有4个模板,这也是DEV在构建多页面网站时,最常使用的一种模式。

      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的无序列表(

        ),其中包含若干列表项(
      • ),每个列表项都描述了支付给login-user/由login-user指出的一个账单。最后有两个链接,一个指向新账单页面,另一个指向logout页面。

        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="/">&times;</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就可在需要时,在模板中进行这种相当简单的计算,来快速动态地决定要显示的信息。

        11.4.1.5. 很多情况下,user经常会输入错误的form-mes,因此11-6会检测是否接受到了complaint字符串。如果有,就将其显示在form的顶部。除此之外,代码的其他部分冗余度相当高。如果form-mes有误且需要重新显示,就需要3个form字段,且要使用user试图提交时,已经填写的值来事先填充这3个字段。

        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的代码在同一个目录下

        11.5. 表单和HTTP方法

        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与好友分享。

        11.5.0. 可使用一个进行GET-req的form来表示要访问的地址,该form只能用来描述目的地址。

        这与另一种表单(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.5.0.1. 使用JS/HTML5-form的输入限制,来尝试事先防止user提交包含非法值的form。如果在form数据全部符合要求,并可以提交前禁用提交按钮/使用JS在不需重新加载页面的情况下,提交整个form并获取res,那么user就不会因为提交了非法数据,而不断停留在POST-req返回的页面内,并收到浏览器弹出的警告对话框。

        11.5.0.2. 当form正确提交并成功执行了POST-req的动作后,Web-app不应直接返回一个,表示动作已完成的200 OK页面,而是应返回一个303 See Other,并在Location-head中指定将要重定向到的URL。这会强制浏览器在成功完成POST-req后,立刻进行一个GET-req,user浏览器会立刻转到该GET-req要访问的页面。此时,user就可进行重新加载他们想要访问的页面/在该页面执行前进、后退操作了。这些操作不会重复提交form,只会对目标页面重复执行GET-req,该操作是安全的

        11-2中的app非常简单,因此user无法从中了解到包含非法mes的POST-form的具体返回细节,但代码也会在/login和/pay-from操作成功时,返回303 See Other。该功能就是由Flask的redirect()提供的。这是所有Web框架中,都应提供的最佳实践。

        11.5.1. 表单使用了错误方法的情况

        误用HTTP方法的Web-app会给自动化工具和浏览器带来问题,执行结果也会与user的预期不同
        在想要进行“读取”操作时,错用了POST方法,这种错误造成的后果没有那么严重。只会影响可用性,不至于删除所有文件。
        如果搜索form错误地使用了POST方法,就无法从浏览器的地址栏中看到真正的查询URL,即通过/search.pl这样的URL来访问我搜索到的那些页面。
        这使得每个查询的URL看上去都是相同的,因此无法共享这些搜索,也无法将他们存为书签。此外,当想通过浏览器的前进/后对操作来浏览其他搜索结果时,总是会弹出弹窗,询问是否真的想要重新提交搜索。楼兰器知道POST操作是可能有副作用的。

        11.5.2. 安全的cookie和不安全的cookie

        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中安全的方法:

        11.5.2.1. 可仍然保留cookie的可读性,但使用数字签名对cookie进行签名。迫使攻击者对此无能为力。他们可从cookie中看到username,也可将他们想要伪造的username重新写入req中。但,由于他们无法伪造数字签名来对req中的cookie进行签名,因此web-site不会信任他们重新构造的cookie

        11.5.2.2. 可对cookie进行完全加密,这样user甚至都无法读懂cookie的值。加密后的cookie是一个人类/PC无法理解/解析的值

        11.5.2.3. 可使用一个纯随机的字符串作为cookie。该str本身没有任何意义,创建该str时,可使用一个标准的UUID库。将这些随机str存储在自己的DB中,每个受信任的user都有一个对应的随机str,之后的req就用该str作为cookie,这样就可通过Serv的认证。如果同一个user发送的多个连续的HTTP-req可能被转发至多态不同的Serv,那么所有Serv都要能访问这一持久化的session存储。有些应用会把session存储在核心DB中,而另一些app则使用Redis/其他存储较短的方式,来防止核心DB的查询负载过高。

        这个示例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')

        11.5.2.4.关于cookie,有一点需要注意:不应使用未加密的HTTP传输cookie,否则,处在一家coffee店wifi中的所有人,都能获取到别人的cookie。

        许多web-site在登录时,都会使用HTTPS来安全地传输cookie。登录成功后,浏览器才会直接使用HTTP从同一host处获取所有CSS、JS、图片,cookie只在使用HTTP时是暴露出来的。
        为防止暴露出cookie的情况发生,需要了解选择的Web框架在将cookie发送至浏览器时,是如何设置Secure参数的。正确设置了Secure参数后,就绝不会在非加密的req中包含cookie了。
        这样一来,即使很多人可以查看非加密req的内容,他们也无法从中得到cookie的内容。

        11.5.3. 非持久型跨站脚本

        如果恶意user无法获取/伪造cookie,就无法通过浏览器伪装成另一个user来执行操作。
        如果他们能控制另一个已登录user的浏览器,他们甚至不需查看cookie,只要通过该浏览器来发送req,req中就自动包含正确的cookie。
        要使用这一类型的攻击,至少有3个注明的方法可选。11-2中的Serv无法抵御这3种方法发起的攻击。

        11.5.3.1. 第一种类型是非持久型(nonpersistent)的跨站脚本(XSS, cross-site scripting)。在进行这种攻击时,攻击者自己编写了一些脚本,web-site会把这些脚本看作web-site本身的脚本来运行。

        假设攻击者想向他们的一个账户支付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已经可以发现并阻止该攻击了。

        11.5.4. 持久型跨站脚本

        如果攻击者无法在一个又长又丑的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的安全性

        11.5.5. 跨站请求伪造

        攻击者还有一招:既然已经没必要从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要载入的页面、论坛帖子、评论中。就可坐等受害者的钱流入他们的账户中了。

        11.5.5.1. 这就是经典的跨站请求伪造(CSRF, Cross-Site Request Forgery)攻击。不需要攻击者攻击支付OS本身,只需找到并解析一个易于构造的字符form,然后将JS嵌入到user可能访问的任何网站中即可。

        也就是说,要抵御这种攻击,我们访问的所有web-site都必须是安全的。
        app要如何进行防御呢?
        答案就是,增加构造及提交form的难度。除了要完成字符必须填写的字段外,form还需要一个额外的字段,其中包含只对form的合法user/合法user的浏览器可见的私钥,user无法在浏览器中获取该私钥/使用form来获取该私钥。这样一来,由于攻击者并不知道/pay-form的隐藏字段信息,因此也就无法伪造出Serv信任的POST-req。

        11.5.5.2. Flask支持在每位user每次登陆时,为其分配一个随机字符串作为私钥,并放在cookie中安全地发送给Cli。

        为了抵御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.5.6. 改进的应用程序

        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在进行网络交互操作时的自动化程度已经很高了。但在处理视图和表单时,还需要进行不少手动操作。

        11.5.7. 首先,需要在代码中手动检测user是否登录。其次,需要从req中将每个form字段手动复制到HTML中,user就不需要重新输入这些字段了。此外,与DB的交互操作还是相当底层的,必须自己打开DB-session。要将账单永久存储在SQLite中的话,需要自己进行提交。

        11.6. 使用Django编写的账单应用程序

        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.6.1. Django内置了对象关系映射(ORM, Object-Relational Mapper), 无需在app中自己编写任何SQL查询,也就不再需要担心任何关于合理引用SQL值的问题。

        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'),
                       ]

        11.6.2. Django使用正则表达式来匹配URL。这一选用相当诡异,因为当URL中包含较多变量时,re匹配的模式会变得难以阅读。使用re表示的URL模式也是难以调试。

        上面这些模式与之前的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('/')

        11.6.3. 代码中什么地方对跨站脚本攻击做了保护?

        当运行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进行标记。

        11.6.4. 在Django-app的view中,只有@require_http_methods()装饰器与Flask-app使用了相同抽象层次的方法来完成辅助功能。这两者都用于房子view使用非法/不支持的HTTP方法。

        在Django-app中与DB进行交互要简单得多。已经完全不需要在bank.py中进行SQL操作了。Django会自动建立一个SQLite-DB(这是settings.py中的默认设置),当代码查询models.py中的模型类时,Django会打开一个DB-session。虽然没有在代码中要求Django打开DB事务,但调用save()来保存一个新账单时,Django也会自动调用COMMIT。

        11.6.4.1. PaymentForm类会负责生成HTML并解析POST参数,不需在pay_view()中将form字段全部列出。pay_view()会使用当前登录的username填充debit字段。而Django-form库则会负责处理所有细节。

        在主页中显示的账单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编写的几乎是一样的。

        11.7 选择Web框架

        11.7.1. Django:内置了CSRF保护、ORM、模板语言。可省去第三方库的麻烦,还能在操作HTML和DB时,通过Django三方库的通用接口,与Django进行交互。可访问/admin页面,admin可通过自动生成的图形界面直接操作DB,创建、修改并删除表单。

        11.7.2. Tornado:异步框架,使用了异步回调函数模式。同一个OS-thd可以支持大量cli连接,在其他同步框架中,一个OS-thd只支持一个Cli连接。另一个不同点是,Tornado不一定要支持WSGI,直接支持WebSocket。要应用Tornado的异步特性还是需要付出一定代价。许多库对Tornado回调模式的兼容性都不是很好,程序员必须找到支持异步模式的ORM/DB连接方式

        11.7.3. Flask:通常与SQLAlchemy/非关系数据库结合使用

        11.7.4. Bottle:Flask的替代品,但只需一个单独文件bottle.py即可,不需安装许多独立的包。模板语言设计极为出色。

        11.7.5. Pyramid:高性能框架,对于流式URL空间的DEV,是第一选择。如果正在编写一个内容管理系统(CMS),允许user通过点击鼠标来创建子文件夹和Web页面,也可能会使用流式URL。不仅支持预先定义的URL结构,还能在user遍历对象时,通过URL主键推断出当前URL访问的容器、内容还是视图。和文件系统通过路径区分目录与文件是类似的。

        11.8. WebSocket

        使用了JS的web-site通常需要实时更新网页的内容。
        假设在浏览自己的Twitter首页,此时关注的好友发表了一条新的Twitter,那么正在浏览的页面就会实时刷新。
        这个过程中,浏览器不会每秒都向Serv进行轮询,检测是否有新Twitter。Websocket协议(RFC 6455)就是用来解决这个“长轮询问题”的最有力解决方案。

        11.8.1. 因为WSGI只支持传统的HTTP,所以无法使用标准的Web框架及兼容WSGI的Web-Serv(Gunicorn、Apache、nginx)来提供对WebSocket的支持,WSGI不支持WebSocket,正是Tornado能流行起来的主要原因之一

        HTTP中,Cli首先会发送一个req,然后等Serv进行res。只有Serv完成了res,Cli才能发送下一个req。但,如果将socket切换到WebSocket模式,就可同时向两个方向发送mes了。
        Cli可以在user与页面交互时,向Serv发送实时更新,Serv也可在,从其他地方收到更新时,向Cli同步更新mes。

        11.8.2. 开始时,WebSocket-session看上去和普通的HTTP-req与res类似,但Websocket-session会在head-mes和状态码进行协商,表明socket不使用HTTP协议。协商完成后,WebSocket-session就采用一个全新的帧数据OS。

        进行WebSocket编程时,一般需要进行大量的前端JS与Serv代码间的交互操作。
        tornado.websocket模块中包含了一段Py和JS代码,通过一对对称的回调函数进行交互。

        11.9. 网络抓取

        11.9.0. 首先找到robots.txt文件,可帮助避免下载到除了广告外,完全相同的内容,也可帮助网站控制负载,则由于负载过高而导致IP被禁用的可能性会降低。

        11.9.0.1. GET和POST方法,以及如何将HTTP方法、路径和head-mes结合起来构造HTTP-req

        11.9.0.2. 状态码和HTTP-res的结构,包括req成功、重定向、暂时失败及永久失败间的区别

        11.9.0.3. 基本HTTP认证,包括Serv-res与Cli-req中的HTTP认证

        11.9.0.4. 基于form的认证以及如何设置后续req认证过程需要提供的cookie

        11.9.0.5. 基于JS的认证。这种认证方式中,浏览器本身不需要提交form,而可在登录form内,直接向Web-Serv发送POST-req

        11.9.0.6. 在HTTP-req中提供隐藏form字段/全新的cookie来为web-site提供对CSRF攻击的保护

        11.9.0.7. 以下两点的不同之处:

        1)查询/操作时直接将数据添加到URL,然后对该URL地址进行GET-req;
        2)在操作时向Serv发送POST-req,将数据放在req-body中传输

        11.9.0.8. 比较用于从浏览器form发送编码数据的POST-URL与用于直接在前端JS中与Serv进行交互的URL的异同。

        11.9.0.9. 程序员需要处理两类需要自动化抓取的问题:

        1)第一类是抓取整个页面。在需要下载大量数据时会这么做。首先可能需要先登录web-site,获取到所需的cookie,然后不断进行GET操作。在使用这些GET操作下载页面时,可能需要通过另一些GET操作来访问页面中的链接。与搜索引擎使用的“爬虫”类似
        2)第二种类型是针对一到两个页面的特定部分进行抓取,而不是抓取整个web-site。有时可能只想获取某个特定页面上的某部分数据,
        如希望在shell中输出从某个天气预报页面抓取的温度;自动化进行一些本来需要在浏览器中进行的操作,通过客户支付/列出昨天的信用卡交易记录检查账户是否被盗用。
        进行此类抓取时,需要在点击量、form和认证的问题上多加小心。web-site可能会使用网页内的JS来阻止尝试非法访问账户mes的自动化脚本,除了Py本身外,还需要一个全功能的浏览器来查看JS。

        11.9.1. 获取页面

        Py中查看Web页面的内容,可用下面3类方法来获取Web页面:

        11.9.1.1. 使用Py库直接发起GET/POST-req。将Request库作为首选解决方案,且使用Session对象来维护cookie与连接池。如果问题比较简单,想使用标准库的话,也可使用urllib.request

        11.9.1.2. 曾有一些工具是介于全功能Web浏览器和Py程序间,能提供基本的Web浏览器功能,可用于解析<form>表单元素,Mechanize不能解析JS。

        11.9.1.3. 也可使用真正的Web浏览器。使用Selemium的WebDriver库来控制Firefox。PhantomJS是非常流行的一个方法。

        在得到了要访问的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

        11.1.9.4. 显然,只要登录form中使用了一个私钥来防止对username/pwd的大量猜测尝试,这个方法就无法成功了。此时,需要在POST前,先进行一次GET-req,来获取/login页面,并得到私钥,然后将私钥与username和pwd结合起来,发送后续的POST-req。

        11.1.9.5. 基于Selenium的代码使用了一种截然不同的方法。像使用浏览器的真实user一样,先访问form,然后选择元素开始填写。完成后,Selenium也像user一样点击按钮提交form。只要Selenium的CSS选择器能正确识别form字段,代码就能成功登录。

        由于Selenium就是通过直接操作Firefox来进行登录,即使form使用了私钥签名/特殊的JS代码发送POST-req,也能成功登录。

        11.1.9.6. Selenium要比Requests慢得多,第一次时尤其慢。

        11.9.2. 抓取页面

        当web-site返回CSV、JSON等数据,可以使用标准库模块/三方库来解析数据,并进行相应的处理。
        如果返回的mes是原始HTML,该怎么办?
        使用实时审查元素功能也存在一个问题,我们看到的文档可能已经被网页内运行的JS修改了,与原始HTML并不相同。
        要查看这样的页面,有两种方法:

        11.9.2.1. 第一招:禁用浏览器的JS,重新载入正在阅读的页面。此时再审查元素面板中看到就是没有经过任何修改的原始HTML,与通过Py代码下载的文档一致。

        11.9.2.2. 使用某种用于对原始HTML的个数进行整理的程序,如tidy包,该包在Debian和Ubuntu上都可使用。

        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要容易得多。

        11.9.2.3. 要获取HTML元素,需要进行以下3个步骤:

        1)使用所选择的库来解析HTML。许多HTML都存在错误,但浏览器通常会试图修复这些问题。
        2)使用选择器(selector)来深入选择文档中的元素。selector提供了一些文本模式,能够自动选择我们需要的元素。尽管可以自己动手选择,花很多时间不停地查看元素的层级结构,但使用选择器就要快得多了。此外,使用selector编写的Py代码可读性会更高
        3)获取所需元素的文本及属性值。然后就能使用普通的Py-str及str方法来处理数据。
        11-12中两个不同的库就分别执行了上述3个步骤:

        11.9.2.4. scrape_with_soup()函数使用了Beautiful Soup库,是第一个提供了方便的文档解析功能的Py库。

        11.9.2.5. soup对象可表示整个文档,也可表示单独的元素。所有soup对象都提供了一个find_all()方法,该方法根据给定的标签名称,寻找当前soup对象的子元素。此外,还提供HTML的class名作为参数。获取到需要的底层元素后,就可使用get_text()方法来得到元素的内容了。只要这两个方法,就能从简单的web-site中获取需要的数据了。即使要抓取很复杂的web-site,需要的步骤通常也并不多。

        11.9.2.6. scrape_with_lxml()函数使用了现代的lxml库。该库速度较快,基于libxml2和libxslt,基于Debian的OS已经将lxml库编译成了一个Py包,通常为python-lxml

        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())来将它们从上下文中提取出来。

        11.9.3. 递归抓取。

        /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.9.3.1. 目前最流行的网络抓取框架是Scrapy

        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只进行了两个主要的操作。

        11.9.3.2. 最简单的属GET()函数,用于下载URL指向的内容。

        如果下载的内容是HTML,就尝试进行解析,只有完成了这些步骤后,GET()才会获取<a>标记的href=""属性,来得到当前页面包含的链接所指向的页面。

        由于这些链接都使用了相对URL,所以GET()调用了urljoin()来提供URL的基地址。

        11.9.3.3. 对于在当前页面上找到的所有URL,GET()函数都会返回一个元组,表示只要抓取引擎还没有访问过该URL,就访问该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()方法。

        11.9.3.4. 将GET()作为一个类方法,多个GET()调用就可共用一个self.browser对象,在调用submit_form()时,也可使用该对象。

        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间的自动化交互。

        11.10. 小结

        11.10.1. HTTP是专万维网设计的。www通过超链接将海量文档连接起来,每个超链接都用URL来表示其指向的页面/页面中的某个小结。

        user可直接点击超链接来访问它所指向的页面。Py标准库也提供了用于解析、构造URL的方法。此外,还可使用标准库提供的功能,根据页面的基URL地址将相对URL转化为绝对URL

        11.10.2. Web-app通常会在对HTTP-req进行res的Serv-app中连接持久化的数据存储(DB),然后构造作为res-mes的HTML。

        应该使用DB本身提供的功能来引用由Web外部传递来的不可信mes。也可在Py中使用DB-API 2.0和任何ORM来正确地引用不可信mes

        11.10.3. Web框架各不相同。有的只提供最简单的功能,有的提供了全栈式服务。

        如果使用简单的Web框架,就需自己选择模板语言、ORM/其他持久层方案。全栈式的框架则内置了工具来提供这些功能。

        无论选择哪种框架,都可在自己的代码中支持静态URL及/person/123/这样包含可变组件的URL。这些框架同样会提供生成与返回模板的方法,以及返回重定向mes/HTTP错误的功能。

        11.10.4. 每个web-site都会遇到一个大麻烦:在像Web这样一个复杂的OS中,组件间的交互可能会使得user违背了自己的操作本意/允许user损害他人的利益。

        在代码中涉及与外部网络的API时,一定要考虑跨站脚本攻击、跨站请求伪造(CSRF)、对user隐私攻击的可能性。在编写会从URL路径、URL查询str、POST-req/文件上传等途径接收数据的代码前,一定要彻底理解这些安全威胁。

        11.10.5. 通常会在全栈式的框架及轻量级的框架间如何进行权衡。

        像Django这样的全栈式解决方案,鼓励user全部使用它所提供的工具,它会为user提供一个很不错的默认配置(如自动提供form的CSRF保护);
        Flask/Bottle这样的轻量级框架要求自己选择其他工具,相互结合,形成最终的解决方案。

        此时需要理解所有用到的组件。如,如果选择使用Flask来开发app,但却不知道要提供CSRF保护,那么最后开发出的app就无法抵御CSRF攻击了。

        11.10.6. Tornado框架因为提供异步方法而与别的框架不同。允许在同一个OS的thd内为多个Cli提供服务。

        随着asyncio的出现,类似于Tornado的方法变得通用。和WSGI为mutil-thd-Web框架提供的支持是类似的。

        11.10.7. 要抓取一个Web页面,需要对web-site的工作原理有透彻的理解,才能在脚本中模拟正常的user交互---包括登录、填写、提交form这些复杂操作。

        在Py中,有很多方法可获取和解析页面。Requests和Selenium是最流行的用来获取页面的库,而Beautiful Soup和lxml是解析页面时最喜欢使用的方案。

      posted on 2020-01-27 22:59  罗生堂下  阅读(...)  评论(...编辑  收藏

猜你喜欢

转载自www.cnblogs.com/wangxue533/p/12237167.html