昨天我們已經把資料放進 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」下載查詢結果
實作:
首頁查詢
下載到的 CSV(打開檢查欄位)
功能說明
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),讓同學或朋友也能線上使用。