深入理解 Flask 檢視與路由

檢視函式是 Flask 應用程式的核心,它接收請求並回傳回應。在實作檢視時,有些重要的考量點:

  1. 路由裝飾器@app.route() 裝飾器將 URL 路徑對映到檢視函式,讓 Flask 知道當特定 URL 被請求時應該呼叫哪個函式。

  2. HTTP 方法:透過在路由裝飾器中指定 methods 引數,可以限制檢視函式只回應特定的 HTTP 方法。例如,methods=['GET'] 表示該檢視只處理 GET 請求。

  3. URL 引數:URL 中的變數部分可以透過 <variable_name> 語法捕捉,並作為引數傳遞給檢視函式。例如 <int:question_id> 捕捉一個整數並將其作為 question_id 引數傳給函式。

  4. 範本渲染:大多數檢視使用 render_template() 函式來渲染 HTML 範本,並將連貫的背景與環境資料傳遞給範本。

  5. 表單處理:POST 請求中的表單資料可以透過 request.form 字典存取。

在開發實際應用時,玄貓發現合理組織檢視函式並確保它們只負責特定邏輯是保持程式碼可讀性和可維護性的關鍵。檢視函式應該簡潔明瞭,複雜的業務邏輯最好抽離到單獨的模組中。

Flask 的路由系統非常直觀與靈活,配合適當的檢視函式設計,可以輕鬆構建出符合 RESTful 原則的 API。透過本文介紹的結構化方法,開發者可以快速搭建一個功能完整的調查應用程式,並根據需求進一步擴充套件功能。

在實際應用中,還可以考慮加入更多進階功能,如使用者認證、表單驗證、CSRF 保護等,使應用程式更加安全和健壯。Flask 的擴充套件生態系統提供了許多現成的解決方案,可以幫助開發者輕鬆實作這些功能。

Flask 問卷系統實戰:從基礎到單元測試的全方位開發

在我多年的 Web 開發生涯中,始終認為掌握一個輕量級框架是每位開發者的必備技能。Flask 作為 Python 世界中最受歡迎的微框架之一,以其靈活性和簡潔性成為許多開發者的首選。今天,我將帶領大家使用 Flask、SQLAlchemy 和 Jinja2 範本引擎,建立一個功能完整的問卷調查系統。

問卷投票功能剖析

我們將先從核心功能開始探討。以下是處理問卷投票的函式:

def create_vote_questions(question_id):
    question = Question.query.get(question_id)
    if request.form["vote"] in ["yes", "no", "maybe"]:
        question.vote(request.form["vote"])
        db.session.add(question)
        db.session.commit()
    return redirect("/questions/%d" % question.id)

這段程式碼的運作邏輯是:

  1. 根據問卷 ID 查詢資料函式庫問卷
  2. 檢查使用者的投票選項是否有效(yes/no/maybe)
  3. 呼叫問卷物件的 vote 方法記錄投票
  4. 將更新後的問卷物件提交到資料函式庫. 重新導向到問卷詳情頁面

這種簡潔的實作方式是 Flask 的魅力所在 — 幾行程式碼就能完成一個完整的 Web 功能。

Jinja2 範本系統:連結前後端的橋樑

Flask 採用 Jinja2 作為範本引擎,這是我認為 Flask 最強大的特性之一。在我的開發經驗中,Jinja2 不僅語法簡潔,還提供了強大的範本繼承機制,讓前端程式碼更易於維護。

範本繼承與基礎範本

Jinja2 的範本繼承概念非常類別似於物件導向程式設計中的繼承。在我們的問卷系統中,首先需要建立一個基礎範本 base.html,作為所有頁面的骨架:

<html>
<head>
    <title>Welcome to Survey Application</title>
</head>
<body>
    {% if message %}
    <p style="text-align: center;">{{ message }}</p>
    {% endif %}
    <div>
        <a href="/">Home</a> |
        <a href="/questions">All Questions</a> |
        <a href="/questions/new">Create a new Question</a>
    </div>
    <hr>
    {% block content %}{% endblock %}
</body>
</html>

這個基礎範本包含了導航選單和一個名為 content 的內容區塊,這個區塊將被子範本覆寫。這樣的設計讓我們能夠在不重複編寫頁頭頁尾的情況下,專注於每個頁面的獨特內容。

問卷列表範本

問卷列表頁面展示所有可用的問卷,以及每個問卷的投票統計。這裡我們使用 Jinja2 的迴圈功能來遍歷所有問卷:

{% extends "base.html" %}
{% block content %}
<p>Number of Questions - <span id="number_of_questions">{{ number_of_questions }}</span></p>
{% for question in questions %}
<div>
    <p>
    <p><a href="/questions/{{ question.id }}">{{ question.question_text }}</a></p>
    <ul>
        <li>Yes - {{ question.number_of_yes_votes }} </li>
        <li>No - {{ question.number_of_no_votes }} </li>
        <li>Maybe - {{ question.number_of_maybe_votes }} </li>
    </ul>
    </p>
</div>
{% endfor %}
<hr />
{% endblock %}

注意這裡的 {% extends "base.html" %} 陳述式,它告訴 Jinja2 這個範本繼承自 base.html。而 {% block content %}{% endblock %} 則定義了要覆寫的內容區塊。

建立新問卷範本

新增問卷頁面需要一個表單供使用者輸入問卷問題:

{% extends "base.html" %}
{% block content %}
<h1>Create a new Survey</h1>
<form method="POST" action="/questions">
    <p>Question: <input type="text" name="question_text"></p>
    <p><input type="submit" value="Create a new Survey"></p>
</form>
{% endblock %}

這個表單提交到 /questions 路徑,後端將處理表單資料並建立新問卷。

問卷詳情範本

問卷詳情頁展示特定問卷的投票結果,並提供投票連結:

{% extends "base.html" %}
{% block content %}
<div>
    <p>
    {% if question %}
        <p>{{ question.question_text }}</p>
        <ul>
            <li>Yes - {{ question.number_of_yes_votes }}</li>
            <li>No - {{ question.number_of_no_votes }}</li>
            <li>Maybe - {{ question.number_of_maybe_votes}}</li>
        </ul>
        <p><a href="/questions/{{ question.id }}/vote">Cast your vote now</a></p>
    {% else %}
        Not match found!
    {% endif %}
    </p>
</div>
<hr />
{% endblock %}

這裡我們使用條件判斷確保問卷存在,並提供一個投票連結引導使用者進行投票。

投票頁面範本

投票頁面包含一個表單,使用者可以選擇 “Yes”、“No” 或 “Maybe” 進行投票:

{% extends "base.html" %}
{% block content %}
<div>
    <p>
    {% if question %}
        <p>{{ question.question_text }}</p>
        <form action="/questions/{{ question.id }}/vote" method="POST">
            <input type="radio" name="vote" value="yes">Yes<br>
            <input type="radio" name="vote" value="no">No<br>
            <input type="radio" name="vote" value="maybe">Maybe<br>
            <input type="submit" value="Submit" /><br>
        </form>
        <p><a href="/questions/{{ question.id }}">Back to Question</a></p>
    {% else %}
        Not match found!
    {% endif %}
    </p>
</div>
<hr />
{% endblock %}

這個表單提交到 /questions/<id>/vote 路徑,該路徑將處理投票邏輯。

啟動應用程式

完成所有範本後,我們需要一個入口點來啟動應用程式。以下是 server.py 檔案的內容:

import sys
from survey import app, db
from survey import views

def main():
    db.create_all()
    app.run(debug=True)
    return 0

if __name__ == '__main__':
    sys.exit(main())

這個檔案匯入應用程式和資料函式庫,建立所有資料表,並以除錯模式啟動應用程式。

啟動應用程式的方式非常簡單:

$ python runserver.py
* Running on http://127.0.0.1:5000/
* Restarting with reloader

現在,我們的問卷系統已經可以透過 http://127.0.0.1:5000/ 存取了。

為何單元測試是專業開發的關鍵

在我的開發生涯中,有一個深刻的教訓:沒有測試的程式碼就像沒有安全網的高空走鋼絲。無論你的程式碼看起來多麼完美,總有可能在某些情況下出現問題。因此,為我們的問卷系統編寫單元測試至關重要。

以下是一些基本的單元測試:

import unittest
import requests
from bs4 import BeautifulSoup
from survey import db
from survey.models import Question

class TestSurveyApp(unittest.TestCase):
    def setUp(self):
        db.drop_all()
        db.create_all()
        
    def test_defaults(self):
        question = Question('Are you from India?')
        db.session.add(question)
        db.session.commit()
        self.assertEqual(question.number_of_yes_votes, 0)
        self.assertEqual(question.number_of_no_votes, 0)
        self.assertEqual(question.number_of_maybe_votes, 0)
        
    def test_votes(self):
        question = Question('Are you from India?')
        question.vote('yes')
        db.session.add(question)
        db.session.commit()
        self.assertEqual(question.number_of_yes_votes, 1)
        self.assertEqual(question.number_of_no_votes, 0)
        self.assertEqual(question.number_of_maybe_votes, 0)
        
    def test_title(self):
        title = "Welcome to Survey Application"
        response = requests.get("http://127.0.0.1:5000/")
        soup = BeautifulSoup(response.text)
        self.assertEqual(soup.title.get_text(), title)

這些測試案例驗證了幾個關鍵功能:

  1. test_defaults:確保新建立的問卷投票計數初始值為 0
  2. test_votes:確保投票功能正確增加對應選項的計數
  3. test_title:確保應用程式首頁標題符合預期

值得注意的是 setUp 方法,它在每個測試前重置資料函式庫保測試環境的一致性。這是一個我在真實專案中常用的技巧 — 每個測試都應該有一個乾淨的起點。

深入理解 Flask 應用程式的架構

在開始實作之前,讓我們先了解 Flask 應用程式的典型架構。Flask 遵循 MVC(Model-View-Controller)設計模式,但實作方式比較靈活。

模型層:資料函式庫與 ORM

在問卷系統中,核心資料模型是 Question,它代表一個問卷問題及其投票結果。我們使用 SQLAlchemy 來定義模型:

class Question(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    question_text = db.Column(db.String(200))
    number_of_yes_votes = db.Column(db.Integer, default=0)
    number_of_no_votes = db.Column(db.Integer, default=0)
    number_of_maybe_votes = db.Column(db.Integer, default=0)
    
    def __init__(self, question_text):
        self.question_text = question_text
        
    def vote(self, choice):
        if choice == "yes":
            self.number_of_yes_votes += 1
        elif choice == "no":
            self.number_of_no_votes += 1
        elif choice == "maybe":
            self.number_of_maybe_votes += 1

這個模型非常直觀:

  • 每個問卷有一個唯一 ID 和問題文字
  • 三個欄位分別記錄 “yes”、“no” 和 “maybe” 的票數
  • vote 方法根據使用者選擇增加相應的票數

控制器層:路由與檢視函式

控制器層負責處理 HTTP 請求並回傳適當的回應。在 Flask 中,這通常透過檢視函式實作。以下是一些關鍵檢視函式:

  1. 列出所有問卷:
@app.route('/')
@app.route('/questions')
def list_questions():
    questions = Question.query.all()
    return render_template('index.html', 
                          questions=questions,
                          number_of_questions=len(questions))
  1. 顯示問卷詳情:
@app.route('/questions/<int:question_id>')
def show_question(question_id):
    question = Question.query.get(question_id)
    return render_template('show.html', question=question)
  1. 建立新問卷:
@app.route('/questions', methods=['POST'])
def create_question():
    if request.form['question_text']:
        question = Question(request.form['question_text'])
        db.session.add(question)
        db.session.commit()
    return redirect('/questions')
  1. 投票處理:
@app.route('/questions/<int:question_id>/vote', methods=['POST'])
def create_vote(question_id):
    question = Question.query.get(question_id)
    if request.form["vote"] in ["yes", "no", "maybe"]:
        question.vote(request.form["vote"])
        db.session.add(question)
        db.session.commit()
    return redirect("/questions/%d" % question.id)

這些檢視函式展示了 Flask 路由系統的靈活性,包括:

  • 多個 URL 對映到同一檢視函式
  • URL 引數捕捉(如 <int:question_id>
  • HTTP 方法限制(如 methods=['POST']

檢視層:Jinja2 範本

網頁爬蟲的世界:從入門到精通

網頁爬蟲是資料科學與自動化領域中不可或缺的技術。在過去十年間,我見證了網頁爬蟲技術從簡單的指令碼工具發展為成熟的資料擷取系統。作為一個經常需要從各種來源收集資料的技術工作者,我深知一個設計良好的爬蟲系統能為專案帶來多大的價值。

在這篇文章中,我將分享網頁爬蟲的關鍵知識與技巧,從基礎概念到實際應用,幫助你建立有效的資料擷取流程。

爬蟲先決條件:開始前你需要了解的事

網頁爬蟲聽起來簡單,但要做好它需要一定的技術基礎。在你開始構建爬蟲前,需要具備以下先決條件:

  1. 基本程式設計知識:熟悉至少一種程式語言(Python尤為適合)
  2. HTTP協定理解:瞭解請求/回應模型、狀態碼和標頭
  3. HTML/CSS基礎:能夠閱讀和理解網頁結構
  4. API概念:瞭解REST API如何工作
  5. 資料處理技能:能夠清理、轉換和儲存所抓取的資料

這些基本技能將為你的爬蟲開發奠定堅實基礎。當我開始第一個爬蟲專案時,就因為忽略了對HTTP重定向處理的理解,導致系統在處理某些網站時不斷失敗。這個教訓讓我認識到紮實的基礎知識有多重要。

爬蟲任務的類別與挑戰

網頁爬蟲任務可以分為幾種主要類別,每種都有其獨特的挑戰:

靜態網頁爬取

最基本的爬蟲類別是抓取靜態HTML頁面。這類別任務相對直接,但仍需注意幾點:

  • 頁面結構變化:網站更新可能導致選擇器失效
  • 請求頻率限制:過快的請求可能被網站封鎖
  • 內容編碼問題:特殊字元和不同語言可能需要特殊處理

動態內容爬取

現代網站大多使用JavaScript動態載入內容,這為爬蟲帶來額外挑戰:

  • JavaScript執行:需要使用瀏覽器自動化工具如Selenium或Playwright
  • 等待時間處理:必須等待動態內容完全載入
  • AJAX請求捕捉:有時需要直接存取API而非解析HTML

API資料擷取

許多網站提供API,雖然這通常是最乾淨的資料取得方式,但也有其挑戰:

  • 認證要求:需要處理API金鑰、OAuth等認證機制
  • 速率限制:API通常有嚴格的使用限制
  • 資料格式處理:需要正確解析JSON、XML等格式

在構建一個金融資料爬蟲時,我發現直接使用網站的非公開API比爬取HTML頁面效率高出十倍。這需要一些額外的分析工作來理解API結構,但回報是值得的。

網頁爬蟲的倫理與法律考量

在技術實作之前,我們必須先討論爬蟲的倫理與法律問題。這不僅關乎合規,也關係到你的爬蟲是否能長期穩定執行。

爬蟲的行為準則

作為負責任的開發者,應遵守以下基本準則:

  1. 尊重robots.txt:這個檔案定義了網站允許爬蟲存取的區域
  2. 合理的請求頻率:模擬人類使用者行為,避免對伺服器造成負擔
  3. 識別你的爬蟲:在User-Agent中標明你的爬蟲身份
  4. 資料使用合規:確保你對抓取資料的使用符合版權法律

避免的常見錯誤

在多年的爬蟲開發中,我觀察到一些常見錯誤:

  • 忽略服務條款:許多網站明確禁止爬蟲,違反可能導致法律問題
  • 過度請求:短時間內傳送大量請求,導致被封IP
  • 忽略網站結構變化:沒有監控機制檢測網站結構變更
  • 未加密敏感認證:在程式碼中硬編碼密碼或API金鑰

我曾見過一個案例,某公司的爬蟲導致目標網站伺服器負載過高,最終收到了法律警告。這提醒我們,技術能力必須與專業責任相結合。

構建高效網頁爬蟲的核心步驟

現在讓我們探討構建高效爬蟲的實際步驟。這是我在多年爬蟲開發中提煉出的方法論。

確定目標與計劃

每個成功的爬蟲專案都始於明確的目標:

  1. 明確需求:你需要什麼資料?需要多頻繁更新?
  2. 評估可行性:目標網站是否允許爬蟲?資料結構是否穩定?
  3. 選擇合適工具:根據需求選擇適當的爬蟲框架和函式庫. 設計資料模型:規劃如何組織和儲存抓取的資料

識別目標URL與頁面結構

成功的爬蟲需要準確定位資料:

  1. 識別起始URL:找到包含所需資料的頁面入口
  2. 分析頁面結構:使用瀏覽器開發工具檢查HTML結構
  3. 確定導航模式:瞭解如何從起始頁到達所有目標頁面
  4. 識別分頁機制:確定如何處理多頁內容

在分析電子商務網站時,我發現產品資料往往分散在多個API呼叫中,而不是直接嵌入HTML。透過網路請求監控,我識別出了真正的資料源,這大簡化了爬蟲設計。

選擇適合的爬蟲工具

Python生態系統提供了豐富的爬蟲工具:

基礎工具

  • Requests:簡單易用的HTTP函式庫合基本請求
  • Beautiful Soup:強大的HTML解析工具
  • lxml:高效能的XML/HTML處理函式庫 Scrapy:全功能的爬蟲框架,適合大型專案

進階工具

  • Selenium/Playwright:瀏覽器自動化工具,處理JavaScript渲染內容
  • Requests-HTML:結合Requests和解析功能的簡化函式庫 PyQuery:提供類別似jQuery的HTML選擇器

選擇工具時應考慮專案規模、效能需求和團隊熟悉度。對於小型專案,我通常使用Requests搭配Beautiful Soup;對於大型生產系統,Scrapy提供了更完整的功能集。

發現與繪製目標資料

一旦確定了目標頁面,下一步是精確定位所需資料:

資料發現技巧

  1. 使用瀏覽器開發工具:檢查元素,找到包含目標資料的HTML元素
  2. 尋找唯一識別符號:查詢可靠的CSS選擇器或XPath表示式
  3. 分析AJAX請求:在網路面板中監控API呼叫
  4. 檢查JavaScript變數:有時資料儲存在頁面的JavaScript變數中

繪製資料結構

一旦找到資料,需要規劃如何提取和組織:

  1. 定義資料模型:確定需要提取的欄位和它們的關係
  2. 建立選擇器對映:為每個欄位建立相應的選擇器
  3. 處理異常情況:規劃如何處理缺失或格式異常的資料
  4. 驗證提取結果:確保提取的資料符合預期格式

在一個房地產資料專案中,我發現價格資訊有時以文字形式(“聯絡詢價”)出現,而不是實際金額。透過提前識別這些異常模式,我在爬蟲中加入了適當的處理邏輯,確保資料一致性。

實作爬蟲核心邏輯

有了清晰的計劃,現在可以實作爬蟲的核心邏輯:

import requests
from bs4 import BeautifulSoup
import csv
import time
import random

class WebScraper:
    def __init__(self, base_url, headers=None):
        self.base_url = base_url
        self.headers = headers or {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
            'Accept-Language': 'zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7',
        }
        self.session = requests.Session()
        self.session.headers.update(self.headers)
        
    def get_page(self, url, retries=3, delay=1):
        """取得頁面內容,包含重試邏輯"""
        for attempt in range(retries):
            try:
                # 新增隨機延遲,模擬人類行為
                time.sleep(delay + random.random())
                response = self.session.get(url)
                response.raise_for_status()  # 檢查是否成功
                return response.text
            except requests.exceptions.RequestException as e:
                print(f"嘗試 {attempt+1}/{retries} 失敗: {e}")
                if attempt == retries - 1:  # 最後一次嘗試
                    raise
                
    def parse_html(self, html):
        """解析HTML內容"""
        return BeautifulSoup(html, 'lxml')
    
    def extract_data(self, soup):
        """從解析後的HTML中提取資料"""
        # 這個方法將根據具體網站結構進行重寫
        pass
    
    def save_to_csv(self, data, filename):
        """將資料儲存為CSV檔案"""
        if not data:
            return
            
        with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
            fieldnames = data[0].keys()
            writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
            writer.writeheader()
            for item in data:
                writer.writerow(item)
                
    def run(self, start_page=1, end_page=1, output_file='data.csv'):
        """執行爬蟲的主方法"""
        all_data = []
        
        for page in range(start_page, end_page + 1):
            page_url = f"{self.base_url}?page={page}"
            html = self.get_page(page_url)
            soup = self.parse_html(html)
            page_data = self.extract_data(soup)
            all_data.extend(page_data)
            
            print(f"已處理第 {page}/{end_page} 頁,取得 {len(page_data)} 條資料")
            
        self.save_to_csv(all_data, output_file)
        print(f"共抓取 {len(all_data)} 條資料,已儲存至 {output_file}")

這個基礎框架包含了大多數爬蟲需要的核心功能:

  • 頁面取得與重試邏輯
  • HTML解析
  • 資料提取(需要針對特定網站定製)
  • 資料儲存

對於特定網站,我們需要擴充套件extract_data方法:

def extract_data(self, soup):
    """從產品列表頁提取資料"""
    products = []
    
    # 找到所有產品卡片元素
    product_cards = soup.select('.product-card')
    
    for card in product_cards:
        try:
            # 提取產品資訊
            product = {
                'name': card.select_one('.product-name').text.strip(),
                'price': card.select_one('.product-price').text.strip(),
                'rating': card.select_one('.rating-value').text.strip() if card.select_one('.rating-value') else 'N/A',
                'url': self.base_url + card.select_one('a.product-link')['href'],
            }
            
            # 處理價格式
            product['price'] = self.clean_price(product['price'])
            
            products