iT邦幫忙

0

[Day 10]用 Flask 打造迷你「全文搜尋網站」—FTS 高亮、分頁、CSV 匯出

  • 分享至 

  • xImage
  •  

昨天我們已經把資料放進 SQLite,還做了 FTS5 全文索引。
今天把它做成能在瀏覽器使用的小網站:輸入關鍵字 → 回傳結果(支援 高亮、分頁、CSV 匯出)。
這是把腳本變成服務(Service)的第一步!

今天目標
用 Flask 建立一個簡單的網站
從 crawler.db 查詢資料
有 FTS 索引(fts_links)時:用 bm25 排名 + snippet 高亮
沒有 FTS 時:自動降級為 LIKE(也能查「全部」)
支援 分頁(page/size)與 CSV 匯出

前置需求
已完成 Day 6–8,並有 crawler.db(建議已重建 FTS:python fts_search.py rebuild)

安裝 Flask:pip install flask

專案結構(參考)project/ ├─ crawler.db ├─ app.py ← 今天要新增 ├─ fts_search.py ← Day 8 ├─ save_to_db.py ← Day 6 └─ (其他檔案略)

程式碼(app.py)
把下面整份貼成 app.py:

# app.py — Day 10:SQLite 迷你搜尋網站 (FTS 高亮 + 分頁 + 匯出)
import sqlite3, math, csv, io
from flask import Flask, request, Response, render_template_string, url_for
from markupsafe import escape

DB = "crawler.db"
app = Flask(__name__)

def connect():
    con = sqlite3.connect(DB)
    con.row_factory = sqlite3.Row
    return con

def has_fts(con):
    cur = con.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='fts_links'")
    return cur.fetchone() is not None

def search(q: str, page: int = 1, size: int = 10):
    con = connect()
    use_fts = has_fts(con) and q.strip() != ""
    offset = (page - 1) * size

    if use_fts:
        # FTS:bm25 排名 + snippet 高亮
        total = con.execute("SELECT COUNT(*) FROM fts_links WHERE fts_links MATCH ?", (q,)).fetchone()[0]
        rows = con.execute(
            """
            SELECT l.id,
                   snippet(fts_links, 0, '<mark>', '</mark>', '…', 10) AS text_hl,
                   l.url,
                   bm25(fts_links, 1.0, 0.2) AS rank
            FROM fts_links
            JOIN links AS l ON l.id = fts_links.rowid
            WHERE fts_links MATCH ?
            ORDER BY rank ASC
            LIMIT ? OFFSET ?
            """,
            (q, size, offset),
        ).fetchall()
        items = [{"id": r["id"], "text": r["text_hl"], "url": r["url"]} for r in rows]
    else:
        # LIKE 或查全部
        if q.strip():
            like = f"%{q}%"
            total = con.execute(
                "SELECT COUNT(*) FROM links WHERE text LIKE ? OR url LIKE ?", (like, like)
            ).fetchone()[0]
            rows = con.execute(
                "SELECT id, text, url FROM links WHERE text LIKE ? OR url LIKE ? "
                "ORDER BY id DESC LIMIT ? OFFSET ?",
                (like, like, size, offset),
            ).fetchall()
        else:
            total = con.execute("SELECT COUNT(*) FROM links").fetchone()[0]
            rows = con.execute(
                "SELECT id, text, url FROM links ORDER BY id DESC LIMIT ? OFFSET ?",
                (size, offset),
            ).fetchall()
        items = [{"id": r["id"], "text": r["text"], "url": r["url"]} for r in rows]

    con.close()
    return total, items, use_fts

@app.route("/")
def index():
    q = request.args.get("q", "")
    page = int(request.args.get("page", 1) or 1)
    size = int(request.args.get("size", 10) or 10)
    page = max(1, page); size = max(1, size)

    total, items, use_fts = search(q, page, size)
    pages = max(1, math.ceil(total / size)) if total else 1

    tmpl = """<!doctype html><meta charset="utf-8">
<title>Mini Search</title>
<style>
body{font-family:system-ui,Segoe UI,Arial;max-width:900px;margin:2rem auto;padding:0 1rem}
form{margin-bottom:1rem} input,button{font-size:1rem;padding:.5rem} mark{background:#fffd88}
.meta{color:#666} nav a{margin-right:.5rem}
</style>
<h1>Mini Search 🔎</h1>
<form method="get">
  <input name="q" placeholder="輸入關鍵字" value="{{ q|e }}">
  <input name="size" type="number" min="1" max="100" value="{{ size }}">
  <button>Search</button>
  <a href="{{ url_for('export_csv', q=q, page=page, size=size) }}">匯出本頁 CSV</a>
</form>
<p class="meta">查詢:<b>{{ q or "(全部)" }}</b> | 總筆數:{{ total }} | 第 {{ page }}/{{ pages }} 頁 | 模式:{{ "FTS" if use_fts else "LIKE/列表" }}</p>
<ol start="{{ (page-1)*size + 1 }}">
{% for r in items %}
  <li>
    <div>{{ (r.text|safe) if use_fts else (r.text|e) }}</div>
    <div class="meta"><a href="{{ r.url }}" target="_blank">{{ r.url }}</a> (#{{ r.id }})</div>
  </li>
{% endfor %}
</ol>
<nav>
{% if page>1 %}
  <a href="{{ url_for('index', q=q, page=1, size=size) }}">« 第一頁</a>
  <a href="{{ url_for('index', q=q, page=page-1, size=size) }}">‹ 上一頁</a>
{% endif %}
{% if page<pages %}
  <a href="{{ url_for('index', q=q, page=page+1, size=size) }}">下一頁 ›</a>
  <a href="{{ url_for('index', q=q, page=pages, size=size) }}">最後一頁 »</a>
{% endif %}
</nav>"""
    return render_template_string(
        tmpl, q=q, page=page, size=size, total=total, pages=pages, items=items, use_fts=use_fts
    )

@app.get("/export")
def export_csv():
    q = request.args.get("q", "")
    page = int(request.args.get("page", 1) or 1)
    size = int(request.args.get("size", 10) or 10)
    total, items, use_fts = search(q, page, size)

    buf = io.StringIO()
    w = csv.writer(buf)
    w.writerow(["id", "text", "url"])
    for r in items:
        text = r["text"].replace("<mark>", "").replace("</mark>", "") if use_fts else r["text"]
        w.writerow([r["id"], text, r["url"]])
    data = buf.getvalue()

    return Response(
        data,
        mimetype="text/csv",
        headers={"Content-Disposition": f'attachment; filename="search_p{page}_s{size}.csv"'},
    )

if __name__ == "__main__":
    app.run(debug=True)

執行步驟

1.啟動伺服器

python app.py

2.瀏覽器打開 http://127.0.0.1:5000/
在搜尋框輸入關鍵字(例如:python、鐵人賽)
調整 size 改每頁筆數
點連結可在新分頁開啟原文
點「匯出本頁 CSV」下載查詢結果

實作:
首頁查詢
https://ithelp.ithome.com.tw/upload/images/20250923/20169368vF1n8OsTqc.png
下載到的 CSV(打開檢查欄位)
https://ithelp.ithome.com.tw/upload/images/20250923/20169368lpaNgXowOj.png

功能說明
FTS 模式:如果偵測到 fts_links 以及你有輸入關鍵字 → 使用 bm25 排名+snippet() 高亮(用 包住命中字)。
LIKE/列表模式:如果沒有 FTS 或是關鍵字空白 → 退回 LIKE 或列出全部。
分頁:page、size 兩個參數決定要看第幾頁、每頁幾筆。
CSV 匯出:把當頁結果輸出成 CSV(自動移除 標籤)。

今日小結
我們把資料腳本升級為可互動的網站
練到 Flask 基本路由、Query String 參數處理、CSV 檔案下載
善用 FTS5 的 bm25 與 snippet(),就能做出簡易的排名與高亮效果

明日預告
Day 11:把 Flask 小網站「發布」到雲端(例如 Render / Railway / ngrok),讓同學或朋友也能線上使用。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言