亚洲在线久爱草,狠狠天天香蕉网,天天搞日日干久草,伊人亚洲日本欧美

為了賬號安全,請及時綁定郵箱和手機立即綁定
4. Jupyter Notebook 的基本使用

Jupyter Notebook 是一款 Web 應用,特別適合做數據處理,包括數據清理、統計建模、機器學習和可視化數據等等,在該工具中我們的代碼可以按獨立單元形式進行編寫,還能方便的進行文檔、公式、圖形圖像的輸入輸出,是我們學習 Pandas 良好的開發工具。下面以編寫 Pandas 程序的開發過程,簡單介紹一下 Jupyter Notebook 的功能使用:1. 在我們安裝的 Anaconda 文件中,找到 Jupyter Notebook 工具,并打開:2. 首先會彈出運行窗口,里面會顯示一些 Jupyter Notebook 工具的配置信息:在程序啟動之后,會打開 Notebook 服務器的運行地址:http://localhost:8888 ,只要我們上面的后臺程序是跑起的,在游覽器中訪問該地址,都會打開 Notebook 的 Web 工作頁面:3. 通過 New 新建文件,這里我們要新建一個 Python 文件,進行 Python 程序的編寫:新建 Python 工作文件之后,會打開到該文件的工作窗口,進行程序的編寫:4. 我們可以通過雙擊文件名稱,打開重命名窗口,進行文件的重命名:5. 編輯和運行代碼,我們在代碼塊單元格中進行程序的編寫,這里我們寫了兩個代碼塊,當鼠標光標選中哪個單元格時,該單元格處于選中狀態(單元格左側有比較突出的藍色豎條),通過點擊運行按鈕,或者快捷鍵運行代碼塊(快捷鍵 Ctrl + Enter 運行選中代碼塊,快捷鍵 shift + Enter 運行選中代碼塊并跳到下一行):上面我們講述了如何運行獨立的代碼塊,那如果我們想要運行所有的代碼塊呢?我們可以通過導航欄中的 Cell 打開進入,可以看到更多的代碼塊操作工具,通過點擊 “Run All” 即可運行所有的代碼塊:如果說,工作頁面有很多多余的代碼塊,我們選擇某個代碼塊,過打開 File ,選中的 “Delete Cells” 既可。6. 關閉工作文件,如果說我們打開了很多的文件進行編寫,當我們把該頁面關閉時,并不意味這我們該文件的工作程序就關閉了,我們可通過主頁面中的文件列表,來關閉文件程序節省資源:在結束之后,可以看到文件列表中的文件狀態“運行”消失:6. 退出 Jupyter Notebook ,只要關閉游覽器,然后把我們的后臺程序運行窗口關閉即可。

2. 使用 git 源碼安裝包進行安裝

一般情況下我們通過前兩種方法就可以很快安裝成功,源碼安裝稍顯麻煩,如果有興趣的可以參考如下步驟:這種方法需要自己下載安裝包、解壓、編譯等一步一步進行操作,我們一起看下:(1) 先下載 git 依賴的包[root@localhost opt]# yum -y install zlib-devel openssl-devel perl cpio expat-devel gettext-devel(2) wget 下載 git 安裝包[root@localhost opt]# wget https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.9.5.tar.gz--2020-09-03 17:38:22-- https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.9.5.tar.gz正在解析主機 mirrors.edge.kernel.org... 147.75.95.133, 2604:1380:3000:1500::1Connecting to mirrors.edge.kernel.org|147.75.95.133|:443... 已連接。已發出 HTTP 請求,正在等待回應... 200 OK長度:5928730 (5.7M) [application/x-gzip]Saving to: `git-2.9.5.tar.gz'100%[======================================>] 5,928,730 1.43M/s in 5.0s 2020-09-03 17:38:29 (1.13 MB/s) - `git-2.9.5.tar.gz' saved [5928730/5928730]出現以上信息則說明下載成功,我們看下當前目錄是否有下載的 tar 包。(3)查看當前目錄的文件[root@localhost opt]# lsgit-2.9.5.tar.gzgit-2.9.5.tar.gz 就是我們下載的 git 安裝包,說明已經下載成功。(4)解壓壓縮包接下來需要將壓縮包解壓,執行如下命令:[root@localhost opt]# tar zxvf git-2.9.5[root@localhost opt]# lsgit-2.9.5 git-2.9.5.tar.gz可以看到,解壓后當前目錄已經多了一個解壓后的文件夾,之后我們都在這個文件夾進行安裝操作。(5)安裝 autoconf[root@localhost opt]# yum install autoconf(6)進入安裝文件目錄,執行 autoconf:[root@localhost opt]# cd git-2.9.5[root@localhost git-2.9.5]# autoconf(7)繼續執行如下命令:[root@localhost git-2.9.5]# ./configure --with-curl=/usr/localconfigure: Setting lib to 'lib' (the default)configure: Will try -pthread then -lpthread to enable POSIX Threads.configure: CHECKS for site configurationchecking for gcc... nochecking for cc... nochecking for cl.exe... noconfigure: error: in `/opt/git-2.9.5':configure: error: no acceptable C compiler found in $PATHSee `config.log' for more details(8)執行編譯:由于 Git 是使用 C 寫的,因此需要進行編譯操作。[root@localhost git-2.9.5]# make CC credential-store.o/bin/sh: cc: command not foundmake: *** [credential-store.o] 錯誤 127咦!好像報錯了,這是怎么回事?別慌,由報錯信息可以知道缺少某個執行文件。我們需要繼續安裝 gcc 等相關包,才可以執行 make。那么我們繼續安裝:(9)安裝 C 環境[root@localhost git-2.9.5]# yum -y install gcc gcc-c++ libstdc++-devel(10)重新執行 make 命令即可成功。[root@localhost git-2.9.5]# make[root@localhost git-2.9.5]# make install(11)編譯完成后,我們用 git 命令驗證下是否安裝成功[root@localhost git-2.9.5]# git --versiongit version 2.9.5現在提示正常,顯示當前 Git 版本是 2.9.5,也就是我們安裝的版本。至此,說明已經安裝成功。但是,別忘了配置當前用戶身份標識,因為每個 Git 提交都使用此信息,并且將它永久地記錄到您開始創建的提交中。(12)配置身份信息[root@localhost git-2.9.5]# git config --global user.name "Your Name"[root@localhost git-2.9.5]# git config --global user.email "Your Email" <font color=#DC143C>將Your Name 和Your Email分別用你自己的名稱和郵箱替換即可。</font>到這里,CentOS7 操作系統 Git 的安裝就已經結束了。

2.2 循環語句

對于一批數據,我們需要對其重復進行操作的時候,就需要利用循環語句來操作。2.2.1 for 循環for 循環語句通常應用在可預估數量的一批對象操作中,默認 for 循環的取值列表是以 $IFS 分割,默認 $IFS 為空白符,如果我們有其他需求可以更改,語法為:for var in item1 item2 ... itemNdo command1 command2 ... commandNdone通過 for 循環每次遍歷一個后面跟的對象,在 do…done 操作塊中對對象進行一些列操作。例如我們來求和 1-10 的和:SUM=0for num in $(seq 1 10)do let SUM=${SUM}+${num}doneecho "1-10的和為:${SUM}"當然在 for 循環語句里面也可以配合 if 條件判斷或其他流程控制語句進行操作。在此我們舉例修改 $IFS 的應用場景,首選備份默認當前的 $IFS,之后為其賦值新的 $IFS 為:,在對 /etc/passwd 進行操作完成后,恢復之前的 $IFS, 在此我們就利用改變 $IFS 對 /etc/passwd 的單個字段進行了變量操作。#!/bin/bashOLD_IFS=$IFSIFS=":"for i in $(head -1 /etc/passwd); doecho $idoneIFS=${OLD_IFS}[root@xuel-terraform-cvm-0 ~]# bash 1.shrootx00root/root/bin/bash[root@xuel-terraform-cvm-0 ~]# cat /etc/passwd |head -1root:x:0:0:root:/root:/bin/bashfor 循環如果條件永遠滿足則,一直執行內部的命令。for (( ; ; ))2.2.2 while 循環while 循環同樣為循環,與 for 循環功能一樣,利用 for 循環的語句同樣也可以使用 while 循環完成,但是 while 循環通常用于處理未知數量對象的操作,語法:while 條件表達式:do commanddonewhile 通常與 test 語句配合使用,如果條件表達式成立,則一直執行。例如求和打印 1-5 個數:#!/bin/bashN=0while [ $N -lt 5 ]; do let N++ echo $Ndone也可以利用 read 讀入文件,例如我們來讀入一個寫有 ip 或域名列表的文件,來判斷該文件內的域名或 IP 網絡是否可達。#!/bin/bash#function:check urlfilename=urllist.txtfor url in $(cat $filename)do status=`curl -I --connect-timeout 5 $url -s|awk '/HTTP/{print $2}'` if [[ $status == "200" ]];then echo "Url:$url is ok! status is $status" else echo "Url:$url is error! status is $status" fidone編寫 urllist.txt。[root@xuel-terraform-cvm-0 ~]# cat urllist.txtbaidu.com114.114.114.114[root@xuel-terraform-cvm-0 ~]# bash urlcheck.shUrl:baidu.com is ok! status is 200Url:114.114.114.114 is error! status is如果 while 的判斷條件為永遠為 true,則稱為無限循環,會一直執行內部的操作,例如:while :do commanddone或者while truedo commanddone2.2.3 until 循環until 循環與 while 循環剛好相反,其也有一定的應用場景,其為條件表達式為 true 時停止,否則一直運行,語法:until 條件表達式do commanddone例如我們使用 until 來打印 1-5 數字:NUM=0until [ ${NUM} -ge 5 ]do let NUM++ echo $NUMdone2.2.4 break 與 continue與上面三個循環語句不同的是,break 為跳出循環,continue 則為不執行下一次操作,直接跳到下一次循環。我們可以利用 break 來跳出終止循環。break#!/bin/bashN=0while true; do let N++ if [ $N -eq 5 ]; then break fi echo $Ndonecontinue#!/bin/bashN=0while [ $N -lt 5 ]; do let N++ if [ $N -eq 3 ]; then continue fi echo $Ndone利用 continue 來跳過特定的條件操作。

3.2 SimpleAdapter 的用法

SimpleAdapter 相比 ArrayAdapter 會更豐富一點,主要體現在 ArrayAdapter 只能適用于列表中只有一項數據(上一小節中的 TextView)的場景,而如果列表項由多個數據組成,比如文字配圖片的形式 ArrayAdapter 就有些力不從心,這時候就需要用到 SimpleAdapter 了。整個 Activity 的布局文件依舊不變,只需要放置一個 ListView 即可。我們在之前的list_view.xml中增加一個 ImageView,如下:<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content"> <ImageView android:id="@+id/imageView" android:layout_width="50dp" android:layout_height="50dp" android:layout_alignParentRight="true" android:layout_centerVertical="true" android:layout_marginRight="10dp" android:padding="5dp" /> <TextView android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerVertical="true" android:padding="30dp" android:textColor="#000" /></RelativeLayout>從上面的布局文件可以看出,我們現在的列表項由兩個部分組成:一個圖片和一個文本。接著修改 Java 代碼,主要是數據格式的變換,現在數據數組需要包含圖片資源和文本內容兩個部分,如下:package com.emercy.myapplication;import android.app.Activity;import android.os.Bundle;import android.view.View;import android.widget.AdapterView;import android.widget.ListView;import android.widget.SimpleAdapter;import android.widget.Toast;import java.util.ArrayList;import java.util.HashMap;public class MainActivity extends Activity { ListView mListView; String[] mDataName = {"蘋果", "梨", "香蕉", "桃子", "西瓜", "荔枝", "橘子"}; int[] mDataImage = {R.drawable.apple, R.drawable.pear, R.drawable.banana, R.drawable.peach, R.drawable.watermelon, R.drawable.lychee, R.drawable.orange, R.drawable.orange}; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mListView = findViewById(R.id.simpleListView); // 將水果圖片和水果名稱整合到一個map當中,最后將所有的水果都存放到ArrayList ArrayList<HashMap<String, String>> arrayList = new ArrayList<>(); for (int i = 0; i < mDataName.length; i++) { HashMap<String, String> hashMap = new HashMap<>(); hashMap.put("name", mDataName[i]); hashMap.put("image", mDataImage[i] + ""); arrayList.add(hashMap); } String[] from = {"name", "image"}; int[] to = {R.id.textView, R.id.imageView}; SimpleAdapter simpleAdapter = new SimpleAdapter(this, arrayList, R.layout.list_view, from, to); mListView.setAdapter(simpleAdapter); mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) { Toast.makeText(getApplicationContext(), mDataName[i], Toast.LENGTH_LONG).show(); } }); }}在這段例子中,我們使用兩個數組分別保存水果名稱及水果圖片,然后再將每個水果的名稱和圖片存入一個 map,接著把所有的水果 map 都整合到一個 ArrayList 當中,最后創建 SimpleAdapter,這一步也是最關鍵的。我們來單獨看看 SimpleAdapter 的創建語句:SimpleAdapter simpleAdapter = new SimpleAdapter(this, arrayList, R.layout.list_view, from, to);SimpleAdapter 構造器參數比較多,我們來仔細分析分析。傳入構造器的第二個參數是數據源,也就是存放所有水果 map 的 ArrayList 對象;傳入的第三個參數是列表項的布局文件,即 list_view.xml;第四個參數是一個字符串數組,表示水果 map 中的 key,也就是水果名和水果圖片的 key,用來與具體的 UI 控件對應;最后一個參數是一個整形數組,用來與第四個參數匹配,告訴系統 map 中的哪些數據需要顯示到哪個 View 上。這樣一來,就完成了列表、列表項、數據的對應關系,接著直接用setAdapter完成適配,最后通過 ListView 的setOnItemClickListener為每個列表項添加點擊事件(具體使用方法會在 ListView 章節詳細介紹),效果如下:

4. TCP/IP 協議族

TCP/IP 協議族包含了很多協議,一些比較重要的、常用的協議展現如下:依據 TCP/IP 參考模型,我們把協議棧從上到下分為 4 層,每一層都有相應的協議。應用層HTTP(Hypertext Transfer Protocol)超文本傳輸協議,只要你瀏覽頁面就會用到此協議。SMTP(Simple Mail Transfer Protocol)簡單郵件傳輸協議,用于電子郵件傳輸。FTP(File Transfer Protocol)文件傳輸協議,用于文件的上傳和下載?,F在 FTP 貌似用的不多了,早期主要是在公司內部、大學等研究機構用的比較多。RTMP(Real-Time Messaging Protocol)實時消息協議,用于實時流媒體傳輸,主要用在音視頻直播領域,屬于 Adobe 公司出品。不過,從 2020 年開始,Adobe 就不再支持 RTMP 協議了。SNMP(Simple Network Management Protocol)簡單網絡管理協議,用于電信網絡設備的管理。比如,監控設備告警,對設備進行業務配置等。主要是在思科、華為等電信網絡設備上用的比較多。其實,應用層除了以上諸多知名協議外,也可以是用戶自定義協議。傳輸層TCP(Transmission Control Protocol)傳輸控制協議,是面向連接的、可靠的、面向字節流的傳輸協議。TCP 應用非常廣泛,是端到端傳輸的基石。UDP(User Datagram Protocol)用戶數據報協議,是無連接的、不可靠的、面向消息的傳輸協議。UDP 實時性好,效率高,在音視頻傳輸中有著廣泛的應用。網絡層IPv4 (Internet Protocol version 4)此協議主要是用于 IP 分組的路由轉發,是路由器主要實現的協議。我們經常說的 IP 地址是指 IPv4 地址,用 32 bit 來表示。IPv6 (Internet Protocol version 6)此協議工作原理類似 IPv4。之所以設計 IPv6 的目的是因為上世紀 90 年代中期,因特網爆炸式的增長,32 位 IPv4 地址不夠用了,為此才設計了 128 位的 IPv6 地址。ICMP(Internet Control Message Protocol)因特網控制消息協議,主要是用于顯示網絡錯誤。比如,我們用 ping 的時候,有時會顯示“網絡不可達”的錯誤。ICMPv6 (Internet Control Message Protocol Version 6)整合了 ICMP 協議,針對 IPv6 開發的協議。IGMP(Internet Group Management Protocol)因特網組管理協議,主要是用于 IP Multicast 的場景,比如觀看 IP 電視節目。鏈路層ARP(Address Resolution Protocol)地址解析協議,主要是用于生成 IP 地址和物理地址(比如以太網 MAC 地址)的映射表,用于數據包的快速轉發。RARP(Reverse Address Resolution Protocol)反向地址解析協議,主要是用于生成物理地址和 IP 地址的映射。以上這些協議屬于計算機網絡的基礎協議,需要很好的掌握其工作原理。

2. 常見的優化配置參數

首先 scrapy 框架有一個命令 (bench) 來幫助我們測試本地環境的效率,它會在本地創建一個 HTTP 服務器,并以最大可能的速度進行爬取,這個模擬的 Spider 只會做跟進連接操作,而不做其他處理。我們來實際看看這個命令的執行效果:(scrapy-test) [root@server qidian_yuepiao]# scrapy bench# ...2020-07-25 23:35:07 [scrapy.statscollectors] INFO: Dumping Scrapy stats:{'downloader/request_bytes': 127918, 'downloader/request_count': 278, 'downloader/request_method_count/GET': 278, 'downloader/response_bytes': 666962, 'downloader/response_count': 278, 'downloader/response_status_count/200': 278, 'elapsed_time_seconds': 11.300798, 'finish_reason': 'closespider_timeout', 'finish_time': datetime.datetime(2020, 7, 25, 15, 35, 7, 370135), 'log_count/INFO': 21, 'memusage/max': 48553984, 'memusage/startup': 48553984, 'request_depth_max': 12, 'response_received_count': 278, 'robotstxt/request_count': 1, 'robotstxt/response_count': 1, 'robotstxt/response_status_count/200': 1, 'scheduler/dequeued': 277, 'scheduler/dequeued/memory': 277, 'scheduler/enqueued': 5540, 'scheduler/enqueued/memory': 5540, 'start_time': datetime.datetime(2020, 7, 25, 15, 34, 56, 69337)}2020-07-25 23:35:07 [scrapy.core.engine] INFO: Spider closed (closespider_timeout)在上面的執行日志中,我們可以很清楚的看到該命令會搜索 settings.py 中的配置并打印項目的基本信息以及啟用的擴展、下載中間件、Spider 中間件以及相應的 item pipelines。接下來是做的一些本地環境測試,測試顯示的是每分鐘平均能抓取1440個頁面,當然實際的爬蟲程序中需要有較多的處理,比如抽取頁面數據、過濾、去重以及保存到數據庫中,這些都是會消耗一定時間的?,F在來介紹一下 settings.py 中比較常見的一個優化配置:并發控制:settings.py 中的 CONCURRENT_REQUESTS 參數用來確定請求的并發數,默認給的是16。而這個參數往往不適用于本地環境,我們需要進行調整。調整的方法是一開始設置一個比較大的值,比如100,然后進行測試,得到 Scrapy 的并發請求數與 CPU 使用率之間的關系,我們選擇大概使得 CPU 使用率在 80%~90% 對應的并發數,這樣能使得 Scrapy 爬蟲充分利用 CPU 進行網頁爬??;關閉 Cookie :這也是一個常見的優化策略。對于一些網站的請求,比如起點網、京東商城等, 不用登錄都可以任意訪問數據的,沒有必要使用 Cookie,使用 Cookie 而會增加 Scrapy 爬蟲的工作量。直接設置 COOKIES_ENABLED = False 即可關閉 Cookie;設置 Log 級別:將默認的 DEBUG 級別調整至 INFO 級別,減少不必要的日志打??;關閉重試:默認情況下 Scrapy 會對失敗的請求進行重試,這種操作會減慢數據的爬取效率,因為對于海量的請求而言,丟失的數個甚至數百個請求都無關緊要;反而不必要的嘗試會影響爬蟲爬取效率;生產環境的做法最好是直接關閉,即 RETRY_ENABLED = False;減少下載超時時間:對于響應很慢的網站,在超時時間結束前,Scrapy 會持續等到響應返回,這樣容易造成資源浪費。因此一個常見的優化策略是,減少超時時間,盡量讓響應慢的請求釋放資源。相應的參數設置示例如下:DOWNLOAD_TIMEOUT = 3關閉重定向:除非對重定向內容感興趣,否則可以考慮關閉重定向。關閉操作 REDIRECT_ENABLED = False;自動調整爬蟲負載:我們啟用這個可以自動調節服務器負載的擴展程序,設置相關的參數,可以一定程度上優化爬蟲的性能;AUTOTHROTTLE_ENABLED = False # 默認不啟用,可以設置為True并調整下面相關參數AUTOTHROTTLE_DEBUG = FalseAUTOTHROTTLE_MAX_DELAY = 60.0AUTOTHROTTLE_START_DELAY = 5.0AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0

2. xpath 解析實戰

lxml 是 Python 中的一個解析庫,支持 HTML 和 XML 的解析,支持 XPath 解析方式,而且解析效率非常高。本節將安裝該模塊解析 html 文本并提取相應的數據。[store@server2 ~]$ sudo pip3 install lxmlWARNING: Running pip install with root privileges is generally not a good idea. Try `pip3 install --user` instead.Collecting lxml Downloading http://mirrors.cloud.aliyuncs.com/pypi/packages/55/6f/c87dffdd88a54dd26a3a9fef1d14b6384a9933c455c54ce3ca7d64a84c88/lxml-4.5.1-cp36-cp36m-manylinux1_x86_64.whl (5.5MB) 100% |████████████████████████████████| 5.5MB 82.9MB/s Installing collected packages: lxmlSuccessfully installed lxml-4.5.1我們先準備好素材,也就是要解析的 HTML 文檔。為了更有代入感,我直接使用慕課網 wiki 頁面的數據進行操作,獲取數據的方式如下圖所示:獲取慕課網 wiki 頁面的 HTML 數據最后保存到一個 test.html 文本,然后我們要準備一段 Python 代碼:from lxml import etreetree = etree.parse('test.html', etree.HTMLParser(encoding='utf8'))def print_result(exp, results): print('xpath表達式為:{},其匹配結果為:'.format(exp)) for res in results: print(res.strip()) print('')def test_xpath_expression(exp): results = tree.xpath(exp) print_result(exp, results)將這個 Python 文件命名為 test_xpath.py 和 test.html 放在同一級目錄下:[store@server2 ~]$ lsshen test.html test_xpath.py接下來我們就可以進行激動人心的測試了,來完成一個簡單的實驗:慕課網 wiki 頁面數據獲取第一個實驗的目標就是拿到 javascript 分類下的教程的三個數據:標題、總節數以及訪問次數。通過 F12 查看相關的 HTML 結構,我們可以通過如下的 Xpath表達式獲取相應的數據:Python 3.6.8 (default, Apr 2 2020, 13:34:55) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linuxType "help", "copyright", "credits" or "license" for more information.>>> from test_xpath import test_xpath_expression>>> exp1 = '//h2[@class="language-title"]/text()'>>> test_xpath_expression(exp1)xpath表達式為://h2[@class="language-title"]/text(),其匹配結果為:JavaScriptHTML & CSS服務器開發工具其他后端語言基礎應用框架應用基礎應用Python Web 開發MySQL接下來看一看元素的結構:javascript 專欄的節點結構可以看到 javascript 專欄標題是 h2 節點,這個節點同級下有一個 div,它下面的四個 div 節點正是那四個專欄。我們首先匹配下這四個專欄元素:>>> exp1 = '//h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]'>>> test_xpath_expression(exp1)xpath表達式為://h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"],其匹配結果為:<Element div at 0x7f7015bf8808><Element div at 0x7f700c656788><Element div at 0x7f700c6567c8><Element div at 0x7f700c656808>那么我們來進一步分析每個 div 內部如何得到教程標題、總節數以及訪問次數這些數據:獲取教程數據可以看到,在前面找到 div 節點的基礎上在往下兩層,找到 class 屬性值為 text 的 div 節點,所有的數據都在這個節點中:標題:上面找到的 div 節點下的第一個 a 節點的文本值;教程總節數:上面找到的 div 節點下的第一個 p 節點下第一個 span 元素的文本值;總訪問次數:上面找到的 div 節點下的第一個 p 節點下第二個 span 元素的文本值;這樣我們就能進行寫出提取相應數據的 Xpath 路徑表達式了,測試如下:>>> exp1 = '//h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/a[1]/text()'>>> test_xpath_expression(exp1)xpath表達式為://h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/a[1]/text(),其匹配結果為:Javascript 入門教程TypeScript 入門教程Vue 入門教程Ajax 入門教程>>> exp2 = '//h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/p/span[1]/text()'>>> test_xpath_expression(exp2)xpath表達式為://h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/p/span[1]/text(),其匹配結果為:56小節38小節39小節9小節>>> exp3 = '//h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/p/span[2]/text()'>>> test_xpath_expression(exp3)xpath表達式為://h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/p/span[2]/text(),其匹配結果為:9832354736281800接下來我們整理下 Python 代碼,將整個 wiki 頁面上的教程都解析出來,并將數據整理成 json 格式。預期最后的結果應該是這樣的:{ '前端開發': { 'JavaScript': [ {'title': 'JavaScript入門教程', 'total_chapters': 56, 'total_visited': 9001}, {...}, {...}, {...} ], 'HTML & CSS': [ ... ] } '服務端相關': { }, ...}這樣的難度再次增加,其核心的獲取數據的過程和上面一致。后面獲取其他數據的結果過程不作分析,大家有興趣仔細研究下代碼,然后動手實操。話不多說,上代碼:# 代碼文件:test_xpath2.pyfrom lxml import etreedef get_direction_data(direction_tree): """ 獲取一個方向下的課程數據 :return: """ direction_data = {} cards = direction_tree.xpath('.//div[@class="language-card"]') for card in cards: title = card.xpath('.//h2[@class="language-title"]/text()')[0] course_list = card.xpath('.//div[@class="course-card"]') courses = [] for course in course_list: course_title = course.xpath('.//div[@class="text"]/a[1]/text()')[0] course_total_chaps = course.xpath('.//div[@class="text"]/p/span[1]/text()')[0] course_total_visit_count = course.xpath('.//div[@class="text"]/p/span[2]/text()')[0] courses.append({ 'course_title': course_title.strip(), 'course_total_chaps': course_total_chaps.strip(), 'course_total_visit_count': int(course_total_visit_count.strip()) }) direction_data[title] = courses return direction_datadef get_all_data(): """ 解析慕課網wiki數據 :return: """ result = {} html = etree.parse('test.html', etree.HTMLParser(encoding='utf8')) directions = html.xpath('//div[@class="direction-con"]') for direction in directions: # 提取方向key,注意一定要有點號,表示從當前元素開始提取 direction_name = direction.xpath('./div[@class="title-con"][1]/text()') if direction_name: result[direction_name[0]] = get_direction_data(direction) return result運行的結果如下:[store@server2 ~]$ python3Python 3.6.8 (default, Apr 2 2020, 13:34:55) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linuxType "help", "copyright", "credits" or "license" for more information.>>> from test_xpath2 import get_all_dat>>> get_all_data(){'前端開發': {'JavaScript': [{'course_title': 'Javascript 入門教程', 'course_total_chaps': '56小節', 'course_total_visit_count': 9832}, {'course_title': 'TypeScript 入門教程', 'course_total_chaps': '38小節', 'course_total_visit_count': 3547}, {'course_title': 'Vue 入門教程', 'course_total_chaps': '39小節', 'course_total_visit_count': 3628}, {'course_title': 'Ajax 入門教程', 'course_total_chaps': '9小節', 'course_total_visit_count': 1800}], 'HTML & CSS': [{'course_title': 'CSS3 入門教程', 'course_total_chaps': '32小節', 'course_total_visit_count': 1512}, {'course_title': 'Less 入門教程', 'course_total_chaps': '22小節', 'course_total_visit_count': 364}, {'course_title': '雪碧圖入門教程', 'course_total_chaps': '24小節', 'course_total_visit_count': 915}]}, '服務端相關': {'服務器': [{'course_title': 'Nginx 入門教程', 'course_total_chaps': '24小節', 'course_total_visit_count': 4500}, {'course_title': 'HTTP 入門教程', 'course_total_chaps': '16小節', 'course_total_visit_count': 456}, {'course_title': 'Docker 入門教程', 'course_total_chaps': '25小節', 'course_total_visit_count': 1067}, {'course_title': 'Shell 入門教程', 'course_total_chaps': '17小節', 'course_total_visit_count': 2060}, {'course_title': 'Linux 入門教程', 'course_total_chaps': '25小節', 'course_total_visit_count': 1430}], '開發工具': [{'course_title': 'Gradle 入門教程', 'course_total_chaps': '12小節', 'course_total_visit_count': 1121}, {'course_title': 'Vim 入門教程', 'course_total_chaps': '14小節', 'course_total_visit_count': 1491}, {'course_title': 'RESTful 規范教程', 'course_total_chaps': '13小節', 'course_total_visit_count': 1316}, {'course_title': 'Markdown 入門教程', 'course_total_chaps': '31小節', 'course_total_visit_count': 733}, {'course_title': 'Maven 入門教程', 'course_total_chaps': '17小節', 'course_total_visit_count': 155}, {'course_title': 'GitHub 入門教程', 'course_total_chaps': '9小節', 'course_total_visit_count': 261}], '其他后端語言': [{'course_title': 'C 語言入門教程', 'course_total_chaps': '45小節', 'course_total_visit_count': 1933}, {'course_title': 'Go 入門教程', 'course_total_chaps': '36小節', 'course_total_visit_count': 691}, {'course_title': 'Ruby 入門教程', 'course_total_chaps': '26小節', 'course_total_visit_count': 410}]}, 'Java': {'基礎應用': [{'course_title': 'Java 入門教程', 'course_total_chaps': '39小節', 'course_total_visit_count': 5229}, {'course_title': 'Android 入門教程', 'course_total_chaps': '29小節', 'course_total_visit_count': 553}, {'course_title': '算法入門教程', 'course_total_chaps': '11小節', 'course_total_visit_count': 628}], '框架應用': [{'course_title': 'Spring Boot 入門教程', 'course_total_chaps': '25小節', 'course_total_visit_count': 4861}, {'course_title': 'Spring 入門教程', 'course_total_chaps': '21小節', 'course_total_visit_count': 850}, {'course_title': 'Hibernate 入門教程', 'course_total_chaps': '23小節', 'course_total_visit_count': 619}, {'course_title': 'MyBatis 入門教程', 'course_total_chaps': '23小節', 'course_total_visit_count': 895}]}, 'Python': {'基礎應用': [{'course_title': 'Python 入門語法教程', 'course_total_chaps': '24小節', 'course_total_visit_count': 3617}, {'course_title': 'Python 原生爬蟲教程', 'course_total_chaps': '19小節', 'course_total_visit_count': 2001}, {'course_title': 'Python 進階應用教程', 'course_total_chaps': '29小節', 'course_total_visit_count': 726}], 'Python Web 開發': [{'course_title': 'Django 入門教程', 'course_total_chaps': '33小節', 'course_total_visit_count': 668}, {'course_title': 'NumPy 入門教程', 'course_total_chaps': '21小節', 'course_total_visit_count': 152}]}, '數據庫': {'MySQL': [{'course_title': 'MySQL 入門教程', 'course_total_chaps': '32小節', 'course_total_visit_count': 3638}, {'course_title': 'SQL 入門教程', 'course_total_chaps': '47小節', 'course_total_visit_count': 2406}]}}是不是實現了預期效果?爬取網頁,解析數據的過程和這個類似。掌握好今天的內容,你就已經掌握了爬蟲的一個核心步驟。

2.1 MultipleObjectTemplateResponseMixin

首先來看 MultipleObjectTemplateResponseMixin 這個對象,它是一個 Mixin。前面我們提到,一個 Mixin 就是一個包含一個或多個功能片段的對象。這里的 Mixin 是用于響應模板文件和展示列表數據的,它繼承至前面介紹到的 TemplateResponseMixin,在 TemplateResponseMixin 上做的擴展就是重寫了 get_template_names() 方法,其源碼如下:class MultipleObjectTemplateResponseMixin(TemplateResponseMixin): """Mixin for responding with a template and list of objects.""" template_name_suffix = '_list' def get_template_names(self): """ Return a list of template names to be used for the request. Must return a list. May not be called if render_to_response is overridden. """ try: names = super().get_template_names() except ImproperlyConfigured: # If template_name isn't specified, it's not a problem -- # we just start with an empty list. names = [] # If the list is a queryset, we'll invent a template name based on the # app and model name. This name gets put at the end of the template # name list so that user-supplied names override the automatically- # generated ones. if hasattr(self.object_list, 'model'): opts = self.object_list.model._meta names.append("%s/%s%s.html" % (opts.app_label, opts.model_name, self.template_name_suffix)) elif not names: raise ImproperlyConfigured( "%(cls)s requires either a 'template_name' attribute " "or a get_queryset() method that returns a QuerySet." % { 'cls': self.__class__.__name__, } ) return names從這里的代碼,我們可以解釋第一個實驗中,第二次添加 get() 方法后報錯的原因,就在這個代碼段里。首先看這個 get() 函數: def get(self, request, *args, **kwargs): return self.render_to_response(context={'content': '正文1', 'spyinx': {'age': 29}})這個 get() 函數調用 self.render_to_response() 方法時會調用這個 get_template_names() 方法。如果是在 TemplateView 中,直接這樣寫是毫無問題的,但是在 ListView 中,ListView 繼承了這個 Mixin,然后調用的get_template_names() 方法正是這里的代碼。這個 get_template_names() 方法相比原來的就是多了下半部分代碼,在程序執行到下面的語句時,由于沒有 object_list 屬性值就會觸發異常:if hasattr(self.object_list, 'model'):修正的方法很簡單,只要一開始加上這個 object_list 屬性值即可。對于這個object_list 屬性,它其實從名字也能看出來,表示一個對象的列表值,其實是一個 QuerySet 結果集。大概知道這些之后,我們就能理解后面的代碼了: if hasattr(self.object_list, 'model'): opts = self.object_list.model._meta names.append("%s/%s%s.html" % (opts.app_label, opts.model_name, self.template_name_suffix)) elif not names: raise ImproperlyConfigured( "%(cls)s requires either a 'template_name' attribute " "or a get_queryset() method that returns a QuerySet." % { 'cls': self.__class__.__name__, } )對于這段代碼指的是,如果self.object_list 對應著一個模型時,代碼會在 names 中添加一個默認的模板文件名,我們可以在 shell 模式下理解下這些代碼:(django-manual) [root@server first_django_app]# python manage.py shellPython 3.8.1 (default, Dec 24 2019, 17:04:00) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linuxType "help", "copyright", "credits" or "license" for more information.(InteractiveConsole)>>> from hello_app.models import Member>>> object_list = Member.objects.all()>>> object_list.model<class 'hello_app.models.Member'>>>> object_list.model._meta<Options for Member>>>> opts = object_list.model._meta>>> opts.app_label'hello_app'>>> opts.model_name'member'這就很明顯了,最后 names 中會加上一個額外的元素:hello_app/member_list.html?,F在我們可以立馬做一個實驗,將實驗1中的 template_name 屬性值去掉,然后將原來的 test1.html 拷貝一份放到 template/hello_app 目錄下,操作如下:(django-manual) [root@server first_django_app]# mkdir templates/hello_app(django-manual) [root@server first_django_app]# cp templates/test1.html templates/hello_app/member_list.htmlclass TestListView1(ListView): # template_name = 'test1.html' model = Member啟動服務,然后運行發現也能成功。這就算對這個 Mixin 掌握了,我們也理解了它的代碼內容并獨立根據這個代碼內容完成了一個實驗。(django-manual) [root@server first_django_app]# curl http://127.0.0.1:8888/hello/test_list_view1/<p>正文1</p><div>29</div>

3. 業務場景實現思路分析與實操

實現思路分析針對上述場景,我們需要將所有拆分出來的微服務都要集成到 Hystrix 的微服務監控平臺中去,但是我們應該怎么集成呢?Hystrix 針對不同場景下,提供了不同的微服務監控方式:針對單體架構或只需要對一個服務實例進行監控的業務場景,我們只需要像我在介紹微服務監控平臺時那樣,進行配置即可;針對分布式的項目結構,或同時需要監控多個微服務的場景,Hystrix 提供了一個名為 Turbine 的多服務器微服務監控組件。我們只需要將 Turbine 引入到我們的項目中去,即可實現分布式架構下的多微服務監控了。實操要想在項目中引入 Turbine ,我們首先需要將 Turbine 的依賴引入到項目中:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-turbine</artifactId></dependency>在引入了 Turbine 依賴之后,我們需要對 Turbine 進行一些簡單的配置,在 yml 配置文件中:turbine: aggregator: clusterConfig: default appConfig: service-one,service-two,service-three clusterNameExpression: "'default'"代碼解釋第 3 行,我們通過 clusterConfig 屬性來配置 turbine 采用的集群策略,一般我們配置 default 即可。第 4 行,我們通過 appConfig 屬性來配置需要繼承到 turbine 中的微服務,即需要同時刻進行監控的微服務,多個微服務使用英文狀態下的逗號隔開。第 5 行,我們通過 clusterNameExpression 屬性來配置集群的名稱表達式,一般也是采用 default 配置即可。yml 配置文件配置完成后,我們需要在項目的啟動類中添加 turbine 的注解:@SpringBootApplication@EnableHystrix@EnableHystrixDashboard@EnableTurbinepublic class SaleApplication { public static void main(String[] args) { SpringApplication.run(SaleApplication.class, args); }代碼解釋第 4 行,我們添加了 @EnableTurbine 注解,來在項目中開啟 turbine 服務,以支持同時監控多個微服務。在配置完了上述兩部分后,我們的 Hystrix Turbine 組件就引入到了我們的項目中,我們就可以對多個微服務進行監控了。配置完成后,運行我們的項目,在瀏覽器地址欄中輸入我們的監控地址:http://localhost:port/turbine.stream我們即可在瀏覽器中看到多個微服務已經被監控了,如下圖所示:Hystrix Dashboard 多微服務同時監控從上圖可知,這里我們對兩個微服務進行了監控,其圖中的各項監控參數和我們在之前介紹 Hystrix 微服務監控平臺中的參數一樣,這里就不再贅述了。Tips: 1. 在 appConfig 屬性配置中,屬性的值為每個需要監控的微服務的 serviceId 名稱,同學們不要配置錯了。 2. 各位在引入 Turbine 的依賴時,注意不要引錯依賴了,如果大家引入了 aitifactId 為 spring-cloud-starter-turbine 的依賴,我們是無法使用 Turbine 的,因為這個依賴并不是 Spring Cloud 微服務組件中所提供的 Turbine 服務,而是單獨的 Turbine 服務,我們無法在微服務項目中使用。 3. 各位同學在訪問 Turbine 多微服務監控平臺時,注意訪問地址發生了變化,大家使用之前的微服務監控平臺訪問地址時訪問不到的,這點需要同學們注意。

5.2 按文檔開發后端 API 接口

相比于使用模板引擎,用 Spring Boot 開發后端接口簡直太輕松了。通過給控制器添加注解,就能將控制器方法返回值序列化為 JSON 。程序員最愛,就是輕松又愉快。5.2.1 使用 Spring Initializr 創建項目Spring Boot 版本選擇 2.2.5 , Group 為 com.imooc , Artifact 為 spring-boot-restful ,生成項目后導入 Eclipse 開發環境。像這種老套的重復操作,我還是拿出來講講,為何,因為要繼續呵護我們的初學者們。萬一有朋友因為一個小地方看不明白,喪失了學習編程的動力,那就是罪過了。5.2.2 引入項目依賴RESTful 項目其實就是標準的 Web 項目,引入 Web 項目依賴即可。實例: <!-- 引入web項目相關依賴 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>5.2.3 創建商品類與商品服務類創建商品類與商品服務類,以完成對商品的增刪改查操作。由于本章我們的重點是演示 RESTful 后端接口,所以此處沒有操作真實的數據庫。實例:/** * 商品類 */public class GoodsDo { /** * 商品id */ private Long id; /** * 商品名稱 */ private String name; /** * 商品價格 */ private String price; /** * 商品圖片 */ private String pic; //省略get set方法}實例:/** * 商品服務 */@Service // 注冊為服務類public class GoodsService { /** * 獲取商品列表 */ public List<GoodsDo> getGoodsList() { List<GoodsDo> goodsList = new ArrayList<GoodsDo>(); GoodsDo goods = new GoodsDo(); goods.setId(1L); goods.setName("蘋果"); goods.setPic("apple.jpg"); goods.setPrice("3.5"); goodsList.add(goods); return goodsList; } /** * 按id獲取商品信息,模擬返回對應商品信息 */ public GoodsDo getGoodsById(Long id) { GoodsDo goods = new GoodsDo(); goods.setId(1L); goods.setName("蘋果"); goods.setPic("apple.jpg"); goods.setPrice("3.5"); return goods; } /** * 新增商品,模擬返回數據庫影響行數 */ public int addGoods(GoodsDo goods) { return 1; } /** * 根據商品id更新商品信息,模擬返回數據庫影響行數 */ public int editGoods(GoodsDo goods) { return 1; } /** * 根據商品id刪除對應商品,模擬返回數據庫影響行數 */ public int removeGoods(Long id) { return 1; }}Tips: 服務層方法,建議不要使用 select /insert/update /delete 命名,因為服務層處理的邏輯往往不止于關系數據庫表的增刪改查。此處采用的是 get /add/edit /remove 。5.2.4 根據 API 文檔實現控制器方法此處需要解釋的地方我都寫在注釋中了。實例:@RestController // 通過該注解,第一是將GoodsController注冊為控制器,可以響應Http請求;第二是可以將控制器中的方法返回值序列化為json格式。public class GoodsController { @Autowired // 自動裝配goodsService private GoodsService goodsService; /** * 查詢商品信息 * 1、@GetMapping表示可以使用get方法請求該api * 2、"/goods/{id}"表示請求路徑為/goods/{id}的形式,其中{id}為占位符 * 3、@PathVariable("id")表示將占位符{id}的值傳遞給id * 4、也就是說/goods/123請求的話,會將123傳遞給參數id */ @GetMapping("/goods/{id}") public GoodsDo getOne(@PathVariable("id") long id) { return goodsService.getGoodsById(id); } /** * 查詢商品列表,使用get方法 */ @GetMapping("/goods") public List<GoodsDo> getList() { return goodsService.getGoodsList(); } /** * 新增商品 * 1、@PostMapping表示使用post方法 * 2、@RequestBody表示將請求中的json信息轉換為GoodsDo類型的對象信息,該轉換也是由SpringMVC自動完成的 */ @PostMapping("/goods") public void add(@RequestBody GoodsDo goods) { goodsService.addGoods(goods); } /** * 修改商品 */ @PutMapping("/goods/{id}") public void update(@PathVariable("id") long id, @RequestBody GoodsDo goods) { // 修改指定id的商品信息 goods.setId(id); goodsService.editGoods(goods); } /** * 刪除商品 */ @DeleteMapping("/goods/{id}") public void delete(@PathVariable("id") long id) { goodsService.removeGoods(id); }}

2. fork 函數

面試官提問: Linux 系統中的 fork 函數是什么,有什么用途?題目解析:首先從定義上看,fork 函數的作用是在一個進程的基礎上創建新的進程,原有的進程被定義為父進程,新的進程被定義為子進程。在 C 語言中調用 fork() 函數即實現 fork 功能,示例:#include<unistd.h> //包含fork函數的頭文件pid_t fork(void); //fork的返回類型為pid_t,我們可以看成int類型認識一個函數,需要從函數的定義入手,了解函數做了什么事情,入參是什么,出參是什么。我們以 C 語言實現的一個典型的 fork 的程序入手,示例:#include <unistd.h>#include <stdio.h> int main (){ pid_t fpid; int count = 0; fpid = fork(); if (fpid == 0) { //返回值是0,說明是子進程 printf("i am the child process, process id is %d\n", getpid()); count++; } else if(fpid > 0) { //返回值>0,說明是父進程 printf("i am the parent process, process id is %d\n", getpid()); count++; } else { //返回值<0,說明fork發生異常 printf("fork encounter exception, process id is %d\n", getpid()); } //打印計數器 printf("after fork, counting result : %d\n", count); return 0;}在 MacOS 系統上編譯運行案例示例代碼,運行結果如下圖。test.c 編譯執行結果如果是不了解函數原理的前提下,僅僅從代碼層面分析,在調用 fork() 函數之后,代碼會進入 if-else 判斷邏輯,在控制臺輸出一條語句,然后在控制臺打印計數器的數值。但是從真正執行的結果來看,這兩個打印動作都分別執行了兩次,并不符合我們的預期。fork 之后的代碼邏輯被執行了兩次,而且兩次進入的不同的分支,所以重點在于 fork 函數到底有啥作用。按照定義、入參和出參三步走的框架,首先是分析函數的定義,調用 fork 函數之后發生了什么事情:(1)分配內存:分配新的內存空間給子進程;(2)拷貝數據:拷貝父進程的數據結構給子進程;(3)加入列表:將新生成的子進程添加到操作系統的進程列表;(4)返回結果:fork 函數調度并且返回。然后是分析 fork 函數的入參,fork 函數入參是 void,也就是自動同步進程的上下文,不需要手動聲明。最后是分析 fork 函數的出參,fork 函數和程序員日常接觸的函數不同,我們在 C 或者 Java 中定義的函數只會有一個返回值,fork 函數則是調用一次,返回二次。調用方(例如上述案例的 main 函數)根據返回值的不同判斷處于父進程還是子進程。(1)返回值 < 0:調用失敗,一般是因為操作系統中的進程個數達到上限或者內存不足以分配給新的進程;(2)返回值 = 0:調用成功,并且處于子進程;(3)返回值 > 0:調用成功,并且處于父進程?,F在就不難理解,從調用 fork() 函數,代碼實際上是被父子進程分別執行了一次,父進程的進程 id 是 52331,子進程的進程 id 是 52332。在掌握原理之后,我們繼續探究 fork 函數的應用場景。fork 函數的本質在原有的進程基礎上創建一個新的進程,所以在網絡通信中使用較多,例如在客戶端發送一個 HTTP 請求打到服務器時,服務器進程 fork 出一個子進程用于處理單個請求,父進程則繼續等待其他的請求。

1. 實戰:直接構建鏡像

首先 我們需要新建一個目錄 dockerfiledir,用于存放 Dockerfile 文件。mkdir dockerfiledir# 在這個目錄下新建個空文件 Dockerfile,之后填充內容 touch dockerfiledir/Dockerfile新建一個目錄code,用來存放flask和c的源代碼。mkdir code將之前 app.py 和 helloworld.c 兩個源碼文件放入到 code 目錄下,當前的目錄結構應該是這樣的:進入 dockerfiledir 目錄,編輯 Dockerfile 文件:# 從 ubuntu系統鏡像開始構建FROM ubuntu # 標記鏡像維護者信息MAINTAINER user <[email protected]># 切換到鏡像的/app目錄,不存在則新建此目錄WORKDIR /app# 將 宿主機的文件拷貝到容器中COPY ../code/app.py .COPY ../code/helloworld.c .# 安裝依賴 編譯helloworldRUN apt update >/dev/null 2>&1 && \ apt install -y gcc python3-flask python3-redis >/dev/null 2>&1 && \ cc /app/helloworld.c -o /usr/bin/helloworld# 設定執行用戶為userRUN useradd userUSER user# 設定flask所需的環境變量ENV FLASK_APP app# 默認啟動執行的命令CMD ["flask", "run", "-h", "0.0.0.0"]# 將flask的默認端口暴露出來EXPOSE 5000然后執行:docker build .出現如下報錯:COPY failed: Forbidden path outside the build context: ../code/app.py ()解決這個問題,需要引入一個重要的概念——構建上下文。docker build .命令在執行時,當前目錄.被指定成了構建上下文,此目錄中的所有文件或目錄都將被發送到 Docker 引擎中去,Dockerfile中的切換目錄和復制文件等操作只會對上下文中的內容生效。Tips:在默認情況下,如果不額外指定 Dockerfile 的話,會將構建上下文對應的目錄下 Dockerfile 的文件作為 Dockerfile。但這只是默認行為,實際上 Dockerfile 的文件名并不要求必須為 Dockerfile,而且并不要求必須位于上下文目錄中,比如可以用 -f ../demo.txt參數指定父級目錄的demo.txt文件作為 Dockerfile。一般來說,我們習慣使用默認的文件名 Dockerfile,將其置于鏡像構建上下文目錄.中。我們需要將 code 目錄納入到上下文中,一個直接的方法是,調整dockerfile中的COPY指令的路徑。# 將 .. 改為 .COPY ./code/app.py .COPY ./code/helloworld.c .然后將 code 所在的目錄指定為構建上下文。由于我們當前的目錄是 dockerfiledir,所以我們執行:docker build -f ./Dockerfile ..如果你留意查看構建過程,會發現類似這樣的提示:Sending build context to Docker daemon 421.309 MB如果..目錄除了code和dockerfiledir,還包含其他的文件或目錄,docker build也會將這個數據傳輸給Docker,這會增加構建時間。避免這種情況,有兩種解決方法:使用.dockerignore文件:在構建上下文的目錄下新建一個.dockerignore文件來指定在傳遞給 docker 時需要忽略掉的文件或文件夾。.dockerignore 文件的排除模式語法和 Git 的 .gitignore 文件相似。使用一個干凈的目錄作為構建上下文(推薦):使用 Dockerfile 構建鏡像時最好是將 Dockerfile 放置在一個新建的空目錄下。然后將構建鏡像所需要的文件添加到該目錄中。在我們當前的示例中,將code目錄移入dockerfiledir。mv ../code .現在的目錄層級如下:執行 docker build -t myhello . 執行構建即可獲得我們的自定義鏡像 myhello。使用鏡像 myhello 創建 myhello 容器:# 這里使用--net=host,方便使用之前章節中部署的redis容器服務,與之進行數據交換docker run -dit --net=host --name myhello myhello 確保部署之前的 redis 容器正常啟動,然后在 Docker 宿主機的瀏覽器中訪問http://127.0.0.1:5000:說明 myhello 中的 flask 應用已經正常運行了。接下來,我們再運行測試一下編譯的 helloworld。docker exec myhello /usr/bin/helloworld得到輸出:Hello, World!Tips: myhello容器已經完成任務,記得執行docker rm -f myhello刪除它.

1. 初識 Admin Web

首先 Django 工程中默認自帶 Admin 管理工具并作為內置應用在 settings.py 中的 INSTALLED_APPS 上進行了注冊:INSTALLED_APPS = [ # 注冊 admin 應用 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', # 注冊應用 'hello_app']接下來為了能訪問到這個后臺管理的 Web 頁面,Django 在初始化項目時,自動為我們添加了這個 Web 頁面的相關的路由信息:# first_django_app/urls.pyurlpatterns = [ # admin后臺管理頁面的地址 url('admin/', admin.site.urls),]注意:如果不想要這個自帶的后臺管理系統,也可以直接刪除這個 URLconf 配置即可。我們啟動測試的 Django 工程,然后手動訪問這個 admin 后臺,具體操作如下:(django-manual) [root@server first_django_app]# python manage.py runserver 0.0.0.0:8888Watching for file changes with StatReloaderPerforming system checks...System check identified no issues (0 silenced).You have 17 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.Run 'python manage.py migrate' to apply them.April 12, 2020 - 15:11:39Django version 2.2.11, using settings 'first_django_app.settings'Starting development server at http://0.0.0.0:8888/Quit the server with CONTROL-C.注意:對于上面出現的告警信息是因為我們沒有做數據庫的遷移。在 Django 中為我們設計了一些內部的表,比如用來保存 admin 管理工具賬號的表,比如保存 session 的表等。只需要使用 Django 提供的 makemigrations 和 migrate 命令即可:(django-manual) [root@server first_django_app]# python manage.py makemigrationsMigrations for 'hello_app': hello_app/migrations/0002_auto_20200412_1512.py - Alter field vip_level on member(django-manual) [root@server first_django_app]# python manage.py migrateOperations to perform: Apply all migrations: admin, auth, contenttypes, hello_app, sessionsRunning migrations: Applying contenttypes.0001_initial... OK Applying auth.0001_initial... OK Applying admin.0001_initial... OK Applying admin.0002_logentry_remove_auto_add... OK Applying admin.0003_logentry_add_action_flag_choices... OK Applying contenttypes.0002_remove_content_type_name... OK Applying auth.0002_alter_permission_name_max_length... OK Applying auth.0003_alter_user_email_max_length... OK Applying auth.0004_alter_user_username_opts... OK Applying auth.0005_alter_user_last_login_null... OK Applying auth.0006_require_contenttypes_0002... OK Applying auth.0007_alter_validators_add_error_messages... OK Applying auth.0008_alter_user_username_max_length... OK Applying auth.0009_alter_user_last_name_max_length... OK Applying auth.0010_alter_group_name_max_length... OK Applying auth.0011_update_proxy_permissions... OK Applying hello_app.0002_auto_20200412_1512... OK Applying sessions.0001_initial... OK可以通過 Navicate 工具查看到 Django 給我們生成了 10 張表內部表,這 10 張表之間存在許多關聯的地方。如下圖所示:再次使用 runserver 命令啟動 Django 工程時,就不會再有遷移相關的告警提示了。這個時候我們訪問 admin/ 地址,就可以看到如下的登錄頁面。這里的登錄賬號和密碼我們可以通過 Django 提供的 createsuperuser 命令完成:(django-manual) [root@server first_django_app]# python manage.py createsuperuserUsername (leave blank to use 'root'): adminEmail address: [email protected]: Password (again): Superuser created successfully.上面命令執行成功后,我們可以看到數據庫的 auth_user 表中多出了一條用戶信息的記錄,正是我們上面設置的用戶名和密碼信息。使用這個剛創建的用戶名和密碼登錄進入管理系統頁面如下:在首頁中,我們可以對用戶和用戶組進行管理,包括對表中數據的更新、修改和刪除。如下是新增用戶以及新增完成后的操作示例。完成新增完成后,還可以選擇該條記錄進行調整,直接影響的就是數據庫中的 auth_user 表。除了管理內置的表外,Django 的 admin 功能還可以管理我們定義的模型表,實現基本的增刪改查操作。我們繼續用前面的會員表和會員等級表進行實操。在 hello_app/admin.py 文件中添加如下代碼,將我們定義的模型注冊到 admin 模塊中:from django.contrib import adminfrom .models import Member, VIPLevel# Register your models here.admin.site.register([Member, VIPLevel])重啟 Django 工程,然后繼續訪問 admin 管理頁面,讓我們可以看到我們定義的模型出現在了管理頁面上。而且我們還可以操作我們的模型,進行增刪改查數據。注意:這里表中數據的展示方式是按照模型類中定義的 __str__ 魔法函數決定的。這里可以看到 Member 表的關聯表中數據,使用起來非常方便。

1. ListView 類視圖介紹和使用

ListView 類從名字上看應該是處理和列表相關的視圖,事實也是如此。我們同樣基于前面 TemplateView 中實現的例子,使用 ListView 來減少代碼,體驗下 ListView 視圖類給我們帶來的便捷。實驗1:重現 TemplateView 功能;首先我們完成前面 TemplateView 的簡單功能,然后在提出幾個報錯的問題,這些問題比較簡單,只要看下報錯位置和源碼信息就非常清楚了。首先我先給出一個基本的知識:ListView 具備 TemplateView 所有的功能與屬性,并做了許多擴展。那么前面由TemplateView 實現的所有示例直接將 TemplateView 替換成 ListView 也是可以運行的?我們以最簡單的一個模板例子進行演示:在 hello_app/views.py 中新增一個視圖類 TestListView1:(django-manual) [root@server first_django_app]# cat templates/test1.html <p>{{ content }}</p><div>{{ spyinx.age }}</div>class TestListView1(ListView): template_name = 'test1.html'在 hello_app/urls.py 中新增一個 URLConf 配置:urlpatterns = [ # ... path('test_list_view1/', views.TestListView1.as_view(extra_context=context_data), name='test_list_view1')]使用 runserver 命令啟動后,請求對應的 URL 地址,發現異常,錯誤原因也很明顯,缺少queryset。上面的出錯是在父類的 get() 方法中,那么修改 hello_app/views.py 位置的視圖類 TestListView1,重新定義自己的 get() 方法,如下:class TestListView1(ListView): template_name = 'test1.html' def get(self, request, *args, **kwargs): return self.render_to_response(context={'content': '正文1', 'spyinx': {'age': 29}})啟動服務后同樣報錯,不過這次錯誤不一樣了,如下:同樣顯示的是沒有對象列表。我們通過查看源碼也能輕易解決這個問題。這個問題留到后面分析原源碼的時候去解決?,F在直接給出兩個報錯的解決方案,如下:# 解決第一個沒有自定義get()函數報錯class TestListView1(ListView): template_name = 'test1.html' queryset = Member.objects.all() # 另一種寫法也是可以的 # model = Member # 解決第二個自定義get()函數報錯class TestListView1(ListView): template_name = 'test1.html' object_list = Member.objects.all() def get(self, request, *args, **kwargs): return self.render_to_response(context={'content': '正文1', 'spyinx': {'age': 29}})最后正確的結果如下,這里直接用 curl 命令請求結果顯示即可。[root@server ~]# curl http://127.0.0.01:8888/hello/test_list_view1/<p>正文1</p><div>29</div>實驗2:簡化分頁代碼。同樣前面 TemplateView 做的那個顯示會員列表的基礎上,簡化原來的代碼。準備原來的模板文件,修改分頁那塊代碼:(django-manual) [root@server first_django_app]# cat templates/test.html<html><head><style type="text/css"> .page{ margin-top: 10px; font-size: 14px; } .member-table { width: 50%; text-align: center; }</style></head><body><p>會員信息-第{{ page_obj.number }}頁/共{{ paginator.num_pages }}頁, 每頁{{ paginator.per_page }}條, 總共{{ paginator.count }}條</p><div><table border="1" class="member-table"> <thead> <tr> <th>姓名</th> <th>年齡</th> <th>性別</th> <th>職業</th> <th>所在城市</th> </tr> </thead> <tbody> {% for member in members %} <tr> <td>{{ member.name }}</td> <td>{{ member.age }}</td> {% if member.sex == 0 %} <td>男</td> {% else %} <td>女</td> {% endif %} <td>{{ member.occupation }}</td> <td>{{ member.city }}</td> </tr> {% endfor %} </tbody></table><div ><div class="page"></div></div></div></body></html>添加一個新的 ListView 視圖類,如下:class TestListView2(ListView): template_name = 'test.html' model = Member queryset=Member.objects.all() paginate_by = 10 ordering = ["-age"] context_object_name = "members"注意:ordering 是設置顯示列表的排序字段,字符串前面的 “-” 號表示的是按照這個字段倒序排列,可以設置多個排序字段。context_object_name 一定要設置,對應模板文件中的列表數據名。添加 URLConf 配置:urlpatterns = [ # ... path('test_list_view2/', views.TestListView2.as_view(), name='test_list_view2')]啟動 first_django_app 工程,從瀏覽器上直接訪問這個 url,就能看到和前面差不多的結果了??梢詡魅?page 參數控制第幾頁,但是頁大小在視圖中已經固定,無法改變。從這個簡單的例子,我們可以看到,相比前面用 TemplateView 手工對數據進行分頁,這里的 ListView 內部已經給我們實現了這樣的功能。我們只需要簡單的配置下,設置好相關屬性,就能夠實現對表的分頁查詢,這樣能節省重復的代碼操作,讓項目看起來簡潔優雅。但是我們一定要了解背后實現的邏輯,能看得懂源碼,這樣每一步的報錯,我們都能在源碼中找到原因,并迅速解決問題。接下來就是對 ListView 視圖類源碼的學習與分析。

2.2 分層定義以及常見協議

在畫出了計算機網絡的分層模型之后,我們還需要向面試官解釋每一層的定義以及介紹常見的協議。2.2.1 應用層應用層(Application Layer)是 5 層協議的頂層,顧名思義,應用層的作用是通過操作系統中應用進程(例如電子郵件、瀏覽器文件傳輸)提供網絡交互。應用層最常被問到的是 HTTP 協議和 DNS 域名解析協議(在之后的小節我們會詳細講解相關題目),其次還有一些后端開發過程中可能會接觸的協議,例如支持文件傳輸的 FTP 協議(例如需要從 Windows 開發機傳輸文件到 Linux 服務器時使用),以及支持電子郵件的 SMTP 協議(例如需要開發電子郵件讀寫的相關爬蟲時需要開放郵箱的 SMTP 協議)。2.2.2 傳輸層傳輸層(Transport Layer)主要是為了實現端口到端口(port to port)的通信,計算機的不同進程都會被分配不同的端口,例如域名默認的 80 端口。從接收和發送信息的角度可以分為兩大功能:復用:把操作系統的多個進程利用一個傳輸層接口發送信息;分用:把收到的信息利用傳輸層接口分發到操作系統的不同進程。傳輸層涉及到兩個常見的協議,幾乎是面試必考協議:傳輸控制協議(TCP,Transmission Control Protocol):特點是面向連接,基于報文段傳輸,能夠保證消息可靠交付的協議;用戶數據包協議(UDP,User Datagram Protocol):特點是無連接,基于用戶數據報傳輸,不保證消息可靠交付,只盡 "最大努力交付"。2.2.3 網絡層計算機之間的通信可以分為位于同一個子網絡(也就是局域網,Local Area Network)和位于不同的子網絡(廣域網,Wide Area Network),網絡層協議解決的問題就是如何判斷兩臺計算機是否屬于同一個子網絡中。網絡層最常涉及的協議是 IP 協議 ,就是 TCP/IP 協議族中的 IP 網絡協議,可見其重要性。此外,還有和 IP 協議相關的 ARP(Address Resolution Protocol,地址解析協議),以太網的數據傳輸最直接依賴的是 MAC 地址,ARP 協議的作用就是將 IP 地址轉換為 MAC 地址。2.2.4 數據鏈路層數據鏈路層(Data-Link Layer)位于物理層和網絡層之間,對于兩個不同主機之間的數據傳輸,可能會經過多個路由器中轉,中間的這條鏈路就是我們關注的重點,我們把兩個主機抽象為兩個點,鏈路層協議解決的問題就是 "點對點" 的數據傳輸。數據鏈路層將網絡層交付的 IP 數據包封裝成幀(Frame),其中每一幀包括了數據以及必要的控制信息(比如同步信息、尋址信息、差錯控制信息),這種設計方案非常類似 TCP 協議中的控制位(由此也能看出計算機網絡設計的互通性)。如果通過差錯控制信息校驗出了錯誤,那么就會在本層丟棄這個幀,糾正錯誤是通過網絡層的 TCP 協議完成。PPP 協議(Point to Point Protocol):在兩個點之間傳輸數據包的協議,因為本層涉及的協議在面試中考察甚少,基本可以只做簡單了解。2.2.5 物理層物理層(Physical Layer)是 5 層協議模型中最底層的協議,就是通過物理手段(例如網線,電纜)將計算機連接起來,提供信息傳輸的物理媒介,數據由 0 和 1 二進制信號構成,傳輸單元是比特位。因為關于物理層的研究更偏向于通信相關的原理,我們只需要了解本層的定義即可。

3. ISO/OSI 七層模型和 TCP/IP 四層模型

網絡分層的主要是采取分治策略,使得復雜問題簡單化,網絡模塊組件化。每一層都有自己的職責,每一層對上一層提供服務,這樣實現了職責單一,進而提高了組件的復用性。。在網絡標準化過程中,國際標準化組織(ISO)把計算機網絡分為 7 層,叫做開放系統互聯模型(OSI)。同時,TCP/IP 四層模型正處于開發階段。最終,OSI 模型和 TCP/IP 模型有一些差異,對比圖如下:從圖中可以看出,ISO/OSI 七層模型和 TCP/IP 四層模型之間存在兩個差異:TCP/IP 參四層模型沒有表示層和會話層。那么,TCP/IP 參考模型不需要表示層和會話層嗎?答案是:“如果需要這兩層,就由應用程序員來實現”。經過這么多年發展來看,需要表示層和會話層的場景并不多。TCP/IP 模型好像沒有鏈路層,其實在網絡協議實現過程中,鏈路層包含了網卡驅動部分和物理介質部分,所以通常把二者統一叫做鏈路層。各層具體功能解釋如下:應用層(Application Layer) 是由應用程序自定義的協議格式。不同的領域,對網絡應用程序的需求是不同的,必須給用戶自定義協議格式的權利。最廣泛的應用層協議應該是 HTTP 了吧,幾乎每個人都在用。表示層(Presentation)主要是定義數據格式。比如,加密和解密、壓縮和解壓縮。在 TCP/IP 模型中,表示層協議包含在應用層里。比如 SSL/TLS 協議。會話層(Session Layer)用于在兩個通信實體之間建立會話、維護會話、終止會話。設置檢查點,當系統出現崩潰拉起后,通過尋找檢查點恢復運行。在 TCP/IP 模型中,會話層協議包含在應用層里。比如 NetBIOS 協議。傳輸層(Transport Layer) 是在兩個通信主機之間進行報文傳送。在 TCP/IP 模型中最重要的兩個傳輸協議就是 TCP 和 UDP,前者是可靠的、面向字節流的傳輸;后者是不可靠的、面向數據報(Datagram)的傳輸。提示:通常把 TCP 傳輸的報文稱作報文段(Segment)。通常把 UDP 傳輸的報文稱作消息(Message)或者是數據報(Datagram)。網絡層(Network Layer)是將 IP 分組從源端路由到目的端。網絡層是為傳輸層服務的,在發送端的網絡層收到傳輸層的數據后,必要時會對數據切片以后再封包發送。實現網絡層協議是路由器的主要職責,是構建通信子網的基礎。比如, RIP、OSPF 就是最基礎的路由協議。路由器是通過 IP 分組的目的 IP 地址查找本地路由表,尋找轉發的目標端。所以路由器的主要工作就是路由選擇和轉發,IP 分組是逐跳(hop)轉發的。提示:通常把網絡層傳輸的報文叫做 IP 分組(Packet)。鏈路層(Data Link Layer)是為網絡層服務的。發送的時候將網絡層的數據分片,封裝成幀(Frame),然后順序發送。與網絡層實現端到端的路由不同,鏈路層是實現節點之間的數據傳送。常用的鏈路層協議就是以太網(Ethernet)協議。提示:通常把鏈路層傳輸的報文叫做幀(Frame)。物理層(Physical Layer)是描述如何在物理介質中傳輸 bit 位。比如,用多少伏電壓表示“1”,用多少伏電壓表示“0”等。我們把分層的網絡參考模型叫做協議棧(Protocol Stack)。目前所有網絡協議棧的實現都采用了 TCP/IP 參考模型。協議棧的實現是包含在操作系統內核中的,比如 Windows 和 Unix-like 系統。

4.4 響應前端的請求

有求也要有應。服務端也需要在前端發出請求的時候做出相應的響應。4.4.1 使用 nodeconst express = require("express");const mysql = require('mysql');const bodyParser = require("body-parser");const router = express.Router(); // express 路由const app = express();// 使用 bodyParser 中間件app.use(bodyParser.json());app.use(bodyParser.urlencoded({ extended: true }));registerRouter(); // 路由注冊執行app.use(router); // 使用 router/** * 構建返回結果 * @param {*} code * @param {*} data * @param {*} error */const buildResponse = (code, data = {}, error = null) => { return { code, data, error }}// 創建 mysql 鏈接, 本地數據庫為 mkconst connection = mysql.createConnection({ host : 'localhost', user : 'root', password : 'ok36369ok', database : 'mk'});connection.connect();// 端口號const port = process.env.PORT || 8080;// 監聽module.exports = app.listen(port, () => { console.log(`Server listening on http://localhost:${port}, Ctrl+C to stop`);});/***********路由注冊模塊*************//***路由注冊函數*/function registerRouter() { // 查詢課程 router.get("/course/get", function(req, res) { connection.query('SELECT id, name, teacher, start_time as startTime, end_time as endTime from course', function (error, results, fields) { if (error) throw error; const responseData = buildResponse(0, {items: results}) // mysql 查詢結果包裝后進行返回 res.send(responseData); // send 結果 }); }); // other router ..}如上所示,我們引入了 Express 框架、bodyParser 以及 mysql 相關的庫。其中, bodyParser 可以解析請求中 body 的內容。 不過我們的重點應該是最下面的函數 registerRouter,這個函數是用來注冊我們所有的路由的。我們之后的路由也都會寫在這個函數里面。好了,回歸正題。為了使服務端響應前端的請求,我們在上面的代碼中注冊了一個路由: router.get("/course/get", callback)如果前端發送請求到 “/course/get” ,那服務端會觸發回調函數 callback,對應到上面代碼中,我們可以看到:內部會執行一個查詢所有課程的 sql 語句;將查詢后的數據進行包裝,變為 {code: 0, data: xxx, error: xxxx} 這樣的格式;返回數據。相同的,我們也可以使用 java 來實現后端的邏輯。4.4.2 使用 Javapackage com.demo;import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.JSONObject;import javax.servlet.ServletException;import javax.servlet.annotation.WebServlet;import javax.servlet.http.HttpServlet;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.io.PrintWriter;import java.sql.*;import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Map;@WebServlet("/course/get")public class HelloWorld extends HttpServlet { // JDBC 驅動名 static final String JDBC_DRIVER = "com.mysql.jdbc.Driver"; // 數據庫 URL static final String DB_URL = "jdbc:mysql://localhost:3306/mk?useUnicode=true&useJDBCCompliantTimezoneShift\n" + "=true&useLegacyDatetimeCode=false&serverTimezone=UTC"; // 數據庫的用戶名 static final String USER = "root"; // 數據庫的密碼 static final String PW = "ok36369ok"; /** * 包裝返回結果 */ private Map buildResponse(int code, Object data, String error) { Map<String, Object> res = new HashMap<String, Object>(); res.put("code", code); res.put("data", data); res.put("error", error); return res; } protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { } // 獲取課程使用 GET, 會進入 doGet protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Connection conn = null; Statement stmt = null; // 設置編碼格式 request.setCharacterEncoding("utf-8"); response.setContentType("text/json; charset=utf-8"); PrintWriter out = response.getWriter(); Map<String,Object> resMap = new HashMap<String,Object>(); // 返回結果, Map 類型 try{ // 注冊 JDBC 驅動 Class.forName(JDBC_DRIVER); // 打開鏈接 System.out.println("連接數據庫..."); conn = DriverManager.getConnection(DB_URL,USER,PW); // 執行查詢 System.out.println(" 實例化Statement對象..."); stmt = conn.createStatement(); String sql; sql = "SELECT * FROM course"; ResultSet rs = stmt.executeQuery(sql); List<Map> Courses = new ArrayList<Map>(); // 展開結果集數據庫 while(rs.next()){ // 通過字段檢索 Map<String,Object> map = new HashMap<String, Object>(); int id = rs.getInt("id"); String name = rs.getString("name"); String teacher = rs.getString("teacher"); Date startTime = rs.getDate("start_time"); Date endTime = rs.getDate("end_time"); // 分別將 mysql 查詢結果 put 到 map中 map.put("id", id); map.put("name", name); map.put("teacher", teacher); map.put("startTime", startTime); map.put("endTime", endTime); Courses.add(map); } Map<String, List> data = new HashMap<String, List>(); // 定義返回數據的 data部分, Map 類型 data.put("items", Courses); // data 添加 items, items 就是我們要的課程列表數據 // 構建輸出數據 resMap = buildResponse(0, data, null); // 完成后關閉 rs.close(); stmt.close(); conn.close(); }catch(SQLException se){ // 處理 JDBC 錯誤 se.printStackTrace(); }catch(Exception e){ // 處理 Class.forName 錯誤 e.printStackTrace(); }finally{ // 關閉資源 try{ if(stmt!=null) stmt.close(); }catch(SQLException se2){ } try{ if(conn!=null) conn.close(); }catch(SQLException se){ se.printStackTrace(); } } String responseData = JSON.toJSONString(resMap);// 將 Map 類型的結果序列化為 String out.println(responseData); // 返回結果 }}這里主要使用的是 servlet 的方式來為前端提供服務,對于請求課程列表來說,使用到 GET 方法,因此本例子中會進入到 doGet 方法中。另外,所用到的技術在章節須知中也有講到,這里就不再累贅。實現的代碼雖然和 node 端有所差別,但是思想都是一樣的。無非也是使用 MySQL 的查詢結果, 拼裝成前端所需要的數據。并進行返回。

4.2 read_excel() 函數

Pandas 提供了 read_excel() 函數用于 Excel 數據文件的讀取,并為其提供了很多的參數用于解析 Excel 數據的設置,接下來我們列舉一些該函數中常用的幾個參數設置:參數名稱描述io傳入 execl 文件的對象,字符串可能是一個 URL 包括的類型(http,ftp,s3和文件)sheet_name指定讀取第幾個 sheet 表,默認是第一個,sheet_name=None 可以讀取所有的 sheetheader指定哪一行作為列名,如果不需要里面的行作為列名,要寫上 header = Nonenames指定列名,如 names=[‘A’,‘B’,‘C’,‘D’,‘E’]1. 首先,我們通過 read_excel() 的 io 將 “execl數據demo.xlsx ”中的數據解析出來:# 導入pandas包import pandas as pd# 指定導入的文件地址 默認是file,這里的路徑中省略了 file:/data_path="C:/Users/13965/Documents/myFuture/IMOOC/pandasCourse-progress/data_source/execl數據demo.xlsx"data = pd.read_excel(data_path)print(data)# --- 輸出結果 --- 編程語言 推出時間 主要創始人0 java 1995年 James Gosling1 python 1991年 Guido van Rossum2 C 1972年 Dennis MacAlistair Ritchie3 js 1995年 Brendan Eich4 php 2012年 Rasmus Lerdorf5 C++ 1983年 Bjarne Stroustrup輸出解析:這里是將 Excel 中的第一個工作表的數據解析出來 Excel 中行和列分別解析為行數據和列數據,默認第一行作為列名稱。2. 參數 sheet_name該參數用于設置讀取 Excel 數據中的哪個工作表,值為工作表名稱,首先我們在 “execl數據demo.xlsx” 文件中新增一個工作表 Sheet2 ,數據的內容直接從工作表1 中復制,并為每一項數據添加 “_2” 作為區分:首先我們通過設置 sheet_name=‘Sheet2’ 來讀取 Sheet2 中的數據:# 導入pandas包import pandas as pd# 指定導入的文件地址 默認是file,這里的路徑中省略了 file:/data_path="C:/Users/13965/Documents/myFuture/IMOOC/pandasCourse-progress/data_source/execl數據demo.xlsx"# 這里我們傳入參數 sheet_namedata = pd.read_excel(data_path,sheet_name='Sheet2')print(data)# --- 輸出結果 ---編程語言_2 推出時間_2 主要創始人_20 java_2 1995年_2 James Gosling_21 python_2 1991年_2 Guido van Rossum_22 C_2 1972年_2 Dennis MacAlistair Ritchie_23 js_2 1995年_2 Brendan Eich_24 php_2 1996年_2 Rasmus Lerdorf_25 C++_2 1997年_2 Bjarne Stroustrup_2輸出解析:這里可以看到解析的數據為 Sheet2 中的數據內容。接下來我們設置 sheet_name=None ,來解析所有工作表的數據:# 導入pandas包import pandas as pd# 指定導入的文件地址 默認是file,這里的路徑中省略了 file:/data_path="C:/Users/13965/Documents/myFuture/IMOOC/pandasCourse-progress/data_source/execl數據demo.xlsx"# 這里我們傳入參數 sheet_namedata = pd.read_excel(data_path,sheet_name=None)print(data)# --- 輸出結果 ---{'Sheet1': 編程語言 推出時間 主要創始人0 java 1995年 James Gosling1 python 1991年 Guido van Rossum2 C 1972年 Dennis MacAlistair Ritchie3 js 1995年 Brendan Eich4 php 2012年 Rasmus Lerdorf5 C++ 1983年 Bjarne Stroustrup, 'Sheet2': 編程語言_2 推出時間_2 主要創始人_20 java_2 1995年_2 James Gosling_21 python_2 1991年_2 Guido van Rossum_22 C_2 1972年_2 Dennis MacAlistair Ritchie_23 js_2 1995年_2 Brendan Eich_24 php_2 1996年_2 Rasmus Lerdorf_25 C++_2 1997年_2 Bjarne Stroustrup_2}輸出解析:可以看到輸出結果是將 Sheet1 和 Sheet2 的數據都進行了解析,格式是字典形式,工作表名為 key,值是解析的工作表的數據值。3. 參數 headerheader 參數用于指定哪一行作為列名:# 導入pandas包import pandas as pd# 指定導入的文件地址 默認是file,這里的路徑中省略了 file:/data_path="C:/Users/13965/Documents/myFuture/IMOOC/pandasCourse-progress/data_source/execl數據demo.xlsx"# 這里我們傳入參數 header data = pd.read_excel(data_path,header =2)print(data)# ---輸出結果--- python 1991年 Guido van Rossum0 C 1972年 Dennis MacAlistair Ritchie1 js 1995年 Brendan Eich2 php 2012年 Rasmus Lerdorf3 C++ 1983年 Bjarne Stroustrup輸出解析:這里可以看到將第二行的數據解析為列名,并從改行詳細繼續解析數據。4. 參數 namesnames 用于手動傳入列名的值:# 導入pandas包import pandas as pd# 指定導入的文件地址 默認是file,這里的路徑中省略了 file:/data_path="C:/Users/13965/Documents/myFuture/IMOOC/pandasCourse-progress/data_source/execl數據demo.xlsx"# 這里我們傳入參數 header data = pd.read_excel(data_path,names =["a1","a2","a3"])print(data)# ---輸出結果--- a1 a2 a30 java 1995年 James Gosling1 python 1991年 Guido van Rossum2 C 1972年 Dennis MacAlistair Ritchie3 js 1995年 Brendan Eich4 php 2012年 Rasmus Lerdorf5 C++ 1983年 Bjarne Stroustrup輸出解析:這里手動傳入了列的名稱為 a1 , a2 , a3。

2. Scrapy 與 Selenium 結合爬取京東圖書數據

接下來我們對上面的代碼進行調整和 Scrapy 框架結合,而第一步需要做的就是建立好相應的工程:# 創建爬蟲項目PS D:\shencong\scrapy-lessons\code\chap17> scrapy startproject jdbooks# ...# 進入到spider目錄,使用genspider命令創建爬蟲文件PS D:\shencong\scrapy-lessons\code\chap17\jd_books\jd_books\spiders> scrapy genspider jd www.jd.com創建好工程后就是編寫 items.py 中的 JdBooksItem 類,這非常簡單,直接根據我們前面定義好的字段編寫相應的代碼即可:class JdBooksItem(scrapy.Item): # define the fields for your item here like: # name = scrapy.Field() book_name = scrapy.Field() price = scrapy.Field() comments = scrapy.Field() shop_name = scrapy.Field() book_detail_url = scrapy.Field()整個項目的難點是如何實現下一頁數據的爬取?前面可以使用 selenium 去自動點擊頁號而進入下一個,然而在 Scrapy 中卻不太好這樣處理。我們通過分析京東搜索的 URL 后發現,其搜索的 URL 可以簡化為如下形式:https://search.jd.com/Search?keyword=搜索關鍵字&page=(頁號* 2 - 1),我們只需要提供搜索的關鍵字以及相應的請求頁號即可。例如下圖所示:京東搜索 URL 參數因此我們在 settings.py 中準備兩個參數:一個是搜索的關鍵字,另一個是爬取的最大頁數。具體的形式如下:# settings.py# ...KEYWORD = "網絡爬蟲"MAX_PAGE = 10緊接著我們可以構造出請求不同頁的 URL 并交給 Scrapy 的引擎和調度器去處理,對應的 Spider 代碼如下:# 代碼位置:jd_books/jd_books/spiders/jd.pyfrom urllib.parse import quotefrom scrapy import Spider, Requestfrom jd_books.items import JdBooksItemclass JdSpider(Spider): name = 'jd' allowed_domains = ['www.jd.com'] start_urls = ['http://www.jd.com/'] base_url = "https://search.jd.com/Search?keyword={}&page={}" def start_requests(self): keyword = self.settings.get('KEYWORD', "Python") for page in range(1, self.settings.get('MAX_PAGE') + 1): url = self.base_url.format(quote(keyword), page * 2 - 1) yield Request(url=url, callback=self.parse_books, dont_filter=True) def parse_books(self, response): goods_list = response.xpath('//div[@id="J_goodsList"]/ul/li') print('本頁獲取圖書數目:{}'.format(len(goods_list))) for good in goods_list: book_name_em = good.xpath('.//div[@class="p-name"]/a/em/text()').extract()[0] book_name_font = good.xpath('.//div[@class="p-name"]/a/em/font/text()').extract() book_name_font = "".join(book_name_font) if book_name_font else "" book_name = f"{book_name_em}{book_name_font}" book_detail_url = good.xpath('.//div[@class="p-name"]/a/@href').extract()[0] price = good.xpath('.//div[@class="p-price"]/strong/i/text()').extract()[0] comments = good.xpath('.//div[@class="p-commit"]/strong/a/text()').extract()[0] shop_name = good.xpath('.//div[@class="p-shopnum"]/a/text()').extract()[0] item = JdBooksItem() item['book_name'] = book_name item['book_detail_url'] = book_detail_url item['price'] = price item['comments'] = comments item['shop_name'] = shop_name yield item上面的代碼就是單純的生成多頁的 Request 請求 (start_requests() 方法) 和解析網頁數據 (parse_books() 方法)。這個解析數據完全依賴于我們獲取完整的頁面源碼,那么如何在 Scrapy 中使用 selenium 去請求 URL 然后獲取頁面源碼呢?答案就是下載中間件。我們在編寫一個下載中間件,攔截發送的 request 請求,對于請求京東圖書數據的請求我們會切換成 selenium 的方式去獲取網頁源碼,然后將得到的頁面源碼封裝成 Response 響應并返回。在生成 Scrapy 項目中已經為我們準備好了一個 middleware.py 文件,我們按照上面的思路來完成相應代碼,具體內容如下:import timefrom scrapy import signalsfrom scrapy.http.response.html import HtmlResponsefrom selenium import webdriverfrom selenium.webdriver.support import expected_conditions as ECfrom selenium.webdriver.support.ui import WebDriverWaitfrom selenium.webdriver.common.by import By# useful for handling different item types with a single interfacefrom itemadapter import is_item, ItemAdapteroptions = webdriver.ChromeOptions()# 注意,使用這個參數我們就不會看到啟動的google瀏覽器,無界面運行options.add_argument('-headless')options.add_experimental_option("excludeSwitches", ['enable-automation'])class JdBooksSpiderMiddleware: # 保持不變 # ... class JdBooksDownloaderMiddleware: # Not all methods need to be defined. If a method is not defined, # scrapy acts as if the downloader middleware does not modify the # passed objects. def __init__(self): self.driver = webdriver.Chrome(options=options, executable_path="C:/Users/Administrator/AppData/Local/Google/Chrome/Application/chromedriver.exe") # ... def process_request(self, request, spider): # Called for each request that goes through the downloader # middleware. # Must either: # - return None: continue processing this request # - or return a Response object # - or return a Request object # - or raise IgnoreRequest: process_exception() methods of # installed downloader middleware will be called print('使用 selenium 請求頁面:{}'.format(request.url)) if request.url.startswith("https://search.jd.com/Search"): # 如果是獲取京東圖書數據的請求,使用selenium方式獲取頁面 self.driver.get(request.url) time.sleep(2) # 將滾動條拖到最底端,獲取一頁完整的60條數據 height = self.driver.execute_script("return document.body.scrollHeight;") self.driver.execute_script(f"window.scrollBy(0, {height})") time.sleep(2) # 將最后渲染得到的頁面源碼作為響應返回 return HtmlResponse(url=request.url, body=self.driver.page_source, request=request, encoding='utf-8', status=200) # ...緊接著,我們需要將這個下載中間件在 settings.py 中啟用:DOWNLOADER_MIDDLEWARES = { 'jd_books.middlewares.JdBooksDownloaderMiddleware': 543,}最后我們來完成下數據的存儲,繼續使用 mongodb 來保存抓取到的數據。從實際測試中發現京東的搜索結果在100頁中肯定會有不少重復的數據。因此我們的 item pipelines 需要完成2個處理,分別是去重和保存。來直接看代碼:import pymongofrom scrapy.exceptions import DropItemfrom itemadapter import ItemAdapterclass JdBooksPipeline: def open_spider(self, spider): self.client = pymongo.MongoClient(host='47.115.61.209', port=27017) self.client.admin.authenticate("admin", "shencong1992") db = self.client.scrapy_manual self.collection = db.jd_books def process_item(self, item, spider): try: book_info = { 'book_name': item['book_name'], 'comments': item['comments'], 'book_detail_url': item['book_detail_url'], 'shop_name': item['shop_name'], 'price': item['price'], } self.collection.insert_one(book_info) except Exception as e: print("插入數據異常:{}".format(str(e))) return item def close_spider(self, spider): self.client.close()class DuplicatePipeline: """ 去除重復的數據,重復數據直接拋出異常,不會進入下一個流水線處理 """ def __init__(self): self.book_url_set = set() def process_item(self, item, spider): if item['book_detail_url'] in self.book_url_set: print('重復搜索結果:book={}, url={}'.format(item['book_name'], item['book_detail_url'])) raise DropItem('duplicate book info, drop it') self.book_url_set.add(item['book_detail_url']) return item我們直接使用 Item 的 book_detail_url 字段來判斷數據是否重復。此外,同樣需要將這兩個 Item Pipelines 在 settings.py 中啟用,且保證 DuplicatePipeline 需要先于 JdBooksPipeline 處理:ITEM_PIPELINES = { 'jd_books.pipelines.DuplicatePipeline': 200, 'jd_books.pipelines.JdBooksPipeline': 300,}最后剩下一步就是禁止遵守 Robot 協議:ROBOTSTXT_OBEY = True至此,我們的 Scrapy 和 Selenium 結合爬取京東圖書數據的項目就算完成了。為了快速演示效果,我們將最大請求頁設置為10,然后運行代碼看看實際的爬取效果:107

1. 基于 Cookie 的自動登錄

如果是想基于基本的 API 方式登錄,我們會面臨兩大難點:手機驗證碼校驗 ,如下圖所示:起點網站登錄手機發送驗證碼滑動驗證碼校驗,如下圖所示:起點網站登錄滑動驗證碼繞過這些校驗的方法超過了本教程的知識范圍,故我們不再次詳細討論。好在起點網支持自動登錄過程,也就是 Cookie 登錄:起點網支持自動登錄第一次手動登錄起點,選擇自動登錄后,起點網站返回的 Cookie 信息就會保存至本地。下次再訪問起點網時,通過請求帶上該 Cookie 信息就能正確識別用戶,實現自動登錄過程。Cookie 存在本地,就存在被代碼讀取的可能。通常而言,我們來使用 Python 中的 browsercookie 庫可以獲取瀏覽器的 cookie,目前它只支持 Chrome 和 FireFox 兩種瀏覽器。不過對于 Chrome 80.X 版本的瀏覽器,其中的 cookie 信息被加密了,我們無法按照早期的操作進行 cookie 讀取。不過網上這個博客給出了一個解密 Cookie 的代碼,我們拿過來簡單改造下,做成一個輔助模塊:# 參考文檔:https://blog.csdn.net/u012552769/article/details/105001108import sqlite3import urllib3import osimport jsonimport sysimport base64from cryptography.hazmat.backends import default_backendfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modesurllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)def dpapi_decrypt(encrypted): import ctypes import ctypes.wintypes class DATA_BLOB(ctypes.Structure): _fields_ = [('cbData', ctypes.wintypes.DWORD), ('pbData', ctypes.POINTER(ctypes.c_char))] p = ctypes.create_string_buffer(encrypted, len(encrypted)) blobin = DATA_BLOB(ctypes.sizeof(p), p) blobout = DATA_BLOB() retval = ctypes.windll.crypt32.CryptUnprotectData( ctypes.byref(blobin), None, None, None, None, 0, ctypes.byref(blobout)) if not retval: raise ctypes.WinError() result = ctypes.string_at(blobout.pbData, blobout.cbData) ctypes.windll.kernel32.LocalFree(blobout.pbData) return resultdef aes_decrypt(encrypted_txt): with open(os.path.join(os.environ['LOCALAPPDATA'], r"Google\Chrome\User Data\Local State"), encoding='utf-8', mode="r") as f: jsn = json.loads(str(f.readline())) encoded_key = jsn["os_crypt"]["encrypted_key"] encrypted_key = base64.b64decode(encoded_key.encode()) encrypted_key = encrypted_key[5:] key = dpapi_decrypt(encrypted_key) nonce = encrypted_txt[3:15] cipher = Cipher(algorithms.AES(key), None, backend=default_backend()) cipher.mode = modes.GCM(nonce) decryptor = cipher.decryptor() return decryptor.update(encrypted_txt[15:])def chrome_decrypt(encrypted_txt): if sys.platform == 'win32': try: if encrypted_txt[:4] == b'x01x00x00x00': decrypted_txt = dpapi_decrypt(encrypted_txt) return decrypted_txt.decode() elif encrypted_txt[:3] == b'v10': decrypted_txt = aes_decrypt(encrypted_txt) return decrypted_txt[:-16].decode() except WindowsError: return None else: raise WindowsErrordef get_cookies_from_chrome(domain, key_list): sql = f'SELECT name, encrypted_value as value FROM cookies where host_key like "%{domain}%"' filename = os.path.join(os.environ['USERPROFILE'], r'AppData\Local\Google\Chrome\User Data\default\Cookies') con = sqlite3.connect(filename) con.row_factory = sqlite3.Row cur = con.cursor() cur.execute(sql) cookie_dict = {} for row in cur: if row['value'] is not None: name = row['name'] value = chrome_decrypt(row['value']) if value is not None and name in key_list: cookie_dict[name] = value return cookie_dictTips:上述這段代碼不用糾結細節,前面函數的主要是替 get_cookies_from_chrome() 函數服務的,而該函數的輸入要搜索的網站以及提取相應網站 cookie 信息中的某個具體字段,返回相應的結果。本人 Python 3.8.2 安裝的是 win32 版本,該段代碼親測有效。來看看起點中文網給讀者生成的 cookie 數據,我們調用上面的獲取 cookie 信息的代碼來從中提取相應數據:起點的cookie信息print(get_cookies_from_chrome('qidian.com', '_csrfToken'))print(get_cookies_from_chrome('qidian.com', 'e1'))print(get_cookies_from_chrome('qidian.com', 'e2'))執行上述代碼我們可以得到如下結果:PS C:\Users\spyinx> & "D:/Program Files (x86)/python3/python.exe" c:/Users/spyinx/Desktop/test_cookie.py{'_csrfToken': 'YJklLmhMNpEfuSmqZZGaK72D4sUVJty52gyKwXXX'}{'e1': '%7B%22pid%22%3A%22qd_p_qidian%22%2C%22eid%22%3A%22qd_A08%22%2C%22l1%22%3A1%7D'}{'e2': '%7B%22pid%22%3A%22qd_p_qidian%22%2C%22eid%22%3A%22qd_A10%22%2C%22l1%22%3A1%7D'}這說明我們通過前面的代碼能爭取獲取到起點網保存在 Chrome 瀏覽器中的 cookie 信息。因此,前面的代碼將作為我們讀取起點用戶登錄 Cookie 的重要輔助模塊。Tips:這個測試只能在裝了 Chrome 瀏覽器的 Windows 系統上進行測試,或者是 Linux 的桌面版我們首先來創建一個起點爬蟲:PS C:\Users\Administrator\Desktop> scrapy startproject qidian_spider接下里我們來看看要提取的我的書架的信息:我的書架書籍信息對應的 items.py 的內容如下:# https://docs.scrapy.org/en/latest/topics/items.htmlimport scrapyclass QidianSpiderItem(scrapy.Item): # define the fields for your item here like: # name = scrapy.Field() # 分組 category = scrapy.Field() # 小說名 name = scrapy.Field() # 最新章節 latest_chapter = scrapy.Field() # 作者 author = scrapy.Field() # 更新時間 update_time = scrapy.Field() # 閱讀進度 progress_status = scrapy.Field()接下來,在爬蟲部分需要請求該頁面然后提取相應的數據,我們的爬蟲代碼如下:"""獲取用戶書架數據"""import jsonfrom urllib import parsefrom scrapy import Requestfrom scrapy.spiders import Spiderfrom .get_cookie import get_cookies_from_chromefrom ..items import QidianSpiderItemclass BookCaseSpider(Spider): name = "bookcase" # 構造函數 def __init__(self): # 最重要的就是這個獲取起點的cookie數據了,這里保存了之前用戶登錄的cookie信息 self.cookie_dict = get_cookies_from_chrome( "qidian.com", ["_csrfToken", "e1", "e2", "newstatisticUUID", "ywguid", "ywkey"] ) def start_requests(self): url = "https://my.qidian.com/bookcase" # http請求時附加上cookie信息 yield Request(url=url, cookies=self.cookie_dict) def parse(self, response): item = QidianSpiderItem() books = response.xpath('//table[@id="shelfTable"]/tbody/tr') for book in books: category = book.xpath('td[@class="col2"]/span/b[1]/a[1]/text()').extract_first() name = book.xpath('td[@class="col2"]/span/b[1]/a[2]/text()').extract_first() latest_chapter = book.xpath('td[@class="col2"]/span/a/text()').extract_first() update_time = book.xpath('td[3]/text()').extract_first() author = book.xpath('td[@class="col4"]/a/text()').extract_first() progress_status = book.xpath('td[@class="col5"]/a/text()').extract_first() item['category'] = category item['name'] = name item['latest_chapter'] = latest_chapter item['update_time'] = update_time item['author'] = author item['progress_status'] = progress_status print(f'get item = {item}') yield item最重要的方法就是那個獲取 cookie 信息的方法了,正是靠著這個 cookie,我們才能獲取相應的用戶書架的網頁并提取相應的書籍信息。接下來,簡單實現一個 item pipeline 用于保存書架上的書籍信息,該代碼位于 scrapy/pipelines.py 文件中,默認的 pipelines 都寫會在這里:# 源碼位置:scrapy/pipelines.py# Define your item pipelines here## Don't forget to add your pipeline to the ITEM_PIPELINES setting# See: https://docs.scrapy.org/en/latest/topics/item-pipeline.htmlimport json# useful for handling different item types with a single interfacefrom itemadapter import ItemAdapterclass QidianSpiderPipeline: def open_spider(self, spider): self.file = open("bookcase.json", 'w+', encoding='utf-8') def close_spider(self, spider): self.file.close() def process_item(self, item, spider): data = json.dumps(dict(item), ensure_ascii=False) self.file.write(f"{data}\n") return item最后別忘了在 settings.py 中添加這個 item pipeline:ITEM_PIPELINES = { 'qidian_spider.pipelines.QidianSpiderPipeline': 300,}我們運行下這個爬蟲,看看是否能抓到我們想要的數據:PS C:\Users\Administrator\Desktop> scrapy crawl bookcase最后的結果如下:獲取用戶的書架上書籍信息這樣,我們就成功實現了用戶登錄后的訪問動作。接下來我們在這個基礎上進一步擴展,實現清除書架上所有的書籍,類似于淘寶的一鍵清除購物車。

1.2 標簽

DTL 中標簽的寫法為: {% 標簽 %},常用的標簽有 for 循環標簽,條件判斷標簽 if/elif/else。部分標簽在使用時,需要匹配對應的結束標簽。Django 中常用的內置標簽如下表格所示:標簽描述{% for %}for 循環,遍歷變量中的內容{% if %}if 判斷{% csrf_token %}生成 csrf_token 的標簽{% static %}讀取靜態資源內容{% with %}多用于給一個復雜的變量起別名{% url %}反向生成相應的 URL 地址{% include 模板名稱 %}加載指定的模板并以標簽內的參數渲染{% extends 模板名稱 %}模板繼承,用于標記當前模板繼承自哪個父模板{% block %}用于定義一個模板塊1.2.1 for 標簽的用法:{# 遍歷列表 #}<ul>{% for person in persons %}<li>{{ person }}</li>{% endfor %}</ul>{# 遍歷字典 #}<ul>{% for key, value in data.items %}<li>{{ key }}:{{ value }}</li>{% endfor %}</ul>在 for 循環標簽中,還提供了一些變量,供我們使用:變量描述forloop.counter當前循環位置,從1開始forloop.counter0當前循環位置,從0開始forloop.revcounter反向循環位置,從n開始,到1結束forloop.revcounter0反向循環位置,從n-1開始,到0結束forloop.first如果是當前循環的第一位,返回Trueforloop.last如果是當前循環的最后一位,返回Trueforloop.parentloop在嵌套for循環中,獲取上層for循環的forloop實驗:(django-manual) [root@server first_django_app]# cat templates/test_for.html 遍歷列表:<ul>{% spaceless %}{% for person in persons %}{% if forloop.first %}<li>第一次:{{ forloop.counter }}:{{ forloop.counter0 }}:{{ person }}:{{ forloop.revcounter }}:{{ forloop.revcounter }}</li>{% elif forloop.last %}<li>最后一次:{{ forloop.counter }}:{{ forloop.counter0 }}:{{ person }}:{{ forloop.revcounter }}:{{ forloop.revcounter }}</li>{% else %}</li>{{ forloop.counter }}:{{ forloop.counter0 }}:{{ person }}:{{ forloop.revcounter }}:{{ forloop.revcounter }}</li>{% endif %}{% endfor %}{% endspaceless %}</ul>{% for name in name_list %} {{ name }}{% empty %} <p>name_list變量為空</p>{% endfor %} 倒序遍歷列:{% spaceless %}{% for person in persons reversed %}<p>{{ person }}:{{ forloop.revcounter }}</p>{% endfor %}{% endspaceless %}遍歷字典:{% spaceless %}{% for key, value in data.items %}<p>{{ key }}:{{ value }}</p>{% endfor %}{% endspaceless %}(django-manual) [root@server first_django_app]# python manage.py shellPython 3.8.1 (default, Dec 24 2019, 17:04:00) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linuxType "help", "copyright", "credits" or "license" for more information.(InteractiveConsole)>>> from django.template.loader import get_template>>> tp = get_template('test_for.html')>>> content = tp.render(context={'persons':['張三', '李四', '王二麻子'], 'name_list': [], 'data': {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'}})>>> print(content)遍歷列表:<ul><li>第一次:1:0:張三:3:3</li></li>2:1:李四:2:2</li><li>最后一次:3:2:王二麻子:1:1</li></ul> <p>name_list變量為空</p> 倒序遍歷列:<p>王二麻子:3</p><p>李四:2</p><p>張三:1</p>遍歷字典:<p>key1:value1</p><p>key2:value2</p><p>key3:value3</p>1.2.2 if 標簽:支持嵌套,判斷的條件符號與變量之間必須使用空格隔開,示例如下。(django-manual) [root@server first_django_app]# cat templates/test_if.html{% if spyinx.sex == 'male' %}<label>他是個男孩子</label>{% else %}<label>她是個女孩子</label>{% endif %}(django-manual) [root@server first_django_app]# python manage.py shellPython 3.8.1 (default, Dec 24 2019, 17:04:00) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linuxType "help", "copyright", "credits" or "license" for more information.(InteractiveConsole)>>> from django.template.loader import get_template>>> tp = get_template('test_if.html')>>> tp.render(context={'spyinx': {'age':29, 'sex': 'male'}})'\n<label>他是個男孩子</label>\n\n'>>> tp.render(context={'spyinx': {'age':29, 'sex': 'male'}})1.2.3 csrf_token 標簽:這個標簽會生成一個隱藏的 input 標簽,其值為一串隨機的字符串。這個標簽通常用在頁面上的 form 標簽中。在渲染模塊時,使用 RequestContext,由它處理 csrf_token 這個標簽。下面來做個簡單的測試:# 模板文件[root@server first_django_app]# cat templates/test_csrf.html <html><head></head><body><form enctype="multipart/form-data" method="post">{% csrf_token %}<div><span>賬號:</span><input type="text" style="margin-bottom: 10px" placeholder="請輸入登錄手機號/郵箱" /></div><div><span>密碼:</span><input type="password" style="margin-bottom: 10px" placeholder="請輸入密碼" /></div><div><label style="font-size: 10px; color: grey"><input type="checkbox" checked="checked"/>7天自動登錄</label></div><div style="margin-top: 10px"><input type="submit"/></div></form></body></html># 定義視圖:hello_app/views.py[root@server first_django_app]# cat hello_app/views.py from django.shortcuts import render# Create your views here.def test_csrf_view(request, *args, **kwargs): return render(request, 'test_csrf.html', context={})# 配置URLconf:hello_app/urls.py[root@server first_django_app]# cat hello_app/urls.pyfrom django.urls import pathurlpatterns = [ path('test-csrf/', views.test_csrf_view),]# 最后激活虛擬環境并啟動django工程[root@server first_django_app] pyenv activate django-manual(django-manual) [root@server first_django_app]# python manage.py runserver 0:8881Watching for file changes with StatReloaderPerforming system checks...System check identified no issues (0 silenced).You have 17 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.Run 'python manage.py migrate' to apply them.March 27, 2020 - 04:10:05Django version 2.2.11, using settings 'first_django_app.settings'Starting development server at http://0:8881/Quit the server with CONTROL-C.現在通過外部請求這個URL,效果圖如下。通過右鍵的檢查功能,可以看到 {% csrf_token %} 被替換成了隱藏的 input 標簽,value 屬性是一個隨機的長字符串:csrf_token標簽1.2.4 with 標簽:對某個變量重新命名并使用:(django-manual) [root@server first_django_app]# cat templates/test_with.html {% spaceless %}{% with age1=spyinx.age %}<p>{{ age1 }}</p>{% endwith %}{% endspaceless %}{% spaceless %}{% with spyinx.age as age2 %}<div>{{ age2 }} </div>{% endwith %}{% endspaceless %}(django-manual) [root@server first_django_app]# python manage.py shellPython 3.8.1 (default, Dec 24 2019, 17:04:00) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linuxType "help", "copyright", "credits" or "license" for more information.(InteractiveConsole)>>> from django.template.loader import get_template>>> tp = get_template('test_with.html')>>> content = tp.render(context={'spyinx': {'age': 29}})>>> print(content)<p>29</p><div>29 </div>1.2.5 spaceless 標簽:移除 HTML 標簽中的空白字符,包括空格、tab鍵、換行等。具體示例參見上面的示例;1.2.6 cycle 標簽:循環提取 cycle 中的值,用法示例如下# 假設模板如下:{% for l in list %}<tr class="{% cycle 'r1' 'r2' 'r3'%}">{{l}}</tr>{% endfor %}# 對于傳入的 list 參數為:['l1', 'l2', 'l3'],最后生成的結果如下:<tr class="r1">l1</tr><tr class="r2">l2</tr><tr class="r3">l3</tr>1.2.7 include 標簽:加載其他模板進來。{% include "base/base.html" %}除了加載模板進來外,include 標簽還可以像加載進來的模板傳遞變量。假設我們有個 base/base.html 模板文件,其內容為:{# base/base.html #}Hello {{ name|default:"Unknown" }}此時,我們引入 base.html 模板文件時,可以給 name 傳遞變量值:{% include "base/base.html" with name="test" %}

1. 創建第一個Django應用程序

在創建第一個 Django 應用程序之前,我們需要使用 pyenv 工具創建相應的虛擬環境,操作如下:新建一個統一的目錄,用于存放 Django 工程代碼:[root@server ~]# mkdir django-manual[root@server ~]# cd django-manual/進入虛擬環境,然后建立 django-manual 虛擬環境。一般而言每個 Django 工程會創建一個虛擬環境,這樣避免各個 Python 項目之間發生包沖突。建立好虛擬環境之后,激活虛擬環境。操作如下:[root@server django-manual]# pyenv versions system* 3.8.1 (set by /root/.pyenv/version) 3.8.1/envs/env-3.8.1 env-3.8.1 # 新建django-manual虛擬環境[root@server django-manual]# pyenv virtualenv 3.8.1 django-manualLooking in links: /tmp/tmpllz1yd5eRequirement already satisfied: setuptools in /root/.pyenv/versions/3.8.1/envs/django-manual/lib/python3.8/site-packages (41.2.0)Requirement already satisfied: pip in /root/.pyenv/versions/3.8.1/envs/django-manual/lib/python3.8/site-packages (19.2.3)# 手動新建的虛擬環境[root@server django-manual]# pyenv activate django-manualpyenv-virtualenv: prompt changing will be removed from future release. configure `export PYENV_VIRTUALENV_DISABLE_PROMPT=1' to simulate the behavior.(django-manual) [root@server django-manual]#接下來,我們需要安裝 Django 2.2.11 版本(提示: django 3.0 最近發布了,但是還處于初步完善階段,所以本次介紹以 Django 2.2.11 版本為準):(django-manual) [root@server django-manual]# pip install django==2.2.11 -i https://pypi.tuna.tsinghua.edu.cn/simpleLooking in indexes: https://pypi.tuna.tsinghua.edu.cn/simpleCollecting django==2.2.11 Downloading https://pypi.tuna.tsinghua.edu.cn/packages/be/76/7ccbcf52366590ca76997ce7860308b257b79962a4e4fada5353f72d7be5/Django-2.2.11-py3-none-any.whl (7.5MB) |████████████████████████████████| 7.5MB 71kB/s Requirement already satisfied: sqlparse in /root/.pyenv/versions/3.8.1/envs/django-manual/lib/python3.8/site-packages (from django==2.2.11) (0.3.1)Requirement already satisfied: pytz in /root/.pyenv/versions/3.8.1/envs/django-manual/lib/python3.8/site-packages (from django==2.2.11) (2019.3)Installing collected packages: djangoSuccessfully installed django-2.2.11WARNING: You are using pip version 19.2.3, however version 20.0.2 is available.You should consider upgrading via the 'pip install --upgrade pip' command.(django-manual) [root@server django-manual]# python -c "import django; print(django.__version__)"2.2.11這樣子,虛擬環境中就安裝好了 Django 2.2.11。Django 提供 django-admin 命令來幫助我們創建項目和應用,我們只需要使用 django-admin 命令即可快速創建我們的第一個 Django 項目:(django-manual) [root@server django-manual]# django-admin startproject first_django_app(django-manual) [root@server django-manual]# (django-manual) [root@server django-manual]# tree ..└── first_django_app ├── first_django_app │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── manage.py2 directories, 5 filesTips:盡量在 Linux 平臺上完成實驗,在 Windows 下操作在安裝 mysqlclient 模塊是會稍微有些工作要做。Django 項目可以由多個應用(app)組成,每個應用是一個邏輯上劃分,即將某一個功能模塊劃歸到這個應用。創建一個應用使用 django-admin starapp 應用名即可:(django-manual) [root@server django-manual]# cd first_django_app/(django-manual) [root@server first_django_app]# django-admin startapp hello_app(django-manual) [root@server first_django_app]# tree ..├── first_django_app│ ├── __init__.py│ ├── settings.py│ ├── urls.py│ └── wsgi.py├── hello_app│ ├── admin.py│ ├── apps.py│ ├── __init__.py│ ├── migrations│ │ └── __init__.py│ ├── models.py│ ├── tests.py│ └── views.py└── manage.py3 directories, 12 files可以看到,在使用 django-admin 執行創建 hello_app 應用后,該命令給我們生成了 hello_app 以及若干代碼文件。為了能讓 Django 項目運行起來,我們需要調整下 settings.py 文件中的配置:# settings.py 中默認使用 sqlite3...DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), }}...# 現在調整成 mysql 數據庫,讀者需要自行準備mysql服務DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'django_manual', 'USER': 'store', 'PASSWORD': 'xxxxxxxxx', 'HOST': '180.76.152.113', 'PORT': '9000', }}有了數據庫支持,還需要在 Django 那邊安裝 mysql 相關的模塊包。通常安裝的是 mysqlclient 模塊:# 安裝相應的依賴包(django-manual) [root@server first_django_app]# yum install mysql-devel -y(django-manual) [root@server first_django_app]# pip install mysqlclient -i https://pypi.tuna.tsinghua.edu.cn/simpleLooking in indexes: https://pypi.tuna.tsinghua.edu.cn/simpleCollecting mysqlclient Downloading https://pypi.tuna.tsinghua.edu.cn/packages/d0/97/7326248ac8d5049968bf4ec708a5d3d4806e412a42e74160d7f266a3e03a/mysqlclient-1.4.6.tar.gz (85kB) |████████████████████████████████| 92kB 22.2MB/s Installing collected packages: mysqlclient Running setup.py install for mysqlclient ... doneSuccessfully installed mysqlclient-1.4.6最后一件事情,在啟動 Django 服務之前,必須要先創建數據庫。Django 服務默認并不會幫我們創建好數據庫,我們必須手工建好數據庫,然后再啟動 Django 服務:[root@server ~]# mysql -u store -pxxxxxxxxx -h 180.76.152.113 -P9000Welcome to the MariaDB monitor. Commands end with ; or \g.Your MySQL connection id is 37328Server version: 5.7.26 MySQL Community Server (GPL)Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.MySQL [(none)]> CREATE DATABASE IF NOT EXISTS django_manual DEFAULT CHARSET utf8;Query OK, 1 row affected (0.00 sec)MySQL [(none)]> show databases;+--------------------+| Database |+--------------------+| information_schema || alarms || dashboard || django_manual || graph || mysql || performance_schema || sys || uic |+--------------------+15 rows in set (0.00 sec)MySQL [(none)]> exit;Bye# ---------------------------------------------------------------------------------------(django-manual) [root@server first_django_app]# python manage.py runserver 0.0.0.0:8888Watching for file changes with StatReloaderPerforming system checks...System check identified no issues (0 silenced).You have 17 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.Run 'python manage.py migrate' to apply them.March 13, 2020 - 07:29:06Django version 2.2.11, using settings 'first_django_app.settings'Starting development server at http://0.0.0.0:8888/Quit the server with CONTROL-C.在完成上述這些步驟后,基本的工程就搭建起來了。接下來我們從外面訪問這個端口結果如下:第一次訪問 django 服務這個是 Django 在配置中做的一個白名單機制,它有一個 ALLOWED_HOSTS 配置參數,它用來設置訪問服務的白名單。如果想要允許任何主機訪問,直接設置如下:(django-manual) [root@server first_django_app]# cat first_django_app/settings.py...DEBUG = TrueALLOWED_HOSTS = ['*']...另外,默認 setting.py 中的 DEBUG 參數為 True。正因為如此,請求報錯才會有如此詳細的提示。在正真上線部署時候,這個參數一定要關閉。如果我設置如下參數再次從外部請求該 Django 服務時,瀏覽器的輸出結果如下圖所示??梢钥吹剑孙@示一個冷冰冰 400 錯誤,無任何提示。這樣屏蔽錯誤信息,防止有人從錯誤結果中推斷服務漏洞,達到滲透的目的。(django-manual) [root@server first_django_app]# cat first_django_app/settings.py...DEBUG = FalseALLOWED_HOSTS = ['127.0.0.1']...設置 Debug=False 的錯誤輸出我們重新設置好 DEBUG 和 ALLOWED_HOSTS 參數后,再次請求 Django 服務,可以得到 Dajngo 內置的歡迎頁面,提示我們服務已經正常啟動和運行。正常訪問 Django 服務現在,我們寫一個最簡單的 Hello, World 字符串輸出到頁面上。改動 first_django_app/first_django_app/url.py 文件,這個文件是所有 url 請求路由的入口,所有的映射關系都會先通過這里:(django-manual) [root@server first_django_app]# pwd/root/django-manual/first_django_app(django-manual) [root@server first_django_app]# cat first_django_app/urls.py """注釋性文本,省略"""from django.contrib import adminfrom django.urls import path## 新導入模塊from django.http import HttpResponse## 視圖函數def hello_world(*args, **kwargs): return HttpResponse("Hello, world.", content_type="text/plain")urlpatterns = [ path('admin/', admin.site.urls), ####添加的url映射,由上面的hello_world()函數處理 path('hello/', hello_world),]再次啟動 Django 服務,訪問 8888 端口的 /hello/ 路徑,可以看到頁面出現 “Hello, world.” 這樣的字符,說明我們的第一個 URL 接口完成。頁面輸出 Hello,World.

2. 基于 Scrapy 框架的頭條熱點新聞數據爬取

還是按照我們以前的套路來進行,第一步是使用 startproject 命令創建熱點新聞項目:[root@server ~]# cd scrapy-test/[root@server scrapy-test]# pyenv activate scrapy-testpyenv-virtualenv: prompt changing will be removed from future release. configure `export PYENV_VIRTUALENV_DISABLE_PROMPT=1' to simulate the behavior.(scrapy-test) [root@server scrapy-test]# scrapy startproject toutiao_hotnewsNew Scrapy project 'toutiao_hotnews', using template directory '/root/.pyenv/versions/3.8.1/envs/scrapy-test/lib/python3.8/site-packages/scrapy/templates/project', created in: /root/scrapy-test/toutiao_hotnewsYou can start your first spider with: cd toutiao_hotnews scrapy genspider example example.com(scrapy-test) [root@server scrapy-test]#接著,根據我們要抓取的新聞數據字段,先定義好 Item:import scrapyclass ToutiaoHotnewsItem(scrapy.Item): # define the fields for your item here like: # name = scrapy.Field() title = scrapy.Field() abstract = scrapy.Field() source = scrapy.Field() source_url = scrapy.Field() comments_count = scrapy.Field() behot_time = scrapy.Field()有了 Item 之后,我們需要新建一個 Spider,可以使用 genspider 命令生成,也可以手工編寫一個 Python 文件,代碼內容如下:# 代碼位置:toutiao_hotnews/toutiao_hotnews/spiders/hotnews.pyimport copyimport hashlibfrom urllib.parse import urlencodeimport jsonimport timefrom scrapy import Request, Spiderfrom toutiao_hotnews.items import ToutiaoHotnewsItemhotnews_url = "https://www.toutiao.com/api/pc/feed/?"params = { 'category': 'news_hot', 'utm_source': 'toutiao', 'widen': 1, 'max_behot_time': '', 'max_behot_time_tmp': '', 'as': '', 'cp': ''}headers = { 'referer': 'https://www.toutiao.com/ch/news_hot/', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.112 Safari/537.36'}cookies = {'tt_webid':'6856365980324382215'} max_behot_time = '0'def get_as_cp(): # 該函數主要是為了獲取as和cp參數,程序參考今日頭條中的加密js文件:home_4abea46.js zz = {} now = round(time.time()) e = hex(int(now)).upper()[2:] a = hashlib.md5() a.update(str(int(now)).encode('utf-8')) i = a.hexdigest().upper() if len(e) != 8: zz = {'as':'479BB4B7254C150', 'cp':'7E0AC8874BB0985'} return zz n = i[:5] a = i[-5:] r = '' s = '' for i in range(5): s = s + n[i] + e[i] for j in range(5): r = r + e[j + 3] + a[j] zz ={ 'as': 'A1' + s + e[-3:], 'cp': e[0:3] + r + 'E1' } return zzclass HotnewsSpider(Spider): name = 'hotnews' allowed_domains = ['www.toutiao.com'] start_urls = ['http://www.toutiao.com/'] # 記錄次數,注意停止 count = 0 def _get_url(self, max_behot_time): new_params = copy.deepcopy(params) zz = get_as_cp() new_params['as'] = zz['as'] new_params['cp'] = zz['cp'] new_params['max_behot_time'] = max_behot_time new_params['max_behot_time_tmp'] = max_behot_time return "{}{}".format(hotnews_url, urlencode(new_params)) def start_requests(self): """ 第一次爬取 """ request_url = self._get_url(max_behot_time) self.logger.info(f"we get the request url : {request_url}") yield Request(request_url, headers=headers, cookies=cookies, callback=self.parse) def parse(self, response): """ 根據得到的結果得到獲取下一次請求的結果 """ self.count += 1 datas = json.loads(response.text) data = datas['data'] for d in data: item = ToutiaoHotnewsItem() item['title'] = d['title'] item['abstract'] = d.get('abstract', '') item['source'] = d['source'] item['source_url'] = d['source_url'] item['comments_count'] = d.get('comments_count', 0) item['behot_time'] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(d['behot_time'])) self.logger.info(f'得到的item={item}') yield item if self.count < self.settings['REFRESH_COUNT']: max_behot_time = datas['next']['max_behot_time'] self.logger.info("we get the next max_behot_time: {}, and the count is {}".format(max_behot_time, self.count)) yield Request(self._get_url(max_behot_time), headers=headers, cookies=cookies)這里的代碼之前一樣,第一次構造 Request 請求在 start_requests() 方法中,接下來在根據每次請求結果中獲取 max_behot_time 值再進行下一次請求。另外我使用了全局計算變量 count 來模擬刷新的次數,控制請求熱點新聞次數,防止無限請求下去。此外,Scrapy logger 在每個 spider 實例中提供了一個可以訪問和使用的實例,我們再需要打印日志的地方直接使用 self.logger 即可,它對應日志的配置如下:# 代碼位置:toutiao_hotnews/settings.py# 注意設置下下載延時DOWNLOAD_DELAY = 5# ...#是否啟動日志記錄,默認TrueLOG_ENABLED = True LOG_ENCODING = 'UTF-8'#日志輸出文件,如果為NONE,就打印到控制臺LOG_FILE = 'toutiao_hotnews.log'#日志級別,默認DEBUGLOG_LEVEL = 'INFO'# 日志日期格式 LOG_DATEFORMAT = "%Y-%m-%d %H:%M:%S"#日志標準輸出,默認False,如果True所有標準輸出都將寫入日志中,比如代碼中的print輸出也會被寫入到LOG_STDOUT = False接下來是 Item Pipelines 部分,這次我們將抓取到的新聞保存到 MySQL 數據庫中。此外,我們還有一個需求就是選擇當前最新的10條新聞發送到本人郵件,這樣每天早上就能定時收到最新的頭條新聞,豈不美哉。首先我想給自己的郵件發送 HTML 格式的數據,然后列出最新的10條新聞,因此第一步是是準備好模板熱點新聞的模板頁面,具體模板頁面如下:# 代碼位置: toutiao_hotnews/html_template.pyhotnews_template_html = """<!DOCTYPE html><html><head> <title>頭條熱點新聞一覽</title></head><style type="text/css"></style><body><div class="container"><h3 style="margin-bottom: 10px">頭條熱點新聞一覽</h3>$news_list</div></body></html>"""要注意一點,Scrapy 的郵箱功能只能發送文本內容,不能發送 HTML 內容。為了能支持發送 HTML 內容,我繼承了原先的 MailSender 類,并對原先的 send() 方法稍做改動:# 代碼位置: mail.pyimport logging from email import encoders as Encodersfrom email.mime.base import MIMEBasefrom email.mime.multipart import MIMEMultipartfrom email.mime.nonmultipart import MIMENonMultipartfrom email.mime.text import MIMETextfrom email.utils import COMMASPACE, formatdatefrom scrapy.mail import MailSenderfrom scrapy.utils.misc import arg_to_iterlogger = logging.getLogger(__name__)class HtmlMailSender(MailSender): def send(self, to, subject, body, cc=None, mimetype='text/plain', charset=None, _callback=None): from twisted.internet import reactor #####去掉了與attachs參數相關的判斷語句,其余代碼不變############# msg = MIMEText(body, 'html', 'utf-8') ########################################################## to = list(arg_to_iter(to)) cc = list(arg_to_iter(cc)) msg['From'] = self.mailfrom msg['To'] = COMMASPACE.join(to) msg['Date'] = formatdate(localtime=True) msg['Subject'] = subject rcpts = to[:] if cc: rcpts.extend(cc) msg['Cc'] = COMMASPACE.join(cc) if charset: msg.set_charset(charset) if _callback: _callback(to=to, subject=subject, body=body, cc=cc, attach=attachs, msg=msg) if self.debug: logger.debug('Debug mail sent OK: To=%(mailto)s Cc=%(mailcc)s ' 'Subject="%(mailsubject)s" Attachs=%(mailattachs)d', {'mailto': to, 'mailcc': cc, 'mailsubject': subject, 'mailattachs': len(attachs)}) return dfd = self._sendmail(rcpts, msg.as_string().encode(charset or 'utf-8')) dfd.addCallbacks( callback=self._sent_ok, errback=self._sent_failed, callbackArgs=[to, cc, subject, len(attachs)], errbackArgs=[to, cc, subject, len(attachs)], ) reactor.addSystemEventTrigger('before', 'shutdown', lambda: dfd) return dfd緊接著就是我們的 pipelines.py 文件中的代碼:import loggingfrom string import Templatefrom itemadapter import ItemAdapterimport pymysqlfrom toutiao_hotnews.mail import HtmlMailSenderfrom toutiao_hotnews.items import ToutiaoHotnewsItemfrom toutiao_hotnews.html_template import hotnews_template_htmlfrom toutiao_hotnews import settingsclass ToutiaoHotnewsPipeline: logger = logging.getLogger('pipelines_log') def open_spider(self, spider): # 使用自己的MailSender類 self.mailer = HtmlMailSender().from_settings(spider.settings) # 初始化連接數據庫 self.db = pymysql.connect( host=spider.settings.get('MYSQL_HOST', 'localhost'), user=spider.settings.get('MYSQL_USER', 'root'), password=spider.settings.get('MYSQL_PASS', '123456'), port=spider.settings.get('MYSQL_PORT', 3306), db=spider.settings.get('MYSQL_DB_NAME', 'mysql'), charset='utf8' ) self.cursor = self.db.cursor() def process_item(self, item, spider): # 插入sql語句 sql = "insert into toutiao_hotnews(title, abstract, source, source_url, comments_count, behot_time) values (%s, %s, %s, %s, %s, %s)" if item and isinstance(item, ToutiaoHotnewsItem): self.cursor.execute(sql, (item['title'], item['abstract'], item['source'], item['source_url'], item['comments_count'], item['behot_time'])) return item def query_data(self, sql): data = {} try: self.cursor.execute(sql) data = self.cursor.fetchall() except Exception as e: logging.error('database operate error:{}'.format(str(e))) self.db.rollback() return data def close_spider(self, spider): sql = "select title, source_url, behot_time from toutiao_hotnews where 1=1 order by behot_time limit 10" # 獲取10條最新的熱點新聞 data = self.query_data(sql) news_list = "" # 生成html文本主體 for i in range(len(data)): news_list += "<div><span>{}、<a href=https://www.toutiao.com{}>{} [{}]</a></span></div>".format(i + 1, data[i][1], data[i][0], data[i][2]) msg_content = Template(hotnews_template_html).substitute({"news_list": news_list}) self.db.commit() self.cursor.close() self.db.close() self.logger.info("最后統一發送郵件") # 必須加return,不然會報錯 return self.mailer.send(to=["[email protected]"], subject="這是一個測試", body=msg_content, cc=["[email protected]"])這里我們會將 MySQL 的配置統一放到 settings.py 文件中,然后使用 spider.settings 來讀取響應的信息。其中 open_spider() 方法用于初始化連接數據庫,process_item() 方法用于生成 SQL 語句并提交插入動作,最后的 close_spider() 方法用于提交數據庫執行動作、關閉數據庫連接以及發送統一新聞熱點郵件。下面是我們將這個 Pipeline 在 settings.py 中開啟以及配置數據庫信息、郵件服務器信息,同時也要注意關閉遵守 Robot 協議,這樣爬蟲才能正常執行。ROBOTSTXT_OBEY = False# 啟動對應的pipelineITEM_PIPELINES = { 'toutiao_hotnews.pipelines.ToutiaoHotnewsPipeline': 300,}# 數據庫配置MYSQL_HOST = "180.76.152.113"MYSQL_PORT = 9002MYSQL_USER = "store"MYSQL_PASS = "數據庫密碼"MYSQL_DB_NAME = "ceph_check"# 郵箱配置MAIL_HOST = 'smtp.qq.com'MAIL_PORT = 25MAIL_FROM = '[email protected]'MAIL_PASS = '你的授權碼'MAIL_USER = '[email protected]'來看看我們這個頭條新聞爬蟲的爬取效果,視頻演示如下:83

1. Windows 系統上的安裝

友情提示:以下內容為安裝步驟演示與補充說明,幫助加深理解。如果只想學習快速安裝的話,記住一句話:一路 Next 就可以。忽略以下內容直接從 1.13 開始看吧。1.1 首先從 Git 官網直接下載安裝程序。打開官網可以看到 Windows 版本的安裝包下載位置,如紅色箭頭所示,點擊即可開始下載最新版本安裝包。安裝包下載完成后,即可進行本地安裝。接下來我將以 Git-2.15.1.2-64 版本來進行講解。1.2 雙擊下載好的 .exe 文件,彈出如下安裝界面,直接點擊 “Next”。1.3 選擇安裝路徑,點擊右側 “Browse” 按鈕更改路徑。建議大家單獨創建一個目錄,專門進行安裝。我一般習慣固定使用一個非 C 盤來專門安裝辦公軟件,每個軟件單獨使用一個文件夾,這樣方便管理,盡量養成一個良好的習慣。1.4 選擇好安裝路徑后,直接點擊 “Next”,出現如下界面。這一步默認勾選了紅色框內容,其他選項大家可以依據需要進行選擇。我還多選擇了 “Additional icons” 項目,表示會在桌面生成圖標。倒數第二項表示:在所有控制臺窗口中使用 TrueType 字體。最后一項表示:是否每天檢查 Git 是否有 Windows 更新。1.5 選擇完畢后,繼續 “Next”,出現如下界面。這一步沒有什么特別需要注意的,默認即可。然后同樣點擊 “Next”。1.6 接下來出現這個頁面是選擇 Git 使用的文本編輯器,默認即可。然后點擊 “Next”。1.7 這一步是用來調整 Path 環境。第一種配置是 “僅從 Git Bash 使用 Git”。這是最安全的選擇,因為您的 PATH 根本不會被修改,只能使用 Git Bash 的 Git 命令行工具。但是這將不能通過第三方軟件使用。第二種配置是 “從命令行以及第三方軟件進行 Git”。該選項也是安全的,因為它僅向 PATH 添加了一些最小的 Git 包裝器,以避免使用可選的 Unix 工具造成環境混亂。能夠從 Git Bash,命令提示符和 Windows PowerShell 以及在 PATH 中尋找 Git 的任何第三方軟件中使用 Git。這也是推薦的選項。第三種配置是 “從命令提示符使用 Git 和可選的 Unix 工具”。警告:這將覆蓋 Windows 工具,如 “ find 和 sort ”。只有在了解其含義后才使用此選項。使用推薦配置即可,點擊 “Next” 按鈕繼續到下圖的界面:  1.8 在這個界面選擇 HTTP 傳輸。第一個選項是 “使用 OpenSSL 庫”。服務器證書將使用 ca-bundle.crt 文件進行驗證。第二個選項是 “使用本地 Windows 安全通道庫”。服務器證書將使用 Windows 證書存儲驗證。此選項還允許您使用公司的內部根 CA 證書,例如通過 Active Directory Domain Services 。我使用默認選項,點擊 “Next” 按鈕繼續到下圖的界面:  1.9 繼續來到這個界面,配置行尾符號轉換。第一個選項是 “簽出 Windows 風格,提交 Unix 風格的行尾”。簽出文本文件時,Git 會將 LF 轉換為 CRLF。提交文本文件時,CRLF 將轉換為 LF。對于跨平臺項目,這是 Windows 上的推薦設置(“ core.autocrlf” 設置為 “ true”)第二個選項是 “按原樣簽出,提交 Unix 樣式的行尾”。簽出文本文件時,Git 不會執行任何轉換。 提交文本文件時,CRLF 將轉換為 LF。對于跨平臺項目,這是 Unix 上的建議設置(“ core.autocrlf” 設置為 “ input”)第三種選項是 “按原樣簽出,按原樣提交”。當簽出或提交文本文件時,Git 不會執行任何轉換。不建議跨平臺項目選擇此選項(“ core.autocrlf” 設置為 “ false”)那么 CRLF 和 LF 有什么區別?CRLF 是 carriage return line feed 的縮寫,中文意思是 回車換行。句尾使用回車換行兩個字符 (即我們常在 Windows 編程時使用”\r\n” 換行)。LF 是 line feed 的縮寫,中文意思是換行。我選擇默認第一項,點擊 “Next” 按鈕繼續到下一步:  1.10 配置終端模擬器和 Git Bash 一起使用第一個選項是 “使用 MinTTY(MSYS2 的默認終端)”。Git Bash 將使用 MinTTY 作為終端模擬器,該模擬器具有可調整大小的窗口,非矩形選擇和 Unicode 字體。Windows 控制臺程序(例如交互式 Python)必須通過 “ winpty” 啟動才能在 MinTTY 中運行。第二個選項是 “使用 Windows 的默認控制臺窗口”。Git 將使用 Windows 的默認控制臺窗口(“cmd.exe”),該窗口可以與 Win32 控制臺程序(如交互式 Python 或 node.js)一起使用,但默認的回滾非常有限,需要配置為使用 unicode 字體以正確顯示非 ASCII 字符,并且在 Windows 10 之前,其窗口不能自由調整大小,并且只允許矩形文本選擇。此處默認選了第一種選項,然后繼續點擊 “Next” 按鈕進入下一步:1.11 配置額外選項第一個選項是 “啟用文件系統緩存”。文件系統數據將被批量讀取并緩存在內存中用于某些操作(“core.fscache” 設置為 “true”),性能顯著提升。第二個選項是 “啟用 Git 憑證管理器”。Windows 的 Git 憑證管理器為 Windows 提供安全的 Git 憑證存儲,最顯著的是對 Visual Studio Team Services 和 GitHub 的多因素身份驗證支持。 (需要 .NET Framework v4.5.1 或更高版本)。第三個選項是 “啟用符號鏈接”。啟用符號鏈接(需要 SeCreateSymbolicLink 權限)。請注意,現有存儲庫不受此設置的影響。默認選了第一、第二選項,繼續點擊 “Next” 按鈕進入下一步界面:  1.12 到這一步點擊 Finish 按鈕就完成安裝了。等安裝進度條滿后,就可以在開始菜單里找到 “Git”->“Git Bash”,點擊后出現一個類似命令行窗口的東西,就說明 Git 安裝成功!此后,就可以在 Git Bash 窗口進行 windows 環境下的 Git 操作了!1.13 同樣,我們不要忘記進行身份信息配置# git config --global user.name "Your Name"# git config --global user.email "Your Email"好了,windows 環境的安裝步驟到這里已經結束了。大家可以看到其中并沒有太多難點,基本上每一步按默認選項選擇,一直點擊 “Next” 往下走就可以完成基本的安裝配置,絕對不會錯。是不是很簡單!接下來向大家介紹一些 Git 的基本命令,以便于在后續的學習中可以隨時查看,同時也為我們之后的正式學習開一個頭。請繼續往下看:

3. 實戰演練

學習了上面的一些知識,我們來使用一個簡單的網站進行實戰演練,在該過程中介紹更多的和 Item Pipeline 相關的用法。假設我們是一名小說愛好者,我想到起點中文網上去找一些好的小說看,我該怎么找呢?起點中文網的月票榜是一個不錯的參考方式,如下圖所示:起點中文網月票榜其實簡單看一看就知道月票榜的 url 組成:主體 url:https://www.qidian.com/rank/yuepiao參數 month:02 表示 2 月份,03 表示 3 月份,目前為止最多到 7 月份;參數 chn:表示的是分類,-1 表示全部分類。21 表示玄幻,22表示仙俠;參數 page:表示第幾頁,一頁有20個作品。目前我們只需要從 01 月份開始到 07 月份的月票榜中,每次都取得第一頁的數據,也就是月票榜的前20 名。7 個月份的前 20 名加起來,然后再去重,就得到了曾經的占據月票榜的作品,這中間大概率都是比較好看的書。完成這個簡單的需求我們按照如下的步驟進行:創建初始項目 qidian_yuepiao:[root@server ~]# pyenv activate scrapy-test(scrapy-test) [root@server ~]# cd scrapy-test(scrapy-test) [root@server scrapy-test]# scrapy startproject qidian_yuepia(scrapy-test) [root@server qidian_yuepiao]# ls __init__.py items.py middlewares.py pipelines.py settings.py spider接下來我們準備獲取小說作品的字段,大概會獲取如下幾個數據:小說名:name;小說作者:author;小說類型:fiction_type。比如玄幻、仙俠、科幻等;小說狀態:state。連載還是完結;封面圖片地址:image_url;images:保存圖片數據;brief_introduction:作品簡介;book_url:小說的具體地址。根據定義的這些字段,我們可以寫出對應的 Items 類,如下:(scrapy-test) [root@server qidian_yuepiao]# cat items.py # Define here the models for your scraped items## See documentation in:# https://docs.scrapy.org/en/latest/topics/items.htmlimport scrapyclass QidianYuepiaoItem(scrapy.Item): # define the fields for your item here like: name = scrapy.Field() author = scrapy.Field() fiction_type = scrapy.Field() state = scrapy.Field() image_url = scrapy.Field() images = scrapy.Field() brief_introduction = scrapy.Field() book_url = scrapy.Field()到了最關鍵的地方,需要解析網頁數據,提取月票榜的作品信息。這個和前面一些,我們只需要完成相應的 xpath 即可。此外,我們會從 01 月份的月票榜開始,每次會新生成一個 url,主要改動的就是月份參數,每次將月份數加一;如果當前月份大于07,則終止。(scrapy-test) [root@server qidian_yuepiao]# touch spiders/qidian_yuepiao_parse.pyimport refrom scrapy import Requestfrom scrapy.spiders import Spiderfrom qidian_yuepiao.items import QidianYuepiaoItemdef num_to_str(num, size=2, padding='0'): """ 0 - > 00 1 -> 01 11 -> 11 :param num: :param size: :param padding: :return: """ str_num = str(num) while len(str_num) < size: str_num = padding + str_num return str_numclass QidianSpider(Spider): name = "qidian_yuepiao_spider" start_urls = [ "https://www.qidian.com/rank/yuepiao?month=01&chn=-1&page=1" ] def parse(self, response): fictions = response.xpath('//div[@id="rank-view-list"]/div/ul/li') for fiction in fictions: name = fiction.xpath('div[@class="book-mid-info"]/h4/a/text()').extract_first() author = fiction.xpath('div[@class="book-mid-info"]/p[@class="author"]/a[1]/text()').extract_first() fiction_type = fiction.xpath('div[@class="book-mid-info"]/p[@class="author"]/a[1]/text()').extract_first() # 注意一定要是列表,不然會報錯 image_url = ['http:{}'.format(fiction.xpath('div[@class="book-img-box"]/a/img/@src').extract()[0])] brief_introduction = fiction.xpath('div[@class="book-mid-info"]/p[@class="intro"]/text()').extract_first() state = fiction.xpath('div[@class="book-mid-info"]/p[@class="author"]/a[2]/text()').extract()[0] book_url = fiction.xpath('div[@class="book-mid-info"]/h4/a/@href').extract()[0] item = QidianYuepiaoItem() item['name'] = name item['author'] = author item['fiction_type'] = fiction_type item['brief_introduction'] = brief_introduction.strip() item['image_url'] = image_url item['state'] = state item['book_url'] = book_url yield item # 提取月份數,同時也要提取請求的url url = response.url regex = "https://(.*)\?month=(.*?)&(.*)" pattern = re.compile(regex) m = pattern.match(url) if not m: return [] prefix = m.group(1) month = int(m.group(2)) suffix = m.group(3) # 大于7月份則停止,目前是2020年7月20日 if month > 7: return # 一定要將月份轉成01, 02, s03這樣的形式,否則不能正確請求到數據 next_month = num_to_str(month + 1) next_url = f"https://{prefix}?month={next_month}&{suffix}" yield Request(next_url)最后到了我們本節課的重點。首先我想要將數據保存成 json 格式,存儲到文本文件中,但是在保存之前,需要對作品去重。因為有些作品會連續好幾個月出現在月票榜的前20位置上,會有比較多重復。我們通過作品的 url 地址來唯一確定該小說。因此需要定義兩個 Item Pipeline:import jsonfrom itemadapter import ItemAdapterfrom scrapy.exceptions import DropItemclass QidianYuepiaoPipeline: """ 保存不重復的數據到文本中 """ def open_spider(self, spider): self.file = open("yuepiao_top.json", 'w+') def close_spider(self, spider): self.file.close() def process_item(self, item, spider): data = json.dumps(dict(item), ensure_ascii=False) self.file.write(f"{data}\n") return itemclass DuplicatePipeline: """ 去除重復的數據,重復數據直接拋出異常,不會進入下一個流水線處理 """ def __init__(self): self.book_url_set = set() def process_item(self, item, spider): if item['book_url'] in self.book_url_set: raise DropItem('duplicate fiction, drop it') self.book_url_set.add(item['book_url']) return item我來簡單介紹下上面實現的兩個 pipelines 的代碼。首先爬蟲抓取的 item 結果經過的是 DuplicatePipeline 這個管道 (我們通過管道的優先級控制),我們在 DuplicatePipeline 中定義了一個全局的集合 (set),在 管道的核心方法process_item() 中,我們先判斷傳過來的 item 中 book_url 的值是否存在,如果存在則判定重復,然后拋出異常,這樣下一個管道 (即 QidianYuepiaoPipeline) 就不會去處理;在經過的第二個管道 (QidianYuepiaoPipeline) 中,我們主要是將不重復 item 保存到本地文件中,因此我們會在 open_spider() 方法中打開文件句柄,在 close_spider() 方法中關閉文件句柄,而在 process_item() 中將 item 數據保存到指定的文件中。接著就是將這兩個 Pipelines 加到 settings.py 中:ITEM_PIPELINES = { 'qidian_yuepiao.pipelines.DuplicatePipeline': 200, 'qidian_yuepiao.pipelines.QidianYuepiaoPipeline': 300,}最后,我們來介紹一個 Scrapy 內置的圖片管道,其實現的 Pipeline 代碼位置為:scrapy/pipelines/images.py,對應的還有一個內置的文件管道。我們不需要編寫任何代碼,只需要在 settings.py 中指定下載的圖片字段即可:# 下載圖片存儲位置IMAGES_STORE = '/root/scrapy-test/qidian_yuepiao/qidian_yuepiao/images'# 保存下載圖片url地址的字段IMAGES_URLS_FIELD = 'image_url'# 圖片保存地址字段IMAGES_RESULT_FIELD = 'images'IMAGES_THUMBS = { 'small': (102, 136), 'big': (150, 200)}# ...ITEM_PIPELINES = { 'scrapy.pipelines.images.ImagesPipeline': 1, 'qidian_yuepiao.pipelines.DuplicatePipeline': 200, 'qidian_yuepiao.pipelines.QidianYuepiaoPipeline': 300,}由于 ImagesPipeline 繼承自 FilesPipeline,我們可以從官網的介紹中知道該圖片下載功能的執行流程如下:在 spider 中爬取需要下載的圖片鏈接,將其放入 item 的 image_url 字段中;spider 將得到的 item 傳送到 pipeline 進行處理;當 item 到達 Image Pipeline 處理時,它會檢測是否有 image_url 字段,如果存在的話,會將該 url 傳遞給 scrapy 調度器和下載器;下載完成后會將結果寫入 item 的另一個字段 images,images 包含了圖片的本地路徑、圖片校驗、以及圖片的url;完成了以上四步之后,我們的這樣一個簡單需求就算完成了。還等什么,快來運行看看!以下是視頻演示:88這樣爬取數據是不是非常有趣?使用了 Scrapy 框架后,我們的爬取流程會變得比較固定化以及流水線化。但我們不僅僅要學會怎么使用 Scrapy 框架,還要能夠基于 Scrapy 框架在特定場景下做些改造,這樣才能達到完全駕馭 Scrapy 框架的目的。

2. Scrapy Shell 實戰

上面介紹了一些 Scrapy Shell 和 Response 的基礎知識,我們現在就來在 Scrapy Shell 中實戰 Selector 選擇器。本次測試的網站為廣州鏈家,測試頁面為二手房頁面:鏈家二手房網站我已經在上面標出了想要爬取的網頁信息,后面也主要測試這些數據的 xpath 表達式 或者 css 表達式。首先使用 scrapy shell 目標網址 命令進行想要的命令行,此時 Scrapy 框架已經為我們將目標網站的網頁數據爬取了下來:(scrapy-test) [root@server ~]# scrapy shell https://gz.lianjia.com/ershoufang/...>>> response<200 https://gz.lianjia.com/ershoufang/>我們看到響應的網頁數據已經有了,接下來我們就可以開始進行網頁分析來抓取圖片中標記的數據了。首先是標題信息:提取二手房數據的標題信息根據上面的網頁結構,可以很快得到標題的 xpath 路徑表達式:標題://ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()在 Scrapy Shell 中我們實戰一把:>>> response.xpath('//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()')[<Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='地鐵口 總價低 精裝實用小兩房'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='剛需精裝小三房/三房兩廳一廚一衛/廣州東綠湖國際城'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='周門社區 綠雅苑六樓 精裝三房'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='金碧領秀國際 精裝修一房 中樓層采光好'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='戶型方正 采光好 通風透氣 小區安靜'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='毛紡小區 南向兩房 方正實用 采光好'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='南奧疊層復式 前后樓距開闊 南北對流通風好'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='丹桂園 實用三房精裝修 南向戶型 擰包入住'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='周門小區大院管理 近地鐵總價低全明正規一房一廳'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='云鶴北街 精裝低樓層 南向兩房'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='中海譽城東南向兩房,住家安靜,無抵押交易快'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='精裝兩房,步梯中層,總價低,交通方便,配套齊全'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='中層南向四房 格局方正 樓層適中'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='精裝修 戶型好 中空一房 采光保養很好'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='業主急售,價格優質看房方便有密碼'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='匯僑新城北區 精裝三房 看花園 戶型靚'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='小區中間,安靜,前無遮擋,視野寬闊,望別墅花園'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='小區側邊位 通風采光好 小區管理 裝修保養好'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='萬科三房 南北對流 中高層采光好'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='南北對流,采光充足,配套設施完善,交通便利'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='美力倚睛居3房南有精裝修拎包入住'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='美心翠擁華庭二期 3室2廳 228萬'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='天河公園門口 交通便利 配套成熟'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='保利香檳花園 高層視野好 保養很好 居家感好'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='恒安大廈兩房,有公交,交通方便,價格方面可談'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='此房是商品房,低層,南北對流,全明屋'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='水蔭直街 原裝電梯戶型 方正三房格局'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='嘉誠國際公寓 可明火 正規一房一廳'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='近地鐵兩房 均價低 業主自住 裝修保養好'>, <Selector xpath='//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()' data='宏城匯 三房 南向 采光好 戶型方正 交通便利'>]上面結果返回的是 SelectorList 類型:>>> data_list = response.xpath('//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()')>>> type(data_list)<class 'scrapy.selector.unified.SelectorList'>最后我們通過提取 Selector 的 root 屬性,只得到相應的文本信息:>>> data = [d.root for d in data_list]>>> data['地鐵口 總價低 精裝實用小兩房', '剛需精裝小三房/三房兩廳一廚一衛/廣州東綠湖國際城', '周門社區 綠雅苑六樓 精裝三房', '金碧領秀國際 精裝修一房 中樓層采光好', '戶型方正 采光好 通風透氣 小區安靜', '毛紡小區 南向兩房 方正實用 采光好', '南奧疊層復式 前后樓距開闊 南北對流通風好', '丹桂園 實用三房精裝修 南向戶型 擰包入住', '周門小區大院管理 近地鐵總價低全明正規一房一廳', '云鶴北街 精裝低樓層 南向兩房', '中海譽城東南向兩房,住家安靜,無抵押交易快', '精裝兩房,步梯中層,總價低,交通方便,配套齊全', '中層南向四房 格局方正 樓層適中', '精裝修 戶型好 中空一房 采光保養很好', '業主急售,價格優質看房方便有密碼', '匯僑新城北區 精裝三房 看花園 戶型靚', '小區中間,安靜,前無遮擋,視野寬闊,望別墅花園', '小區側邊位 通風采光好 小區管理 裝修保養好', '萬科三房 南北對流 中高層采光好', '南北對流,采光充足,配套設施完善,交通便利', '美力倚睛居3房南有精裝修拎包入住', '美心翠擁華庭二期 3室2廳 228萬', '天河公園門口 交通便利 配套成熟', '保利香檳花園 高層視野好 保養很好 居家感好', '恒安大廈兩房,有公交,交通方便,價格方面可談', '此房是商品房,低層,南北對流,全明屋', '水蔭直街 原裝電梯戶型 方正三房格局', '嘉誠國際公寓 可明火 正規一房一廳', '近地鐵兩房 均價低 業主自住 裝修保養好', '宏城匯 三房 南向 采光好 戶型方正 交通便利']是不是非常簡單就爬到了數據?另外,我們還可以使用 extract()[0] 或者 extract_first() 這樣的方式來提取結果列表中的第一個文本數據:>>> data = response.xpath('//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()').extract()[0]>>> data2 = response.xpath('//ul[@class="sellListContent"]/li/div/div[@class="title"]/a/text()').extract_first()>>> data == data2True>>> data'地鐵口 總價低 精裝實用小兩房'接下來,我們依次找出獲取二手房位置、房屋價格、房屋信息的 xpath 路徑表達式:房屋位置://ul[@class="sellListContent"]/li/div/div[@class="food"]/div[@class="positionInfo"]/a[]/text()房屋信息://ul[@class="sellListContent"]/li/div/div[@class="address"]/div[@class="houseInfo"]/text()房屋價格://ul[@class="sellListContent"]/li/div/div[@class="priceInfo"]/div[@class="totalPrice"]/span/text()有了這些之后,我們就可以依次提取出二手房的【標題介紹】、【房屋位置】、【房屋信息】以及【房屋價格】這些信息。此外對于提取的【房屋信息】字段還要進一步處理,分割成【房屋結構】、【房屋大小】以及【朝向】等信息。這些信息將在 Spider 模塊中進行提取,也就是我們前面互動出版網爬蟲的 ChinaPubCrawler.py 文件中的 ChinaPubCrawler 類來解析。最后我們在介紹下 scrapy shell 命令的參數:(scrapy-test) [root@server ~]# scrapy shell --helpUsage===== scrapy shell [url|file]Interactive console for scraping the given url or file. Use ./file.html syntaxor full path for local file.Options=======--help, -h show this help message and exit-c CODE evaluate the code in the shell, print the result and exit--spider=SPIDER use this spider--no-redirect do not handle HTTP 3xx status codes and print response as-isGlobal Options----------------logfile=FILE log file. if omitted stderr will be used--loglevel=LEVEL, -L LEVEL log level (default: DEBUG)--nolog disable logging completely--profile=FILE write python cProfile stats to FILE--pidfile=FILE write process ID to FILE--set=NAME=VALUE, -s NAME=VALUE set/override setting (may be repeated)--pdb enable pdb on failure比較常用的有 --no-redirect 和 -s 選項:--no-redirect : 指的是不處理重定向,直接按照原始響應返回即可;-s:替換 settings.py 中的配置。常用的有設置 USER_AGENT 等。

1. 深入 Django 中 CSRF 校驗過程

上一節中提到了,針對 CSRF 攻擊有效的解決方案是在網頁上添加一個隨機的校驗 token 值,我們前面的登錄的模板頁面中添加的 {% csrf_token %},這里正好對應著一個隨機值。我們拿之前的登錄表單來進行觀察,會發現這樣幾個現象:網頁上隱藏的 csrf_token 值會在每次刷新時變化;對應在請求和響應頭部的 cookie 中的 csrftoken值卻一直不變;這樣子我們對應會產生幾個思考問題:為什么網頁上的 token 值會變,而 cookie 中的 token 則一直不變?整個 token 的校驗過程是怎樣的,有密碼?如果有密碼,密碼存在哪里?今天我們會帶著這兩個問題,查看下 Django 內部源碼,找到這些問題的代碼位置。我可能不會很完整的描述整個代碼運行的邏輯,因為篇幅不夠,而且細節太多,容易迷失在代碼的海洋里。首先毋庸置疑的第一步是找我們在 settings.py 中設置的 CSRF 中間件:MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',]我們在上一講中提到過中間件類的兩個函數:process_request() 和 process_response()。而在 CSRF 中間件文件中還有一個方法:process_view()。中間類比較完整的處理流程示意圖如下所示,可以看到中間件的 process_view() 方法如果返回 None,則會執行下一個 中間件的 process_view() 方法。一旦它返回 HttpResponse 實例,則直接跳過視圖函數到達最后一個中間件的 process_response() 方法中。我們來關注下 django.middleware 目錄下的 csrf.py 文件,所有的答案都在這里可以找到。首先看最核心的中間件類:# 源碼位置:django/middleware/csrf.py# ...class CsrfViewMiddleware(MiddlewareMixin): def _accept(self, request): # Avoid checking the request twice by adding a custom attribute to # request. This will be relevant when both decorator and middleware # are used. request.csrf_processing_done = True return None def _reject(self, request, reason): response = _get_failure_view()(request, reason=reason) log_response( 'Forbidden (%s): %s', reason, request.path, response=response, request=request, logger=logger, ) return response def _get_token(self, request): # ... def _set_token(self, request, response): # ... def process_request(self, request): csrf_token = self._get_token(request) if csrf_token is not None: # Use same token next time. request.META['CSRF_COOKIE'] = csrf_token def process_view(self, request, callback, callback_args, callback_kwargs): if getattr(request, 'csrf_processing_done', False): return None # Wait until request.META["CSRF_COOKIE"] has been manipulated before # bailing out, so that get_token still works if getattr(callback, 'csrf_exempt', False): return None # Assume that anything not defined as 'safe' by RFC7231 needs protection if request.method not in ('GET', 'HEAD', 'OPTIONS', 'TRACE'): if getattr(request, '_dont_enforce_csrf_checks', False): # Mechanism to turn off CSRF checks for test suite. # It comes after the creation of CSRF cookies, so that # everything else continues to work exactly the same # (e.g. cookies are sent, etc.), but before any # branches that call reject(). return self._accept(request) # 判斷是不是 https 協議,不然不用執行這里 if request.is_secure(): # ... csrf_token = request.META.get('CSRF_COOKIE') if csrf_token is None: # No CSRF cookie. For POST requests, we insist on a CSRF cookie, # and in this way we can avoid all CSRF attacks, including login # CSRF. return self._reject(request, REASON_NO_CSRF_COOKIE) # Check non-cookie token for match. request_csrf_token = "" if request.method == "POST": try: request_csrf_token = request.POST.get('csrfmiddlewaretoken', '') except IOError: # Handle a broken connection before we've completed reading # the POST data. process_view shouldn't raise any # exceptions, so we'll ignore and serve the user a 403 # (assuming they're still listening, which they probably # aren't because of the error). pass if request_csrf_token == "": # Fall back to X-CSRFToken, to make things easier for AJAX, # and possible for PUT/DELETE. request_csrf_token = request.META.get(settings.CSRF_HEADER_NAME, '') request_csrf_token = _sanitize_token(request_csrf_token) if not _compare_salted_tokens(request_csrf_token, csrf_token): return self._reject(request, REASON_BAD_TOKEN) return self._accept(request) def process_response(self, request, response): if not getattr(request, 'csrf_cookie_needs_reset', False): if getattr(response, 'csrf_cookie_set', False): return response if not request.META.get("CSRF_COOKIE_USED", False): return response # Set the CSRF cookie even if it's already set, so we renew # the expiry timer. self._set_token(request, response) response.csrf_cookie_set = True return response這里比較復雜的部分就是 process_view() 方法。process_request() 方法只是從請求頭中取出 csrftoken 值或者生成一個 csrftoken 值放到 request.META 屬性中去;process_response() 會設置對應的 csrftoken 值到 cookie 或者 session 中去。這里獲取 csrftoken 和 設置 csrftoken 調用的正是 _get_token() 和 set_token()方法:class CsrfViewMiddleware(MiddlewareMixin): # ... def _get_token(self, request): if settings.CSRF_USE_SESSIONS: try: return request.session.get(CSRF_SESSION_KEY) except AttributeError: raise ImproperlyConfigured( 'CSRF_USE_SESSIONS is enabled, but request.session is not ' 'set. SessionMiddleware must appear before CsrfViewMiddleware ' 'in MIDDLEWARE%s.' % ('_CLASSES' if settings.MIDDLEWARE is None else '') ) else: try: cookie_token = request.COOKIES[settings.CSRF_COOKIE_NAME] except KeyError: return None csrf_token = _sanitize_token(cookie_token) if csrf_token != cookie_token: # Cookie token needed to be replaced; # the cookie needs to be reset. request.csrf_cookie_needs_reset = True return csrf_token def _set_token(self, request, response): if settings.CSRF_USE_SESSIONS: if request.session.get(CSRF_SESSION_KEY) != request.META['CSRF_COOKIE']: request.session[CSRF_SESSION_KEY] = request.META['CSRF_COOKIE'] else: response.set_cookie( settings.CSRF_COOKIE_NAME, request.META['CSRF_COOKIE'], max_age=settings.CSRF_COOKIE_AGE, domain=settings.CSRF_COOKIE_DOMAIN, path=settings.CSRF_COOKIE_PATH, secure=settings.CSRF_COOKIE_SECURE, httponly=settings.CSRF_COOKIE_HTTPONLY, samesite=settings.CSRF_COOKIE_SAMESITE, ) # Set the Vary header since content varies with the CSRF cookie. patch_vary _headers(response, ('Cookie',)) # ...如果我們沒在 settings.py 中設置 CSRF_USE_SESSIONS 值時,在 django/conf/global_settings.py 默認設置為 False,那么我們就是調用前面熟悉的 response.set_cookie() 方法取設置 cookie 中的 key-value 值,也是我們在上面第二張圖片所看到的 Set-Cookie 里面的值。我們來看最核心的處理方法:process_view()。它的執行流程如下所列,略有刪減,請仔細研讀和對照代碼:判斷視圖方法是否有 csrf_exempt 屬性。相當于該視圖方法添加了 @csrf_exempt 裝飾器,這樣不用檢驗 csrf_token 值,直接返回 None,進入下面的中間件執行,直到視圖函數去處理 HTTP 請求;對于 GET、HEAD、 OPTIONS、 TRACE 這四種請求不用檢查 csrf_token,會直接跳到最后執行 self._accept(request) 方法。但是我們常用的如 POST、PUT 以及 DELETE 等請求會進行特別的處理;來看針對 POST、PUT 以及 DELETE 的特殊處理,要注意兩處代碼:request_csrf_token 值的獲?。簩τ?POST 請求,我們要從請求參數中獲取,這個值正是表單中隱藏的隨機 csrf_token,也是我們在第一張圖中看到的值,每次請求都會刷新該值;而且對于其它的請求,該值則是從 request.META 中獲??;校驗 csrf_token 值是否正確。如果是不正確的 csrf_token 值,則會直接返回 403 錯誤;if not _compare_salted_tokens(request_csrf_token, csrf_token): return self._reject(request, REASON_BAD_TOKEN)可以看到,這里校驗的是兩個值:一個是我們從 cookie 中獲取的,另一個是前端表單中隱藏的那個隨機數?,F在我們大致心里有個數了,Django 的校驗方法竟然是用 cookie 中的值和頁面上的隨機值進行校驗,這兩個值都是64位的,你必須同時拿到這兩個正確 token 值才能通過 Django 的 csrf 中間件校驗。比較原理,2個 token,一個放到 cookie 中,另一個放到表單中,會一直變得那種。接下來就是對這兩個 token 進行對比。我們繼續追蹤 _compare_salted_tokens() 方法,可以在 csrf.py 中找到如下兩個方法,它們分別對應著 csrf_token 值的生成和解碼:# 源碼位置:django/middleware/csrf.py# ...def _salt_cipher_secret(secret): """ Given a secret (assumed to be a string of CSRF_ALLOWED_CHARS), generate a token by adding a salt and using it to encrypt the secret. """ salt = _get_new_csrf_string() chars = CSRF_ALLOWED_CHARS pairs = zip((chars.index(x) for x in secret), (chars.index(x) for x in salt)) cipher = ''.join(chars[(x + y) % len(chars)] for x, y in pairs) return salt + cipherdef _unsalt_cipher_token(token): """ Given a token (assumed to be a string of CSRF_ALLOWED_CHARS, of length CSRF_TOKEN_LENGTH, and that its first half is a salt), use it to decrypt the second half to produce the original secret. """ salt = token[:CSRF_SECRET_LENGTH] token = token[CSRF_SECRET_LENGTH:] chars = CSRF_ALLOWED_CHARS pairs = zip((chars.index(x) for x in token), (chars.index(x) for x in salt)) secret = ''.join(chars[x - y] for x, y in pairs) # Note negative values are ok return secret# ...來看這兩個函數,首先是 _salt_cipher_secret() 方法,需要傳入一個長度為 32 的 secret,就可以得到一個64位的隨機字符。這個 secret 值在使用時也是隨機生成的32個字符:# 源碼位置:django/middleware/csrf.pydef _get_new_csrf_string(): return get_random_string(CSRF_SECRET_LENGTH, allowed_chars=CSRF_ALLOWED_CHARS)# 源碼位置:django/utils/crypto.pydef get_random_string(length=12, allowed_chars='abcdefghijklmnopqrstuvwxyz' 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'): """ Return a securely generated random string. The default length of 12 with the a-z, A-Z, 0-9 character set returns a 71-bit value. log_2((26+26+10)^12) =~ 71 bits """ if not using_sysrandom: # This is ugly, and a hack, but it makes things better than # the alternative of predictability. This re-seeds the PRNG # using a value that is hard for an attacker to predict, every # time a random string is required. This may change the # properties of the chosen random sequence slightly, but this # is better than absolute predictability. random.seed( hashlib.sha256( ('%s%s%s' % (random.getstate(), time.time(), settings.SECRET_KEY)).encode() ).digest() ) return ''.join(random.choice(allowed_chars) for i in range(length))在 _salt_cipher_secret() 方法中我們可以看到,傳入32位的密鑰 secret,最后的 csrf_token 的生成是 salt + cipher,前32位是 salt,后32位是加密字符串。解密的過程差不多就是 _salt_cipher_secret() 的逆過程了,最后得到 secret。我們可以在 Django 的 shell 模式下使用下這兩個函數:(django-manual) [root@server first_django_app]# python manage.py shellPython 3.8.1 (default, Dec 24 2019, 17:04:00) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linuxType "help", "copyright", "credits" or "license" for more information.(InteractiveConsole)>>> from django.middleware.csrf import _get_new_csrf_token, _unsalt_cipher_token>>> x1 = _get_new_csrf_token()>>> x2 = _get_new_csrf_token()>>> x3 = _get_new_csrf_token()>>> print('x1={}\nx2={}\nx3={}'.format(x1, x2, x3))x1=dvK3CRLiyHJ6Xgt0B6eZ7kUjxXgZ5CKkhl8HbHq8CKR0ZXMOxYnigzDTIZIdk3xZx2=TMazqRDst3BSiyxIAI1XDiFKdbmxu8nKRVvMogERiZi6IG6KNhDSxcgEOPTqU0qFx3=gy998wPOCZJiXHo7HYQtY3dfwaevPHKAs2YXPAeJmWUaA5vV2xdXqvlidLR4XM1T>>> _unsalt_cipher_token(x1)'e0yOJ0P0edi4cRtY62jtjpTKlcCopBXP'>>> _unsalt_cipher_token(x2)'8jvn8zbzZ6RoAiJcnJM544L4LOH3A2d5'>>> _unsalt_cipher_token(x3)'mEZYRez5U7l2NyhYvJxECCidRLNJifrt'>>> 了解了上述這些方法后,現在來思考前面提出的問題:為什么每次刷新表單中的 csrf_token 值會一直變化,而 cookie 中的 csrf_token 值卻一直不變呢?首先我們看在頁面上生成隨機 token 值的代碼,也就是將標簽 {{ csrf_token }} 轉成 64位隨機碼的地方:# 源碼位置: django/template/[email protected] csrf_token(parser, token): return CsrfTokenNode()class CsrfTokenNode(Node): def render(self, context): csrf_token = context.get('csrf_token') if csrf_token: if csrf_token == 'NOTPROVIDED': return format_html("") else: return format_html('<input type="hidden" name="csrfmiddlewaretoken" value="{}">', csrf_token) else: # It's very probable that the token is missing because of # misconfiguration, so we raise a warning if settings.DEBUG: warnings.warn( "A {% csrf_token %} was used in a template, but the context " "did not provide the value. This is usually caused by not " "using RequestContext." ) return ''可以看到 csrf_token 值是從 context 中取出來的,而在 context 中的 csrf_token 值又是由如下代碼生成的:# 源碼位置:django/template/context_processors.pyfrom django.middleware.csrf import get_token# ...def csrf(request): """ Context processor that provides a CSRF token, or the string 'NOTPROVIDED' if it has not been provided by either a view decorator or the middleware """ def _get_val(): token = get_token(request) if token is None: # In order to be able to provide debugging info in the # case of misconfiguration, we use a sentinel value # instead of returning an empty dict. return 'NOTPROVIDED' else: return token return {'csrf_token': SimpleLazyObject(_get_val)}可以看到,最后 csrf_token 值還是由 csrf.py 文件中的 get_token() 方法生成的。來繼續看這個 get_token() 方法的代碼:# 源碼位置:django/middleware/csrf.pydef get_token(request): """ Return the CSRF token required for a POST form. The token is an alphanumeric value. A new token is created if one is not already set. A side effect of calling this function is to make the csrf_protect decorator and the CsrfViewMiddleware add a CSRF cookie and a 'Vary: Cookie' header to the outgoing response. For this reason, you may need to use this function lazily, as is done by the csrf context processor. """ if "CSRF_COOKIE" not in request.META: csrf_secret = _get_new_csrf_string() request.META["CSRF_COOKIE"] = _salt_cipher_secret(csrf_secret) else: csrf_secret = _unsalt_cipher_token(request.META["CSRF_COOKIE"]) request.META["CSRF_COOKIE_USED"] = True return _salt_cipher_secret(csrf_secret)注意!注意!最關鍵的地方來了,這個加密的 secret 的值是從哪里來的?正是從請求頭中的 cookie 信息中來的,如果沒有將生成一個新的密鑰,接著把該密鑰生成的 token 放到 cookie 中。最后使用 _salt_cipher_secret() 方法生成的 csrf_token 和 cookie 中的 csrf_token 具有相同的密鑰。同時拿到了這兩個值,就可以進行校驗和判斷,下面我們在 ``_salt_cipher_secret()方法中加上一個print()` 語句,然后執行下看看是否如我們所說。22可以看到每次生成 token 時加密的秘鑰都是一樣的。我們從上面生成的 csrf_token 中選一個進行解密,得到的結果和 cookie 中的正是一樣的密鑰:>>> from django.middleware.csrf import _unsalt_cipher_token# 兩個 token 解密是相同的,這才是正確的>>> _unsalt_cipher_token('2Tt8StiU4rZcvCrTb2KqJwTTOTCP0WvJhp7GyTj58RGv97IvJInxyrAN4DKCdt1M')'pGOIQAbleARtOFrMIQNhZ5R4qUiXnHGd'>>> _unsalt_cipher_token('VI68m6xT1JczSsnuJvxqtcr0L0EvCN1DaeKG2wy459TSwXE6hbaxi78U1KMiPkxG')'pGOIQAbleARtOFrMIQNhZ5R4qUiXnHGd'現在大部分代碼我們也算清楚了,csrf_token 的校驗原理我們也知道了。那么如果想自己生成 csrf_token 并通過 Django 的校驗也非常簡單,只需要通過那個密鑰生成一個 csrf_token 或者直接輸入使用密鑰都可以通過校驗。我們首先使用密鑰在 shell 模式下隨機生成一個 csrf_token 值:>>> from django.middleware.csrf import _salt_cipher_secret>>> _salt_cipher_secret('pGOIQAbleARtOFrMIQNhZ5R4qUiXnHGd')'ObaC9DEZfn4seXbOhgBCph2Y5PMjm0Eo3HOaP3FajNLLSssqPWeJecJSlzU6zxar接下來在看我的演示,第一此我隨機改動 csrf_token 的字符,這樣校驗肯定通不過;接下來我是用自己生成的 token 值以及直接填寫密鑰再去提交都能通過校驗。為了方便演示結果,我們在 csrf.py 的 process_view() 函數中添加幾個 print() 方法,方便我們理解執行的過程。23

直播
查看課程詳情
微信客服

購課補貼
聯系客服咨詢優惠詳情

幫助反饋 APP下載

慕課網APP
您的移動學習伙伴

公眾號

掃描二維碼
關注慕課網微信公眾號