Initial commit: HBR 文章爬蟲專案
- Scrapy 爬蟲框架,爬取 HBR 繁體中文文章 - Flask Web 應用程式,提供文章查詢介面 - SQL Server 資料庫整合 - 自動化排程與郵件通知功能 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
28
.github/workflows/weekly.yml
vendored
Normal file
28
.github/workflows/weekly.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: weekly-crawl
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * 1" # 週一 00:00 UTC ≈ 台北 08:00
|
||||||
|
workflow_dispatch: {}
|
||||||
|
jobs:
|
||||||
|
crawl-and-mail:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- run: pip install scrapy
|
||||||
|
- name: Run crawler
|
||||||
|
run: scrapy crawl hbr
|
||||||
|
- name: Send mail with CSV
|
||||||
|
env:
|
||||||
|
GMAIL_USERNAME: ${{ secrets.GMAIL_USERNAME }}
|
||||||
|
GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }}
|
||||||
|
MAIL_TO: ${{ secrets.MAIL_TO }}
|
||||||
|
run: |
|
||||||
|
python send_mail.py hbr_articles.csv
|
||||||
|
- name: Upload CSV as artifact (optional)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: hbr_articles_csv
|
||||||
|
path: hbr_articles.csv
|
||||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Python 虛擬環境
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Python 編譯檔案
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# 日誌檔案
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 環境變數與敏感資料
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
credentials.json
|
||||||
|
config.local.py
|
||||||
|
|
||||||
|
# IDE 設定
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# 資料庫檔案
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# 匯出檔案(如需要可移除此行)
|
||||||
|
*.csv
|
||||||
|
|
||||||
|
# OS 產生的檔案
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude 設定(如需要可移除此行)
|
||||||
|
.claude/
|
||||||
142
AI爬蟲設計.txt
Normal file
142
AI爬蟲設計.txt
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
|
||||||
|
一、結論(可直接採用)
|
||||||
|
|
||||||
|
排程時間:每週一 08:00 Asia/Taipei。
|
||||||
|
|
||||||
|
流程:scrapy crawl hbr 產出 hbr_articles.csv → 以 Gmail SMTP 寄出(需 App Password)。
|
||||||
|
|
||||||
|
寄信對象:kaeruzak@gmail.com,主旨含日期,附檔 hbr_articles.csv。
|
||||||
|
|
||||||
|
|
||||||
|
GitHub Actions(免自備機器,建議做法)。
|
||||||
|
|
||||||
|
下方已提供寄信腳本 send_mail.py 與兩種排程設定檔,照貼即可運作。
|
||||||
|
|
||||||
|
二、寄信腳本(共用)
|
||||||
|
|
||||||
|
檔名:send_mail.py(放在專案根目錄,與 hbr_articles.csv 同層或以參數指定路徑)
|
||||||
|
|
||||||
|
# send_mail.py
|
||||||
|
import os, smtplib, sys, mimetypes
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
# 讀環境變數(請在 crontab 或 GitHub Actions secrets 設定)
|
||||||
|
GMAIL_USER = os.environ.get("GMAIL_USERNAME") # 例如:yourname@gmail.com
|
||||||
|
GMAIL_PASS = os.environ.get("GMAIL_APP_PASSWORD") # 16碼 App Password(非一般登入密碼)
|
||||||
|
TO = os.environ.get("MAIL_TO", "kaeruzak@gmail.com")
|
||||||
|
|
||||||
|
# 參數:CSV 路徑(預設 ./hbr_articles.csv)
|
||||||
|
csv_path = sys.argv[1] if len(sys.argv) > 1 else "hbr_articles.csv"
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
print(f"[WARN] CSV not found: {csv_path}")
|
||||||
|
# 可選:直接結束或改為寄送「今日無檔案」通知
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# 產生台北時間日期字串
|
||||||
|
tz = timezone(timedelta(hours=8))
|
||||||
|
date_str = datetime.now(tz).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
# 組信
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = f"[HBRTW 每週爬取] 文章清單 CSV - {date_str}"
|
||||||
|
msg["From"] = GMAIL_USER
|
||||||
|
msg["To"] = TO
|
||||||
|
msg.set_content(f"""您好,
|
||||||
|
附件為本週 HBR Taiwan 最新/熱門文章彙整(CSV)。
|
||||||
|
產生時間:{date_str}(Asia/Taipei)
|
||||||
|
若您需要改排程或加上上傳雲端,回覆此信即可。
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 夾帶 CSV
|
||||||
|
ctype, encoding = mimetypes.guess_type(csv_path)
|
||||||
|
if ctype is None or encoding is not None:
|
||||||
|
ctype = "application/octet-stream"
|
||||||
|
maintype, subtype = ctype.split("/", 1)
|
||||||
|
with open(csv_path, "rb") as f:
|
||||||
|
msg.add_attachment(f.read(),
|
||||||
|
maintype=maintype,
|
||||||
|
subtype=subtype,
|
||||||
|
filename=os.path.basename(csv_path))
|
||||||
|
|
||||||
|
# 寄送
|
||||||
|
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp:
|
||||||
|
smtp.login(GMAIL_USER, GMAIL_PASS)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
print("[OK] Mail sent to", TO)
|
||||||
|
|
||||||
|
|
||||||
|
安全性:請使用 Gmail App Password(兩步驟驗證後生成),不要用一般密碼。
|
||||||
|
取得方式:Google 帳戶 → 安全性 → 兩步驟驗證 → App 密碼 → 選「郵件」、裝置隨意命名 → 取得 16 碼,填入下述 Secrets/環境變數。
|
||||||
|
|
||||||
|
四、方案 B:GitHub Actions(建議)
|
||||||
|
|
||||||
|
在 GitHub 專案的 Settings → Secrets and variables → Actions → New repository secret 新增:
|
||||||
|
|
||||||
|
GMAIL_USERNAME:您的 Gmail 地址
|
||||||
|
|
||||||
|
GMAIL_APP_PASSWORD:16 碼 App Password
|
||||||
|
|
||||||
|
MAIL_TO:kaeruzak@gmail.com
|
||||||
|
|
||||||
|
建立 .github/workflows/weekly.yml:
|
||||||
|
|
||||||
|
name: weekly-crawl
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * 1" # 週一 00:00 UTC ≈ 台北 08:00
|
||||||
|
workflow_dispatch: {}
|
||||||
|
jobs:
|
||||||
|
crawl-and-mail:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- run: pip install scrapy
|
||||||
|
- name: Run crawler
|
||||||
|
run: scrapy crawl hbr
|
||||||
|
- name: Send mail with CSV
|
||||||
|
env:
|
||||||
|
GMAIL_USERNAME: ${{ secrets.GMAIL_USERNAME }}
|
||||||
|
GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }}
|
||||||
|
MAIL_TO: ${{ secrets.MAIL_TO }}
|
||||||
|
run: |
|
||||||
|
python send_mail.py hbr_articles.csv
|
||||||
|
- name: Upload CSV as artifact (optional)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: hbr_articles_csv
|
||||||
|
path: hbr_articles.csv
|
||||||
|
|
||||||
|
|
||||||
|
優點:免維護伺服器;Secrets 隔離;失敗可在 Actions 介面看 Log。
|
||||||
|
|
||||||
|
五、快速驗證(手動)
|
||||||
|
|
||||||
|
在本機或任一環境先手動測一次:
|
||||||
|
|
||||||
|
# 1) 先跑爬蟲
|
||||||
|
scrapy crawl hbr
|
||||||
|
|
||||||
|
# 2) 設定環境變數(僅當前終端有效)
|
||||||
|
export GMAIL_USERNAME='yourname@gmail.com'
|
||||||
|
export GMAIL_APP_PASSWORD='xxxxxxxxxxxxxxxx'
|
||||||
|
export MAIL_TO='kaeruzak@gmail.com'
|
||||||
|
|
||||||
|
# 3) 寄信
|
||||||
|
python send_mail.py hbr_articles.csv
|
||||||
|
|
||||||
|
|
||||||
|
若收得到信,表示排程也會正常。
|
||||||
|
|
||||||
|
六、例外與健壯性建議
|
||||||
|
|
||||||
|
robots.txt/付費牆:既有設定已遵守;付費文章僅做 is_paywalled=1 標記,不抓內文。
|
||||||
|
|
||||||
|
站台改版:若有解析錯誤,優先檢查 spiders/hbr.py 中 CSS 選擇器。
|
||||||
|
|
||||||
|
空結果週:預設若找不到 CSV 會跳過寄信(可改為寄送「本週無新檔案」通知)。
|
||||||
|
|
||||||
|
多環境:如需同時上傳雲端(S3/Drive)或加 Slack/Teams 通知,可再加一步。
|
||||||
129
README.md
Normal file
129
README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# HBR Taiwan 文章爬蟲系統
|
||||||
|
|
||||||
|
這是一個自動化的 HBR Taiwan 文章爬蟲系統,每週一自動爬取最新文章並透過 Gmail 發送 CSV 檔案。
|
||||||
|
|
||||||
|
## 功能特色
|
||||||
|
|
||||||
|
- 🕷️ 自動爬取 HBR Taiwan 網站文章
|
||||||
|
- 📧 每週一自動發送文章清單到指定信箱
|
||||||
|
- 🔒 支援付費文章識別(不爬取內容)
|
||||||
|
- 📊 輸出結構化 CSV 格式
|
||||||
|
- ⚡ 使用 GitHub Actions 免維護伺服器
|
||||||
|
|
||||||
|
## 專案結構
|
||||||
|
|
||||||
|
```
|
||||||
|
├── hbr_crawler/ # Scrapy 爬蟲專案
|
||||||
|
│ ├── hbr_crawler/
|
||||||
|
│ │ ├── spiders/
|
||||||
|
│ │ │ └── hbr.py # HBR 爬蟲主程式
|
||||||
|
│ │ ├── items.py # 資料結構定義
|
||||||
|
│ │ ├── pipelines.py # CSV 輸出管道
|
||||||
|
│ │ └── settings.py # 爬蟲設定
|
||||||
|
│ └── scrapy.cfg
|
||||||
|
├── .github/workflows/
|
||||||
|
│ └── weekly.yml # GitHub Actions 排程
|
||||||
|
├── send_mail.py # 郵件發送腳本
|
||||||
|
├── requirements.txt # Python 依賴
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速開始
|
||||||
|
|
||||||
|
### 1. 安裝依賴
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 手動測試爬蟲
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd hbr_crawler
|
||||||
|
scrapy crawl hbr
|
||||||
|
```
|
||||||
|
|
||||||
|
這會產生 `hbr_articles.csv` 檔案在專案根目錄。
|
||||||
|
|
||||||
|
### 3. 設定 Gmail 環境變數
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GMAIL_USERNAME='yourname@gmail.com'
|
||||||
|
export GMAIL_APP_PASSWORD='your-16-digit-app-password'
|
||||||
|
export MAIL_TO='kaeruzak@gmail.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 測試郵件發送
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python send_mail.py hbr_articles.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gmail App Password 設定
|
||||||
|
|
||||||
|
1. 前往 [Google 帳戶設定](https://myaccount.google.com/)
|
||||||
|
2. 安全性 → 兩步驟驗證(需先啟用)
|
||||||
|
3. 應用程式密碼 → 選擇「郵件」
|
||||||
|
4. 複製產生的 16 碼密碼
|
||||||
|
|
||||||
|
## GitHub Actions 自動化
|
||||||
|
|
||||||
|
### 設定 Secrets
|
||||||
|
|
||||||
|
在 GitHub 專案的 Settings → Secrets and variables → Actions 新增:
|
||||||
|
|
||||||
|
- `GMAIL_USERNAME`: 您的 Gmail 地址
|
||||||
|
- `GMAIL_APP_PASSWORD`: 16 碼 App Password
|
||||||
|
- `MAIL_TO`: kaeruzak@gmail.com
|
||||||
|
|
||||||
|
### 排程設定
|
||||||
|
|
||||||
|
系統預設每週一 08:00 (Asia/Taipei) 自動執行,對應 GitHub Actions 的 cron 設定:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * 1" # 週一 00:00 UTC ≈ 台北 08:00
|
||||||
|
```
|
||||||
|
|
||||||
|
## 輸出格式
|
||||||
|
|
||||||
|
CSV 檔案包含以下欄位:
|
||||||
|
|
||||||
|
- `title`: 文章標題
|
||||||
|
- `url`: 文章連結
|
||||||
|
- `author`: 作者
|
||||||
|
- `publish_date`: 發布日期
|
||||||
|
- `summary`: 文章摘要
|
||||||
|
- `is_paywalled`: 是否為付費文章 (1/0)
|
||||||
|
- `category`: 文章分類
|
||||||
|
- `tags`: 標籤(逗號分隔)
|
||||||
|
- `content`: 文章內容(僅非付費文章)
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
|
||||||
|
- 系統遵守 robots.txt 規則
|
||||||
|
- 付費文章僅標記為 `is_paywalled=1`,不爬取內容
|
||||||
|
- 若網站改版,可能需要調整 `hbr.py` 中的 CSS 選擇器
|
||||||
|
- 空結果週會跳過寄信(可修改為發送通知)
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 爬蟲無法找到文章
|
||||||
|
|
||||||
|
檢查 `hbr_crawler/hbr_crawler/spiders/hbr.py` 中的 CSS 選擇器是否與網站結構匹配。
|
||||||
|
|
||||||
|
### 郵件發送失敗
|
||||||
|
|
||||||
|
1. 確認 Gmail App Password 正確
|
||||||
|
2. 檢查環境變數設定
|
||||||
|
3. 確認網路連線正常
|
||||||
|
|
||||||
|
### GitHub Actions 失敗
|
||||||
|
|
||||||
|
1. 檢查 Secrets 設定
|
||||||
|
2. 查看 Actions 執行日誌
|
||||||
|
3. 確認 Python 版本相容性
|
||||||
|
|
||||||
|
## 授權
|
||||||
|
|
||||||
|
此專案僅供學習和研究使用,請遵守 HBR Taiwan 網站的使用條款。
|
||||||
654
SDD.md
Normal file
654
SDD.md
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
# HBR Taiwan 文章爬蟲系統 - 軟體設計文件 (SDD)
|
||||||
|
|
||||||
|
**文件版本**: 2.0
|
||||||
|
**建立日期**: 2024-12-22
|
||||||
|
**最後更新**: 2024-12-22
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 文件概述
|
||||||
|
|
||||||
|
### 1.1 文件目的
|
||||||
|
本文件描述 HBR Taiwan 文章爬蟲系統的軟體設計規格,包含系統架構、功能模組、資料流程、資料庫設計等技術細節。
|
||||||
|
|
||||||
|
### 1.2 專案範圍
|
||||||
|
本系統旨在自動化爬取 HBR Taiwan 網站文章,並將結果儲存至本地資料庫,同時透過 Gmail 發送 CSV 檔案給指定收件人。
|
||||||
|
|
||||||
|
### 1.3 目標讀者
|
||||||
|
- 開發人員
|
||||||
|
- 系統維護人員
|
||||||
|
- 專案管理人員
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 系統概述
|
||||||
|
|
||||||
|
### 2.1 系統簡介
|
||||||
|
HBR Taiwan 文章爬蟲系統是一個基於 Scrapy 框架的自動化爬蟲系統,主要功能包括:
|
||||||
|
- 定期爬取 HBR Taiwan 網站文章
|
||||||
|
- 識別並標記付費文章
|
||||||
|
- 將資料儲存至本地資料庫
|
||||||
|
- 產生 CSV 檔案並透過 Gmail 發送
|
||||||
|
|
||||||
|
### 2.2 系統目標
|
||||||
|
- **自動化**: 可設定的排程時間(預設每天 08:00 Asia/Taipei)自動執行
|
||||||
|
- **可靠性**: 遵守 robots.txt,處理付費牆限制
|
||||||
|
- **可維護性**: 模組化設計,易於擴展和維護
|
||||||
|
- **資料完整性**: 確保資料正確儲存和備份
|
||||||
|
- **增量爬取**: 僅爬取新文章,提升效率
|
||||||
|
- **資料管理**: 自動清理舊資料,保留最近30次爬取記錄
|
||||||
|
|
||||||
|
### 2.3 技術架構
|
||||||
|
- **爬蟲框架**: Scrapy 2.x
|
||||||
|
- **程式語言**: Python 3.11+
|
||||||
|
- **資料庫**: MySQL / MariaDB(區域網路伺服器,SSL/TLS 加密連線)
|
||||||
|
- **郵件服務**: Gmail SMTP
|
||||||
|
- **排程系統**: Crontab(自有伺服器部署)
|
||||||
|
- **資料格式**: CSV, Excel, 關聯式資料庫
|
||||||
|
- **Web 介面**: Flask / FastAPI(資料查詢和統計)
|
||||||
|
- **多語言支援**: 繁體中文、英文、韓文
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 系統架構設計
|
||||||
|
|
||||||
|
### 3.1 整體架構
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 排程觸發器 │
|
||||||
|
│ (GitHub Actions / Crontab) │
|
||||||
|
└────────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Scrapy 爬蟲引擎 │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ HBR Spider │→ │ Pipeline │→ │ Items │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
└────────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴───────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ 資料庫儲存 │ │ CSV 檔案產生 │
|
||||||
|
│ (Database) │ │ (CSV Export) │
|
||||||
|
└─────────────────┘ └────────┬─────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 郵件發送模組 │
|
||||||
|
│ (send_mail.py) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Gmail SMTP │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 模組劃分
|
||||||
|
|
||||||
|
#### 3.2.1 爬蟲模組 (Spider Module)
|
||||||
|
- **檔案**: `hbr_crawler/hbr_crawler/spiders/hbr.py`
|
||||||
|
- **功能**:
|
||||||
|
- 解析 HBR Taiwan 網站結構
|
||||||
|
- 提取文章資訊(標題、作者、日期、內容等)
|
||||||
|
- 識別付費文章
|
||||||
|
- 處理分頁和連結追蹤
|
||||||
|
|
||||||
|
#### 3.2.2 資料處理模組 (Pipeline Module)
|
||||||
|
- **檔案**: `hbr_crawler/hbr_crawler/pipelines.py`
|
||||||
|
- **功能**:
|
||||||
|
- CSV 匯出處理
|
||||||
|
- 資料庫儲存處理(待實作)
|
||||||
|
- 資料清理和驗證
|
||||||
|
|
||||||
|
#### 3.2.3 資料模型模組 (Items Module)
|
||||||
|
- **檔案**: `hbr_crawler/hbr_crawler/items.py`
|
||||||
|
- **功能**:
|
||||||
|
- 定義文章資料結構
|
||||||
|
- 資料欄位驗證
|
||||||
|
|
||||||
|
#### 3.2.4 郵件發送模組 (Email Module)
|
||||||
|
- **檔案**: `send_mail.py`
|
||||||
|
- **功能**:
|
||||||
|
- 讀取 CSV 檔案
|
||||||
|
- 透過 Gmail SMTP 發送郵件
|
||||||
|
- 處理附件和郵件格式
|
||||||
|
|
||||||
|
#### 3.2.5 資料庫模組 (Database Module)
|
||||||
|
- **檔案**: `hbr_crawler/hbr_crawler/database.py`
|
||||||
|
- **功能**:
|
||||||
|
- 資料庫連線管理(支援 SSL/TLS 加密)
|
||||||
|
- 資料插入和更新
|
||||||
|
- 查詢和統計功能
|
||||||
|
- 資料清理(自動刪除超過30天的資料)
|
||||||
|
|
||||||
|
#### 3.2.6 Web 查詢介面模組 (Web Interface Module)
|
||||||
|
- **檔案**: 待建立(`web_app.py` 或 `web_app/`)
|
||||||
|
- **功能**:
|
||||||
|
- 提供 Web 介面查詢文章
|
||||||
|
- 支援按日期、分類、標籤查詢
|
||||||
|
- 資料統計功能(文章數量、分類分布、作者統計等)
|
||||||
|
- 多語言支援(繁體中文、英文、韓文)
|
||||||
|
|
||||||
|
#### 3.2.7 資料匯出模組 (Export Module)
|
||||||
|
- **檔案**: 待建立(`export.py`)
|
||||||
|
- **功能**:
|
||||||
|
- CSV 匯出(已實作)
|
||||||
|
- Excel 匯出(待實作)
|
||||||
|
- JSON 匯出(可選)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 資料流程設計
|
||||||
|
|
||||||
|
### 4.1 爬蟲執行流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 排程觸發
|
||||||
|
↓
|
||||||
|
2. Scrapy 啟動,載入 HBR Spider
|
||||||
|
↓
|
||||||
|
3. 訪問起始 URL 列表
|
||||||
|
↓
|
||||||
|
4. 解析文章列表頁面
|
||||||
|
↓
|
||||||
|
5. 提取文章連結
|
||||||
|
↓
|
||||||
|
6. 訪問每篇文章頁面
|
||||||
|
↓
|
||||||
|
7. 解析文章內容
|
||||||
|
├─ 提取標題、作者、日期
|
||||||
|
├─ 檢查付費牆標記
|
||||||
|
├─ 提取分類、標籤
|
||||||
|
└─ 提取內容(僅非付費文章)
|
||||||
|
↓
|
||||||
|
8. 產生 Item 物件
|
||||||
|
↓
|
||||||
|
9. 通過 Pipeline 處理
|
||||||
|
├─ CSV 匯出
|
||||||
|
└─ 資料庫儲存
|
||||||
|
↓
|
||||||
|
10. 爬蟲完成
|
||||||
|
↓
|
||||||
|
11. 觸發郵件發送
|
||||||
|
↓
|
||||||
|
12. 讀取 CSV 並發送郵件
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 資料處理流程
|
||||||
|
|
||||||
|
```
|
||||||
|
原始網頁 HTML
|
||||||
|
↓
|
||||||
|
CSS 選擇器解析
|
||||||
|
↓
|
||||||
|
提取資料欄位
|
||||||
|
↓
|
||||||
|
資料清理和驗證
|
||||||
|
↓
|
||||||
|
建立 Item 物件
|
||||||
|
↓
|
||||||
|
Pipeline 處理
|
||||||
|
├─→ CSV 檔案
|
||||||
|
└─→ 資料庫表格
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 資料庫設計
|
||||||
|
|
||||||
|
### 5.1 資料庫選擇
|
||||||
|
- **資料庫類型**: MySQL / MariaDB
|
||||||
|
- **連線方式**: 透過環境變數設定
|
||||||
|
- **資料庫位置**: 區域網路內的伺服器
|
||||||
|
- **連線加密**: SSL/TLS 加密連線
|
||||||
|
- **資料庫名稱**: HBR_scraper
|
||||||
|
- **連線資訊**:
|
||||||
|
- Host: mysql.theaken.com
|
||||||
|
- Port: 33306
|
||||||
|
- User: A101
|
||||||
|
- Password: Aa123456(透過環境變數設定)
|
||||||
|
|
||||||
|
### 5.2 資料表設計
|
||||||
|
|
||||||
|
#### 5.2.1 articles 表(文章主表)
|
||||||
|
|
||||||
|
| 欄位名稱 | 資料型態 | 約束 | 說明 |
|
||||||
|
|---------|---------|------|------|
|
||||||
|
| id | BIGINT UNSIGNED | PRIMARY KEY, AUTO_INCREMENT | 文章唯一識別碼 |
|
||||||
|
| title | VARCHAR(500) | NOT NULL | 文章標題 |
|
||||||
|
| url | VARCHAR(1000) | NOT NULL, UNIQUE | 文章網址 |
|
||||||
|
| author | VARCHAR(200) | | 作者名稱 |
|
||||||
|
| publish_date | DATETIME | | 發布日期(資料庫原生格式) |
|
||||||
|
| summary | TEXT | | 文章摘要 |
|
||||||
|
| is_paywalled | TINYINT(1) | NOT NULL, DEFAULT 0 | 是否為付費文章 (1/0) |
|
||||||
|
| category | VARCHAR(100) | | 文章分類 |
|
||||||
|
| tags | VARCHAR(500) | | 標籤(逗號分隔字串,非正規化設計) |
|
||||||
|
| content | TEXT | | 文章內容(僅非付費文章,無長度限制) |
|
||||||
|
| language | VARCHAR(10) | DEFAULT 'zh-TW' | 語言代碼(zh-TW, en, ko) |
|
||||||
|
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 資料建立時間 |
|
||||||
|
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | 資料更新時間 |
|
||||||
|
| crawled_at | DATETIME | | 爬取時間 |
|
||||||
|
| crawl_count | INT | DEFAULT 1 | 爬取次數(用於追蹤歷史記錄) |
|
||||||
|
|
||||||
|
**索引設計**:
|
||||||
|
- `articles.url`: UNIQUE INDEX(避免重複爬取)
|
||||||
|
- `articles.publish_date`: INDEX(方便按日期查詢)
|
||||||
|
- `articles.category`: INDEX(方便按分類查詢)
|
||||||
|
- `articles.language`: INDEX(多語言支援)
|
||||||
|
- `articles.crawled_at`: INDEX(資料清理查詢)
|
||||||
|
- `articles.crawl_count`: INDEX(歷史記錄管理)
|
||||||
|
|
||||||
|
**注意**: 標籤採用非正規化設計,直接以逗號分隔字串儲存在 articles 表中,簡化查詢並提升效能。
|
||||||
|
|
||||||
|
#### 5.2.2 logs 表(日誌表)
|
||||||
|
|
||||||
|
| 欄位名稱 | 資料型態 | 約束 | 說明 |
|
||||||
|
|---------|---------|------|------|
|
||||||
|
| id | BIGINT UNSIGNED | PRIMARY KEY, AUTO_INCREMENT | 日誌唯一識別碼 |
|
||||||
|
| level | VARCHAR(20) | NOT NULL | 日誌級別(DEBUG, INFO, WARNING, ERROR) |
|
||||||
|
| message | TEXT | NOT NULL | 日誌訊息 |
|
||||||
|
| module | VARCHAR(100) | | 模組名稱 |
|
||||||
|
| function | VARCHAR(100) | | 函數名稱 |
|
||||||
|
| line_number | INT | | 行號 |
|
||||||
|
| traceback | TEXT | | 錯誤堆疊追蹤 |
|
||||||
|
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 建立時間 |
|
||||||
|
|
||||||
|
**索引設計**:
|
||||||
|
- `logs.level`: INDEX(按級別查詢)
|
||||||
|
- `logs.created_at`: INDEX(按時間查詢)
|
||||||
|
- `logs.module`: INDEX(按模組查詢)
|
||||||
|
|
||||||
|
### 5.3 資料庫操作邏輯
|
||||||
|
|
||||||
|
#### 5.3.1 插入/更新邏輯
|
||||||
|
1. 檢查文章 URL 是否已存在
|
||||||
|
2. 若存在:
|
||||||
|
- 更新所有欄位(標題、作者、內容等可能變更)
|
||||||
|
- 更新 `updated_at` 和 `crawled_at` 時間戳
|
||||||
|
- 增加 `crawl_count` 計數
|
||||||
|
3. 若不存在:插入新記錄
|
||||||
|
4. 處理標籤:
|
||||||
|
- 標籤以逗號分隔字串儲存在 `tags` 欄位
|
||||||
|
- 直接更新 `tags` 欄位,無需關聯表操作
|
||||||
|
|
||||||
|
#### 5.3.2 增量爬取邏輯
|
||||||
|
- 依據 URL 判斷是否為新文章
|
||||||
|
- 僅爬取 URL 不在資料庫中的文章
|
||||||
|
- 可選:依據發布日期判斷(僅爬取最近 N 天的文章)
|
||||||
|
|
||||||
|
#### 5.3.3 資料清理邏輯
|
||||||
|
- **文章資料清理**:
|
||||||
|
- 自動刪除超過 30 天的資料(依據 `crawled_at`)
|
||||||
|
- 保留最近 30 次爬取記錄(依據 `crawl_count`)
|
||||||
|
- 清理策略:先刪除超過 30 天的資料,再檢查是否超過 30 次爬取記錄
|
||||||
|
- **日誌清理**:
|
||||||
|
- 保留最近 90 天的日誌記錄
|
||||||
|
- 自動刪除超過 90 天的日誌
|
||||||
|
- **清理任務**:
|
||||||
|
- 可設定為定期執行(建議每天執行一次)
|
||||||
|
- 執行時間:建議在爬蟲執行後執行
|
||||||
|
- 記錄清理操作到日誌表
|
||||||
|
|
||||||
|
#### 5.3.4 查詢邏輯
|
||||||
|
- 支援按日期範圍查詢
|
||||||
|
- 支援按分類查詢
|
||||||
|
- 支援按標籤查詢(使用 LIKE 查詢)
|
||||||
|
- 支援付費/非付費文章篩選
|
||||||
|
- 支援多語言篩選(language 欄位)
|
||||||
|
- 支援統計查詢(文章數量、分類分布、作者統計等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 功能模組詳細設計
|
||||||
|
|
||||||
|
### 6.1 HBR Spider 設計
|
||||||
|
|
||||||
|
#### 6.1.1 起始 URL
|
||||||
|
```python
|
||||||
|
start_urls = [
|
||||||
|
'https://www.hbrtaiwan.com/',
|
||||||
|
'https://www.hbrtaiwan.com/topic/management',
|
||||||
|
'https://www.hbrtaiwan.com/topic/leadership',
|
||||||
|
'https://www.hbrtaiwan.com/topic/strategy',
|
||||||
|
'https://www.hbrtaiwan.com/topic/innovation',
|
||||||
|
'https://www.hbrtaiwan.com/topic/technology',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.1.2 解析邏輯
|
||||||
|
- **列表頁解析** (`parse` 方法):
|
||||||
|
- 使用多種 CSS 選擇器嘗試定位文章元素
|
||||||
|
- 提取文章連結
|
||||||
|
- 處理相對路徑轉絕對路徑
|
||||||
|
- 追蹤分頁連結
|
||||||
|
|
||||||
|
- **文章頁解析** (`parse_article` 方法):
|
||||||
|
- 提取標題(優先順序:h1 → .article-title → title)
|
||||||
|
- 提取作者(優先順序:.author → meta author)
|
||||||
|
- 提取發布日期(優先順序:.date → meta published_time)
|
||||||
|
- 提取摘要(優先順序:.summary → meta description)
|
||||||
|
- 檢查付費牆標記(.paywall, .premium, .subscription-required)
|
||||||
|
- 提取分類(優先順序:.category → URL 路徑推斷)
|
||||||
|
- 提取標籤(.tags a, .tag)
|
||||||
|
- 提取內容(僅非付費文章,多種選擇器嘗試)
|
||||||
|
|
||||||
|
#### 6.1.3 錯誤處理
|
||||||
|
- 選擇器失敗時使用備用選擇器
|
||||||
|
- 處理空值情況
|
||||||
|
- 記錄解析失敗的 URL(可選)
|
||||||
|
|
||||||
|
### 6.2 Pipeline 設計
|
||||||
|
|
||||||
|
#### 6.2.1 CSV Export Pipeline
|
||||||
|
- **功能**: 將爬取的資料匯出為 CSV 檔案
|
||||||
|
- **檔案位置**: 專案根目錄 `hbr_articles.csv`
|
||||||
|
- **編碼**: UTF-8
|
||||||
|
- **處理邏輯**:
|
||||||
|
- 將 tags 列表轉換為逗號分隔字串
|
||||||
|
- 處理 None 值,轉換為空字串
|
||||||
|
- 寫入 CSV 標題列
|
||||||
|
|
||||||
|
#### 6.2.2 Database Pipeline
|
||||||
|
- **功能**: 將資料儲存至資料庫
|
||||||
|
- **處理邏輯**:
|
||||||
|
- 建立資料庫連線(支援 SSL/TLS 加密)
|
||||||
|
- 檢查文章是否已存在(依據 URL)
|
||||||
|
- 執行插入或更新操作(更新所有欄位)
|
||||||
|
- 處理標籤(以逗號分隔字串儲存)
|
||||||
|
- 錯誤處理和重試機制
|
||||||
|
- 記錄爬取次數(crawl_count)
|
||||||
|
|
||||||
|
#### 6.2.3 Data Cleanup Pipeline(待實作)
|
||||||
|
- **功能**: 定期清理舊資料
|
||||||
|
- **處理邏輯**:
|
||||||
|
- 刪除超過 30 天的資料(依據 crawled_at)
|
||||||
|
- 保留最近 30 次爬取記錄
|
||||||
|
- 記錄清理操作日誌
|
||||||
|
|
||||||
|
### 6.3 郵件發送模組設計
|
||||||
|
|
||||||
|
#### 6.3.1 功能流程
|
||||||
|
1. 讀取環境變數(Gmail 帳號、密碼、收件人)
|
||||||
|
2. 檢查 CSV 檔案是否存在
|
||||||
|
3. 產生郵件內容(含日期時間)
|
||||||
|
4. 附加 CSV 檔案
|
||||||
|
5. 透過 SMTP_SSL 發送郵件
|
||||||
|
|
||||||
|
#### 6.3.2 安全性
|
||||||
|
- 使用 Gmail App Password(非一般密碼)
|
||||||
|
- 環境變數儲存敏感資訊
|
||||||
|
- 支援 GitHub Actions Secrets
|
||||||
|
|
||||||
|
#### 6.3.3 錯誤處理
|
||||||
|
- CSV 檔案不存在時跳過寄信(可改為發送通知)
|
||||||
|
- SMTP 連線失敗時記錄錯誤
|
||||||
|
- 郵件發送失敗時記錄日誌
|
||||||
|
- 發送錯誤通知郵件(當爬蟲執行失敗時)
|
||||||
|
|
||||||
|
### 6.4 Web 查詢介面設計(待實作)
|
||||||
|
|
||||||
|
#### 6.4.1 功能需求
|
||||||
|
- **查詢功能**:
|
||||||
|
- 按日期範圍查詢
|
||||||
|
- 按分類查詢
|
||||||
|
- 按標籤查詢(使用 LIKE 查詢)
|
||||||
|
- 按語言查詢
|
||||||
|
- 付費/非付費文章篩選
|
||||||
|
- 關鍵字搜尋(標題、內容)
|
||||||
|
|
||||||
|
- **統計功能**:
|
||||||
|
- 文章總數統計
|
||||||
|
- 分類分布統計
|
||||||
|
- 作者統計(發文數量)
|
||||||
|
- 標籤統計(熱門標籤)
|
||||||
|
- 時間趨勢分析(每日/每週/每月文章數量)
|
||||||
|
- 語言分布統計
|
||||||
|
|
||||||
|
- **匯出功能**:
|
||||||
|
- CSV 匯出
|
||||||
|
- Excel 匯出(.xlsx)
|
||||||
|
- 支援匯出查詢結果
|
||||||
|
|
||||||
|
#### 6.4.2 技術架構
|
||||||
|
- **框架**: Flask 或 FastAPI
|
||||||
|
- **前端**: HTML + CSS + JavaScript(可選:Vue.js / React)
|
||||||
|
- **多語言支援**: i18n(繁體中文、英文、韓文)
|
||||||
|
- **資料庫**: 使用現有 DatabaseManager
|
||||||
|
|
||||||
|
#### 6.4.3 頁面設計
|
||||||
|
- **首頁**: 查詢表單和統計摘要
|
||||||
|
- **查詢結果頁**: 文章列表(分頁顯示)
|
||||||
|
- **統計頁**: 圖表和統計數據
|
||||||
|
- **文章詳情頁**: 單篇文章完整資訊
|
||||||
|
|
||||||
|
### 6.5 資料匯出模組設計(待實作)
|
||||||
|
|
||||||
|
#### 6.5.1 Excel 匯出功能
|
||||||
|
- **檔案格式**: .xlsx(使用 openpyxl 或 xlsxwriter)
|
||||||
|
- **功能**:
|
||||||
|
- 匯出所有欄位
|
||||||
|
- 支援多工作表(按分類或日期分組)
|
||||||
|
- 格式化(標題、日期格式等)
|
||||||
|
- 支援大量資料匯出
|
||||||
|
|
||||||
|
#### 6.5.2 匯出選項
|
||||||
|
- 匯出所有資料
|
||||||
|
- 匯出查詢結果
|
||||||
|
- 匯出特定日期範圍
|
||||||
|
- 匯出特定分類
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 設定檔設計
|
||||||
|
|
||||||
|
### 7.1 Scrapy Settings
|
||||||
|
- **ROBOTSTXT_OBEY**: True(遵守 robots.txt)
|
||||||
|
- **DOWNLOAD_DELAY**: 2-3(保守模式,較長延遲,避免對伺服器造成負擔)
|
||||||
|
- **ITEM_PIPELINES**: 啟用 CSV 和資料庫 Pipeline
|
||||||
|
- **USER_AGENT**: 自訂 User-Agent
|
||||||
|
- **CONCURRENT_REQUESTS**: 降低並發請求數(保守模式)
|
||||||
|
|
||||||
|
### 7.2 環境變數
|
||||||
|
- `GMAIL_USERNAME`: Gmail 帳號
|
||||||
|
- `GMAIL_APP_PASSWORD`: Gmail App Password(16 碼)
|
||||||
|
- `MAIL_TO`: 收件人信箱(預設:kaeruzak@gmail.com)
|
||||||
|
- `DB_HOST`: 資料庫主機(mysql.theaken.com)
|
||||||
|
- `DB_PORT`: 資料庫埠號(33306)
|
||||||
|
- `DB_NAME`: 資料庫名稱(HBR_scraper)
|
||||||
|
- `DB_USER`: 資料庫使用者(A101)
|
||||||
|
- `DB_PASSWORD`: 資料庫密碼(透過環境變數設定,不寫入程式碼)
|
||||||
|
- `DB_SSL`: 是否使用 SSL/TLS 加密(True)
|
||||||
|
|
||||||
|
### 7.3 資料庫連線設定
|
||||||
|
- **連線方式**: 透過環境變數設定(優先使用環境變數,其次使用 settings.py 預設值)
|
||||||
|
- **SSL/TLS**: 啟用加密連線
|
||||||
|
- **字元集**: utf8mb4(支援多語言)
|
||||||
|
- **連線池**: 可選,提升效能
|
||||||
|
|
||||||
|
### 7.4 CSV 檔案管理
|
||||||
|
- **保留策略**: 保留最近 N 個 CSV 檔案作為備份
|
||||||
|
- **檔案命名**: `hbr_articles_YYYYMMDD_HHMMSS.csv`
|
||||||
|
- **自動清理**: 刪除超過保留數量的舊檔案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 排程設計
|
||||||
|
|
||||||
|
### 8.1 排程設定
|
||||||
|
- **預設排程**: 每天 08:00 (Asia/Taipei)
|
||||||
|
- **可設定性**: 支援透過設定檔或環境變數設定排程時間
|
||||||
|
- **部署環境**: 自有伺服器(使用 Crontab)
|
||||||
|
|
||||||
|
### 8.2 Crontab 排程
|
||||||
|
```bash
|
||||||
|
# 每天 08:00 執行
|
||||||
|
0 8 * * * cd /path/to/project && scrapy crawl hbr && python send_mail.py
|
||||||
|
|
||||||
|
# 或使用可設定的排程腳本
|
||||||
|
0 8 * * * cd /path/to/project && python run_crawler.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 排程設定檔(可選)
|
||||||
|
- **檔案**: `schedule_config.json` 或環境變數
|
||||||
|
- **設定項目**:
|
||||||
|
- 執行時間(小時、分鐘)
|
||||||
|
- 執行頻率(每天、每週、每月)
|
||||||
|
- 是否啟用增量爬取
|
||||||
|
- 爬取日期範圍
|
||||||
|
|
||||||
|
### 8.4 手動觸發
|
||||||
|
- 支援命令列手動執行爬蟲
|
||||||
|
- 支援 Web 介面手動觸發(待實作)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 錯誤處理與日誌
|
||||||
|
|
||||||
|
### 9.1 錯誤類型
|
||||||
|
- **網路錯誤**: 連線超時、DNS 解析失敗
|
||||||
|
- **解析錯誤**: CSS 選擇器失效、網站結構變更
|
||||||
|
- **資料庫錯誤**: 連線失敗、SQL 語法錯誤
|
||||||
|
- **郵件錯誤**: SMTP 連線失敗、認證失敗
|
||||||
|
|
||||||
|
### 9.2 日誌設計
|
||||||
|
- **日誌級別**: DEBUG, INFO, WARNING, ERROR
|
||||||
|
- **日誌輸出**:
|
||||||
|
- 控制台輸出(開發和除錯用)
|
||||||
|
- 檔案輸出(`logs/crawler.log`)
|
||||||
|
- 資料庫儲存(`logs` 表,用於長期追蹤和查詢)
|
||||||
|
- **日誌內容**:
|
||||||
|
- 爬蟲啟動和結束時間
|
||||||
|
- 爬取的文章數量
|
||||||
|
- 錯誤訊息和堆疊追蹤
|
||||||
|
- 資料庫操作記錄
|
||||||
|
- 資料清理操作記錄
|
||||||
|
- 郵件發送狀態
|
||||||
|
|
||||||
|
### 9.3 錯誤通知
|
||||||
|
- **通知方式**: 發送錯誤通知郵件
|
||||||
|
- **觸發條件**:
|
||||||
|
- 爬蟲執行失敗
|
||||||
|
- 資料庫連線失敗
|
||||||
|
- 郵件發送失敗
|
||||||
|
- 資料清理失敗
|
||||||
|
- **通知內容**:
|
||||||
|
- 錯誤類型
|
||||||
|
- 錯誤訊息
|
||||||
|
- 發生時間
|
||||||
|
- 相關日誌片段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 安全性設計
|
||||||
|
|
||||||
|
### 10.1 資料安全
|
||||||
|
- 敏感資訊(密碼、API Key)使用環境變數
|
||||||
|
- 資料庫連線使用 SSL/TLS 加密連線
|
||||||
|
- 避免在程式碼中硬編碼敏感資訊
|
||||||
|
- 資料庫使用者具有完整權限(SELECT, INSERT, UPDATE, DELETE, CREATE TABLE)
|
||||||
|
|
||||||
|
### 10.2 爬蟲倫理
|
||||||
|
- 遵守 robots.txt 規則
|
||||||
|
- 設定合理的下載延遲
|
||||||
|
- 尊重網站服務條款
|
||||||
|
- 不爬取付費文章內容
|
||||||
|
|
||||||
|
### 10.3 資料保護
|
||||||
|
- 手動備份資料庫(定期執行)
|
||||||
|
- CSV 檔案保留作為備份(保留最近 N 個檔案)
|
||||||
|
- 資料保留政策:
|
||||||
|
- 自動刪除超過 30 天的資料
|
||||||
|
- 保留最近 30 次爬取記錄
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 擴展性設計
|
||||||
|
|
||||||
|
### 11.1 已規劃擴展功能
|
||||||
|
- **Web 查詢介面**: 提供 Web 介面查詢文章(按日期、分類、標籤)
|
||||||
|
- **資料統計功能**: 文章數量、分類分布、作者統計等
|
||||||
|
- **Excel 匯出**: 支援 Excel 格式匯出(.xlsx)
|
||||||
|
- **多語言支援**: 繁體中文、英文、韓文
|
||||||
|
- **增量爬取**: 僅爬取新文章,提升效率
|
||||||
|
- **資料清理**: 自動清理舊資料,保留最近 30 次爬取記錄
|
||||||
|
|
||||||
|
### 11.2 未來擴展方向
|
||||||
|
- 支援多個網站爬取
|
||||||
|
- 產生報表(週報、月報)
|
||||||
|
- 視覺化圖表
|
||||||
|
- 支援即時通知(Slack, Teams)
|
||||||
|
- 雲端儲存整合(S3, Google Drive)
|
||||||
|
- API 介面(RESTful API)
|
||||||
|
|
||||||
|
### 11.2 模組化設計
|
||||||
|
- Pipeline 可插拔設計
|
||||||
|
- 支援自訂資料處理邏輯
|
||||||
|
- 易於新增新的資料來源
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 測試策略
|
||||||
|
|
||||||
|
### 12.1 單元測試
|
||||||
|
- Spider 解析邏輯測試
|
||||||
|
- Pipeline 處理邏輯測試
|
||||||
|
- 資料庫操作測試
|
||||||
|
|
||||||
|
### 12.2 整合測試
|
||||||
|
- 完整爬蟲流程測試
|
||||||
|
- 郵件發送測試
|
||||||
|
- 資料庫儲存測試
|
||||||
|
|
||||||
|
### 12.3 手動測試
|
||||||
|
- 本地環境測試
|
||||||
|
- 排程觸發測試
|
||||||
|
- 錯誤情境測試
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 部署與維護
|
||||||
|
|
||||||
|
### 13.1 部署步驟
|
||||||
|
1. 設定環境變數(資料庫連線資訊、Gmail 設定)
|
||||||
|
2. 安裝依賴套件(`pip install -r requirements.txt`)
|
||||||
|
3. 測試資料庫連線(`python test_db_connection.py`)
|
||||||
|
4. 建立資料表結構(執行 `create_tables.sql`)
|
||||||
|
5. 設定排程(Crontab,預設每天 08:00)
|
||||||
|
6. 執行測試爬取
|
||||||
|
7. 設定資料清理排程(可選,定期清理舊資料)
|
||||||
|
8. 部署 Web 查詢介面(待實作)
|
||||||
|
|
||||||
|
### 13.2 維護事項
|
||||||
|
- 定期檢查爬蟲執行狀態
|
||||||
|
- 監控資料庫空間使用
|
||||||
|
- 檢查網站結構變更
|
||||||
|
- 更新 CSS 選擇器(如需要)
|
||||||
|
- 手動備份資料庫(定期執行)
|
||||||
|
- 清理舊 CSV 檔案(保留最近 N 個)
|
||||||
|
- 檢查日誌檔案大小
|
||||||
|
- 監控錯誤通知郵件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 附錄
|
||||||
|
|
||||||
|
### 14.1 參考資料
|
||||||
|
- [Scrapy 官方文件](https://docs.scrapy.org/)
|
||||||
|
- [Python SMTP 文件](https://docs.python.org/3/library/smtplib.html)
|
||||||
|
- [GitHub Actions 文件](https://docs.github.com/en/actions)
|
||||||
|
|
||||||
|
### 14.2 變更記錄
|
||||||
|
| 版本 | 日期 | 變更內容 | 作者 |
|
||||||
|
|------|------|---------|------|
|
||||||
|
| 1.0 | 2024-12-22 | 初始版本 | - |
|
||||||
|
| 2.0 | 2024-12-22 | 根據需求問卷更新:<br>- 更新資料庫設計(標籤非正規化、多語言支援)<br>- 新增增量爬取邏輯<br>- 新增資料清理機制<br>- 新增 Web 查詢介面規劃<br>- 新增統計功能規劃<br>- 更新排程設計(可設定、每天執行)<br>- 更新日誌設計(資料庫儲存)<br>- 更新錯誤通知機制<br>- 新增 Excel 匯出規劃 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文件結束**
|
||||||
|
|
||||||
89
Security-Audit-Prompt_English.md
Normal file
89
Security-Audit-Prompt_English.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
**[ROLE]**
|
||||||
|
You are a top-tier security consultant (Senior Security Architect) with 30 years of experience, proficient in both aggressive penetration testing and defensive system hardening. Your mindset combines a hacker's creative attack thinking with a white-hat hacker's rigorous defensive strategies. Your main task today is to serve as a security mentor, particularly focusing on those "impossible mistakes that no one would make" that experienced developers think, but novices often commit due to unfamiliarity or convenience-seeking. Your mission is not only to find vulnerabilities but also to teach developers to understand the principles behind vulnerabilities and attackers' mindset in the most accessible way.
|
||||||
|
|
||||||
|
**[CONTEXT]**
|
||||||
|
I have just completed the initial development of a project, a phase I call "Vibe Coding," focusing on rapid feature implementation. I know that as a novice, I may have made catastrophic errors in places I cannot see. Now, before going live (Go-Live), I need you to conduct a comprehensive, thorough, merciless security audit of the entire project, particularly approaching from the angle of "mistakes novices most commonly make."
|
||||||
|
|
||||||
|
Please read the files in this directory to obtain my project content, and ask me about the following items if unclear (also record them when you finish listing these items in your report):
|
||||||
|
* Project name and description:
|
||||||
|
* Target users:
|
||||||
|
* Types of data processed:
|
||||||
|
* Does it process Personally Identifiable Information (PII)?
|
||||||
|
* Does it process payment or financial information?
|
||||||
|
* Does it have User Generated Content (UGC)?
|
||||||
|
* Tech Stack:
|
||||||
|
* Frontend:
|
||||||
|
* Backend:
|
||||||
|
* Database:
|
||||||
|
* Deployment environment/server type:
|
||||||
|
* External dependencies and services:
|
||||||
|
* NPM/Pip/Maven package lists (package.json, requirements.txt, etc. file contents):
|
||||||
|
* External API services:
|
||||||
|
* Cloud services used:
|
||||||
|
* Code access (can provide code repository link or paste key code sections):
|
||||||
|
|
||||||
|
**[CORE TASK]**
|
||||||
|
Based on the above information, please execute the following multi-dimensional security risk assessment and provide solutions. Your analysis must be like examining with a magnifying glass, not missing any seemingly minor mistake.
|
||||||
|
|
||||||
|
**Part One: Disaster-Class Novice Mistake Check**
|
||||||
|
* **Publicly accessible sensitive files:**
|
||||||
|
* **Frontend leaks:** Check all public JavaScript files (.js) for hardcoded API Keys, backend API addresses, or any form of usernames and passwords.
|
||||||
|
* **Server leaks:** Check the website root directory and subdirectories for files that should not be publicly accessible. Examples: database backup files (.sql, .bak), debug log files (debug.log), original configuration files (config.php.bak), source code or dependency files (composer.json, package.json).
|
||||||
|
* **Insecure file/directory permissions:**
|
||||||
|
* **Overly permissive permissions:** Check if any directories or files are set to 777.
|
||||||
|
* **Permission setting recommendations:** Point out which directories should be set as non-writable, how user upload directories should be configured, what minimum permissions sensitive configuration files should have.
|
||||||
|
* **Key files that should be prohibited from download:**
|
||||||
|
* **Check web server (Apache/Nginx) configuration** to see if it effectively blocks direct URL downloads of .env, .git directories, .htaccess, and other files.
|
||||||
|
|
||||||
|
**Part Two: Standard Application Security Audit**
|
||||||
|
* **Secrets Management:** Check backend code and any configuration files (.ini, .xml, .yml) for hardcoded database connection strings, passwords, third-party service keys, etc.
|
||||||
|
* **OWASP Top 10 (2021) Review:** Systematically check for the following vulnerabilities:
|
||||||
|
* A01: Broken Access Control
|
||||||
|
* A02: Cryptographic Failures
|
||||||
|
* A03: Injection Attacks (SQL, NoSQL, Command Injection)
|
||||||
|
* A04: Insecure Design
|
||||||
|
* A05: Security Misconfiguration
|
||||||
|
* A06: Vulnerable and Outdated Components
|
||||||
|
* A07: Identification and Authentication Failures
|
||||||
|
* A08: Software and Data Integrity Failures
|
||||||
|
* A09: Security Logging and Monitoring Failures
|
||||||
|
* A10: Server-Side Request Forgery (SSRF)
|
||||||
|
* **Business Logic Flaws:** Find vulnerabilities that don't violate technical specifications but violate business expectations.
|
||||||
|
* **Dependency & Supply Chain Security:** Analyze dependency files to find packages with known vulnerabilities (CVEs).
|
||||||
|
* **Database & Data Flow Security:** Check encryption measures for data in transit (TLS) and data at rest (Encryption at Rest), as well as database account permissions.
|
||||||
|
* **Third-Party Service & API Integration Security:** Check API key permission scopes, webhook verification mechanisms, CORS security settings.
|
||||||
|
* **Infrastructure & DevOps Security:** Check for environment configuration errors (like public S3 buckets), adequate logging and monitoring, error message handling that might leak too much information.
|
||||||
|
|
||||||
|
**Part Three: Special Strategy for Large-Scale Projects**
|
||||||
|
* **When you discover a high-risk code pattern** (e.g., some form of SQL injection or insecure file handling), and based on the project's scale, you suspect this pattern might be repeated throughout the codebase, you should adopt the following strategy:
|
||||||
|
1. **Phased audit recommendations:** You can suggest to developers: "Due to the large project scale, to ensure no omissions, we might consider conducting audit work in phases or by modules to ensure coverage and analysis depth."
|
||||||
|
2. **Request authorization for automated scanning:** You must proactively ask developers: **"I've discovered a potential risk pattern. To ensure we find all similar issues, would you agree to let me generate a Python/Shell script for you that uses Regular Expressions (RegEx) to quickly scan the entire codebase? This script will only read and search, not modify any files."**
|
||||||
|
|
||||||
|
**[OUTPUT FORMAT]**
|
||||||
|
Please present your audit results using the following formatted approach. For each issue found, provide clear, actionable recommendations. For **high** risk items, or any disaster-level errors belonging to "Part One," you must deeply explain attack methods and fix principles.
|
||||||
|
- **Basic project information:**
|
||||||
|
- **Threat Title:** (e.g., High Risk - API Key hardcoded in public JavaScript files)
|
||||||
|
* **Risk Level:** `High` / `Medium` / `Low`
|
||||||
|
* **Threat Description:** (Clearly describe what this vulnerability is and why it's a problem.)
|
||||||
|
* **Affected Components:** (Point out problematic files, line numbers, directories, or server configurations.)
|
||||||
|
|
||||||
|
**(--- Following section exclusive to high-risk/disaster-level errors ---)**
|
||||||
|
|
||||||
|
* **Hacker's Playbook:**
|
||||||
|
> **(Please use first-person, narrative style to describe in an accessible way how a hacker would exploit this error.)**
|
||||||
|
> Example: "I'm just a regular user who pressed F12 to open browser developer tools. In a file called api.js, I saw const MAP_API_KEY = 'AIzaSy...';. Great, this Google Maps API Key is now mine. I'll use it for my own commercial services, and all charges will be billed to your account..."
|
||||||
|
|
||||||
|
* **Principle of the Fix:**
|
||||||
|
> **(Please use simple, understandable analogies or methods to explain why the suggested fix method is effective.)**
|
||||||
|
> Example: "Why can't you put Keys in frontend JS? Because frontend JS is like 'flyers' you print for all passersby - everyone can see what's written on them. The backend server is your secure 'office.' The correct approach is to let the flyer (frontend) guide customers to the office (backend), where office staff (backend programs) use keys (API Keys) stored in a safe (environment variables) to call external services, then only tell customers the 'results,' not hand them the 'keys.'"
|
||||||
|
**(--- End of exclusive section ---)**
|
||||||
|
|
||||||
|
* **Fix Recommendations & Code Examples:**
|
||||||
|
* (Provide specific, actionable fix steps.)
|
||||||
|
* (If applicable, provide "before fix" and "after fix" code or configuration examples.)
|
||||||
|
* (Recommend tools or libraries to use.)
|
||||||
|
|
||||||
|
**[FINAL INSTRUCTION]**
|
||||||
|
Begin your analysis. Your goal is to be the guardian angel of novices, finding those most easily overlooked yet most deadly errors. Please question all seemingly "obvious" security assumptions. Assume developers, for convenience, may have taken any insecure shortcuts. Use your experience to help me thoroughly eliminate these catastrophic hidden dangers before going live.
|
||||||
|
|
||||||
|
Save the above report to security-fixes.md in the root directory.
|
||||||
20
check_db_data.py
Normal file
20
check_db_data.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""檢查資料庫中的資料"""
|
||||||
|
from hbr_crawler.hbr_crawler.database import get_database_manager
|
||||||
|
|
||||||
|
db = get_database_manager()
|
||||||
|
result = db.execute_query('SELECT COUNT(*) as count FROM articles', database='db_A101')
|
||||||
|
if result and len(result) > 0:
|
||||||
|
print(f"資料庫中的文章數量: {result[0]['count']}")
|
||||||
|
# 顯示最近5筆
|
||||||
|
recent = db.execute_query('SELECT id, title, url, crawled_at FROM articles ORDER BY crawled_at DESC LIMIT 5', database='db_A101')
|
||||||
|
if recent:
|
||||||
|
print("\n最近5筆文章:")
|
||||||
|
for article in recent:
|
||||||
|
title = article.get('title', '無標題')
|
||||||
|
print(f" - {article['id']}: {title[:50]}...")
|
||||||
|
else:
|
||||||
|
print("無法查詢資料庫或資料庫中沒有資料")
|
||||||
|
|
||||||
|
|
||||||
11
check_table_structure.py
Normal file
11
check_table_structure.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""檢查資料表結構"""
|
||||||
|
from hbr_crawler.hbr_crawler.database import get_database_manager
|
||||||
|
|
||||||
|
db = get_database_manager()
|
||||||
|
result = db.execute_query('DESCRIBE articles', database='db_A101')
|
||||||
|
print('articles 資料表結構:')
|
||||||
|
for row in result:
|
||||||
|
print(f" 欄位: {row['Field']}, 類型: {row['Type']}, 允許NULL: {row['Null']}")
|
||||||
|
|
||||||
15
crawler_config.json
Normal file
15
crawler_config.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"urls": [
|
||||||
|
"https://www.hbrtaiwan.com/"
|
||||||
|
],
|
||||||
|
"downloadDelay": 2,
|
||||||
|
"maxDepth": 3,
|
||||||
|
"concurrentRequests": 10,
|
||||||
|
"skipPaywalled": false,
|
||||||
|
"followPagination": true,
|
||||||
|
"obeyRobotsTxt": true,
|
||||||
|
"articleListSelector": "article, .article-item, .post-item, .content-item",
|
||||||
|
"titleSelector": "h1, .article-title, .post-title",
|
||||||
|
"authorSelector": ".author, .byline, .writer",
|
||||||
|
"contentSelector": ".article-content, .post-content, .content"
|
||||||
|
}
|
||||||
55
create_tables.sql
Normal file
55
create_tables.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
-- HBR 爬蟲系統資料表結構
|
||||||
|
-- 資料庫: HBR_scraper
|
||||||
|
-- 建立日期: 2024-12-22
|
||||||
|
|
||||||
|
-- 1. 文章主表 (articles)
|
||||||
|
CREATE TABLE IF NOT EXISTS `articles` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '文章唯一識別碼',
|
||||||
|
`title` VARCHAR(500) NOT NULL COMMENT '文章標題',
|
||||||
|
`url` VARCHAR(1000) NOT NULL COMMENT '文章網址',
|
||||||
|
`author` VARCHAR(200) DEFAULT NULL COMMENT '作者名稱',
|
||||||
|
`publish_date` DATETIME DEFAULT NULL COMMENT '發布日期',
|
||||||
|
`summary` TEXT DEFAULT NULL COMMENT '文章摘要',
|
||||||
|
`is_paywalled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否為付費文章 (1=是, 0=否)',
|
||||||
|
`category` VARCHAR(100) DEFAULT NULL COMMENT '文章分類',
|
||||||
|
`tags` VARCHAR(500) DEFAULT NULL COMMENT '標籤(逗號分隔字串,非正規化設計)',
|
||||||
|
`content` TEXT DEFAULT NULL COMMENT '文章內容(僅非付費文章)',
|
||||||
|
`language` VARCHAR(10) DEFAULT 'zh-TW' COMMENT '語言代碼(zh-TW, en, ko)',
|
||||||
|
`crawl_count` INT DEFAULT 1 COMMENT '爬取次數(用於追蹤歷史記錄)',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '資料建立時間',
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '資料更新時間',
|
||||||
|
`crawled_at` DATETIME DEFAULT NULL COMMENT '爬取時間',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `idx_url` (`url`(255)),
|
||||||
|
KEY `idx_publish_date` (`publish_date`),
|
||||||
|
KEY `idx_category` (`category`),
|
||||||
|
KEY `idx_is_paywalled` (`is_paywalled`),
|
||||||
|
KEY `idx_crawled_at` (`crawled_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章主表';
|
||||||
|
|
||||||
|
-- 2. 標籤表 (tags)
|
||||||
|
CREATE TABLE IF NOT EXISTS `tags` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '標籤唯一識別碼',
|
||||||
|
`name` VARCHAR(100) NOT NULL COMMENT '標籤名稱',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `idx_name` (`name`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='標籤表';
|
||||||
|
|
||||||
|
-- 3. 文章標籤關聯表 (article_tags)
|
||||||
|
CREATE TABLE IF NOT EXISTS `article_tags` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '關聯唯一識別碼',
|
||||||
|
`article_id` BIGINT UNSIGNED NOT NULL COMMENT '文章 ID',
|
||||||
|
`tag_id` BIGINT UNSIGNED NOT NULL COMMENT '標籤 ID',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `idx_article_tag` (`article_id`, `tag_id`),
|
||||||
|
KEY `idx_article_id` (`article_id`),
|
||||||
|
KEY `idx_tag_id` (`tag_id`),
|
||||||
|
CONSTRAINT `fk_article_tags_article` FOREIGN KEY (`article_id`)
|
||||||
|
REFERENCES `articles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT `fk_article_tags_tag` FOREIGN KEY (`tag_id`)
|
||||||
|
REFERENCES `tags` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章標籤關聯表';
|
||||||
|
|
||||||
|
|
||||||
4
hbr_crawler/hbr_crawler/__init__.py
Normal file
4
hbr_crawler/hbr_crawler/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# This package will contain the spiders of your Scrapy project
|
||||||
|
#
|
||||||
|
# Please refer to the documentation for information on how to create and manage
|
||||||
|
# your spiders.
|
||||||
232
hbr_crawler/hbr_crawler/database.py
Normal file
232
hbr_crawler/hbr_crawler/database.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
資料庫連線模組
|
||||||
|
提供資料庫連線、查詢、插入等功能
|
||||||
|
"""
|
||||||
|
import pymysql
|
||||||
|
import logging
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseManager:
|
||||||
|
"""資料庫管理類別"""
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int, user: str, password: str,
|
||||||
|
database: str = None, charset: str = 'utf8mb4'):
|
||||||
|
"""
|
||||||
|
初始化資料庫連線參數
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: 資料庫主機位址
|
||||||
|
port: 資料庫埠號
|
||||||
|
user: 資料庫使用者名稱
|
||||||
|
password: 資料庫密碼
|
||||||
|
database: 資料庫名稱(可選,用於建立連線時指定)
|
||||||
|
charset: 字元集(預設 utf8mb4)
|
||||||
|
"""
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.user = user
|
||||||
|
self.password = password
|
||||||
|
self.database = database
|
||||||
|
self.charset = charset
|
||||||
|
self.connection = None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection(self, database: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
取得資料庫連線(使用 context manager 自動管理連線)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database: 資料庫名稱(可選,覆蓋初始化時的設定)
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
pymysql.Connection: 資料庫連線物件
|
||||||
|
"""
|
||||||
|
db_name = database or self.database
|
||||||
|
connection = None
|
||||||
|
try:
|
||||||
|
connection = pymysql.connect(
|
||||||
|
host=self.host,
|
||||||
|
port=self.port,
|
||||||
|
user=self.user,
|
||||||
|
password=self.password,
|
||||||
|
database=db_name,
|
||||||
|
charset=self.charset,
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
autocommit=False
|
||||||
|
)
|
||||||
|
yield connection
|
||||||
|
connection.commit()
|
||||||
|
except Exception as e:
|
||||||
|
if connection:
|
||||||
|
connection.rollback()
|
||||||
|
logger.error(f"資料庫連線錯誤: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if connection:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_connection(self, database: Optional[str] = None) -> bool:
|
||||||
|
"""
|
||||||
|
測試資料庫連線
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database: 資料庫名稱(可選)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 連線成功返回 True,失敗返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self.get_connection(database) as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
result = cursor.fetchone()
|
||||||
|
logger.info("資料庫連線測試成功")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"資料庫連線測試失敗: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_database(self, database_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
建立資料庫(如果不存在)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_name: 資料庫名稱
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 建立成功返回 True,失敗返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 先連接到系統資料庫(不指定資料庫)
|
||||||
|
with self.get_connection(None) as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 建立資料庫(如果不存在)
|
||||||
|
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{database_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"資料庫 {database_name} 建立成功(或已存在)")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"建立資料庫失敗: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute_sql_file(self, sql_file_path: str, database: Optional[str] = None) -> bool:
|
||||||
|
"""
|
||||||
|
執行 SQL 檔案(用於建立資料表)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sql_file_path: SQL 檔案路徑
|
||||||
|
database: 資料庫名稱(可選)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 執行成功返回 True,失敗返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_name = database or self.database
|
||||||
|
with open(sql_file_path, 'r', encoding='utf-8') as f:
|
||||||
|
sql_content = f.read()
|
||||||
|
|
||||||
|
with self.get_connection(db_name) as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 分割 SQL 語句(以分號分隔)
|
||||||
|
statements = [s.strip() for s in sql_content.split(';') if s.strip()]
|
||||||
|
for statement in statements:
|
||||||
|
if statement:
|
||||||
|
cursor.execute(statement)
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"SQL 檔案執行成功: {sql_file_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"執行 SQL 檔案失敗: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute_query(self, query: str, params: tuple = None,
|
||||||
|
database: Optional[str] = None) -> list:
|
||||||
|
"""
|
||||||
|
執行查詢語句
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: SQL 查詢語句
|
||||||
|
params: 查詢參數(可選)
|
||||||
|
database: 資料庫名稱(可選)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 查詢結果列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_name = database or self.database
|
||||||
|
with self.get_connection(db_name) as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
if params:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
else:
|
||||||
|
cursor.execute(query)
|
||||||
|
return cursor.fetchall()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查詢執行失敗: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def execute_update(self, query: str, params: tuple = None,
|
||||||
|
database: Optional[str] = None) -> int:
|
||||||
|
"""
|
||||||
|
執行更新語句(INSERT, UPDATE, DELETE)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: SQL 更新語句
|
||||||
|
params: 更新參數(可選)
|
||||||
|
database: 資料庫名稱(可選)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: 受影響的列數
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_name = database or self.database
|
||||||
|
with self.get_connection(db_name) as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
if params:
|
||||||
|
affected_rows = cursor.execute(query, params)
|
||||||
|
else:
|
||||||
|
affected_rows = cursor.execute(query)
|
||||||
|
conn.commit()
|
||||||
|
return affected_rows
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新執行失敗: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_manager() -> DatabaseManager:
|
||||||
|
"""
|
||||||
|
從環境變數或設定檔取得資料庫連線資訊,建立 DatabaseManager 實例
|
||||||
|
|
||||||
|
優先順序:
|
||||||
|
1. 環境變數
|
||||||
|
2. Scrapy settings(如果可用)
|
||||||
|
3. 預設值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DatabaseManager: 資料庫管理物件
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 嘗試從 Scrapy settings 取得設定
|
||||||
|
try:
|
||||||
|
from scrapy.utils.project import get_project_settings
|
||||||
|
settings = get_project_settings()
|
||||||
|
host = settings.get('DB_HOST', os.environ.get('DB_HOST', 'mysql.theaken.com'))
|
||||||
|
port = settings.getint('DB_PORT', int(os.environ.get('DB_PORT', 33306)))
|
||||||
|
user = settings.get('DB_USER', os.environ.get('DB_USER', 'A101'))
|
||||||
|
password = settings.get('DB_PASSWORD', os.environ.get('DB_PASSWORD', 'Aa123456'))
|
||||||
|
database = settings.get('DB_NAME', os.environ.get('DB_NAME', 'db_A101'))
|
||||||
|
except:
|
||||||
|
# 如果無法取得 Scrapy settings,使用環境變數或預設值
|
||||||
|
host = os.environ.get('DB_HOST', 'mysql.theaken.com')
|
||||||
|
port = int(os.environ.get('DB_PORT', 33306))
|
||||||
|
user = os.environ.get('DB_USER', 'A101')
|
||||||
|
password = os.environ.get('DB_PASSWORD', 'Aa123456')
|
||||||
|
database = os.environ.get('DB_NAME', 'db_A101')
|
||||||
|
|
||||||
|
return DatabaseManager(host, port, user, password, database)
|
||||||
|
|
||||||
19
hbr_crawler/hbr_crawler/items.py
Normal file
19
hbr_crawler/hbr_crawler/items.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Define here the models for your scraped items
|
||||||
|
#
|
||||||
|
# See documentation in:
|
||||||
|
# https://docs.scrapy.org/en/latest/topics/items.html
|
||||||
|
|
||||||
|
import scrapy
|
||||||
|
|
||||||
|
|
||||||
|
class HbrArticleItem(scrapy.Item):
|
||||||
|
# define the fields for your item here like:
|
||||||
|
title = scrapy.Field()
|
||||||
|
url = scrapy.Field()
|
||||||
|
author = scrapy.Field()
|
||||||
|
publish_date = scrapy.Field()
|
||||||
|
summary = scrapy.Field()
|
||||||
|
is_paywalled = scrapy.Field()
|
||||||
|
category = scrapy.Field()
|
||||||
|
tags = scrapy.Field()
|
||||||
|
content = scrapy.Field()
|
||||||
378
hbr_crawler/hbr_crawler/pipelines.py
Normal file
378
hbr_crawler/hbr_crawler/pipelines.py
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
# 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.html
|
||||||
|
|
||||||
|
|
||||||
|
# useful for handling different item types with a single interface
|
||||||
|
from itemadapter import ItemAdapter
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from hbr_crawler.database import get_database_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CsvExportPipeline:
|
||||||
|
def __init__(self):
|
||||||
|
self.file = None
|
||||||
|
self.writer = None
|
||||||
|
self.items = []
|
||||||
|
|
||||||
|
def open_spider(self, spider):
|
||||||
|
# 確保在專案根目錄建立 CSV 檔案
|
||||||
|
import os
|
||||||
|
# 取得專案根目錄(上一層目錄)
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
csv_path = os.path.join(project_root, 'hbr_articles.csv')
|
||||||
|
self.file = open(csv_path, 'w', newline='', encoding='utf-8')
|
||||||
|
fieldnames = ['title', 'url', 'author', 'publish_date', 'summary', 'is_paywalled', 'category', 'tags', 'content']
|
||||||
|
self.writer = csv.DictWriter(self.file, fieldnames=fieldnames)
|
||||||
|
self.writer.writeheader()
|
||||||
|
|
||||||
|
def close_spider(self, spider):
|
||||||
|
if self.file:
|
||||||
|
self.file.close()
|
||||||
|
|
||||||
|
def process_item(self, item, spider):
|
||||||
|
adapter = ItemAdapter(item)
|
||||||
|
# 將 tags 列表轉換為字串
|
||||||
|
if 'tags' in adapter and isinstance(adapter['tags'], list):
|
||||||
|
adapter['tags'] = ', '.join(adapter['tags'])
|
||||||
|
|
||||||
|
# 將所有欄位轉換為字串,避免 None 值
|
||||||
|
row = {}
|
||||||
|
for field in ['title', 'url', 'author', 'publish_date', 'summary', 'is_paywalled', 'category', 'tags', 'content']:
|
||||||
|
value = adapter.get(field, '')
|
||||||
|
row[field] = str(value) if value is not None else ''
|
||||||
|
|
||||||
|
self.writer.writerow(row)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
class DatabasePipeline:
|
||||||
|
"""資料庫儲存 Pipeline"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.db_manager = None
|
||||||
|
self.db_name = 'db_A101' # 預設資料庫名稱
|
||||||
|
self.tag_cache = {} # 標籤快取,避免重複查詢
|
||||||
|
|
||||||
|
def open_spider(self, spider):
|
||||||
|
"""爬蟲開始時初始化資料庫連線"""
|
||||||
|
try:
|
||||||
|
self.db_manager = get_database_manager()
|
||||||
|
# 取得資料庫名稱
|
||||||
|
self.db_name = self.db_manager.database or 'db_A001'
|
||||||
|
# 測試連線
|
||||||
|
if not self.db_manager.test_connection(self.db_name):
|
||||||
|
logger.warning("資料庫連線失敗,DatabasePipeline 將不會儲存資料")
|
||||||
|
logger.warning("提示:請確認:")
|
||||||
|
logger.warning(f" 1. 資料庫 {self.db_name} 是否可存取")
|
||||||
|
logger.warning(" 2. 使用者是否有存取該資料庫的權限")
|
||||||
|
logger.warning(" 3. 可執行 python test_db_connection.py 檢查連線")
|
||||||
|
self.db_manager = None
|
||||||
|
else:
|
||||||
|
logger.info("資料庫連線成功")
|
||||||
|
# 載入標籤快取
|
||||||
|
self._load_tag_cache()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"初始化資料庫連線失敗: {e}")
|
||||||
|
logger.warning("DatabasePipeline 將不會儲存資料,但爬蟲會繼續執行")
|
||||||
|
self.db_manager = None
|
||||||
|
|
||||||
|
def close_spider(self, spider):
|
||||||
|
"""爬蟲結束時關閉連線"""
|
||||||
|
self.tag_cache = {}
|
||||||
|
logger.info("資料庫 Pipeline 關閉")
|
||||||
|
|
||||||
|
def _load_tag_cache(self):
|
||||||
|
"""載入現有標籤到快取"""
|
||||||
|
try:
|
||||||
|
tags = self.db_manager.execute_query(
|
||||||
|
"SELECT id, name FROM tags",
|
||||||
|
database=self.db_name
|
||||||
|
)
|
||||||
|
self.tag_cache = {tag['name']: tag['id'] for tag in tags}
|
||||||
|
logger.info(f"載入 {len(self.tag_cache)} 個標籤到快取")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"載入標籤快取失敗: {e}")
|
||||||
|
self.tag_cache = {}
|
||||||
|
|
||||||
|
def _get_or_create_tag(self, tag_name: str) -> int:
|
||||||
|
"""
|
||||||
|
取得或建立標籤,返回標籤 ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag_name: 標籤名稱
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: 標籤 ID
|
||||||
|
"""
|
||||||
|
if not tag_name or not tag_name.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
tag_name = tag_name.strip()
|
||||||
|
|
||||||
|
# 先檢查快取
|
||||||
|
if tag_name in self.tag_cache:
|
||||||
|
return self.tag_cache[tag_name]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 查詢資料庫
|
||||||
|
tags = self.db_manager.execute_query(
|
||||||
|
"SELECT id FROM tags WHERE name = %s",
|
||||||
|
params=(tag_name,),
|
||||||
|
database=self.db_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
tag_id = tags[0]['id']
|
||||||
|
self.tag_cache[tag_name] = tag_id
|
||||||
|
return tag_id
|
||||||
|
|
||||||
|
# 建立新標籤
|
||||||
|
affected_rows = self.db_manager.execute_update(
|
||||||
|
"INSERT INTO tags (name) VALUES (%s)",
|
||||||
|
params=(tag_name,),
|
||||||
|
database=self.db_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if affected_rows > 0:
|
||||||
|
# 取得新建立的標籤 ID
|
||||||
|
tags = self.db_manager.execute_query(
|
||||||
|
"SELECT id FROM tags WHERE name = %s",
|
||||||
|
params=(tag_name,),
|
||||||
|
database=self.db_name
|
||||||
|
)
|
||||||
|
if tags:
|
||||||
|
tag_id = tags[0]['id']
|
||||||
|
self.tag_cache[tag_name] = tag_id
|
||||||
|
logger.debug(f"建立新標籤: {tag_name} (ID: {tag_id})")
|
||||||
|
return tag_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得或建立標籤失敗: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_article_exists(self, url: str) -> int:
|
||||||
|
"""
|
||||||
|
檢查文章是否存在,返回文章 ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: 文章 URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: 文章 ID,如果不存在返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
articles = self.db_manager.execute_query(
|
||||||
|
"SELECT id FROM articles WHERE url = %s",
|
||||||
|
params=(url,),
|
||||||
|
database=self.db_name
|
||||||
|
)
|
||||||
|
if articles:
|
||||||
|
return articles[0]['id']
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"檢查文章是否存在失敗: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _insert_article(self, item) -> int:
|
||||||
|
"""
|
||||||
|
插入新文章,返回文章 ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: Scrapy Item 物件
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: 文章 ID
|
||||||
|
"""
|
||||||
|
adapter = ItemAdapter(item)
|
||||||
|
crawled_at = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 處理發布日期
|
||||||
|
publish_date = adapter.get('publish_date')
|
||||||
|
if publish_date and str(publish_date).strip():
|
||||||
|
# 嘗試解析日期字串
|
||||||
|
try:
|
||||||
|
# 如果已經是 datetime 物件,直接使用
|
||||||
|
if isinstance(publish_date, datetime):
|
||||||
|
publish_date = publish_date
|
||||||
|
else:
|
||||||
|
# 嘗試解析常見日期格式
|
||||||
|
from dateutil import parser
|
||||||
|
publish_date = parser.parse(str(publish_date))
|
||||||
|
except:
|
||||||
|
publish_date = None
|
||||||
|
else:
|
||||||
|
publish_date = None
|
||||||
|
|
||||||
|
affected_rows = self.db_manager.execute_update(
|
||||||
|
"""INSERT INTO articles
|
||||||
|
(title, url, author, publish_date, summary, is_paywalled,
|
||||||
|
category, content, crawled_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
|
params=(
|
||||||
|
adapter.get('title', ''),
|
||||||
|
adapter.get('url', ''),
|
||||||
|
adapter.get('author') or None,
|
||||||
|
publish_date,
|
||||||
|
adapter.get('summary') or None,
|
||||||
|
adapter.get('is_paywalled', 0),
|
||||||
|
adapter.get('category') or None,
|
||||||
|
adapter.get('content') or None,
|
||||||
|
crawled_at
|
||||||
|
),
|
||||||
|
database=self.db_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if affected_rows > 0:
|
||||||
|
# 取得新建立的文章 ID
|
||||||
|
articles = self.db_manager.execute_query(
|
||||||
|
"SELECT id FROM articles WHERE url = %s",
|
||||||
|
params=(adapter.get('url'),),
|
||||||
|
database=self.db_name
|
||||||
|
)
|
||||||
|
if articles:
|
||||||
|
return articles[0]['id']
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"插入文章失敗: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_article(self, article_id: int, item):
|
||||||
|
"""
|
||||||
|
更新現有文章
|
||||||
|
|
||||||
|
Args:
|
||||||
|
article_id: 文章 ID
|
||||||
|
item: Scrapy Item 物件
|
||||||
|
"""
|
||||||
|
adapter = ItemAdapter(item)
|
||||||
|
crawled_at = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 處理發布日期
|
||||||
|
publish_date = adapter.get('publish_date')
|
||||||
|
if publish_date and str(publish_date).strip():
|
||||||
|
try:
|
||||||
|
if isinstance(publish_date, datetime):
|
||||||
|
publish_date = publish_date
|
||||||
|
else:
|
||||||
|
from dateutil import parser
|
||||||
|
publish_date = parser.parse(str(publish_date))
|
||||||
|
except:
|
||||||
|
publish_date = None
|
||||||
|
else:
|
||||||
|
publish_date = None
|
||||||
|
|
||||||
|
self.db_manager.execute_update(
|
||||||
|
"""UPDATE articles
|
||||||
|
SET title = %s, author = %s, publish_date = %s,
|
||||||
|
summary = %s, is_paywalled = %s, category = %s,
|
||||||
|
content = %s, crawled_at = %s
|
||||||
|
WHERE id = %s""",
|
||||||
|
params=(
|
||||||
|
adapter.get('title', ''),
|
||||||
|
adapter.get('author') or None,
|
||||||
|
publish_date,
|
||||||
|
adapter.get('summary') or None,
|
||||||
|
adapter.get('is_paywalled', 0),
|
||||||
|
adapter.get('category') or None,
|
||||||
|
adapter.get('content') or None,
|
||||||
|
crawled_at,
|
||||||
|
article_id
|
||||||
|
),
|
||||||
|
database=self.db_name
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新文章失敗: {e}")
|
||||||
|
|
||||||
|
def _link_article_tags(self, article_id: int, tags: list):
|
||||||
|
"""
|
||||||
|
建立文章與標籤的關聯
|
||||||
|
|
||||||
|
Args:
|
||||||
|
article_id: 文章 ID
|
||||||
|
tags: 標籤名稱列表
|
||||||
|
"""
|
||||||
|
if not article_id or not tags:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for tag_name in tags:
|
||||||
|
if not tag_name or not tag_name.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
tag_id = self._get_or_create_tag(tag_name.strip())
|
||||||
|
if not tag_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 檢查關聯是否已存在
|
||||||
|
existing = self.db_manager.execute_query(
|
||||||
|
"SELECT id FROM article_tags WHERE article_id = %s AND tag_id = %s",
|
||||||
|
params=(article_id, tag_id),
|
||||||
|
database=self.db_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# 建立新關聯
|
||||||
|
self.db_manager.execute_update(
|
||||||
|
"INSERT INTO article_tags (article_id, tag_id) VALUES (%s, %s)",
|
||||||
|
params=(article_id, tag_id),
|
||||||
|
database=self.db_name
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"建立文章標籤關聯失敗: {e}")
|
||||||
|
|
||||||
|
def process_item(self, item, spider):
|
||||||
|
"""處理爬取的項目"""
|
||||||
|
if not self.db_manager:
|
||||||
|
return item
|
||||||
|
|
||||||
|
adapter = ItemAdapter(item)
|
||||||
|
url = adapter.get('url', '')
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
logger.warning("文章 URL 為空,跳過資料庫儲存")
|
||||||
|
return item
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 檢查文章是否已存在
|
||||||
|
article_id = self._check_article_exists(url)
|
||||||
|
|
||||||
|
if article_id:
|
||||||
|
# 更新現有文章
|
||||||
|
self._update_article(article_id, item)
|
||||||
|
logger.debug(f"更新文章: {url} (ID: {article_id})")
|
||||||
|
else:
|
||||||
|
# 插入新文章
|
||||||
|
article_id = self._insert_article(item)
|
||||||
|
if article_id:
|
||||||
|
logger.debug(f"插入新文章: {url} (ID: {article_id})")
|
||||||
|
else:
|
||||||
|
logger.warning(f"插入文章失敗: {url}")
|
||||||
|
return item
|
||||||
|
|
||||||
|
# 處理標籤
|
||||||
|
tags = adapter.get('tags', [])
|
||||||
|
if tags:
|
||||||
|
if isinstance(tags, str):
|
||||||
|
# 如果是字串(逗號分隔),轉換為列表
|
||||||
|
tags = [t.strip() for t in tags.split(',') if t.strip()]
|
||||||
|
elif isinstance(tags, list):
|
||||||
|
tags = [str(t).strip() for t in tags if t and str(t).strip()]
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
self._link_article_tags(article_id, tags)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"處理文章項目失敗: {e}")
|
||||||
|
|
||||||
|
return item
|
||||||
94
hbr_crawler/hbr_crawler/settings.py
Normal file
94
hbr_crawler/hbr_crawler/settings.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Scrapy settings for hbr_crawler project
|
||||||
|
#
|
||||||
|
# For simplicity, this file contains only settings considered important or
|
||||||
|
# commonly used. You can find more settings consulting the documentation:
|
||||||
|
#
|
||||||
|
# https://docs.scrapy.org/en/latest/topics/settings.html
|
||||||
|
# https://docs.scrapy.org/en/latest/topics/downloader-middleware.html
|
||||||
|
# https://docs.scrapy.org/en/latest/topics/spider-middleware.html
|
||||||
|
|
||||||
|
BOT_NAME = 'hbr_crawler'
|
||||||
|
|
||||||
|
SPIDER_MODULES = ['hbr_crawler.spiders']
|
||||||
|
NEWSPIDER_MODULE = 'hbr_crawler.spiders'
|
||||||
|
|
||||||
|
# Obey robots.txt rules
|
||||||
|
ROBOTSTXT_OBEY = True
|
||||||
|
|
||||||
|
# Configure a delay for requests for the same website (default: 0)
|
||||||
|
# See https://docs.scrapy.org/en/latest/topics/settings.html#download-delay
|
||||||
|
# See also autothrottle settings and docs
|
||||||
|
DOWNLOAD_DELAY = 1
|
||||||
|
# The download delay setting will honor only one of:
|
||||||
|
#CONCURRENT_REQUESTS_PER_DOMAIN = 16
|
||||||
|
#CONCURRENT_REQUESTS_PER_IP = 16
|
||||||
|
|
||||||
|
# Disable cookies (enabled by default)
|
||||||
|
#COOKIES_ENABLED = False
|
||||||
|
|
||||||
|
# Disable Telnet Console (enabled by default)
|
||||||
|
#TELNETCONSOLE_ENABLED = False
|
||||||
|
|
||||||
|
# Override the default request headers:
|
||||||
|
#DEFAULT_REQUEST_HEADERS = {
|
||||||
|
# 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
# 'Accept-Language': 'en',
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Enable or disable spider middlewares
|
||||||
|
# See https://docs.scrapy.org/en/latest/topics/spider-middleware.html
|
||||||
|
#SPIDER_MIDDLEWARES = {
|
||||||
|
# 'hbr_crawler.middlewares.HbrCrawlerSpiderMiddleware': 543,
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Enable or disable downloader middlewares
|
||||||
|
# See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html
|
||||||
|
#DOWNLOADER_MIDDLEWARES = {
|
||||||
|
# 'hbr_crawler.middlewares.HbrCrawlerDownloaderMiddleware': 543,
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Enable or disable extensions
|
||||||
|
# See https://docs.scrapy.org/en/latest/topics/extensions.html
|
||||||
|
#EXTENSIONS = {
|
||||||
|
# 'scrapy.extensions.telnet.TelnetConsole': None,
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Configure item pipelines
|
||||||
|
# See https://docs.scrapy.org/en/latest/topics/item-pipeline.html
|
||||||
|
ITEM_PIPELINES = {
|
||||||
|
'hbr_crawler.pipelines.CsvExportPipeline': 300,
|
||||||
|
'hbr_crawler.pipelines.DatabasePipeline': 400,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 資料庫設定
|
||||||
|
DB_HOST = 'mysql.theaken.com'
|
||||||
|
DB_PORT = 33306
|
||||||
|
DB_USER = 'A101'
|
||||||
|
DB_PASSWORD = 'Aa123456'
|
||||||
|
DB_NAME = 'db_A101'
|
||||||
|
|
||||||
|
# Enable and configure the AutoThrottle extension (disabled by default)
|
||||||
|
# See https://docs.scrapy.org/en/latest/topics/autothrottle.html
|
||||||
|
#AUTOTHROTTLE_ENABLED = True
|
||||||
|
# The initial download delay
|
||||||
|
#AUTOTHROTTLE_START_DELAY = 1
|
||||||
|
# The maximum download delay to be set in case of high latencies
|
||||||
|
#AUTOTHROTTLE_MAX_DELAY = 60
|
||||||
|
# The average number of requests Scrapy should be sending in parallel to
|
||||||
|
# each remote server
|
||||||
|
#AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
|
||||||
|
# Enable showing throttling stats for every response received:
|
||||||
|
#AUTOTHROTTLE_DEBUG = False
|
||||||
|
|
||||||
|
# Enable and configure HTTP caching (disabled by default)
|
||||||
|
# See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html#httpcache-middleware-settings
|
||||||
|
#HTTPCACHE_ENABLED = True
|
||||||
|
#HTTPCACHE_EXPIRATION_SECS = 0
|
||||||
|
#HTTPCACHE_DIR = 'httpcache'
|
||||||
|
#HTTPCACHE_IGNORE_HTTP_CODES = []
|
||||||
|
#HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.FilesystemCacheStorage'
|
||||||
|
|
||||||
|
# Set settings whose default value is deprecated to a future-proof value
|
||||||
|
REQUEST_FINGERPRINTER_IMPLEMENTATION = '2.7'
|
||||||
|
TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'
|
||||||
|
FEED_EXPORT_ENCODING = 'utf-8'
|
||||||
4
hbr_crawler/hbr_crawler/spiders/__init__.py
Normal file
4
hbr_crawler/hbr_crawler/spiders/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# This package will contain the spiders of your Scrapy project
|
||||||
|
#
|
||||||
|
# Please refer to the documentation for information on how to create and manage
|
||||||
|
# your spiders.
|
||||||
158
hbr_crawler/hbr_crawler/spiders/hbr.py
Normal file
158
hbr_crawler/hbr_crawler/spiders/hbr.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import scrapy
|
||||||
|
from hbr_crawler.items import HbrArticleItem
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class HbrSpider(scrapy.Spider):
|
||||||
|
name = 'hbr'
|
||||||
|
allowed_domains = ['hbrtaiwan.com']
|
||||||
|
start_urls = [
|
||||||
|
'https://www.hbrtaiwan.com/',
|
||||||
|
'https://www.hbrtaiwan.com/topic/management',
|
||||||
|
'https://www.hbrtaiwan.com/topic/leadership',
|
||||||
|
'https://www.hbrtaiwan.com/topic/strategy',
|
||||||
|
'https://www.hbrtaiwan.com/topic/innovation',
|
||||||
|
'https://www.hbrtaiwan.com/topic/technology',
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, start_url=None, test_mode=False, *args, **kwargs):
|
||||||
|
super(HbrSpider, self).__init__(*args, **kwargs)
|
||||||
|
self.test_mode = test_mode == 'true' or test_mode is True
|
||||||
|
|
||||||
|
# 如果提供了 start_url,則使用它作為唯一的起始 URL
|
||||||
|
if start_url:
|
||||||
|
self.start_urls = [start_url]
|
||||||
|
self.logger.info(f"使用自訂起始 URL: {start_url}")
|
||||||
|
|
||||||
|
if self.test_mode:
|
||||||
|
self.logger.info("測試模式:僅爬取第一層,不追蹤分頁")
|
||||||
|
|
||||||
|
def parse(self, response):
|
||||||
|
# 解析文章列表頁面
|
||||||
|
# 優先使用 HBR Taiwan 實際使用的選擇器
|
||||||
|
articles = response.css('.articleItem, article, .article-item, .post-item, .content-item')
|
||||||
|
|
||||||
|
if not articles:
|
||||||
|
# 嘗試其他可能的选择器
|
||||||
|
articles = response.css('.article, .post, .item')
|
||||||
|
|
||||||
|
# 如果還是沒有找到,嘗試直接查找包含文章連結的元素
|
||||||
|
if not articles:
|
||||||
|
# 查找所有包含 /article/ 路徑的連結
|
||||||
|
article_links = response.css('a[href*="/article/"]')
|
||||||
|
seen_urls = set()
|
||||||
|
for link in article_links:
|
||||||
|
href = link.css('::attr(href)').get()
|
||||||
|
if href and '/article/' in href:
|
||||||
|
if not href.startswith('http'):
|
||||||
|
href = response.urljoin(href)
|
||||||
|
if href not in seen_urls:
|
||||||
|
seen_urls.add(href)
|
||||||
|
yield response.follow(href, self.parse_article)
|
||||||
|
return
|
||||||
|
|
||||||
|
for article in articles:
|
||||||
|
# 提取文章連結 - 優先查找 h1, h3 內的連結,然後是 div 內的連結
|
||||||
|
link = article.css('h1 a::attr(href), h3 a::attr(href), .itemthumb a::attr(href), .imgBox a::attr(href), a::attr(href)').get()
|
||||||
|
|
||||||
|
if link and not link.startswith('javascript:') and not link.startswith('#'):
|
||||||
|
if not link.startswith('http'):
|
||||||
|
link = response.urljoin(link)
|
||||||
|
|
||||||
|
# 只處理文章連結
|
||||||
|
if '/article/' in link:
|
||||||
|
yield response.follow(link, self.parse_article)
|
||||||
|
|
||||||
|
# 尋找分頁連結(測試模式下不追蹤分頁)
|
||||||
|
if not self.test_mode:
|
||||||
|
next_page = response.css('a.next::attr(href), .pagination a:last-child::attr(href)').get()
|
||||||
|
if next_page:
|
||||||
|
yield response.follow(next_page, self.parse)
|
||||||
|
|
||||||
|
def parse_article(self, response):
|
||||||
|
item = HbrArticleItem()
|
||||||
|
|
||||||
|
# 標題 - 優先使用 HBR Taiwan 實際使用的選擇器
|
||||||
|
title = response.css('h1.articleTitle::text, h1.article-title::text, h1::text, .article-title::text, .post-title::text').get()
|
||||||
|
if not title:
|
||||||
|
# 嘗試從 title 標籤提取
|
||||||
|
title = response.css('title::text').get()
|
||||||
|
# 移除網站名稱後綴
|
||||||
|
if title and '・' in title:
|
||||||
|
title = title.split('・')[0].strip()
|
||||||
|
item['title'] = title.strip() if title else ''
|
||||||
|
|
||||||
|
# URL
|
||||||
|
item['url'] = response.url
|
||||||
|
|
||||||
|
# 作者 - 優先使用 HBR Taiwan 實際使用的選擇器
|
||||||
|
author = response.css('.authorName::text, .author::text, .byline::text, .writer::text, .author-name::text').get()
|
||||||
|
if not author:
|
||||||
|
# 嘗試從 meta 標籤獲取
|
||||||
|
author = response.css('meta[name="author"]::attr(content)').get()
|
||||||
|
if not author:
|
||||||
|
# 嘗試從作者區塊提取
|
||||||
|
author = response.css('.authorBox .authorName::text, .author-info .authorName::text').get()
|
||||||
|
item['author'] = author.strip() if author else ''
|
||||||
|
|
||||||
|
# 發布日期
|
||||||
|
publish_date = response.css('.date::text, .publish-date::text, .post-date::text').get()
|
||||||
|
if not publish_date:
|
||||||
|
publish_date = response.css('meta[property="article:published_time"]::attr(content)').get()
|
||||||
|
item['publish_date'] = publish_date.strip() if publish_date else ''
|
||||||
|
|
||||||
|
# 摘要
|
||||||
|
summary = response.css('.summary::text, .excerpt::text, .description::text').get()
|
||||||
|
if not summary:
|
||||||
|
summary = response.css('meta[name="description"]::attr(content)').get()
|
||||||
|
item['summary'] = summary.strip() if summary else ''
|
||||||
|
|
||||||
|
# 檢查是否為付費文章
|
||||||
|
paywall_indicators = response.css('.paywall, .premium, .subscription-required, .member-only')
|
||||||
|
is_paywalled = 1 if paywall_indicators else 0
|
||||||
|
item['is_paywalled'] = is_paywalled
|
||||||
|
|
||||||
|
# 分類
|
||||||
|
category = response.css('.category::text, .section::text, .topic::text').get()
|
||||||
|
if not category:
|
||||||
|
# 從 URL 路徑推斷分類
|
||||||
|
url_parts = response.url.split('/')
|
||||||
|
if len(url_parts) > 3:
|
||||||
|
category = url_parts[3]
|
||||||
|
item['category'] = category.strip() if category else ''
|
||||||
|
|
||||||
|
# 標籤
|
||||||
|
tags = response.css('.tags a::text, .tag::text, .keywords a::text').getall()
|
||||||
|
item['tags'] = [tag.strip() for tag in tags if tag.strip()]
|
||||||
|
|
||||||
|
# 文章內容(僅非付費文章)
|
||||||
|
content = ''
|
||||||
|
if not is_paywalled:
|
||||||
|
content_selectors = [
|
||||||
|
'.articleContent',
|
||||||
|
'.article-content',
|
||||||
|
'.post-content',
|
||||||
|
'.content',
|
||||||
|
'.entry-content',
|
||||||
|
'.article-body',
|
||||||
|
'.post-body',
|
||||||
|
'.articleText'
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in content_selectors:
|
||||||
|
content_elements = response.css(selector)
|
||||||
|
if content_elements:
|
||||||
|
# 提取所有段落文字
|
||||||
|
paragraphs = content_elements.css('p::text').getall()
|
||||||
|
if paragraphs:
|
||||||
|
content = ' '.join(paragraphs)
|
||||||
|
else:
|
||||||
|
# 如果沒有段落,提取所有文字
|
||||||
|
content = ' '.join(content_elements.css('::text').getall())
|
||||||
|
if content.strip():
|
||||||
|
break
|
||||||
|
|
||||||
|
item['content'] = content.strip() if content else ''
|
||||||
|
|
||||||
|
yield item
|
||||||
11
hbr_crawler/scrapy.cfg
Normal file
11
hbr_crawler/scrapy.cfg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically created by: scrapy startproject
|
||||||
|
#
|
||||||
|
# For more information about the [deploy] section see:
|
||||||
|
# https://scrapyd.readthedocs.io/en/latest/deploy.html
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
default = hbr_crawler.settings
|
||||||
|
|
||||||
|
[deploy]
|
||||||
|
#url = http://localhost:6800/
|
||||||
|
project = hbr_crawler
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
scrapy>=2.11.0
|
||||||
|
itemadapter>=0.7.0
|
||||||
|
pymysql>=1.1.0
|
||||||
|
python-dateutil>=2.8.2
|
||||||
|
flask>=2.3.0
|
||||||
219
run_crawler.py
Normal file
219
run_crawler.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
HBR 爬蟲系統主啟動腳本
|
||||||
|
整合爬蟲執行、資料庫儲存、CSV 匯出和郵件發送功能
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 設定日誌
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
logging.FileHandler('logs/crawler.log', encoding='utf-8') if os.path.exists('logs') or os.makedirs('logs', exist_ok=True) else logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def check_scrapy_installed():
|
||||||
|
"""檢查 Scrapy 是否已安裝"""
|
||||||
|
try:
|
||||||
|
import scrapy
|
||||||
|
logger.info(f"✓ Scrapy 已安裝 (版本: {scrapy.__version__})")
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
logger.error("✗ Scrapy 未安裝")
|
||||||
|
logger.error("請執行以下命令安裝依賴:")
|
||||||
|
logger.error(" pip install -r requirements.txt")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_scrapy_crawler():
|
||||||
|
"""執行 Scrapy 爬蟲"""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("開始執行 HBR 爬蟲")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
# 檢查 Scrapy 是否已安裝
|
||||||
|
if not check_scrapy_installed():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 切換到爬蟲目錄
|
||||||
|
crawler_dir = Path(__file__).parent / "hbr_crawler"
|
||||||
|
|
||||||
|
if not crawler_dir.exists():
|
||||||
|
logger.error(f"✗ 爬蟲目錄不存在: {crawler_dir}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用 python -m scrapy 的方式執行(更可靠)
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "scrapy", "crawl", "hbr"],
|
||||||
|
cwd=str(crawler_dir),
|
||||||
|
capture_output=False, # 直接輸出到控制台
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
logger.info("✓ 爬蟲執行成功")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"✗ 爬蟲執行失敗,退出碼: {result.returncode}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("✗ 找不到 Python 或 Scrapy 模組")
|
||||||
|
logger.error("請確認:")
|
||||||
|
logger.error(" 1. Python 已正確安裝")
|
||||||
|
logger.error(" 2. 已執行: pip install -r requirements.txt")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"✗ 執行爬蟲時發生錯誤: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_email():
|
||||||
|
"""發送郵件(如果已設定)"""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("檢查郵件發送功能")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用 shell=True 和正確的編碼處理(Windows)
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "send_mail.py"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding='utf-8',
|
||||||
|
errors='replace' # 遇到編碼錯誤時替換而不是失敗
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "send_mail.py"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding='utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.stdout:
|
||||||
|
print(result.stdout)
|
||||||
|
|
||||||
|
if result.stderr:
|
||||||
|
print(result.stderr)
|
||||||
|
|
||||||
|
return result.returncode == 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"執行郵件發送時發生錯誤: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_csv_file():
|
||||||
|
"""檢查 CSV 檔案是否產生"""
|
||||||
|
csv_path = Path(__file__).parent / "hbr_articles.csv"
|
||||||
|
|
||||||
|
if csv_path.exists():
|
||||||
|
file_size = csv_path.stat().st_size
|
||||||
|
logger.info(f"[OK] CSV 檔案已產生: {csv_path} ({file_size:,} bytes)")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"✗ CSV 檔案不存在: {csv_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函數"""
|
||||||
|
start_time = datetime.now()
|
||||||
|
logger.info(f"爬蟲系統啟動時間: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
|
||||||
|
# 步驟 1: 執行爬蟲
|
||||||
|
crawler_success = run_scrapy_crawler()
|
||||||
|
|
||||||
|
if not crawler_success:
|
||||||
|
logger.error("爬蟲執行失敗,終止流程")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 步驟 2: 檢查 CSV 檔案
|
||||||
|
csv_exists = check_csv_file()
|
||||||
|
|
||||||
|
if not csv_exists:
|
||||||
|
logger.warning("CSV 檔案未產生,可能沒有爬取到資料")
|
||||||
|
|
||||||
|
# 步驟 3: 發送郵件(可選)
|
||||||
|
email_sent = send_email()
|
||||||
|
|
||||||
|
# 完成 - 顯示執行摘要
|
||||||
|
end_time = datetime.now()
|
||||||
|
duration = (end_time - start_time).total_seconds()
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("爬蟲系統執行完成")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("執行摘要:")
|
||||||
|
logger.info(f" [{'OK' if crawler_success else 'FAIL'}] 爬蟲執行: {'成功' if crawler_success else '失敗'}")
|
||||||
|
logger.info(f" [{'OK' if csv_exists else 'FAIL'}] CSV 檔案: {'已產生' if csv_exists else '未產生'}")
|
||||||
|
logger.info(f" [{'OK' if email_sent else 'SKIP'}] 郵件發送: {'已發送' if email_sent else '已跳過(未設定)'}")
|
||||||
|
logger.info(f" 執行時間: {duration:.2f} 秒")
|
||||||
|
logger.info(f" 完成時間: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
|
||||||
|
# 顯示注意事項
|
||||||
|
if not csv_exists:
|
||||||
|
logger.warning("")
|
||||||
|
logger.warning("⚠️ 注意:CSV 檔案未產生,請檢查爬蟲是否成功爬取到資料")
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
# 如果爬蟲成功但沒有 CSV,返回警告碼(非錯誤)
|
||||||
|
if crawler_success and not csv_exists:
|
||||||
|
return 2 # 警告碼
|
||||||
|
return 0 if crawler_success else 1
|
||||||
|
|
||||||
|
|
||||||
|
def run_web_server():
|
||||||
|
"""啟動 Web 服務"""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("啟動 Web 服務")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from web_app import app
|
||||||
|
logger.info("Web 服務啟動成功")
|
||||||
|
logger.info("服務地址: http://localhost:5000")
|
||||||
|
logger.info("按 Ctrl+C 停止服務")
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||||
|
except ImportError:
|
||||||
|
logger.error("無法匯入 web_app 模組,請確認 web_app.py 存在")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"啟動 Web 服務失敗: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# 檢查是否要啟動 Web 服務
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == '--web':
|
||||||
|
try:
|
||||||
|
run_web_server()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("\nWeb 服務已停止")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
# 預設執行爬蟲
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("\n使用者中斷執行")
|
||||||
|
sys.exit(130)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"發生未預期的錯誤: {e}", exc_info=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
869
security-fixes.md
Normal file
869
security-fixes.md
Normal file
@@ -0,0 +1,869 @@
|
|||||||
|
# HBR Taiwan 爬蟲系統 - 安全審計報告
|
||||||
|
|
||||||
|
**審計日期**: 2024-12-22
|
||||||
|
**審計員**: Claude (AI Security Consultant)
|
||||||
|
**報告版本**: 1.0
|
||||||
|
**風險評級**: 🔴 **高風險** - 發現災難級漏洞,需立即修復
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 基本專案資訊
|
||||||
|
|
||||||
|
| 項目 | 內容 |
|
||||||
|
|------|------|
|
||||||
|
| **專案名稱** | HBR Taiwan Web Scraper |
|
||||||
|
| **專案描述** | 自動化爬取 Harvard Business Review Taiwan 文章的系統 |
|
||||||
|
| **目標用戶** | 內部/個人使用 |
|
||||||
|
| **處理的資料類型** | 文章元資料(標題、URL、作者、日期、分類、標籤)、文章內容 |
|
||||||
|
| **是否處理 PII** | 否 |
|
||||||
|
| **是否處理付款資訊** | 否 |
|
||||||
|
| **是否有 UGC** | 否 |
|
||||||
|
| **技術棧** | Python 3.11+, Scrapy, MySQL/MariaDB, Gmail SMTP |
|
||||||
|
| **部署環境** | GitHub Actions (私有倉庫), 本地 Crontab |
|
||||||
|
| **外部依賴** | scrapy>=2.11.0, itemadapter>=0.7.0, pymysql>=1.1.0, python-dateutil>=2.8.2 |
|
||||||
|
| **資料庫位置** | mysql.theaken.com:33306 (公網可訪問) |
|
||||||
|
| **未來計畫** | 將新增 Web 介面/API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 執行摘要
|
||||||
|
|
||||||
|
本次審計發現 **3 個高風險**、**4 個中風險**、**3 個低風險** 問題。最嚴重的問題是 **資料庫密碼硬編碼在多個原始碼檔案中**,且該資料庫可從公網訪問。這是一個典型的「新手災難級錯誤」,必須立即修復。
|
||||||
|
|
||||||
|
### 風險統計
|
||||||
|
|
||||||
|
| 風險等級 | 數量 | 狀態 |
|
||||||
|
|----------|------|------|
|
||||||
|
| 🔴 高風險 (High) | 3 | 需立即修復 |
|
||||||
|
| 🟠 中風險 (Medium) | 4 | 建議盡快修復 |
|
||||||
|
| 🟢 低風險 (Low) | 3 | 建議改善 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一部分:災難級新手錯誤檢查
|
||||||
|
|
||||||
|
### 🔴 HIGH-001: 資料庫密碼硬編碼在原始碼中
|
||||||
|
|
||||||
|
* **風險等級**: `High` - 災難級
|
||||||
|
* **威脅描述**: 資料庫連線密碼 `Aa123456` 直接硬編碼在多個 Python 檔案中,包括 `settings.py`、`database.py` 和 `test_db_connection.py`。由於資料庫伺服器可從公網訪問,任何能夠看到這些檔案的人都可以直接連線到您的資料庫。
|
||||||
|
* **受影響組件**:
|
||||||
|
- `hbr_crawler/hbr_crawler/settings.py` (第 64-68 行)
|
||||||
|
- `hbr_crawler/hbr_crawler/database.py` (第 220, 227 行)
|
||||||
|
- `test_db_connection.py` (第 26-32 行)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**駭客劇本 (Hacker's Playbook)**:
|
||||||
|
|
||||||
|
> 「我是一個好奇的人,偶然間在某個地方看到了這個專案的原始碼(可能是開發者不小心把私有倉庫設成公開、在論壇分享了程式碼片段、或者我拿到了開發者的電腦存取權限)。
|
||||||
|
>
|
||||||
|
> 我打開 `settings.py`,哇!第 67 行寫著 `DB_PASSWORD = 'Aa123456'`。再往上看,主機是 `mysql.theaken.com`,埠號是 `33306`,用戶名是 `A101`,資料庫名是 `HBR_scraper`。
|
||||||
|
>
|
||||||
|
> 我打開終端機,輸入:
|
||||||
|
> ```bash
|
||||||
|
> mysql -h mysql.theaken.com -P 33306 -u A101 -pAa123456
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 成功連線!現在我可以:
|
||||||
|
> 1. **讀取所有資料** - `SELECT * FROM articles;`
|
||||||
|
> 2. **刪除所有資料** - `DROP TABLE articles;`(如果用戶權限允許)
|
||||||
|
> 3. **植入惡意資料** - 在文章內容中注入惡意腳本
|
||||||
|
> 4. **竊取其他資料庫** - 如果這個用戶有權限訪問其他資料庫
|
||||||
|
> 5. **作為跳板** - 使用這個資料庫伺服器攻擊同一網路中的其他系統
|
||||||
|
>
|
||||||
|
> 更糟糕的是,密碼 `Aa123456` 是一個極弱的密碼,即使我沒看到原始碼,使用暴力破解工具幾分鐘內就能猜出來。」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**修復原理 (Principle of the Fix)**:
|
||||||
|
|
||||||
|
> 為什麼不能把密碼寫在程式碼裡?想像一下,你的程式碼是一本「食譜書」,你會把食譜印成書賣給很多人看。你會在食譜裡寫「我家保險箱的密碼是 123456」嗎?當然不會!
|
||||||
|
>
|
||||||
|
> 正確的做法是把密碼放在「環境變數」裡。環境變數就像是寫在便條紙上、只有你自己能看到的秘密。程式執行時會去讀這張便條紙,但便條紙不會被複製到食譜書裡。
|
||||||
|
>
|
||||||
|
> 在不同環境(開發、測試、生產),你可以用不同的便條紙(不同的環境變數),這樣也更安全、更靈活。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
* **修復建議與程式碼範例**:
|
||||||
|
|
||||||
|
**步驟 1**: 建立 `.env` 檔案(此檔案絕對不能提交到版本控制)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env (放在專案根目錄)
|
||||||
|
DB_HOST=mysql.theaken.com
|
||||||
|
DB_PORT=33306
|
||||||
|
DB_USER=A101
|
||||||
|
DB_PASSWORD=您的新強密碼
|
||||||
|
DB_NAME=HBR_scraper
|
||||||
|
```
|
||||||
|
|
||||||
|
**步驟 2**: 建立 `.gitignore` 檔案(如果未來要使用 Git)
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# .gitignore
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
.env.*
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
hbr_articles.csv
|
||||||
|
*.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**步驟 3**: 安裝 python-dotenv
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install python-dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
並更新 `requirements.txt`:
|
||||||
|
```
|
||||||
|
scrapy>=2.11.0
|
||||||
|
itemadapter>=0.7.0
|
||||||
|
pymysql>=1.1.0
|
||||||
|
python-dateutil>=2.8.2
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**步驟 4**: 修改 `settings.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前 (危險!)
|
||||||
|
DB_HOST = 'mysql.theaken.com'
|
||||||
|
DB_PORT = 33306
|
||||||
|
DB_USER = 'A101'
|
||||||
|
DB_PASSWORD = 'Aa123456' # 🔴 密碼暴露!
|
||||||
|
DB_NAME = 'HBR_scraper'
|
||||||
|
|
||||||
|
# 修改後 (安全)
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# 載入 .env 檔案
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
DB_HOST = os.environ.get('DB_HOST', 'localhost')
|
||||||
|
DB_PORT = int(os.environ.get('DB_PORT', 3306))
|
||||||
|
DB_USER = os.environ.get('DB_USER')
|
||||||
|
DB_PASSWORD = os.environ.get('DB_PASSWORD')
|
||||||
|
DB_NAME = os.environ.get('DB_NAME')
|
||||||
|
|
||||||
|
# 檢查必要的環境變數
|
||||||
|
if not all([DB_USER, DB_PASSWORD, DB_NAME]):
|
||||||
|
raise ValueError("Missing required database environment variables: DB_USER, DB_PASSWORD, DB_NAME")
|
||||||
|
```
|
||||||
|
|
||||||
|
**步驟 5**: 修改 `database.py` 的 `get_database_manager()` 函數
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前 (危險!)
|
||||||
|
def get_database_manager() -> DatabaseManager:
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
from scrapy.utils.project import get_project_settings
|
||||||
|
settings = get_project_settings()
|
||||||
|
host = settings.get('DB_HOST', os.environ.get('DB_HOST', 'mysql.theaken.com'))
|
||||||
|
# ... 預設值包含真實密碼 ...
|
||||||
|
password = settings.get('DB_PASSWORD', os.environ.get('DB_PASSWORD', 'Aa123456')) # 🔴
|
||||||
|
except:
|
||||||
|
password = os.environ.get('DB_PASSWORD', 'Aa123456') # 🔴
|
||||||
|
|
||||||
|
# 修改後 (安全)
|
||||||
|
def get_database_manager() -> DatabaseManager:
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scrapy.utils.project import get_project_settings
|
||||||
|
settings = get_project_settings()
|
||||||
|
host = settings.get('DB_HOST') or os.environ.get('DB_HOST')
|
||||||
|
port = settings.getint('DB_PORT', int(os.environ.get('DB_PORT', 3306)))
|
||||||
|
user = settings.get('DB_USER') or os.environ.get('DB_USER')
|
||||||
|
password = settings.get('DB_PASSWORD') or os.environ.get('DB_PASSWORD')
|
||||||
|
database = settings.get('DB_NAME') or os.environ.get('DB_NAME')
|
||||||
|
except:
|
||||||
|
host = os.environ.get('DB_HOST')
|
||||||
|
port = int(os.environ.get('DB_PORT', 3306))
|
||||||
|
user = os.environ.get('DB_USER')
|
||||||
|
password = os.environ.get('DB_PASSWORD')
|
||||||
|
database = os.environ.get('DB_NAME')
|
||||||
|
|
||||||
|
if not all([host, user, password]):
|
||||||
|
raise ValueError("Database configuration missing. Please set environment variables.")
|
||||||
|
|
||||||
|
return DatabaseManager(host, port, user, password, database)
|
||||||
|
```
|
||||||
|
|
||||||
|
**步驟 6**: 修改 `test_db_connection.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前 (危險!)
|
||||||
|
DB_CONFIG = {
|
||||||
|
'host': 'mysql.theaken.com',
|
||||||
|
'port': 33306,
|
||||||
|
'user': 'A101',
|
||||||
|
'password': 'Aa123456', # 🔴
|
||||||
|
'database': 'HBR_scraper'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 修改後 (安全)
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
DB_CONFIG = {
|
||||||
|
'host': os.environ.get('DB_HOST'),
|
||||||
|
'port': int(os.environ.get('DB_PORT', 3306)),
|
||||||
|
'user': os.environ.get('DB_USER'),
|
||||||
|
'password': os.environ.get('DB_PASSWORD'),
|
||||||
|
'database': os.environ.get('DB_NAME')
|
||||||
|
}
|
||||||
|
|
||||||
|
# 驗證配置
|
||||||
|
if not all([DB_CONFIG['host'], DB_CONFIG['user'], DB_CONFIG['password']]):
|
||||||
|
print("錯誤: 請設定環境變數 DB_HOST, DB_USER, DB_PASSWORD")
|
||||||
|
sys.exit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**步驟 7**: 立即更換資料庫密碼
|
||||||
|
|
||||||
|
由於密碼已經暴露在原始碼中,即使您現在修復了程式碼,原來的密碼可能已經被他人知道。請:
|
||||||
|
|
||||||
|
1. 登入 MySQL 伺服器
|
||||||
|
2. 更換 A101 用戶的密碼為強密碼(至少 16 字元,包含大小寫字母、數字、特殊符號)
|
||||||
|
3. 更新 `.env` 檔案使用新密碼
|
||||||
|
4. 更新 GitHub Actions secrets
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 在 MySQL 中更換密碼
|
||||||
|
ALTER USER 'A101'@'%' IDENTIFIED BY '新的強密碼';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔴 HIGH-002: 弱密碼 - 資料庫密碼強度不足
|
||||||
|
|
||||||
|
* **風險等級**: `High`
|
||||||
|
* **威脅描述**: 目前使用的密碼 `Aa123456` 是一個極其常見的弱密碼,在大多數密碼字典攻擊中會在前 100 個嘗試內被破解。
|
||||||
|
* **受影響組件**: MySQL 用戶 `A101`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**駭客劇本 (Hacker's Playbook)**:
|
||||||
|
|
||||||
|
> 「即使我沒有看到原始碼,我知道 `mysql.theaken.com:33306` 是一個公開的 MySQL 伺服器。我啟動 Hydra(一個密碼暴力破解工具),使用常見密碼字典:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> hydra -l A101 -P /usr/share/wordlists/rockyou.txt mysql://mysql.theaken.com:33306
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 不到一分鐘,工具告訴我密碼是 `Aa123456`。這個密碼太常見了,幾乎每個密碼字典都有它。」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
* **修復建議**:
|
||||||
|
|
||||||
|
1. 使用密碼管理器生成強密碼(建議 20+ 字元)
|
||||||
|
2. 密碼應包含:
|
||||||
|
- 大寫字母 (A-Z)
|
||||||
|
- 小寫字母 (a-z)
|
||||||
|
- 數字 (0-9)
|
||||||
|
- 特殊符號 (!@#$%^&*)
|
||||||
|
3. 避免使用:
|
||||||
|
- 字典單詞
|
||||||
|
- 常見模式(123456, password, qwerty)
|
||||||
|
- 個人資訊(生日、名字)
|
||||||
|
|
||||||
|
**強密碼範例** (請勿直接使用,僅作示例):
|
||||||
|
```
|
||||||
|
Hbr$Cr@wl3r#2024!Secure_DB
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔴 HIGH-003: 資料庫伺服器公網暴露且缺乏防護
|
||||||
|
|
||||||
|
* **風險等級**: `High`
|
||||||
|
* **威脅描述**: MySQL 伺服器 `mysql.theaken.com:33306` 可從公網直接訪問,沒有 IP 白名單限制,增加了被暴力破解和未授權訪問的風險。
|
||||||
|
* **受影響組件**: MySQL 伺服器配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**駭客劇本 (Hacker's Playbook)**:
|
||||||
|
|
||||||
|
> 「我使用 Shodan(物聯網搜尋引擎)搜尋開放的 MySQL 伺服器:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> port:33306 product:MySQL
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 發現 `mysql.theaken.com` 出現在列表中。這個伺服器對全世界開放,任何人都可以嘗試連線。我可以:
|
||||||
|
> 1. 使用暴力破解工具嘗試常見用戶名/密碼組合
|
||||||
|
> 2. 嘗試已知的 MySQL 漏洞
|
||||||
|
> 3. 進行 DoS 攻擊使服務不可用」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
* **修復建議**:
|
||||||
|
|
||||||
|
**方案 A**: 設定防火牆白名單(推薦)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在伺服器上設定 iptables,只允許特定 IP 訪問
|
||||||
|
# 假設您的 IP 是 203.0.113.100,GitHub Actions IP 範圍需另外查詢
|
||||||
|
|
||||||
|
# 先封鎖所有對 33306 埠的訪問
|
||||||
|
iptables -A INPUT -p tcp --dport 33306 -j DROP
|
||||||
|
|
||||||
|
# 允許特定 IP
|
||||||
|
iptables -I INPUT -p tcp -s 203.0.113.100 --dport 33306 -j ACCEPT
|
||||||
|
|
||||||
|
# 允許 GitHub Actions IP 範圍(需定期更新)
|
||||||
|
# 可從 https://api.github.com/meta 獲取
|
||||||
|
```
|
||||||
|
|
||||||
|
**方案 B**: 使用 SSH 隧道
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 不直接暴露 MySQL,而是通過 SSH 隧道連線
|
||||||
|
ssh -L 3306:localhost:3306 user@mysql.theaken.com
|
||||||
|
|
||||||
|
# 然後本地連線
|
||||||
|
mysql -h 127.0.0.1 -P 3306 -u A101 -p
|
||||||
|
```
|
||||||
|
|
||||||
|
**方案 C**: 使用 VPN
|
||||||
|
|
||||||
|
設定 VPN 伺服器,只有連接到 VPN 後才能訪問資料庫。
|
||||||
|
|
||||||
|
**方案 D**: MySQL 用戶 IP 限制
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 限制 A101 用戶只能從特定 IP 連線
|
||||||
|
-- 先刪除現有用戶
|
||||||
|
DROP USER 'A101'@'%';
|
||||||
|
|
||||||
|
-- 創建只允許從特定 IP 連線的用戶
|
||||||
|
CREATE USER 'A101'@'203.0.113.100' IDENTIFIED BY '新的強密碼';
|
||||||
|
GRANT ALL PRIVILEGES ON HBR_scraper.* TO 'A101'@'203.0.113.100';
|
||||||
|
|
||||||
|
-- 如果需要 GitHub Actions,需要為其 IP 範圍創建用戶
|
||||||
|
-- 注意:GitHub Actions IP 範圍很廣,此方案可能不實際
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二部分:標準應用程式安全審計
|
||||||
|
|
||||||
|
### 🟠 MEDIUM-001: 缺少 .gitignore 檔案
|
||||||
|
|
||||||
|
* **風險等級**: `Medium`
|
||||||
|
* **威脅描述**: 專案沒有 `.gitignore` 檔案。如果未來將專案加入版本控制,可能會意外提交敏感檔案(如 `.env`、`hbr_articles.csv`、`__pycache__`)。
|
||||||
|
* **受影響組件**: 整個專案
|
||||||
|
|
||||||
|
* **修復建議**:
|
||||||
|
|
||||||
|
創建 `.gitignore` 檔案:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# 環境變數(最重要!)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# 虛擬環境
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# 專案特定
|
||||||
|
hbr_articles.csv
|
||||||
|
*.csv
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Scrapy
|
||||||
|
.scrapy/
|
||||||
|
|
||||||
|
# 資料庫備份
|
||||||
|
*.sql.bak
|
||||||
|
*.sql.backup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 MEDIUM-002: SQL 注入風險 - create_database 函數
|
||||||
|
|
||||||
|
* **風險等級**: `Medium`
|
||||||
|
* **威脅描述**: `database.py` 中的 `create_database` 函數使用字串格式化構建 SQL 語句,而非參數化查詢,存在 SQL 注入風險。
|
||||||
|
* **受影響組件**: `hbr_crawler/hbr_crawler/database.py` (第 107 行)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 危險的程式碼
|
||||||
|
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{database_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||||
|
```
|
||||||
|
|
||||||
|
雖然使用了反引號包裹,但如果 `database_name` 包含特殊字元(如反引號本身),仍可能導致 SQL 注入。
|
||||||
|
|
||||||
|
* **修復建議**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改後 - 驗證資料庫名稱
|
||||||
|
import re
|
||||||
|
|
||||||
|
def create_database(self, database_name: str) -> bool:
|
||||||
|
# 驗證資料庫名稱只包含安全字元
|
||||||
|
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', database_name):
|
||||||
|
logger.error(f"無效的資料庫名稱: {database_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self.get_connection(None) as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 由於已驗證名稱,現在可以安全使用
|
||||||
|
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{database_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"資料庫 {database_name} 建立成功(或已存在)")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"建立資料庫失敗: {e}")
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 MEDIUM-003: 資料庫連線未使用 SSL/TLS 加密
|
||||||
|
|
||||||
|
* **風險等級**: `Medium`
|
||||||
|
* **威脅描述**: 雖然 SDD 文件提到應使用 SSL/TLS 加密連線,但實際程式碼中並未實作。資料在傳輸過程中以明文形式傳送,可能被中間人攻擊竊取。
|
||||||
|
* **受影響組件**: `hbr_crawler/hbr_crawler/database.py` (第 50-59 行)
|
||||||
|
|
||||||
|
* **修復建議**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前
|
||||||
|
connection = pymysql.connect(
|
||||||
|
host=self.host,
|
||||||
|
port=self.port,
|
||||||
|
user=self.user,
|
||||||
|
password=self.password,
|
||||||
|
database=db_name,
|
||||||
|
charset=self.charset,
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
autocommit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 修改後 - 加入 SSL 設定
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int, user: str, password: str,
|
||||||
|
database: str = None, charset: str = 'utf8mb4', use_ssl: bool = True):
|
||||||
|
# ... 原有程式碼 ...
|
||||||
|
self.use_ssl = use_ssl
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection(self, database: Optional[str] = None):
|
||||||
|
db_name = database or self.database
|
||||||
|
|
||||||
|
# SSL 設定
|
||||||
|
ssl_config = None
|
||||||
|
if self.use_ssl:
|
||||||
|
ssl_config = {
|
||||||
|
'ssl': {
|
||||||
|
'ssl_disabled': False,
|
||||||
|
'check_hostname': True,
|
||||||
|
'verify_mode': ssl.CERT_REQUIRED,
|
||||||
|
# 如果有 CA 證書,可以指定
|
||||||
|
# 'ca': '/path/to/ca-cert.pem'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
connect_args = {
|
||||||
|
'host': self.host,
|
||||||
|
'port': self.port,
|
||||||
|
'user': self.user,
|
||||||
|
'password': self.password,
|
||||||
|
'database': db_name,
|
||||||
|
'charset': self.charset,
|
||||||
|
'cursorclass': pymysql.cursors.DictCursor,
|
||||||
|
'autocommit': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if ssl_config:
|
||||||
|
connect_args.update(ssl_config)
|
||||||
|
|
||||||
|
connection = pymysql.connect(**connect_args)
|
||||||
|
yield connection
|
||||||
|
connection.commit()
|
||||||
|
# ... 原有錯誤處理 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 MEDIUM-004: 錯誤處理可能洩漏敏感資訊
|
||||||
|
|
||||||
|
* **風險等級**: `Medium`
|
||||||
|
* **威脅描述**: 多處錯誤處理直接記錄完整的例外訊息,這些訊息可能包含敏感資訊(如資料庫連線字串、檔案路徑等)。當未來新增 Web 介面時,這些錯誤訊息可能會暴露給最終用戶。
|
||||||
|
* **受影響組件**:
|
||||||
|
- `database.py` (多處 logger.error)
|
||||||
|
- `pipelines.py` (多處 logger.error)
|
||||||
|
|
||||||
|
* **修復建議**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前 - 可能洩漏敏感資訊
|
||||||
|
logger.error(f"資料庫連線錯誤: {e}")
|
||||||
|
|
||||||
|
# 修改後 - 安全的錯誤記錄
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
# 開發環境可以記錄詳細錯誤
|
||||||
|
if os.environ.get('DEBUG', 'false').lower() == 'true':
|
||||||
|
logger.error(f"資料庫連線錯誤: {e}")
|
||||||
|
logger.debug(traceback.format_exc())
|
||||||
|
else:
|
||||||
|
# 生產環境只記錄錯誤類型,不記錄詳細訊息
|
||||||
|
logger.error(f"資料庫連線錯誤: {type(e).__name__}")
|
||||||
|
|
||||||
|
# 當未來有 Web 介面時,返回給用戶的錯誤訊息
|
||||||
|
def get_user_friendly_error(e: Exception) -> str:
|
||||||
|
"""返回對用戶友善的錯誤訊息,不洩漏內部細節"""
|
||||||
|
error_map = {
|
||||||
|
'OperationalError': '資料庫暫時無法連線,請稍後再試',
|
||||||
|
'IntegrityError': '資料處理錯誤,請聯繫管理員',
|
||||||
|
'ProgrammingError': '系統錯誤,請聯繫管理員',
|
||||||
|
}
|
||||||
|
return error_map.get(type(e).__name__, '發生未知錯誤,請稍後再試')
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 LOW-001: 未實作資料庫連線池
|
||||||
|
|
||||||
|
* **風險等級**: `Low`
|
||||||
|
* **威脅描述**: 每次資料庫操作都會建立新連線,效能較差且可能耗盡資料庫連線數。
|
||||||
|
* **受影響組件**: `hbr_crawler/hbr_crawler/database.py`
|
||||||
|
|
||||||
|
* **修復建議**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 使用連線池
|
||||||
|
from dbutils.pooled_db import PooledDB
|
||||||
|
|
||||||
|
class DatabaseManager:
|
||||||
|
_pool = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_pool(cls, host, port, user, password, database, charset='utf8mb4'):
|
||||||
|
if cls._pool is None:
|
||||||
|
cls._pool = PooledDB(
|
||||||
|
creator=pymysql,
|
||||||
|
maxconnections=10,
|
||||||
|
mincached=2,
|
||||||
|
maxcached=5,
|
||||||
|
blocking=True,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
database=database,
|
||||||
|
charset=charset,
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
return cls._pool
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection(self, database: Optional[str] = None):
|
||||||
|
conn = self.get_pool(...).connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close() # 歸還到連線池,非真正關閉
|
||||||
|
```
|
||||||
|
|
||||||
|
需要安裝 DBUtils:
|
||||||
|
```bash
|
||||||
|
pip install DBUtils
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 LOW-002: GitHub Actions 工作流程安全性可加強
|
||||||
|
|
||||||
|
* **風險等級**: `Low`
|
||||||
|
* **威脅描述**: GitHub Actions 工作流程沒有明確限制權限,使用預設權限可能過於寬鬆。
|
||||||
|
* **受影響組件**: `.github/workflows/weekly.yml`
|
||||||
|
|
||||||
|
* **修復建議**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: weekly-crawl
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * 1"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
# 明確限制權限
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
crawl-and-mail:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# 限制可訪問的 secrets
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
# 使用 pip cache 加速
|
||||||
|
- name: Cache pip packages
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pip
|
||||||
|
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Run crawler
|
||||||
|
run: |
|
||||||
|
cd hbr_crawler
|
||||||
|
scrapy crawl hbr
|
||||||
|
|
||||||
|
- name: Send mail with CSV
|
||||||
|
env:
|
||||||
|
GMAIL_USERNAME: ${{ secrets.GMAIL_USERNAME }}
|
||||||
|
GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }}
|
||||||
|
MAIL_TO: ${{ secrets.MAIL_TO }}
|
||||||
|
# 加入資料庫環境變數
|
||||||
|
DB_HOST: ${{ secrets.DB_HOST }}
|
||||||
|
DB_PORT: ${{ secrets.DB_PORT }}
|
||||||
|
DB_USER: ${{ secrets.DB_USER }}
|
||||||
|
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||||
|
DB_NAME: ${{ secrets.DB_NAME }}
|
||||||
|
run: python send_mail.py hbr_articles.csv
|
||||||
|
|
||||||
|
- name: Upload CSV as artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: hbr_articles_csv
|
||||||
|
path: hbr_articles.csv
|
||||||
|
retention-days: 7 # 限制保留天數
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 LOW-003: 缺少輸入驗證
|
||||||
|
|
||||||
|
* **風險等級**: `Low`
|
||||||
|
* **威脅描述**: 爬蟲提取的資料沒有經過驗證就直接儲存,可能導致 XSS 攻擊(當未來有 Web 介面時)或資料完整性問題。
|
||||||
|
* **受影響組件**: `hbr_crawler/hbr_crawler/spiders/hbr.py`, `pipelines.py`
|
||||||
|
|
||||||
|
* **修復建議**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 在 pipelines.py 中加入資料清理
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
|
||||||
|
def sanitize_html(text: str) -> str:
|
||||||
|
"""移除或轉義 HTML 標籤"""
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
# 移除 script 標籤
|
||||||
|
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
# 轉義 HTML 特殊字元
|
||||||
|
text = html.escape(text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def validate_url(url: str) -> bool:
|
||||||
|
"""驗證 URL 格式"""
|
||||||
|
pattern = r'^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&\'()*+,;=%]+$'
|
||||||
|
return bool(re.match(pattern, url))
|
||||||
|
|
||||||
|
class DatabasePipeline:
|
||||||
|
def process_item(self, item, spider):
|
||||||
|
adapter = ItemAdapter(item)
|
||||||
|
|
||||||
|
# 驗證 URL
|
||||||
|
url = adapter.get('url', '')
|
||||||
|
if not validate_url(url):
|
||||||
|
logger.warning(f"無效的 URL: {url}")
|
||||||
|
return item
|
||||||
|
|
||||||
|
# 清理文字欄位
|
||||||
|
for field in ['title', 'summary', 'content', 'author']:
|
||||||
|
if adapter.get(field):
|
||||||
|
adapter[field] = sanitize_html(adapter[field])
|
||||||
|
|
||||||
|
# ... 原有邏輯 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三部分:OWASP Top 10 (2021) 檢查清單
|
||||||
|
|
||||||
|
| 編號 | 漏洞類型 | 狀態 | 說明 |
|
||||||
|
|------|---------|------|------|
|
||||||
|
| A01 | Broken Access Control | ⚠️ 需注意 | 未來 Web 介面需實作適當的存取控制 |
|
||||||
|
| A02 | Cryptographic Failures | 🔴 問題 | 密碼硬編碼、弱密碼、未使用 SSL |
|
||||||
|
| A03 | Injection | ⚠️ 部分問題 | `create_database` 有潛在 SQL 注入風險,其他查詢已使用參數化 |
|
||||||
|
| A04 | Insecure Design | ✅ 尚可 | 基本設計合理 |
|
||||||
|
| A05 | Security Misconfiguration | 🔴 問題 | 資料庫公網暴露、缺少 .gitignore |
|
||||||
|
| A06 | Vulnerable Components | ⚠️ 需檢查 | 需定期更新依賴套件 |
|
||||||
|
| A07 | Auth Failures | ⚠️ 需注意 | 未來 Web 介面需實作適當的身分驗證 |
|
||||||
|
| A08 | Software/Data Integrity | ✅ 良好 | 使用 GitHub Actions 官方 actions |
|
||||||
|
| A09 | Logging Failures | ⚠️ 需改善 | 日誌可能洩漏敏感資訊 |
|
||||||
|
| A10 | SSRF | ✅ 不適用 | 爬蟲目標是固定的,無 SSRF 風險 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第四部分:依賴套件安全性分析
|
||||||
|
|
||||||
|
### 目前依賴 (requirements.txt)
|
||||||
|
|
||||||
|
| 套件 | 版本要求 | 已知漏洞 | 建議 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| scrapy | >=2.11.0 | 無重大漏洞 | 保持更新 |
|
||||||
|
| itemadapter | >=0.7.0 | 無已知漏洞 | 保持更新 |
|
||||||
|
| pymysql | >=1.1.0 | 無重大漏洞 | 保持更新 |
|
||||||
|
| python-dateutil | >=2.8.2 | 無已知漏洞 | 保持更新 |
|
||||||
|
|
||||||
|
### 建議新增的安全相關套件
|
||||||
|
|
||||||
|
```
|
||||||
|
# requirements.txt 建議更新
|
||||||
|
scrapy>=2.11.0
|
||||||
|
itemadapter>=0.7.0
|
||||||
|
pymysql>=1.1.0
|
||||||
|
python-dateutil>=2.8.2
|
||||||
|
python-dotenv>=1.0.0 # 環境變數管理
|
||||||
|
DBUtils>=3.0.0 # 資料庫連線池(可選)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 建議:設定依賴漏洞掃描
|
||||||
|
|
||||||
|
在 GitHub 倉庫啟用 Dependabot:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/dependabot.yml
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第五部分:未來 Web 介面安全建議
|
||||||
|
|
||||||
|
由於您計畫新增 Web 介面,以下是預防性安全建議:
|
||||||
|
|
||||||
|
### 1. 身分驗證與授權
|
||||||
|
- 使用成熟的身分驗證框架(如 Flask-Login, FastAPI-Users)
|
||||||
|
- 實作 CSRF 保護
|
||||||
|
- 使用 JWT 或 Session-based 認證
|
||||||
|
- 實作速率限制防止暴力破解
|
||||||
|
|
||||||
|
### 2. API 安全
|
||||||
|
- 所有 API 端點使用 HTTPS
|
||||||
|
- 實作 CORS 限制
|
||||||
|
- 驗證所有輸入參數
|
||||||
|
- 使用 API Key 或 OAuth 進行認證
|
||||||
|
|
||||||
|
### 3. XSS 防護
|
||||||
|
- 對所有輸出進行 HTML 轉義
|
||||||
|
- 使用 Content-Security-Policy 標頭
|
||||||
|
- 驗證和清理所有用戶輸入
|
||||||
|
|
||||||
|
### 4. 資料庫查詢
|
||||||
|
- 繼續使用參數化查詢
|
||||||
|
- 實作查詢結果分頁(防止大量資料洩漏)
|
||||||
|
- 限制返回欄位(不返回敏感欄位)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修復優先順序
|
||||||
|
|
||||||
|
### 🔴 立即修復 (今天就要做)
|
||||||
|
|
||||||
|
1. **HIGH-001**: 從所有原始碼中移除硬編碼的密碼,改用環境變數
|
||||||
|
2. **HIGH-002**: 更換資料庫密碼為強密碼
|
||||||
|
3. **HIGH-003**: 設定資料庫存取限制(IP 白名單或 VPN)
|
||||||
|
|
||||||
|
### 🟠 本週修復
|
||||||
|
|
||||||
|
4. **MEDIUM-001**: 建立 .gitignore 檔案
|
||||||
|
5. **MEDIUM-002**: 修復 `create_database` SQL 注入風險
|
||||||
|
6. **MEDIUM-003**: 啟用資料庫 SSL/TLS 連線
|
||||||
|
7. **MEDIUM-004**: 改善錯誤處理,避免資訊洩漏
|
||||||
|
|
||||||
|
### 🟢 可排程修復
|
||||||
|
|
||||||
|
8. **LOW-001**: 實作資料庫連線池
|
||||||
|
9. **LOW-002**: 加強 GitHub Actions 安全設定
|
||||||
|
10. **LOW-003**: 加入輸入驗證和資料清理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 結語
|
||||||
|
|
||||||
|
本次審計發現了幾個需要立即修復的嚴重安全問題,最關鍵的是 **資料庫密碼硬編碼** 和 **弱密碼** 問題。由於資料庫伺服器可從公網訪問,這些問題的風險被大幅放大。
|
||||||
|
|
||||||
|
好消息是,您的程式碼在其他方面相對安全:
|
||||||
|
- ✅ 使用參數化查詢(大部分情況)
|
||||||
|
- ✅ 遵守 robots.txt
|
||||||
|
- ✅ 使用 Gmail App Password 而非一般密碼
|
||||||
|
- ✅ 合理的程式碼結構
|
||||||
|
|
||||||
|
請按照優先順序儘快修復這些問題,特別是在將專案部署到生產環境之前。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**報告結束**
|
||||||
|
|
||||||
|
如有任何問題,請隨時詢問。
|
||||||
72
send_mail.py
Normal file
72
send_mail.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# send_mail.py
|
||||||
|
import os, smtplib, sys, mimetypes
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
# 讀環境變數(請在 crontab 或 GitHub Actions secrets 設定)
|
||||||
|
# 如果未設定,則跳過郵件發送功能
|
||||||
|
GMAIL_USER = os.environ.get("GMAIL_USERNAME") # 例如:yourname@gmail.com
|
||||||
|
GMAIL_PASS = os.environ.get("GMAIL_APP_PASSWORD") # 16碼 App Password(非一般登入密碼)
|
||||||
|
TO = os.environ.get("MAIL_TO", "kaeruzak@gmail.com")
|
||||||
|
|
||||||
|
# 檢查是否啟用郵件功能
|
||||||
|
ENABLE_MAIL = GMAIL_USER and GMAIL_PASS
|
||||||
|
|
||||||
|
# 參數:CSV 路徑(預設 ./hbr_articles.csv)
|
||||||
|
csv_path = sys.argv[1] if len(sys.argv) > 1 else "hbr_articles.csv"
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
print(f"[WARN] CSV not found: {csv_path}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# 如果未啟用郵件功能,僅顯示資訊並退出
|
||||||
|
if not ENABLE_MAIL:
|
||||||
|
print("[INFO] Gmail SMTP 未設定,跳過郵件發送功能")
|
||||||
|
print(f"[INFO] CSV 檔案已產生: {csv_path}")
|
||||||
|
print("[INFO] 如需啟用郵件功能,請設定以下環境變數:")
|
||||||
|
print(" - GMAIL_USERNAME")
|
||||||
|
print(" - GMAIL_APP_PASSWORD")
|
||||||
|
print(" - MAIL_TO (可選,預設: kaeruzak@gmail.com)")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# 產生台北時間日期字串
|
||||||
|
tz = timezone(timedelta(hours=8))
|
||||||
|
date_str = datetime.now(tz).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
# 組信
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = f"[HBRTW 每週爬取] 文章清單 CSV - {date_str}"
|
||||||
|
msg["From"] = GMAIL_USER
|
||||||
|
msg["To"] = TO
|
||||||
|
msg.set_content(f"""您好,
|
||||||
|
附件為本週 HBR Taiwan 最新/熱門文章彙整(CSV)。
|
||||||
|
產生時間:{date_str}(Asia/Taipei)
|
||||||
|
若您需要改排程或加上上傳雲端,回覆此信即可。
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 夾帶 CSV
|
||||||
|
ctype, encoding = mimetypes.guess_type(csv_path)
|
||||||
|
if ctype is None or encoding is not None:
|
||||||
|
ctype = "application/octet-stream"
|
||||||
|
maintype, subtype = ctype.split("/", 1)
|
||||||
|
with open(csv_path, "rb") as f:
|
||||||
|
msg.add_attachment(f.read(),
|
||||||
|
maintype=maintype,
|
||||||
|
subtype=subtype,
|
||||||
|
filename=os.path.basename(csv_path))
|
||||||
|
|
||||||
|
# 寄送郵件
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp:
|
||||||
|
smtp.login(GMAIL_USER, GMAIL_PASS)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
print(f"[OK] Mail sent to {TO}")
|
||||||
|
except smtplib.SMTPAuthenticationError as e:
|
||||||
|
print(f"[ERROR] Gmail 認證失敗: {e}")
|
||||||
|
print("請確認:")
|
||||||
|
print(" 1. GMAIL_USERNAME 是否正確")
|
||||||
|
print(" 2. GMAIL_APP_PASSWORD 是否為有效的 App Password(16碼)")
|
||||||
|
print(" 3. 是否已啟用 Gmail 的兩步驟驗證")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 郵件發送失敗: {e}")
|
||||||
|
sys.exit(1)
|
||||||
727
static/app.js
Normal file
727
static/app.js
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
// 全域變數
|
||||||
|
let currentPage = 1;
|
||||||
|
let currentFilters = {};
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
try {
|
||||||
|
loadStatistics();
|
||||||
|
loadCategories();
|
||||||
|
loadArticles();
|
||||||
|
loadCrawlerConfig();
|
||||||
|
|
||||||
|
// 綁定事件
|
||||||
|
const searchBtn = document.getElementById('searchBtn');
|
||||||
|
const resetBtn = document.getElementById('resetBtn');
|
||||||
|
const refreshBtn = document.getElementById('refreshBtn');
|
||||||
|
const runCrawlerBtn = document.getElementById('runCrawlerBtn');
|
||||||
|
const closeBtn = document.querySelector('.close');
|
||||||
|
|
||||||
|
if (searchBtn) searchBtn.addEventListener('click', handleSearch);
|
||||||
|
if (resetBtn) resetBtn.addEventListener('click', handleReset);
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', function() {
|
||||||
|
loadStatistics();
|
||||||
|
loadArticles();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (runCrawlerBtn) runCrawlerBtn.addEventListener('click', handleRunCrawler);
|
||||||
|
|
||||||
|
// 模態框關閉
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', function() {
|
||||||
|
const modal = document.getElementById('articleModal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('click', function(event) {
|
||||||
|
const modal = document.getElementById('articleModal');
|
||||||
|
if (event.target === modal && modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 頁籤切換
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const tabName = this.getAttribute('data-tab');
|
||||||
|
switchTab(tabName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 爬蟲設定相關事件
|
||||||
|
const addUrlBtn = document.getElementById('addUrlBtn');
|
||||||
|
const saveConfigBtn = document.getElementById('saveConfigBtn');
|
||||||
|
const loadConfigBtn = document.getElementById('loadConfigBtn');
|
||||||
|
const resetConfigBtn = document.getElementById('resetConfigBtn');
|
||||||
|
const testConfigBtn = document.getElementById('testConfigBtn');
|
||||||
|
|
||||||
|
if (addUrlBtn) addUrlBtn.addEventListener('click', addUrlItem);
|
||||||
|
if (saveConfigBtn) saveConfigBtn.addEventListener('click', saveCrawlerConfig);
|
||||||
|
if (loadConfigBtn) loadConfigBtn.addEventListener('click', loadCrawlerConfig);
|
||||||
|
if (resetConfigBtn) resetConfigBtn.addEventListener('click', resetCrawlerConfig);
|
||||||
|
if (testConfigBtn) testConfigBtn.addEventListener('click', testCrawlerConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化失敗:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 載入統計資料
|
||||||
|
async function loadStatistics() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/statistics');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const stats = result.data;
|
||||||
|
|
||||||
|
document.getElementById('totalArticles').textContent = stats.total_articles || 0;
|
||||||
|
document.getElementById('paywalledArticles').textContent = stats.paywall[1] || 0;
|
||||||
|
document.getElementById('freeArticles').textContent = stats.paywall[0] || 0;
|
||||||
|
document.getElementById('categoryCount').textContent = stats.categories.length || 0;
|
||||||
|
|
||||||
|
// 繪製圖表
|
||||||
|
drawCategoryChart(stats.categories);
|
||||||
|
drawAuthorChart(stats.authors);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('載入統計資料失敗:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 載入分類列表
|
||||||
|
async function loadCategories() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/categories');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const categorySelect = document.getElementById('category');
|
||||||
|
result.data.forEach(category => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = category;
|
||||||
|
option.textContent = category;
|
||||||
|
categorySelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('載入分類列表失敗:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 載入文章列表
|
||||||
|
async function loadArticles(page = 1) {
|
||||||
|
currentPage = page;
|
||||||
|
showLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page,
|
||||||
|
per_page: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加入篩選條件
|
||||||
|
Object.keys(currentFilters).forEach(key => {
|
||||||
|
if (currentFilters[key]) {
|
||||||
|
params.append(key, currentFilters[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/articles?${params}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
console.log('API 返回結果:', result); // 除錯用
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const articles = result.data || [];
|
||||||
|
console.log('文章數量:', articles.length); // 除錯用
|
||||||
|
displayArticles(articles);
|
||||||
|
if (result.pagination) {
|
||||||
|
displayPagination(result.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('API 返回錯誤:', result.error);
|
||||||
|
const errorMsg = result.error || '未知錯誤';
|
||||||
|
alert('載入文章失敗: ' + errorMsg);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('載入文章失敗:', error);
|
||||||
|
alert('載入文章失敗,請稍後再試: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顯示文章列表
|
||||||
|
function displayArticles(articles) {
|
||||||
|
const list = document.getElementById('articlesList');
|
||||||
|
if (!list) {
|
||||||
|
console.error('找不到 articlesList 元素');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
if (!articles || articles.length === 0) {
|
||||||
|
list.innerHTML = '<div style="text-align: center; padding: 40px; color: #666;">沒有找到符合條件的文章</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('準備顯示', articles.length, '篇文章'); // 除錯用
|
||||||
|
|
||||||
|
articles.forEach((article, index) => {
|
||||||
|
try {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'article-card';
|
||||||
|
card.onclick = () => showArticleDetail(article.id);
|
||||||
|
|
||||||
|
// 處理標籤(可能是字串或空值)
|
||||||
|
let tags = [];
|
||||||
|
if (article.tags) {
|
||||||
|
if (typeof article.tags === 'string' && article.tags.trim()) {
|
||||||
|
tags = article.tags.split(',').map(t => t.trim()).filter(t => t);
|
||||||
|
} else if (Array.isArray(article.tags)) {
|
||||||
|
tags = article.tags.filter(t => t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = escapeHtml(article.title || '無標題');
|
||||||
|
const author = article.author ? escapeHtml(article.author) : '';
|
||||||
|
const category = article.category ? escapeHtml(article.category) : '';
|
||||||
|
const summary = article.summary ? escapeHtml(String(article.summary).substring(0, 200)) + (String(article.summary).length > 200 ? '...' : '') : '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="article-title">${title}</div>
|
||||||
|
<div class="article-meta">
|
||||||
|
${author ? `<span>👤 ${author}</span>` : ''}
|
||||||
|
${article.publish_date ? `<span>📅 ${formatDate(article.publish_date)}</span>` : ''}
|
||||||
|
${category ? `<span>📁 ${category}</span>` : ''}
|
||||||
|
${article.is_paywalled ? '<span class="paywall-badge">💰 付費文章</span>' : ''}
|
||||||
|
</div>
|
||||||
|
${summary ? `<div class="article-summary">${summary}</div>` : ''}
|
||||||
|
${tags.length > 0 ? `<div class="article-tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
list.appendChild(card);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`處理第 ${index + 1} 篇文章時發生錯誤:`, error, article);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('文章列表顯示完成'); // 除錯用
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顯示分頁
|
||||||
|
function displayPagination(pagination) {
|
||||||
|
const paginationDiv = document.getElementById('pagination');
|
||||||
|
const infoDiv = document.getElementById('paginationInfo');
|
||||||
|
|
||||||
|
infoDiv.textContent = `第 ${pagination.page} 頁,共 ${pagination.pages} 頁,總計 ${pagination.total} 篇文章`;
|
||||||
|
|
||||||
|
paginationDiv.innerHTML = '';
|
||||||
|
|
||||||
|
// 上一頁
|
||||||
|
const prevBtn = document.createElement('button');
|
||||||
|
prevBtn.textContent = '← 上一頁';
|
||||||
|
prevBtn.disabled = pagination.page === 1;
|
||||||
|
prevBtn.onclick = () => loadArticles(pagination.page - 1);
|
||||||
|
paginationDiv.appendChild(prevBtn);
|
||||||
|
|
||||||
|
// 頁碼
|
||||||
|
const startPage = Math.max(1, pagination.page - 2);
|
||||||
|
const endPage = Math.min(pagination.pages, pagination.page + 2);
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const pageBtn = document.createElement('button');
|
||||||
|
pageBtn.textContent = i;
|
||||||
|
pageBtn.className = i === pagination.page ? 'active' : '';
|
||||||
|
pageBtn.onclick = () => loadArticles(i);
|
||||||
|
paginationDiv.appendChild(pageBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一頁
|
||||||
|
const nextBtn = document.createElement('button');
|
||||||
|
nextBtn.textContent = '下一頁 →';
|
||||||
|
nextBtn.disabled = pagination.page === pagination.pages;
|
||||||
|
nextBtn.onclick = () => loadArticles(pagination.page + 1);
|
||||||
|
paginationDiv.appendChild(nextBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顯示文章詳情
|
||||||
|
async function showArticleDetail(articleId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/article/${articleId}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const article = result.data;
|
||||||
|
const detailDiv = document.getElementById('articleDetail');
|
||||||
|
|
||||||
|
const tags = article.tags ? article.tags.split(',').map(t => t.trim()).filter(t => t) : [];
|
||||||
|
|
||||||
|
detailDiv.innerHTML = `
|
||||||
|
<h2 class="article-detail-title">${escapeHtml(article.title || '無標題')}</h2>
|
||||||
|
<div class="article-meta">
|
||||||
|
${article.author ? `<span>👤 作者: ${escapeHtml(article.author)}</span>` : ''}
|
||||||
|
${article.publish_date ? `<span>📅 發布日期: ${formatDate(article.publish_date)}</span>` : ''}
|
||||||
|
${article.category ? `<span>📁 分類: ${escapeHtml(article.category)}</span>` : ''}
|
||||||
|
${article.is_paywalled ? '<span class="paywall-badge">💰 付費文章</span>' : ''}
|
||||||
|
</div>
|
||||||
|
${article.summary ? `<div style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 5px;"><strong>摘要:</strong>${escapeHtml(article.summary)}</div>` : ''}
|
||||||
|
${tags.length > 0 ? `<div style="margin: 15px 0;">標籤: ${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
|
||||||
|
${article.content ? `<div class="article-detail-content">${escapeHtml(article.content)}</div>` : '<div style="color: #999; font-style: italic;">此文章無內容(可能是付費文章)</div>'}
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<a href="${article.url}" target="_blank" class="btn btn-primary">🔗 查看原文</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('articleModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('載入文章詳情失敗:', error);
|
||||||
|
alert('載入文章詳情失敗');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 處理搜尋
|
||||||
|
function handleSearch() {
|
||||||
|
currentFilters = {
|
||||||
|
keyword: document.getElementById('keyword').value.trim(),
|
||||||
|
category: document.getElementById('category').value,
|
||||||
|
tag: document.getElementById('tag').value.trim(),
|
||||||
|
start_date: document.getElementById('startDate').value,
|
||||||
|
end_date: document.getElementById('endDate').value,
|
||||||
|
is_paywalled: document.getElementById('isPaywalled').value
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除空值
|
||||||
|
Object.keys(currentFilters).forEach(key => {
|
||||||
|
if (!currentFilters[key]) delete currentFilters[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
loadArticles(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 處理重置
|
||||||
|
function handleReset() {
|
||||||
|
document.getElementById('keyword').value = '';
|
||||||
|
document.getElementById('category').value = '';
|
||||||
|
document.getElementById('tag').value = '';
|
||||||
|
document.getElementById('startDate').value = '';
|
||||||
|
document.getElementById('endDate').value = '';
|
||||||
|
document.getElementById('isPaywalled').value = '';
|
||||||
|
currentFilters = {};
|
||||||
|
loadArticles(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 執行爬蟲
|
||||||
|
async function handleRunCrawler() {
|
||||||
|
if (!confirm('確定要執行爬蟲嗎?這可能需要幾分鐘時間。')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('runCrawlerBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '執行中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/run-crawler', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert('爬蟲執行成功!');
|
||||||
|
loadStatistics();
|
||||||
|
loadArticles();
|
||||||
|
} else {
|
||||||
|
alert('爬蟲執行失敗: ' + result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('執行爬蟲失敗:', error);
|
||||||
|
alert('執行爬蟲失敗,請稍後再試');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🚀 執行爬蟲';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 繪製分類圖表
|
||||||
|
function drawCategoryChart(categories) {
|
||||||
|
const ctx = document.getElementById('categoryChart').getContext('2d');
|
||||||
|
|
||||||
|
if (window.categoryChart && typeof window.categoryChart.destroy === 'function') {
|
||||||
|
window.categoryChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.categoryChart = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: categories.map(c => c.name),
|
||||||
|
datasets: [{
|
||||||
|
data: categories.map(c => c.count),
|
||||||
|
backgroundColor: [
|
||||||
|
'#667eea', '#764ba2', '#f093fb', '#4facfe',
|
||||||
|
'#00f2fe', '#43e97b', '#fa709a', '#fee140',
|
||||||
|
'#30cfd0', '#330867'
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 繪製作者圖表
|
||||||
|
function drawAuthorChart(authors) {
|
||||||
|
const ctx = document.getElementById('authorChart').getContext('2d');
|
||||||
|
|
||||||
|
if (window.authorChart && typeof window.authorChart.destroy === 'function') {
|
||||||
|
window.authorChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.authorChart = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: authors.map(a => a.name),
|
||||||
|
datasets: [{
|
||||||
|
label: '文章數量',
|
||||||
|
data: authors.map(a => a.count),
|
||||||
|
backgroundColor: '#667eea'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具函數
|
||||||
|
function showLoading(show) {
|
||||||
|
document.getElementById('loading').classList.toggle('show', show);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('zh-TW');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 頁籤功能 ====================
|
||||||
|
|
||||||
|
function switchTab(tabName) {
|
||||||
|
// 隱藏所有頁籤內容
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => {
|
||||||
|
content.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 移除所有按鈕的 active 狀態
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 顯示選中的頁籤
|
||||||
|
const targetTab = document.getElementById(`${tabName}-tab`);
|
||||||
|
const targetBtn = document.querySelector(`[data-tab="${tabName}"]`);
|
||||||
|
|
||||||
|
if (targetTab) targetTab.classList.add('active');
|
||||||
|
if (targetBtn) targetBtn.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 爬蟲設定功能 ====================
|
||||||
|
|
||||||
|
// 預設的起始 URL
|
||||||
|
const defaultUrls = [
|
||||||
|
'https://www.hbrtaiwan.com/',
|
||||||
|
'https://www.hbrtaiwan.com/topic/management',
|
||||||
|
'https://www.hbrtaiwan.com/topic/leadership',
|
||||||
|
'https://www.hbrtaiwan.com/topic/strategy',
|
||||||
|
'https://www.hbrtaiwan.com/topic/innovation',
|
||||||
|
'https://www.hbrtaiwan.com/topic/technology'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 載入爬蟲設定
|
||||||
|
async function loadCrawlerConfig() {
|
||||||
|
try {
|
||||||
|
// 先嘗試從伺服器載入設定
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/load-crawler-config');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
applyConfigToForm(result.data);
|
||||||
|
// 同時儲存到 localStorage
|
||||||
|
localStorage.setItem('crawlerConfig', JSON.stringify(result.data));
|
||||||
|
showConfigStatus('設定已從伺服器載入', 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (apiError) {
|
||||||
|
console.log('無法從伺服器載入設定,嘗試從本地載入:', apiError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 從 localStorage 載入設定(如果有的話)
|
||||||
|
const savedConfig = localStorage.getItem('crawlerConfig');
|
||||||
|
if (savedConfig) {
|
||||||
|
const config = JSON.parse(savedConfig);
|
||||||
|
applyConfigToForm(config);
|
||||||
|
showConfigStatus('設定已從本地載入', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 載入預設設定
|
||||||
|
loadDefaultConfig();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('載入設定失敗:', error);
|
||||||
|
loadDefaultConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 載入預設設定
|
||||||
|
function loadDefaultConfig() {
|
||||||
|
// 載入預設 URL
|
||||||
|
const urlList = document.getElementById('urlList');
|
||||||
|
if (urlList) {
|
||||||
|
urlList.innerHTML = '';
|
||||||
|
defaultUrls.forEach(url => {
|
||||||
|
addUrlItem(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 載入預設值
|
||||||
|
document.getElementById('downloadDelay').value = '1';
|
||||||
|
document.getElementById('maxDepth').value = '3';
|
||||||
|
document.getElementById('concurrentRequests').value = '16';
|
||||||
|
document.getElementById('skipPaywalled').checked = true;
|
||||||
|
document.getElementById('followPagination').checked = true;
|
||||||
|
document.getElementById('obeyRobotsTxt').checked = true;
|
||||||
|
document.getElementById('articleListSelector').value = '.articleItem, article, .article-item, .post-item, .content-item';
|
||||||
|
document.getElementById('titleSelector').value = 'h1.articleTitle, h1.article-title, h1, .article-title, .post-title';
|
||||||
|
document.getElementById('authorSelector').value = '.authorName, .author, .byline, .writer, .author-name';
|
||||||
|
document.getElementById('contentSelector').value = '.articleContent, .article-content, .post-content, .content, .articleText';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 將設定應用到表單
|
||||||
|
function applyConfigToForm(config) {
|
||||||
|
if (config.urls && Array.isArray(config.urls)) {
|
||||||
|
const urlList = document.getElementById('urlList');
|
||||||
|
if (urlList) {
|
||||||
|
urlList.innerHTML = '';
|
||||||
|
config.urls.forEach(url => {
|
||||||
|
addUrlItem(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.downloadDelay !== undefined) {
|
||||||
|
document.getElementById('downloadDelay').value = config.downloadDelay;
|
||||||
|
}
|
||||||
|
if (config.maxDepth !== undefined) {
|
||||||
|
document.getElementById('maxDepth').value = config.maxDepth;
|
||||||
|
}
|
||||||
|
if (config.concurrentRequests !== undefined) {
|
||||||
|
document.getElementById('concurrentRequests').value = config.concurrentRequests;
|
||||||
|
}
|
||||||
|
if (config.skipPaywalled !== undefined) {
|
||||||
|
document.getElementById('skipPaywalled').checked = config.skipPaywalled;
|
||||||
|
}
|
||||||
|
if (config.followPagination !== undefined) {
|
||||||
|
document.getElementById('followPagination').checked = config.followPagination;
|
||||||
|
}
|
||||||
|
if (config.obeyRobotsTxt !== undefined) {
|
||||||
|
document.getElementById('obeyRobotsTxt').checked = config.obeyRobotsTxt;
|
||||||
|
}
|
||||||
|
if (config.articleListSelector) {
|
||||||
|
document.getElementById('articleListSelector').value = config.articleListSelector;
|
||||||
|
}
|
||||||
|
if (config.titleSelector) {
|
||||||
|
document.getElementById('titleSelector').value = config.titleSelector;
|
||||||
|
}
|
||||||
|
if (config.authorSelector) {
|
||||||
|
document.getElementById('authorSelector').value = config.authorSelector;
|
||||||
|
}
|
||||||
|
if (config.contentSelector) {
|
||||||
|
document.getElementById('contentSelector').value = config.contentSelector;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增 URL 項目
|
||||||
|
function addUrlItem(url = '') {
|
||||||
|
const urlList = document.getElementById('urlList');
|
||||||
|
if (!urlList) return;
|
||||||
|
|
||||||
|
const urlItem = document.createElement('div');
|
||||||
|
urlItem.className = 'url-item';
|
||||||
|
urlItem.innerHTML = `
|
||||||
|
<input type="text" class="url-input" value="${escapeHtml(url)}" placeholder="https://www.hbrtaiwan.com/...">
|
||||||
|
<button type="button" class="btn-remove-url">🗑️ 刪除</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
urlItem.querySelector('.btn-remove-url').addEventListener('click', function() {
|
||||||
|
urlItem.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
urlList.appendChild(urlItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 儲存爬蟲設定
|
||||||
|
async function saveCrawlerConfig() {
|
||||||
|
try {
|
||||||
|
const config = {
|
||||||
|
urls: getUrlList(),
|
||||||
|
downloadDelay: parseFloat(document.getElementById('downloadDelay').value) || 1,
|
||||||
|
maxDepth: parseInt(document.getElementById('maxDepth').value) || 3,
|
||||||
|
concurrentRequests: parseInt(document.getElementById('concurrentRequests').value) || 16,
|
||||||
|
skipPaywalled: document.getElementById('skipPaywalled').checked,
|
||||||
|
followPagination: document.getElementById('followPagination').checked,
|
||||||
|
obeyRobotsTxt: document.getElementById('obeyRobotsTxt').checked,
|
||||||
|
articleListSelector: document.getElementById('articleListSelector').value,
|
||||||
|
titleSelector: document.getElementById('titleSelector').value,
|
||||||
|
authorSelector: document.getElementById('authorSelector').value,
|
||||||
|
contentSelector: document.getElementById('contentSelector').value
|
||||||
|
};
|
||||||
|
|
||||||
|
// 驗證設定
|
||||||
|
if (config.urls.length === 0) {
|
||||||
|
showConfigStatus('請至少新增一個起始 URL', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 儲存到 localStorage
|
||||||
|
localStorage.setItem('crawlerConfig', JSON.stringify(config));
|
||||||
|
|
||||||
|
// 同時儲存到伺服器(可選)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/save-crawler-config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
showConfigStatus('設定已儲存到伺服器', 'success');
|
||||||
|
} else {
|
||||||
|
showConfigStatus('設定已儲存到本地,但伺服器儲存失敗: ' + result.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 如果伺服器儲存失敗,至少本地已儲存
|
||||||
|
showConfigStatus('設定已儲存到本地', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
showConfigStatus('設定已儲存', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('儲存設定失敗:', error);
|
||||||
|
showConfigStatus('儲存設定失敗: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取得 URL 列表
|
||||||
|
function getUrlList() {
|
||||||
|
const urlInputs = document.querySelectorAll('.url-input');
|
||||||
|
const urls = [];
|
||||||
|
urlInputs.forEach(input => {
|
||||||
|
const url = input.value.trim();
|
||||||
|
if (url) {
|
||||||
|
urls.push(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置爬蟲設定
|
||||||
|
function resetCrawlerConfig() {
|
||||||
|
if (confirm('確定要重置為預設設定嗎?')) {
|
||||||
|
loadDefaultConfig();
|
||||||
|
showConfigStatus('已重置為預設設定', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試爬蟲設定
|
||||||
|
async function testCrawlerConfig() {
|
||||||
|
const config = {
|
||||||
|
urls: getUrlList(),
|
||||||
|
downloadDelay: parseFloat(document.getElementById('downloadDelay').value) || 1,
|
||||||
|
maxDepth: parseInt(document.getElementById('maxDepth').value) || 3,
|
||||||
|
concurrentRequests: parseInt(document.getElementById('concurrentRequests').value) || 16,
|
||||||
|
skipPaywalled: document.getElementById('skipPaywalled').checked,
|
||||||
|
followPagination: document.getElementById('followPagination').checked,
|
||||||
|
obeyRobotsTxt: document.getElementById('obeyRobotsTxt').checked,
|
||||||
|
articleListSelector: document.getElementById('articleListSelector').value,
|
||||||
|
titleSelector: document.getElementById('titleSelector').value,
|
||||||
|
authorSelector: document.getElementById('authorSelector').value,
|
||||||
|
contentSelector: document.getElementById('contentSelector').value
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.urls.length === 0) {
|
||||||
|
showConfigStatus('請至少新增一個起始 URL', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showConfigStatus('正在測試設定...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/test-crawler-config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
if (result.data.articles_found > 0) {
|
||||||
|
showConfigStatus('測試成功!找到 ' + result.data.articles_found + ' 篇文章', 'success');
|
||||||
|
} else {
|
||||||
|
const warning = result.data.warning || '未找到文章';
|
||||||
|
showConfigStatus('測試完成,但 ' + warning + '。請檢查 CSS 選擇器設定或查看詳細輸出。', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showConfigStatus('測試失敗: ' + result.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('測試設定失敗:', error);
|
||||||
|
showConfigStatus('測試設定失敗: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顯示設定狀態訊息
|
||||||
|
function showConfigStatus(message, type = 'info') {
|
||||||
|
const statusDiv = document.getElementById('configStatus');
|
||||||
|
if (statusDiv) {
|
||||||
|
statusDiv.textContent = message;
|
||||||
|
statusDiv.className = `config-status ${type}`;
|
||||||
|
statusDiv.style.display = 'block';
|
||||||
|
|
||||||
|
// 3 秒後自動隱藏(成功訊息)
|
||||||
|
if (type === 'success') {
|
||||||
|
setTimeout(() => {
|
||||||
|
statusDiv.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
537
static/style.css
Normal file
537
static/style.css
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Microsoft JhengHei', 'Segoe UI', Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #45a049;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #2196F3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background: #0b7dda;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #757575;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #616161;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 30px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 統計面板 */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-box {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-box h3 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 查詢表單 */
|
||||||
|
.search-form {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文章列表 */
|
||||||
|
.articles-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.articles-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
transform: translateX(5px);
|
||||||
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-title:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-summary {
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-tags {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paywall-badge {
|
||||||
|
background: #ff9800;
|
||||||
|
color: white;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分頁 */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
padding: 8px 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .active {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 載入動畫 */
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #667eea;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模態框 */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: white;
|
||||||
|
margin: 5% auto;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
color: #aaa;
|
||||||
|
float: right;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#articleDetail {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-detail-title {
|
||||||
|
font-size: 1.8em;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-detail-content {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 頁籤樣式 */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
padding: 0 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 15px 30px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: #667eea;
|
||||||
|
background: rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: #667eea;
|
||||||
|
border-bottom-color: #667eea;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 爬蟲設定頁面 */
|
||||||
|
.config-section {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-group {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-group h3 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-group small {
|
||||||
|
display: block;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-list-container {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-list {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-item input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-item button {
|
||||||
|
padding: 8px 15px;
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-item button:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status.info {
|
||||||
|
background: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
border: 1px solid #bee5eb;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 響應式設計 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
header {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
235
templates/index.html
Normal file
235
templates/index.html
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>HBR 爬蟲系統 - 文章查詢與統計</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>📚 HBR Taiwan 文章爬蟲系統</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="refreshBtn" class="btn btn-primary">🔄 重新整理</button>
|
||||||
|
<button id="runCrawlerBtn" class="btn btn-success">🚀 執行爬蟲</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 頁籤導航 -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="dashboard">📊 儀表板</button>
|
||||||
|
<button class="tab-btn" data-tab="crawler-config">⚙️ 爬蟲設定</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 儀表板頁籤 -->
|
||||||
|
<div id="dashboard-tab" class="tab-content active">
|
||||||
|
<!-- 統計面板 -->
|
||||||
|
<section class="stats-section">
|
||||||
|
<h2>📊 統計資訊</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">文章總數</div>
|
||||||
|
<div class="stat-value" id="totalArticles">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">付費文章</div>
|
||||||
|
<div class="stat-value" id="paywalledArticles">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">免費文章</div>
|
||||||
|
<div class="stat-value" id="freeArticles">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">分類數量</div>
|
||||||
|
<div class="stat-value" id="categoryCount">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="charts-container">
|
||||||
|
<div class="chart-box">
|
||||||
|
<h3>分類分布</h3>
|
||||||
|
<canvas id="categoryChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-box">
|
||||||
|
<h3>作者統計(Top 10)</h3>
|
||||||
|
<canvas id="authorChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 查詢面板 -->
|
||||||
|
<section class="search-section">
|
||||||
|
<h2>🔍 文章查詢</h2>
|
||||||
|
<div class="search-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>關鍵字</label>
|
||||||
|
<input type="text" id="keyword" placeholder="搜尋標題、摘要、內容...">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>分類</label>
|
||||||
|
<select id="category">
|
||||||
|
<option value="">全部</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>標籤</label>
|
||||||
|
<input type="text" id="tag" placeholder="輸入標籤...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>開始日期</label>
|
||||||
|
<input type="date" id="startDate">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>結束日期</label>
|
||||||
|
<input type="date" id="endDate">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>付費狀態</label>
|
||||||
|
<select id="isPaywalled">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="0">免費</option>
|
||||||
|
<option value="1">付費</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button id="searchBtn" class="btn btn-primary">🔍 搜尋</button>
|
||||||
|
<button id="resetBtn" class="btn btn-secondary">🔄 重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 文章列表 -->
|
||||||
|
<section class="articles-section">
|
||||||
|
<div class="articles-header">
|
||||||
|
<h2>📄 文章列表</h2>
|
||||||
|
<div class="pagination-info" id="paginationInfo"></div>
|
||||||
|
</div>
|
||||||
|
<div id="loading" class="loading">載入中...</div>
|
||||||
|
<div id="articlesList" class="articles-list"></div>
|
||||||
|
<div id="pagination" class="pagination"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 爬蟲設定頁籤 -->
|
||||||
|
<div id="crawler-config-tab" class="tab-content">
|
||||||
|
<section class="config-section">
|
||||||
|
<h2>⚙️ 爬蟲設定</h2>
|
||||||
|
<div class="config-form">
|
||||||
|
<div class="config-group">
|
||||||
|
<h3>起始 URL 設定</h3>
|
||||||
|
<div class="url-list-container">
|
||||||
|
<div id="urlList" class="url-list">
|
||||||
|
<!-- URL 列表將由 JavaScript 動態生成 -->
|
||||||
|
</div>
|
||||||
|
<button id="addUrlBtn" class="btn btn-secondary">➕ 新增 URL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-group">
|
||||||
|
<h3>爬取設定</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>下載延遲(秒)</label>
|
||||||
|
<input type="number" id="downloadDelay" min="0" max="10" step="0.5" value="1">
|
||||||
|
<small>建議值:1-3 秒(保守模式)</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>最大深度</label>
|
||||||
|
<input type="number" id="maxDepth" min="1" max="10" value="3">
|
||||||
|
<small>爬取的最大深度層級</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>並發請求數</label>
|
||||||
|
<input type="number" id="concurrentRequests" min="1" max="32" value="16">
|
||||||
|
<small>同時進行的請求數量</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-group">
|
||||||
|
<h3>內容過濾設定</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="skipPaywalled" checked>
|
||||||
|
跳過付費文章內容
|
||||||
|
</label>
|
||||||
|
<small>僅標記為付費,不爬取內容</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="followPagination" checked>
|
||||||
|
追蹤分頁連結
|
||||||
|
</label>
|
||||||
|
<small>自動追蹤「下一頁」連結</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="obeyRobotsTxt" checked>
|
||||||
|
遵守 robots.txt
|
||||||
|
</label>
|
||||||
|
<small>遵守網站的 robots.txt 規則</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-group">
|
||||||
|
<h3>CSS 選擇器設定(進階)</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>文章列表選擇器</label>
|
||||||
|
<input type="text" id="articleListSelector" value=".articleItem, article, .article-item, .post-item, .content-item" placeholder=".articleItem, article, .article-item">
|
||||||
|
<small>用於識別文章列表的 CSS 選擇器(已優化為 HBR Taiwan 實際結構)</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>標題選擇器</label>
|
||||||
|
<input type="text" id="titleSelector" value="h1.articleTitle, h1.article-title, h1, .article-title, .post-title" placeholder="h1.articleTitle, h1, .article-title">
|
||||||
|
<small>用於提取文章標題的 CSS 選擇器(已優化為 HBR Taiwan 實際結構)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>作者選擇器</label>
|
||||||
|
<input type="text" id="authorSelector" value=".authorName, .author, .byline, .writer, .author-name" placeholder=".authorName, .author, .byline">
|
||||||
|
<small>用於提取作者的 CSS 選擇器(已優化為 HBR Taiwan 實際結構)</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>內容選擇器</label>
|
||||||
|
<input type="text" id="contentSelector" value=".articleContent, .article-content, .post-content, .content, .articleText" placeholder=".articleContent, .article-content, .post-content">
|
||||||
|
<small>用於提取文章內容的 CSS 選擇器(已優化為 HBR Taiwan 實際結構)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-actions">
|
||||||
|
<button id="saveConfigBtn" class="btn btn-primary">💾 儲存設定</button>
|
||||||
|
<button id="loadConfigBtn" class="btn btn-secondary">📂 載入設定</button>
|
||||||
|
<button id="resetConfigBtn" class="btn btn-secondary">🔄 重置為預設值</button>
|
||||||
|
<button id="testConfigBtn" class="btn btn-success">🧪 測試設定</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="configStatus" class="config-status"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模態框 -->
|
||||||
|
<div id="articleModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<div id="articleDetail"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
||||||
212
test_db_connection.py
Normal file
212
test_db_connection.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
資料庫連線測試腳本
|
||||||
|
用於測試資料庫連線、建立資料庫和資料表
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 設定日誌
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 加入專案路徑
|
||||||
|
project_root = Path(__file__).parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from hbr_crawler.hbr_crawler.database import DatabaseManager, get_database_manager
|
||||||
|
|
||||||
|
# 資料庫連線資訊
|
||||||
|
DB_CONFIG = {
|
||||||
|
'host': 'mysql.theaken.com',
|
||||||
|
'port': 33306,
|
||||||
|
'user': 'A101',
|
||||||
|
'password': 'Aa123456',
|
||||||
|
'database': 'db_A101'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_connection():
|
||||||
|
"""測試基本連線(不指定資料庫)"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("測試 1: 基本資料庫連線(不指定資料庫)")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
db_manager = DatabaseManager(
|
||||||
|
host=DB_CONFIG['host'],
|
||||||
|
port=DB_CONFIG['port'],
|
||||||
|
user=DB_CONFIG['user'],
|
||||||
|
password=DB_CONFIG['password'],
|
||||||
|
database=None
|
||||||
|
)
|
||||||
|
|
||||||
|
if db_manager.test_connection():
|
||||||
|
print("✓ 基本連線測試成功")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("✗ 基本連線測試失敗")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_database():
|
||||||
|
"""建立 HBR_scraper 資料庫(如果需要)"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("測試 2: 檢查資料庫連線(使用現有資料庫 db_A101)")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
db_manager = DatabaseManager(
|
||||||
|
host=DB_CONFIG['host'],
|
||||||
|
port=DB_CONFIG['port'],
|
||||||
|
user=DB_CONFIG['user'],
|
||||||
|
password=DB_CONFIG['password'],
|
||||||
|
database=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# 嘗試建立資料庫(可能需要管理員權限)
|
||||||
|
try:
|
||||||
|
if db_manager.create_database('HBR_scraper'):
|
||||||
|
print("✓ 資料庫建立成功(或已存在)")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("✗ 資料庫建立失敗")
|
||||||
|
print("提示:如果沒有 CREATE DATABASE 權限,請請管理員協助建立資料庫")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 資料庫建立失敗: {e}")
|
||||||
|
print("提示:如果沒有 CREATE DATABASE 權限,請請管理員協助建立資料庫")
|
||||||
|
print("或者使用現有的資料庫,修改 DB_NAME 設定")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_connection():
|
||||||
|
"""測試連接到 db_A101 資料庫"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("測試 3: 連接到 db_A101 資料庫")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
db_manager = DatabaseManager(
|
||||||
|
host=DB_CONFIG['host'],
|
||||||
|
port=DB_CONFIG['port'],
|
||||||
|
user=DB_CONFIG['user'],
|
||||||
|
password=DB_CONFIG['password'],
|
||||||
|
database=DB_CONFIG['database']
|
||||||
|
)
|
||||||
|
|
||||||
|
if db_manager.test_connection(DB_CONFIG['database']):
|
||||||
|
print("✓ 資料庫連線測試成功")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("✗ 資料庫連線測試失敗")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_tables():
|
||||||
|
"""建立資料表"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("測試 4: 建立資料表")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
db_manager = DatabaseManager(
|
||||||
|
host=DB_CONFIG['host'],
|
||||||
|
port=DB_CONFIG['port'],
|
||||||
|
user=DB_CONFIG['user'],
|
||||||
|
password=DB_CONFIG['password'],
|
||||||
|
database=DB_CONFIG['database']
|
||||||
|
)
|
||||||
|
|
||||||
|
sql_file = project_root / 'create_tables.sql'
|
||||||
|
|
||||||
|
if not sql_file.exists():
|
||||||
|
print(f"✗ SQL 檔案不存在: {sql_file}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if db_manager.execute_sql_file(str(sql_file), DB_CONFIG['database']):
|
||||||
|
print("✓ 資料表建立成功")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("✗ 資料表建立失敗")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_tables():
|
||||||
|
"""驗證資料表是否建立成功"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("測試 5: 驗證資料表")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
db_manager = DatabaseManager(
|
||||||
|
host=DB_CONFIG['host'],
|
||||||
|
port=DB_CONFIG['port'],
|
||||||
|
user=DB_CONFIG['user'],
|
||||||
|
password=DB_CONFIG['password'],
|
||||||
|
database=DB_CONFIG['database']
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_tables = ['articles', 'tags', 'article_tags']
|
||||||
|
|
||||||
|
try:
|
||||||
|
tables = db_manager.execute_query(
|
||||||
|
"SHOW TABLES",
|
||||||
|
database=DB_CONFIG['database']
|
||||||
|
)
|
||||||
|
|
||||||
|
# 取得資料表名稱列表
|
||||||
|
table_names = [list(table.values())[0] for table in tables]
|
||||||
|
|
||||||
|
print(f"找到 {len(table_names)} 個資料表: {', '.join(table_names)}")
|
||||||
|
|
||||||
|
for table in expected_tables:
|
||||||
|
if table in table_names:
|
||||||
|
print(f"✓ 資料表 {table} 存在")
|
||||||
|
else:
|
||||||
|
print(f"✗ 資料表 {table} 不存在")
|
||||||
|
|
||||||
|
return all(table in table_names for table in expected_tables)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 驗證資料表失敗: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函數"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("HBR 爬蟲系統 - 資料庫連線測試")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 執行測試
|
||||||
|
results.append(("基本連線", test_basic_connection()))
|
||||||
|
results.append(("建立資料庫", create_database()))
|
||||||
|
results.append(("資料庫連線", test_database_connection()))
|
||||||
|
results.append(("建立資料表", create_tables()))
|
||||||
|
results.append(("驗證資料表", verify_tables()))
|
||||||
|
|
||||||
|
# 顯示測試結果摘要
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("測試結果摘要")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
for test_name, result in results:
|
||||||
|
status = "✓ 通過" if result else "✗ 失敗"
|
||||||
|
print(f"{test_name}: {status}")
|
||||||
|
|
||||||
|
all_passed = all(result for _, result in results)
|
||||||
|
|
||||||
|
if all_passed:
|
||||||
|
print("\n✓ 所有測試通過!資料庫設定完成。")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("\n✗ 部分測試失敗,請檢查錯誤訊息。")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
|
|
||||||
557
web_app.py
Normal file
557
web_app.py
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
HBR 爬蟲系統 Web 服務
|
||||||
|
提供查詢介面和統計功能
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
try:
|
||||||
|
from flask_cors import CORS
|
||||||
|
CORS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
CORS_AVAILABLE = False
|
||||||
|
|
||||||
|
# 加入專案路徑
|
||||||
|
project_root = Path(__file__).parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from hbr_crawler.hbr_crawler.database import get_database_manager
|
||||||
|
|
||||||
|
# 設定日誌
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static')
|
||||||
|
|
||||||
|
# 啟用 CORS(如果需要跨域請求)
|
||||||
|
if CORS_AVAILABLE:
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
|
# 取得資料庫管理物件
|
||||||
|
db_manager = None
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""取得資料庫管理物件(單例模式)"""
|
||||||
|
global db_manager
|
||||||
|
if db_manager is None:
|
||||||
|
db_manager = get_database_manager()
|
||||||
|
return db_manager
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""首頁"""
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/articles', methods=['GET'])
|
||||||
|
def get_articles():
|
||||||
|
"""取得文章列表 API"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
# 取得查詢參數
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = int(request.args.get('per_page', 20))
|
||||||
|
category = request.args.get('category', '')
|
||||||
|
tag = request.args.get('tag', '')
|
||||||
|
start_date = request.args.get('start_date', '')
|
||||||
|
end_date = request.args.get('end_date', '')
|
||||||
|
keyword = request.args.get('keyword', '')
|
||||||
|
is_paywalled = request.args.get('is_paywalled', '')
|
||||||
|
language = request.args.get('language', '')
|
||||||
|
|
||||||
|
# 建立查詢條件
|
||||||
|
where_conditions = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
# 使用別名 'a' 建立查詢條件(因為查詢會使用 JOIN)
|
||||||
|
if category:
|
||||||
|
where_conditions.append("a.category = %s")
|
||||||
|
params.append(category)
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
# 使用 article_tags 關聯表查詢標籤
|
||||||
|
where_conditions.append("t.name LIKE %s")
|
||||||
|
params.append(f'%{tag}%')
|
||||||
|
use_join = True
|
||||||
|
else:
|
||||||
|
use_join = False
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
where_conditions.append("a.publish_date >= %s")
|
||||||
|
params.append(start_date)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
where_conditions.append("a.publish_date <= %s")
|
||||||
|
params.append(end_date)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
where_conditions.append("(a.title LIKE %s OR a.summary LIKE %s OR a.content LIKE %s)")
|
||||||
|
params.extend([f'%{keyword}%', f'%{keyword}%', f'%{keyword}%'])
|
||||||
|
|
||||||
|
if is_paywalled != '':
|
||||||
|
where_conditions.append("a.is_paywalled = %s")
|
||||||
|
params.append(int(is_paywalled))
|
||||||
|
|
||||||
|
# language 欄位不存在,暫時跳過
|
||||||
|
# if language:
|
||||||
|
# where_conditions.append("a.language = %s")
|
||||||
|
# params.append(language)
|
||||||
|
|
||||||
|
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||||
|
|
||||||
|
# 計算總數
|
||||||
|
if use_join:
|
||||||
|
count_query = f"""
|
||||||
|
SELECT COUNT(DISTINCT a.id) as count
|
||||||
|
FROM articles a
|
||||||
|
LEFT JOIN article_tags at ON a.id = at.article_id
|
||||||
|
LEFT JOIN tags t ON at.tag_id = t.id
|
||||||
|
WHERE {where_clause}
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
count_query = f"SELECT COUNT(*) as count FROM articles a WHERE {where_clause}"
|
||||||
|
|
||||||
|
count_params = tuple(params) if params else None
|
||||||
|
count_result = db.execute_query(count_query, count_params, database='db_A101')
|
||||||
|
total = count_result[0]['count'] if count_result and len(count_result) > 0 else 0
|
||||||
|
|
||||||
|
# 取得文章列表
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
query_params = list(params)
|
||||||
|
query_params.extend([per_page, offset])
|
||||||
|
|
||||||
|
# 查詢文章列表(使用 LEFT JOIN 取得標籤)
|
||||||
|
if use_join:
|
||||||
|
query = f"""
|
||||||
|
SELECT a.id, a.title, a.url, a.author, a.publish_date, a.summary,
|
||||||
|
a.is_paywalled, a.category, a.crawled_at,
|
||||||
|
GROUP_CONCAT(DISTINCT t.name SEPARATOR ', ') as tags
|
||||||
|
FROM articles a
|
||||||
|
LEFT JOIN article_tags at ON a.id = at.article_id
|
||||||
|
LEFT JOIN tags t ON at.tag_id = t.id
|
||||||
|
WHERE {where_clause}
|
||||||
|
GROUP BY a.id, a.title, a.url, a.author, a.publish_date, a.summary,
|
||||||
|
a.is_paywalled, a.category, a.crawled_at
|
||||||
|
ORDER BY a.crawled_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
# 沒有標籤查詢時,使用子查詢取得標籤
|
||||||
|
query = f"""
|
||||||
|
SELECT a.id, a.title, a.url, a.author, a.publish_date, a.summary,
|
||||||
|
a.is_paywalled, a.category, a.crawled_at,
|
||||||
|
(SELECT GROUP_CONCAT(t.name SEPARATOR ', ')
|
||||||
|
FROM article_tags at
|
||||||
|
INNER JOIN tags t ON at.tag_id = t.id
|
||||||
|
WHERE at.article_id = a.id) as tags
|
||||||
|
FROM articles a
|
||||||
|
WHERE {where_clause}
|
||||||
|
ORDER BY a.crawled_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
articles = db.execute_query(query, tuple(query_params), database='db_A101')
|
||||||
|
|
||||||
|
# 確保 articles 是列表
|
||||||
|
if not articles:
|
||||||
|
articles = []
|
||||||
|
|
||||||
|
# 為每篇文章添加預設值並處理資料格式
|
||||||
|
for article in articles:
|
||||||
|
if 'tags' not in article or article['tags'] is None:
|
||||||
|
article['tags'] = ''
|
||||||
|
if 'language' not in article:
|
||||||
|
article['language'] = 'zh-TW'
|
||||||
|
# 確保日期格式正確
|
||||||
|
if article.get('publish_date') and isinstance(article['publish_date'], datetime):
|
||||||
|
article['publish_date'] = article['publish_date'].strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
if article.get('crawled_at') and isinstance(article['crawled_at'], datetime):
|
||||||
|
article['crawled_at'] = article['crawled_at'].strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
logger.info(f"查詢到 {len(articles)} 篇文章,總數: {total}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': articles,
|
||||||
|
'pagination': {
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
'total': total,
|
||||||
|
'pages': (total + per_page - 1) // per_page
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得文章列表失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/article/<int:article_id>', methods=['GET'])
|
||||||
|
def get_article(article_id):
|
||||||
|
"""取得單篇文章詳情"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
query = "SELECT * FROM articles WHERE id = %s"
|
||||||
|
result = db.execute_query(query, (article_id,), database='db_A101')
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return jsonify({'success': True, 'data': result[0]})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': '文章不存在'}), 404
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得文章詳情失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/statistics', methods=['GET'])
|
||||||
|
def get_statistics():
|
||||||
|
"""取得統計資料"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
db_name = 'db_A101'
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
# 文章總數
|
||||||
|
total_result = db.execute_query("SELECT COUNT(*) as count FROM articles", database=db_name)
|
||||||
|
stats['total_articles'] = total_result[0]['count'] if total_result else 0
|
||||||
|
|
||||||
|
# 付費/非付費文章統計
|
||||||
|
paywall_result = db.execute_query(
|
||||||
|
"SELECT is_paywalled, COUNT(*) as count FROM articles GROUP BY is_paywalled",
|
||||||
|
database=db_name
|
||||||
|
)
|
||||||
|
stats['paywall'] = {row['is_paywalled']: row['count'] for row in paywall_result}
|
||||||
|
|
||||||
|
# 分類分布
|
||||||
|
category_result = db.execute_query(
|
||||||
|
"SELECT category, COUNT(*) as count FROM articles WHERE category IS NOT NULL AND category != '' GROUP BY category ORDER BY count DESC LIMIT 10",
|
||||||
|
database=db_name
|
||||||
|
)
|
||||||
|
stats['categories'] = [{'name': row['category'], 'count': row['count']} for row in category_result]
|
||||||
|
|
||||||
|
# 作者統計
|
||||||
|
author_result = db.execute_query(
|
||||||
|
"SELECT author, COUNT(*) as count FROM articles WHERE author IS NOT NULL AND author != '' GROUP BY author ORDER BY count DESC LIMIT 10",
|
||||||
|
database=db_name
|
||||||
|
)
|
||||||
|
stats['authors'] = [{'name': row['author'], 'count': row['count']} for row in author_result]
|
||||||
|
|
||||||
|
# 語言分布
|
||||||
|
language_result = db.execute_query(
|
||||||
|
"SELECT language, COUNT(*) as count FROM articles GROUP BY language",
|
||||||
|
database=db_name
|
||||||
|
)
|
||||||
|
stats['languages'] = {row['language']: row['count'] for row in language_result}
|
||||||
|
|
||||||
|
# 最近30天文章數量趨勢
|
||||||
|
date_result = db.execute_query(
|
||||||
|
"""
|
||||||
|
SELECT DATE(crawled_at) as date, COUNT(*) as count
|
||||||
|
FROM articles
|
||||||
|
WHERE crawled_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY DATE(crawled_at)
|
||||||
|
ORDER BY date DESC
|
||||||
|
""",
|
||||||
|
database=db_name
|
||||||
|
)
|
||||||
|
stats['daily_trend'] = [{'date': str(row['date']), 'count': row['count']} for row in date_result]
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'data': stats})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得統計資料失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/categories', methods=['GET'])
|
||||||
|
def get_categories():
|
||||||
|
"""取得所有分類列表"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
result = db.execute_query(
|
||||||
|
"SELECT DISTINCT category FROM articles WHERE category IS NOT NULL AND category != '' ORDER BY category",
|
||||||
|
database='db_A101'
|
||||||
|
)
|
||||||
|
categories = [row['category'] for row in result]
|
||||||
|
return jsonify({'success': True, 'data': categories})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得分類列表失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/tags', methods=['GET'])
|
||||||
|
def get_tags():
|
||||||
|
"""取得所有標籤列表"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
result = db.execute_query(
|
||||||
|
"SELECT DISTINCT tags FROM articles WHERE tags IS NOT NULL AND tags != ''",
|
||||||
|
database='db_A101'
|
||||||
|
)
|
||||||
|
# 解析逗號分隔的標籤
|
||||||
|
all_tags = set()
|
||||||
|
for row in result:
|
||||||
|
tags = [t.strip() for t in row['tags'].split(',') if t.strip()]
|
||||||
|
all_tags.update(tags)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'data': sorted(list(all_tags))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得標籤列表失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/run-crawler', methods=['POST'])
|
||||||
|
def run_crawler():
|
||||||
|
"""手動觸發爬蟲執行"""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, 'run_crawler.py'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300 # 5分鐘超時
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': result.returncode == 0,
|
||||||
|
'output': result.stdout,
|
||||||
|
'error': result.stderr
|
||||||
|
})
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return jsonify({'success': False, 'error': '爬蟲執行超時'}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"執行爬蟲失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/save-crawler-config', methods=['POST'])
|
||||||
|
def save_crawler_config():
|
||||||
|
"""儲存爬蟲設定"""
|
||||||
|
try:
|
||||||
|
config = request.get_json()
|
||||||
|
|
||||||
|
# 驗證設定
|
||||||
|
if not config:
|
||||||
|
return jsonify({'success': False, 'error': '設定資料為空'}), 400
|
||||||
|
|
||||||
|
# 儲存到檔案(可選)
|
||||||
|
import json
|
||||||
|
config_file = Path(__file__).parent / 'crawler_config.json'
|
||||||
|
with open(config_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
logger.info(f"爬蟲設定已儲存: {config_file}")
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': '設定已儲存'})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"儲存爬蟲設定失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/load-crawler-config', methods=['GET'])
|
||||||
|
def load_crawler_config():
|
||||||
|
"""載入爬蟲設定"""
|
||||||
|
try:
|
||||||
|
config_file = Path(__file__).parent / 'crawler_config.json'
|
||||||
|
|
||||||
|
if config_file.exists():
|
||||||
|
import json
|
||||||
|
with open(config_file, 'r', encoding='utf-8') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
return jsonify({'success': True, 'data': config})
|
||||||
|
else:
|
||||||
|
# 返回預設設定
|
||||||
|
default_config = {
|
||||||
|
'urls': [
|
||||||
|
'https://www.hbrtaiwan.com/',
|
||||||
|
'https://www.hbrtaiwan.com/topic/management',
|
||||||
|
'https://www.hbrtaiwan.com/topic/leadership',
|
||||||
|
'https://www.hbrtaiwan.com/topic/strategy',
|
||||||
|
'https://www.hbrtaiwan.com/topic/innovation',
|
||||||
|
'https://www.hbrtaiwan.com/topic/technology'
|
||||||
|
],
|
||||||
|
'downloadDelay': 1,
|
||||||
|
'maxDepth': 3,
|
||||||
|
'concurrentRequests': 16,
|
||||||
|
'skipPaywalled': True,
|
||||||
|
'followPagination': True,
|
||||||
|
'obeyRobotsTxt': True,
|
||||||
|
'articleListSelector': '.articleItem, article, .article-item, .post-item, .content-item',
|
||||||
|
'titleSelector': 'h1.articleTitle, h1.article-title, h1, .article-title, .post-title',
|
||||||
|
'authorSelector': '.authorName, .author, .byline, .writer, .author-name',
|
||||||
|
'contentSelector': '.articleContent, .article-content, .post-content, .content, .articleText'
|
||||||
|
}
|
||||||
|
return jsonify({'success': True, 'data': default_config})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"載入爬蟲設定失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/test-crawler-config', methods=['POST'])
|
||||||
|
def test_crawler_config():
|
||||||
|
"""測試爬蟲設定(僅測試第一個 URL)"""
|
||||||
|
try:
|
||||||
|
config = request.get_json()
|
||||||
|
|
||||||
|
if not config or not config.get('urls') or len(config['urls']) == 0:
|
||||||
|
return jsonify({'success': False, 'error': '請至少提供一個起始 URL'}), 400
|
||||||
|
|
||||||
|
# 使用 Scrapy 測試第一個 URL
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 建立臨時設定檔
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f:
|
||||||
|
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||||
|
temp_config_file = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 執行測試爬蟲(僅爬取第一個 URL,深度 1)
|
||||||
|
# 使用 stats 收集器來獲取準確的統計資訊
|
||||||
|
test_result = subprocess.run(
|
||||||
|
[sys.executable, '-m', 'scrapy', 'crawl', 'hbr',
|
||||||
|
'-a', f'start_url={config["urls"][0]}',
|
||||||
|
'-a', 'test_mode=true',
|
||||||
|
'-s', 'LOG_LEVEL=INFO',
|
||||||
|
'-s', 'STATS_CLASS=scrapy.statscollectors.MemoryStatsCollector'],
|
||||||
|
cwd=str(Path(__file__).parent / 'hbr_crawler'),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
encoding='utf-8',
|
||||||
|
errors='replace'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 從輸出中解析文章數量
|
||||||
|
articles_found = 0
|
||||||
|
output_lines = test_result.stdout.split('\n') if test_result.stdout else []
|
||||||
|
stderr_lines = test_result.stderr.split('\n') if test_result.stderr else []
|
||||||
|
all_lines = output_lines + stderr_lines
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 方法1: 查找 Scrapy 統計資訊中的 item_scraped_count
|
||||||
|
# Scrapy 輸出格式: 'item_scraped_count': 5 或 'items': 5
|
||||||
|
for line in all_lines:
|
||||||
|
# 匹配 'item_scraped_count': 數字 或 'items': 數字
|
||||||
|
match = re.search(r"['\"]?item_scraped_count['\"]?\s*[:=]\s*(\d+)", line, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
articles_found = int(match.group(1))
|
||||||
|
break
|
||||||
|
|
||||||
|
# 匹配 'items': 數字(在某些 Scrapy 版本中)
|
||||||
|
match = re.search(r"['\"]?items['\"]?\s*[:=]\s*(\d+)", line, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
articles_found = int(match.group(1))
|
||||||
|
break
|
||||||
|
|
||||||
|
# 方法2: 查找日誌中的 "Scraped from" 或 "item_scraped" 訊息
|
||||||
|
if articles_found == 0:
|
||||||
|
for line in all_lines:
|
||||||
|
# Scrapy 日誌格式: [hbr] DEBUG: Scraped from <200 https://...>
|
||||||
|
if 'Scraped from' in line or 'item_scraped' in line.lower():
|
||||||
|
articles_found += 1
|
||||||
|
|
||||||
|
# 方法3: 查找統計摘要中的數字(格式: "items": 5)
|
||||||
|
if articles_found == 0:
|
||||||
|
for line in all_lines:
|
||||||
|
# 匹配 JSON 格式的統計: "items": 5 或 'items': 5
|
||||||
|
match = re.search(r"['\"]items['\"]\s*:\s*(\d+)", line, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
articles_found = int(match.group(1))
|
||||||
|
break
|
||||||
|
|
||||||
|
# 方法4: 如果還是0,檢查是否有錯誤或警告
|
||||||
|
if articles_found == 0:
|
||||||
|
has_error = False
|
||||||
|
error_lines = []
|
||||||
|
for line in all_lines:
|
||||||
|
if 'ERROR' in line.upper() or 'CRITICAL' in line.upper():
|
||||||
|
has_error = True
|
||||||
|
error_lines.append(line)
|
||||||
|
elif 'No module named' in line or 'ImportError' in line:
|
||||||
|
has_error = True
|
||||||
|
error_lines.append(line)
|
||||||
|
|
||||||
|
if has_error:
|
||||||
|
error_msg = '\n'.join(error_lines[:5]) # 只取前5行錯誤
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': f'爬蟲執行時發生錯誤',
|
||||||
|
'data': {
|
||||||
|
'articles_found': 0,
|
||||||
|
'output': test_result.stdout[:1000] if test_result.stdout else '',
|
||||||
|
'error': error_msg[:500],
|
||||||
|
'returncode': test_result.returncode
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 如果找到文章,返回成功;如果沒找到但沒有錯誤,可能是選擇器問題
|
||||||
|
if articles_found == 0:
|
||||||
|
# 檢查是否成功連接到網站
|
||||||
|
has_connection = False
|
||||||
|
for line in all_lines:
|
||||||
|
if '200' in line or 'downloaded' in line.lower() or 'response' in line.lower():
|
||||||
|
has_connection = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if has_connection:
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'articles_found': 0,
|
||||||
|
'output': test_result.stdout[:1000] if test_result.stdout else '',
|
||||||
|
'error': '成功連接到網站,但未找到文章。可能是 CSS 選擇器不正確,或網站結構已變更。',
|
||||||
|
'returncode': test_result.returncode,
|
||||||
|
'warning': '未找到文章,請檢查 CSS 選擇器設定'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'articles_found': articles_found,
|
||||||
|
'output': test_result.stdout[:1000] if test_result.stdout else '', # 返回前 1000 字元
|
||||||
|
'error': test_result.stderr[:500] if test_result.stderr else '',
|
||||||
|
'returncode': test_result.returncode
|
||||||
|
}
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
# 清理臨時檔案
|
||||||
|
try:
|
||||||
|
os.unlink(temp_config_file)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return jsonify({'success': False, 'error': '測試超時'}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"測試爬蟲設定失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# 建立必要的目錄
|
||||||
|
os.makedirs('templates', exist_ok=True)
|
||||||
|
os.makedirs('static', exist_ok=True)
|
||||||
|
|
||||||
|
# 啟動服務
|
||||||
|
print("=" * 60)
|
||||||
|
print("HBR 爬蟲系統 Web 服務")
|
||||||
|
print("=" * 60)
|
||||||
|
print("服務地址: http://localhost:5000")
|
||||||
|
print("按 Ctrl+C 停止服務")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
|
|
||||||
|
|
||||||
344
web_app_fixed.py
Normal file
344
web_app_fixed.py
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
HBR 爬蟲系統 Web 服務
|
||||||
|
提供查詢介面和統計功能
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# 加入專案路徑
|
||||||
|
project_root = Path(__file__).parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from hbr_crawler.hbr_crawler.database import get_database_manager
|
||||||
|
|
||||||
|
# 設定日誌
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static')
|
||||||
|
|
||||||
|
# 取得資料庫管理物件
|
||||||
|
db_manager = None
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""取得資料庫管理物件(單例模式)"""
|
||||||
|
global db_manager
|
||||||
|
if db_manager is None:
|
||||||
|
db_manager = get_database_manager()
|
||||||
|
return db_manager
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""首頁"""
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/articles', methods=['GET'])
|
||||||
|
def get_articles():
|
||||||
|
"""取得文章列表 API"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
# 取得查詢參數
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = int(request.args.get('per_page', 20))
|
||||||
|
category = request.args.get('category', '')
|
||||||
|
tag = request.args.get('tag', '')
|
||||||
|
start_date = request.args.get('start_date', '')
|
||||||
|
end_date = request.args.get('end_date', '')
|
||||||
|
keyword = request.args.get('keyword', '')
|
||||||
|
is_paywalled = request.args.get('is_paywalled', '')
|
||||||
|
|
||||||
|
# 建立查詢條件(使用別名 'a')
|
||||||
|
where_conditions = []
|
||||||
|
params = []
|
||||||
|
use_join = False
|
||||||
|
|
||||||
|
if category:
|
||||||
|
where_conditions.append("a.category = %s")
|
||||||
|
params.append(category)
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
where_conditions.append("t.name LIKE %s")
|
||||||
|
params.append(f'%{tag}%')
|
||||||
|
use_join = True
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
where_conditions.append("a.publish_date >= %s")
|
||||||
|
params.append(start_date)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
where_conditions.append("a.publish_date <= %s")
|
||||||
|
params.append(end_date)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
where_conditions.append("(a.title LIKE %s OR a.summary LIKE %s OR a.content LIKE %s)")
|
||||||
|
params.extend([f'%{keyword}%', f'%{keyword}%', f'%{keyword}%'])
|
||||||
|
|
||||||
|
if is_paywalled != '':
|
||||||
|
where_conditions.append("a.is_paywalled = %s")
|
||||||
|
params.append(int(is_paywalled))
|
||||||
|
|
||||||
|
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||||
|
|
||||||
|
# 計算總數
|
||||||
|
if use_join:
|
||||||
|
count_query = f"""
|
||||||
|
SELECT COUNT(DISTINCT a.id) as count
|
||||||
|
FROM articles a
|
||||||
|
LEFT JOIN article_tags at ON a.id = at.article_id
|
||||||
|
LEFT JOIN tags t ON at.tag_id = t.id
|
||||||
|
WHERE {where_clause}
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
count_query = f"SELECT COUNT(*) as count FROM articles a WHERE {where_clause}"
|
||||||
|
|
||||||
|
count_params = tuple(params) if params else None
|
||||||
|
count_result = db.execute_query(count_query, count_params, database='db_A101')
|
||||||
|
total = count_result[0]['count'] if count_result and len(count_result) > 0 else 0
|
||||||
|
|
||||||
|
# 取得文章列表
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
query_params = list(params)
|
||||||
|
query_params.extend([per_page, offset])
|
||||||
|
|
||||||
|
# 查詢文章列表(使用 LEFT JOIN 取得標籤)
|
||||||
|
if use_join:
|
||||||
|
query = f"""
|
||||||
|
SELECT a.id, a.title, a.url, a.author, a.publish_date, a.summary,
|
||||||
|
a.is_paywalled, a.category, a.crawled_at,
|
||||||
|
GROUP_CONCAT(DISTINCT t.name SEPARATOR ', ') as tags
|
||||||
|
FROM articles a
|
||||||
|
LEFT JOIN article_tags at ON a.id = at.article_id
|
||||||
|
LEFT JOIN tags t ON at.tag_id = t.id
|
||||||
|
WHERE {where_clause}
|
||||||
|
GROUP BY a.id, a.title, a.url, a.author, a.publish_date, a.summary,
|
||||||
|
a.is_paywalled, a.category, a.crawled_at
|
||||||
|
ORDER BY a.crawled_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
# 沒有標籤查詢時,使用子查詢取得標籤
|
||||||
|
query = f"""
|
||||||
|
SELECT a.id, a.title, a.url, a.author, a.publish_date, a.summary,
|
||||||
|
a.is_paywalled, a.category, a.crawled_at,
|
||||||
|
(SELECT GROUP_CONCAT(t.name SEPARATOR ', ')
|
||||||
|
FROM article_tags at
|
||||||
|
INNER JOIN tags t ON at.tag_id = t.id
|
||||||
|
WHERE at.article_id = a.id) as tags
|
||||||
|
FROM articles a
|
||||||
|
WHERE {where_clause}
|
||||||
|
ORDER BY a.crawled_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
articles = db.execute_query(query, tuple(query_params), database='db_A101')
|
||||||
|
|
||||||
|
# 確保 articles 是列表
|
||||||
|
if not articles:
|
||||||
|
articles = []
|
||||||
|
|
||||||
|
# 為每篇文章添加預設值並處理資料格式
|
||||||
|
for article in articles:
|
||||||
|
if 'tags' not in article or article['tags'] is None:
|
||||||
|
article['tags'] = ''
|
||||||
|
if 'language' not in article:
|
||||||
|
article['language'] = 'zh-TW'
|
||||||
|
# 確保日期格式正確
|
||||||
|
if article.get('publish_date') and isinstance(article['publish_date'], datetime):
|
||||||
|
article['publish_date'] = article['publish_date'].strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
if article.get('crawled_at') and isinstance(article['crawled_at'], datetime):
|
||||||
|
article['crawled_at'] = article['crawled_at'].strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
logger.info(f"查詢到 {len(articles)} 篇文章,總數: {total}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': articles,
|
||||||
|
'pagination': {
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
'total': total,
|
||||||
|
'pages': (total + per_page - 1) // per_page if total > 0 else 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得文章列表失敗: {e}", exc_info=True)
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/article/<int:article_id>', methods=['GET'])
|
||||||
|
def get_article(article_id):
|
||||||
|
"""取得單篇文章詳情"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
query = """
|
||||||
|
SELECT a.*,
|
||||||
|
(SELECT GROUP_CONCAT(t.name SEPARATOR ', ')
|
||||||
|
FROM article_tags at
|
||||||
|
INNER JOIN tags t ON at.tag_id = t.id
|
||||||
|
WHERE at.article_id = a.id) as tags
|
||||||
|
FROM articles a
|
||||||
|
WHERE a.id = %s
|
||||||
|
"""
|
||||||
|
result = db.execute_query(query, (article_id,), database='db_A101')
|
||||||
|
|
||||||
|
if result and len(result) > 0:
|
||||||
|
article = result[0]
|
||||||
|
# 處理日期格式
|
||||||
|
if article.get('publish_date') and isinstance(article['publish_date'], datetime):
|
||||||
|
article['publish_date'] = article['publish_date'].strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
if article.get('crawled_at') and isinstance(article['crawled_at'], datetime):
|
||||||
|
article['crawled_at'] = article['crawled_at'].strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
if 'tags' not in article or article['tags'] is None:
|
||||||
|
article['tags'] = ''
|
||||||
|
return jsonify({'success': True, 'data': article})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': '文章不存在'}), 404
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得文章詳情失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/statistics', methods=['GET'])
|
||||||
|
def get_statistics():
|
||||||
|
"""取得統計資料"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
db_name = 'db_A101'
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
# 文章總數
|
||||||
|
total_result = db.execute_query("SELECT COUNT(*) as count FROM articles", database=db_name)
|
||||||
|
stats['total_articles'] = total_result[0]['count'] if total_result and len(total_result) > 0 else 0
|
||||||
|
|
||||||
|
# 付費/非付費文章統計
|
||||||
|
paywall_result = db.execute_query(
|
||||||
|
"SELECT is_paywalled, COUNT(*) as count FROM articles GROUP BY is_paywalled",
|
||||||
|
database=db_name
|
||||||
|
)
|
||||||
|
stats['paywall'] = {}
|
||||||
|
if paywall_result:
|
||||||
|
for row in paywall_result:
|
||||||
|
stats['paywall'][row['is_paywalled']] = row['count']
|
||||||
|
|
||||||
|
# 分類分布
|
||||||
|
category_result = db.execute_query(
|
||||||
|
"SELECT category, COUNT(*) as count FROM articles WHERE category IS NOT NULL AND category != '' GROUP BY category ORDER BY count DESC LIMIT 10",
|
||||||
|
database=db_name
|
||||||
|
)
|
||||||
|
stats['categories'] = [{'name': row['category'], 'count': row['count']} for row in category_result] if category_result else []
|
||||||
|
|
||||||
|
# 作者統計
|
||||||
|
author_result = db.execute_query(
|
||||||
|
"SELECT author, COUNT(*) as count FROM articles WHERE author IS NOT NULL AND author != '' GROUP BY author ORDER BY count DESC LIMIT 10",
|
||||||
|
database=db_name
|
||||||
|
)
|
||||||
|
stats['authors'] = [{'name': row['author'], 'count': row['count']} for row in author_result] if author_result else []
|
||||||
|
|
||||||
|
# 語言分布(暫時跳過,因為欄位不存在)
|
||||||
|
stats['languages'] = {}
|
||||||
|
|
||||||
|
# 最近30天文章數量趨勢
|
||||||
|
try:
|
||||||
|
date_result = db.execute_query(
|
||||||
|
"""
|
||||||
|
SELECT DATE(crawled_at) as date, COUNT(*) as count
|
||||||
|
FROM articles
|
||||||
|
WHERE crawled_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY DATE(crawled_at)
|
||||||
|
ORDER BY date DESC
|
||||||
|
""",
|
||||||
|
database=db_name
|
||||||
|
)
|
||||||
|
stats['daily_trend'] = [{'date': str(row['date']), 'count': row['count']} for row in date_result] if date_result else []
|
||||||
|
except:
|
||||||
|
stats['daily_trend'] = []
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'data': stats})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得統計資料失敗: {e}", exc_info=True)
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/categories', methods=['GET'])
|
||||||
|
def get_categories():
|
||||||
|
"""取得所有分類列表"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
result = db.execute_query(
|
||||||
|
"SELECT DISTINCT category FROM articles WHERE category IS NOT NULL AND category != '' ORDER BY category",
|
||||||
|
database='db_A101'
|
||||||
|
)
|
||||||
|
categories = [row['category'] for row in result] if result else []
|
||||||
|
return jsonify({'success': True, 'data': categories})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得分類列表失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/tags', methods=['GET'])
|
||||||
|
def get_tags():
|
||||||
|
"""取得所有標籤列表"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
result = db.execute_query(
|
||||||
|
"SELECT DISTINCT name FROM tags ORDER BY name",
|
||||||
|
database='db_A101'
|
||||||
|
)
|
||||||
|
tags = [row['name'] for row in result] if result else []
|
||||||
|
return jsonify({'success': True, 'data': tags})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"取得標籤列表失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/run-crawler', methods=['POST'])
|
||||||
|
def run_crawler():
|
||||||
|
"""手動觸發爬蟲執行"""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, 'run_crawler.py'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300 # 5分鐘超時
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': result.returncode == 0,
|
||||||
|
'output': result.stdout,
|
||||||
|
'error': result.stderr
|
||||||
|
})
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return jsonify({'success': False, 'error': '爬蟲執行超時'}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"執行爬蟲失敗: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# 建立必要的目錄
|
||||||
|
os.makedirs('templates', exist_ok=True)
|
||||||
|
os.makedirs('static', exist_ok=True)
|
||||||
|
|
||||||
|
# 啟動服務
|
||||||
|
print("=" * 60)
|
||||||
|
print("HBR 爬蟲系統 Web 服務")
|
||||||
|
print("=" * 60)
|
||||||
|
print("服務地址: http://localhost:5000")
|
||||||
|
print("按 Ctrl+C 停止服務")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
|
|
||||||
110
問題排除說明.md
Normal file
110
問題排除說明.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 問題排除說明
|
||||||
|
|
||||||
|
## 當前問題
|
||||||
|
|
||||||
|
### 1. 資料庫權限問題
|
||||||
|
|
||||||
|
**錯誤訊息**:
|
||||||
|
```
|
||||||
|
Access denied for user 'A101'@'%' to database 'hbr_scraper'
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 使用者 `A101` 沒有建立資料庫的權限
|
||||||
|
- 資料庫 `HBR_scraper` 可能尚未建立
|
||||||
|
|
||||||
|
**解決方案**:
|
||||||
|
|
||||||
|
#### 方案 A:請資料庫管理員協助建立資料庫
|
||||||
|
|
||||||
|
請資料庫管理員執行以下 SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE IF NOT EXISTS `HBR_scraper` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
GRANT ALL PRIVILEGES ON `HBR_scraper`.* TO 'A101'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
然後執行:
|
||||||
|
```bash
|
||||||
|
python test_db_connection.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方案 B:使用現有資料庫
|
||||||
|
|
||||||
|
如果已有其他資料庫可用,修改 `hbr_crawler/hbr_crawler/settings.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
DB_NAME = 'your_existing_database'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方案 C:暫時跳過資料庫儲存
|
||||||
|
|
||||||
|
系統會自動跳過資料庫儲存,僅產生 CSV 檔案。這不影響爬蟲的基本功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 爬蟲 URL 404 問題
|
||||||
|
|
||||||
|
**問題**:
|
||||||
|
- 多個分類 URL 返回 404
|
||||||
|
- 只爬取到 1 個項目
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 網站結構已改變
|
||||||
|
- URL 路徑不正確
|
||||||
|
|
||||||
|
**解決方案**:
|
||||||
|
|
||||||
|
1. **檢查網站結構**:
|
||||||
|
- 訪問 https://www.hbrtaiwan.com/ 查看實際的分類路徑
|
||||||
|
- 更新 `hbr_crawler/hbr_crawler/spiders/hbr.py` 中的 `start_urls`
|
||||||
|
|
||||||
|
2. **調整爬蟲邏輯**:
|
||||||
|
- 可能需要更新 CSS 選擇器
|
||||||
|
- 檢查網站是否有反爬蟲機制
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 當前系統狀態
|
||||||
|
|
||||||
|
✅ **正常運作的功能**:
|
||||||
|
- Scrapy 爬蟲框架已安裝
|
||||||
|
- 爬蟲可以執行
|
||||||
|
- CSV 檔案可以產生
|
||||||
|
- 郵件發送功能(可選)
|
||||||
|
|
||||||
|
⚠️ **需要處理的問題**:
|
||||||
|
- 資料庫連線(需要管理員權限建立資料庫)
|
||||||
|
- 爬蟲 URL 路徑(部分 URL 返回 404)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 建議處理順序
|
||||||
|
|
||||||
|
1. **先處理資料庫問題**(如果需要資料庫儲存):
|
||||||
|
- 請管理員建立資料庫
|
||||||
|
- 執行 `python test_db_connection.py` 驗證
|
||||||
|
|
||||||
|
2. **再處理爬蟲問題**:
|
||||||
|
- 檢查網站實際結構
|
||||||
|
- 更新 `start_urls` 和 CSS 選擇器
|
||||||
|
|
||||||
|
3. **測試完整流程**:
|
||||||
|
- 執行 `python run_crawler.py`
|
||||||
|
- 檢查 CSV 檔案和資料庫資料
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 臨時解決方案
|
||||||
|
|
||||||
|
如果暫時無法解決資料庫問題,系統仍可正常運作:
|
||||||
|
|
||||||
|
- ✅ 爬蟲會繼續執行
|
||||||
|
- ✅ CSV 檔案會正常產生
|
||||||
|
- ⚠️ 資料不會儲存到資料庫(但可以手動匯入 CSV)
|
||||||
|
|
||||||
|
這不影響基本功能,只是資料不會自動儲存到資料庫。
|
||||||
|
|
||||||
|
|
||||||
89
啟動Web服務.md
Normal file
89
啟動Web服務.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# HBR 爬蟲系統 - Web 服務啟動說明
|
||||||
|
|
||||||
|
## 啟動方式
|
||||||
|
|
||||||
|
### 方式一:使用 run_crawler.py(推薦)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 啟動 Web 服務
|
||||||
|
python run_crawler.py --web
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:直接啟動 web_app.py
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 啟動 Web 服務
|
||||||
|
python web_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 訪問地址
|
||||||
|
|
||||||
|
啟動後,在瀏覽器中訪問:
|
||||||
|
- **本地訪問**: http://localhost:5000
|
||||||
|
- **網路訪問**: http://你的IP:5000
|
||||||
|
|
||||||
|
## 功能說明
|
||||||
|
|
||||||
|
### 1. 統計資訊
|
||||||
|
- 文章總數
|
||||||
|
- 付費/免費文章統計
|
||||||
|
- 分類分布圖表
|
||||||
|
- 作者統計圖表
|
||||||
|
|
||||||
|
### 2. 文章查詢
|
||||||
|
- 關鍵字搜尋(標題、摘要、內容)
|
||||||
|
- 分類篩選
|
||||||
|
- 標籤篩選
|
||||||
|
- 日期範圍查詢
|
||||||
|
- 付費狀態篩選
|
||||||
|
|
||||||
|
### 3. 文章列表
|
||||||
|
- 分頁顯示
|
||||||
|
- 點擊查看文章詳情
|
||||||
|
- 顯示文章基本資訊(作者、日期、分類、標籤)
|
||||||
|
|
||||||
|
### 4. 手動執行爬蟲
|
||||||
|
- 點擊「執行爬蟲」按鈕
|
||||||
|
- 自動執行爬蟲並更新資料
|
||||||
|
|
||||||
|
## API 端點
|
||||||
|
|
||||||
|
### GET /api/articles
|
||||||
|
取得文章列表
|
||||||
|
- 參數: page, per_page, category, tag, start_date, end_date, keyword, is_paywalled, language
|
||||||
|
|
||||||
|
### GET /api/article/<id>
|
||||||
|
取得單篇文章詳情
|
||||||
|
|
||||||
|
### GET /api/statistics
|
||||||
|
取得統計資料
|
||||||
|
|
||||||
|
### GET /api/categories
|
||||||
|
取得所有分類列表
|
||||||
|
|
||||||
|
### GET /api/tags
|
||||||
|
取得所有標籤列表
|
||||||
|
|
||||||
|
### POST /api/run-crawler
|
||||||
|
手動觸發爬蟲執行
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
|
||||||
|
1. 確保資料庫連線正常
|
||||||
|
2. 確保已建立必要的資料表(執行 `python test_db_connection.py`)
|
||||||
|
3. Web 服務預設監聽所有網路介面(0.0.0.0),生產環境建議設定防火牆
|
||||||
|
4. 如需修改埠號,編輯 `web_app.py` 中的 `app.run()` 參數
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 無法啟動服務
|
||||||
|
- 確認 Flask 已安裝: `pip install flask`
|
||||||
|
- 確認資料庫連線正常
|
||||||
|
- 檢查埠號 5000 是否被占用
|
||||||
|
|
||||||
|
### 無法載入資料
|
||||||
|
- 確認資料庫中有資料
|
||||||
|
- 檢查資料庫連線設定
|
||||||
|
- 查看瀏覽器控制台的錯誤訊息
|
||||||
|
|
||||||
|
|
||||||
132
啟動說明.md
Normal file
132
啟動說明.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# HBR 爬蟲系統 - 啟動說明
|
||||||
|
|
||||||
|
## 主要啟動檔案
|
||||||
|
|
||||||
|
### 🚀 `run_crawler.py` - **主啟動腳本(推薦使用)**
|
||||||
|
|
||||||
|
這是整合所有功能的主啟動腳本,會依序執行:
|
||||||
|
1. 執行 Scrapy 爬蟲
|
||||||
|
2. 檢查 CSV 檔案是否產生
|
||||||
|
3. 發送郵件(如果已設定 Gmail)
|
||||||
|
|
||||||
|
**使用方式**:
|
||||||
|
```bash
|
||||||
|
python run_crawler.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**適用場景**:
|
||||||
|
- 手動執行爬蟲
|
||||||
|
- 排程任務(Crontab)
|
||||||
|
- 自動化流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 其他 Python 檔案說明
|
||||||
|
|
||||||
|
### 📧 `send_mail.py` - 郵件發送腳本
|
||||||
|
|
||||||
|
僅負責發送郵件,不執行爬蟲。
|
||||||
|
|
||||||
|
**使用方式**:
|
||||||
|
```bash
|
||||||
|
python send_mail.py [csv檔案路徑]
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能**:
|
||||||
|
- 讀取 CSV 檔案
|
||||||
|
- 透過 Gmail SMTP 發送郵件(如果已設定)
|
||||||
|
- 如果未設定 Gmail,會跳過郵件發送並顯示提示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🧪 `test_db_connection.py` - 資料庫連線測試
|
||||||
|
|
||||||
|
測試資料庫連線並建立資料表結構。
|
||||||
|
|
||||||
|
**使用方式**:
|
||||||
|
```bash
|
||||||
|
python test_db_connection.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能**:
|
||||||
|
- 測試資料庫連線
|
||||||
|
- 建立 HBR_scraper 資料庫(如果不存在)
|
||||||
|
- 建立資料表結構
|
||||||
|
- 驗證資料表是否建立成功
|
||||||
|
|
||||||
|
**建議**:在首次使用前執行一次
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🕷️ `hbr_crawler/` - Scrapy 爬蟲專案
|
||||||
|
|
||||||
|
這是 Scrapy 爬蟲的核心程式碼,包含:
|
||||||
|
- `spiders/hbr.py` - 爬蟲主程式
|
||||||
|
- `pipelines.py` - 資料處理管道(CSV 匯出、資料庫儲存)
|
||||||
|
- `items.py` - 資料結構定義
|
||||||
|
- `settings.py` - 爬蟲設定
|
||||||
|
- `database.py` - 資料庫連線模組
|
||||||
|
|
||||||
|
**直接使用 Scrapy 命令**:
|
||||||
|
```bash
|
||||||
|
cd hbr_crawler
|
||||||
|
scrapy crawl hbr
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:建議使用 `run_crawler.py` 而不是直接執行 Scrapy 命令,因為它會整合所有功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速開始
|
||||||
|
|
||||||
|
### 1. 首次設定
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安裝依賴
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 測試資料庫連線(建立資料庫和資料表)
|
||||||
|
python test_db_connection.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 執行爬蟲
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式一:使用主啟動腳本(推薦)
|
||||||
|
python run_crawler.py
|
||||||
|
|
||||||
|
# 方式二:直接使用 Scrapy 命令
|
||||||
|
cd hbr_crawler
|
||||||
|
scrapy crawl hbr
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 排程設定(Crontab)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 每天 08:00 執行
|
||||||
|
0 8 * * * cd /path/to/project && /usr/bin/python3 run_crawler.py >> logs/cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 檔案功能對照表
|
||||||
|
|
||||||
|
| 檔案 | 功能 | 是否可獨立執行 | 用途 |
|
||||||
|
|------|------|---------------|------|
|
||||||
|
| `run_crawler.py` | 整合所有功能 | ✅ 是 | **主要啟動腳本** |
|
||||||
|
| `send_mail.py` | 發送郵件 | ✅ 是 | 郵件發送 |
|
||||||
|
| `test_db_connection.py` | 測試資料庫 | ✅ 是 | 資料庫設定 |
|
||||||
|
| `hbr_crawler/spiders/hbr.py` | 爬蟲核心 | ❌ 需透過 Scrapy | 爬蟲邏輯 |
|
||||||
|
| `hbr_crawler/pipelines.py` | 資料處理 | ❌ 需透過 Scrapy | 資料處理 |
|
||||||
|
| `hbr_crawler/database.py` | 資料庫模組 | ❌ 被其他模組引用 | 資料庫連線 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 建議使用流程
|
||||||
|
|
||||||
|
1. **首次設定**:執行 `test_db_connection.py`
|
||||||
|
2. **日常使用**:執行 `run_crawler.py`
|
||||||
|
3. **僅發送郵件**:執行 `send_mail.py`
|
||||||
|
4. **排程任務**:在 Crontab 中設定 `run_crawler.py`
|
||||||
|
|
||||||
|
|
||||||
239
待釐清問題.md
Normal file
239
待釐清問題.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# HBR 爬蟲系統 - 待釐清問題清單
|
||||||
|
|
||||||
|
請以選擇題方式回答以下問題,以便完成系統開發。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、資料庫相關問題
|
||||||
|
|
||||||
|
### 1. 資料庫類型
|
||||||
|
您希望使用哪種資料庫系統?
|
||||||
|
|
||||||
|
- [V ] A. MySQL / MariaDB
|
||||||
|
- [ ] B. PostgreSQL
|
||||||
|
- [ ] C. SQLite
|
||||||
|
- [ ] D. Microsoft SQL Server
|
||||||
|
- [ ] E. 其他(請說明):___________
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 2. 資料庫連線方式
|
||||||
|
您希望如何提供資料庫連線資訊?
|
||||||
|
|
||||||
|
- [ ] A. 直接在此文件中提供(主機、埠號、資料庫名稱、使用者、密碼)
|
||||||
|
- [V ] B. 透過環境變數設定
|
||||||
|
- [ ] C. 使用連線字串(Connection String)
|
||||||
|
- [ ] D. 使用設定檔(config.ini 或 config.json)
|
||||||
|
- [ ] E. 其他方式(請說明):___________
|
||||||
|
|
||||||
|
### 3. 資料庫位置
|
||||||
|
資料庫位於何處?
|
||||||
|
|
||||||
|
- [ ] A. 本地電腦(localhost)
|
||||||
|
- [V ] B. 區域網路內的伺服器
|
||||||
|
- [ ] C. 雲端服務(AWS RDS、Azure SQL、Google Cloud SQL 等)
|
||||||
|
- [ ] D. 其他(請說明):___________
|
||||||
|
|
||||||
|
### 4. 資料庫權限
|
||||||
|
資料庫使用者權限設定?
|
||||||
|
|
||||||
|
- [ ] A. 僅有 SELECT 和 INSERT 權限(建議)
|
||||||
|
- [V ] B. 完整權限(SELECT, INSERT, UPDATE, DELETE, CREATE TABLE)
|
||||||
|
- [ ] C. 需要我協助建立資料表結構
|
||||||
|
- [ ] D. 資料表已存在,只需插入資料
|
||||||
|
|
||||||
|
### 5. 資料庫連線加密
|
||||||
|
是否需要使用 SSL/TLS 加密連線?
|
||||||
|
|
||||||
|
- [V ] A. 是,需要 SSL/TLS 加密
|
||||||
|
- [ ] B. 否,不需要加密
|
||||||
|
- [ ] C. 不確定,使用預設設定
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、資料儲存相關問題
|
||||||
|
|
||||||
|
### 6. 重複文章處理
|
||||||
|
當爬取到已存在的文章(相同 URL)時,應如何處理?
|
||||||
|
|
||||||
|
- [ ] A. 跳過,不更新資料庫
|
||||||
|
- [V ] B. 更新現有記錄(更新 crawled_at 時間戳)
|
||||||
|
- [V ] C. 更新現有記錄,並更新所有欄位(標題、內容等可能變更)
|
||||||
|
- [ ] D. 建立新記錄(允許重複)
|
||||||
|
|
||||||
|
### 7. 歷史資料保留
|
||||||
|
是否需要保留歷史爬取記錄?
|
||||||
|
|
||||||
|
- [ ] A. 是,保留所有歷史記錄(每次爬取都記錄)
|
||||||
|
- [ ] B. 否,只保留最新一次爬取的資料
|
||||||
|
- [V ] C. 保留最近 N 次爬取記錄(請指定次數:__30___)
|
||||||
|
|
||||||
|
### 8. 資料清理
|
||||||
|
是否需要定期清理舊資料?
|
||||||
|
|
||||||
|
- [V ] A. 是,自動刪除超過 N 天的資料(請指定天數:_30____)
|
||||||
|
- [ ] B. 否,保留所有資料
|
||||||
|
- [ ] C. 手動清理
|
||||||
|
|
||||||
|
### 9. CSV 檔案處理
|
||||||
|
CSV 檔案產生後,是否需要額外處理?
|
||||||
|
|
||||||
|
- [ ] A. 僅發送郵件,不額外處理
|
||||||
|
- [ ] B. 發送郵件後刪除 CSV 檔案
|
||||||
|
- [V ] C. 保留 CSV 檔案作為備份(保留最近 N 個檔案,請指定:_____)
|
||||||
|
- [ ] D. 上傳至雲端儲存(Google Drive、Dropbox、S3 等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、功能擴展相關問題
|
||||||
|
|
||||||
|
### 10. 資料查詢功能
|
||||||
|
是否需要提供資料查詢功能?
|
||||||
|
|
||||||
|
- [ ] A. 否,僅需爬取和儲存功能
|
||||||
|
- [ ] B. 是,需要簡單的查詢腳本(按日期、分類、標籤查詢)
|
||||||
|
- [V ] C. 是,需要 Web 介面查詢
|
||||||
|
- [ ] D. 是,需要 API 介面
|
||||||
|
|
||||||
|
### 11. 通知方式
|
||||||
|
除了郵件通知,是否需要其他通知方式?
|
||||||
|
|
||||||
|
- [V ] A. 僅需郵件通知
|
||||||
|
- [ ] B. 需要 Slack 通知
|
||||||
|
- [ ] C. 需要 Microsoft Teams 通知
|
||||||
|
- [ ] D. 需要 LINE 通知
|
||||||
|
- [ ] E. 需要其他方式(請說明):___________
|
||||||
|
|
||||||
|
### 12. 資料分析功能
|
||||||
|
是否需要資料分析功能?
|
||||||
|
|
||||||
|
- [ ] A. 否,僅需儲存原始資料
|
||||||
|
- [V ] B. 是,需要統計功能(文章數量、分類分布、作者統計等)
|
||||||
|
- [ ] C. 是,需要產生報表(週報、月報)
|
||||||
|
- [ ] D. 是,需要視覺化圖表
|
||||||
|
|
||||||
|
### 13. 增量爬取
|
||||||
|
是否需要支援增量爬取(僅爬取新文章)?
|
||||||
|
|
||||||
|
- [V ] A. 是,僅爬取新文章(依據 URL 或發布日期判斷)
|
||||||
|
- [ ] B. 否,每次完整爬取所有文章
|
||||||
|
- [ ] C. 是,但需要可設定爬取日期範圍
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、系統設定相關問題
|
||||||
|
|
||||||
|
### 14. 爬取頻率
|
||||||
|
除了每週一 08:00,是否需要其他爬取時間?
|
||||||
|
|
||||||
|
- [ ] A. 僅每週一 08:00
|
||||||
|
- [ ] B. 需要多個排程時間(請說明):____每天早上8:00_______
|
||||||
|
- [ ] C. 需要手動觸發功能
|
||||||
|
- [V ] D. 需要可設定的排程時間
|
||||||
|
|
||||||
|
### 15. 錯誤通知
|
||||||
|
當爬蟲執行失敗時,是否需要通知?
|
||||||
|
|
||||||
|
- [ ] A. 否,僅記錄日誌
|
||||||
|
- [V ] B. 是,發送錯誤通知郵件
|
||||||
|
- [ ] C. 是,發送錯誤通知到其他平台(請指定):___________
|
||||||
|
|
||||||
|
### 16. 日誌記錄
|
||||||
|
日誌記錄的需求?
|
||||||
|
|
||||||
|
- [ ] A. 僅記錄到控制台
|
||||||
|
- [ ] B. 記錄到檔案(請指定路徑:_____)
|
||||||
|
- [V ] C. 記錄到資料庫
|
||||||
|
- [ ] D. 記錄到日誌服務(如 ELK、Splunk)
|
||||||
|
|
||||||
|
### 17. 效能設定
|
||||||
|
爬取時的效能設定偏好?
|
||||||
|
|
||||||
|
- [ ] A. 快速爬取(較短延遲,可能對伺服器造成較大負擔)
|
||||||
|
- [ ] B. 平衡模式(適中延遲,預設 1 秒)
|
||||||
|
- [V ] C. 保守模式(較長延遲,避免對伺服器造成負擔)
|
||||||
|
- [ ] D. 可設定延遲時間(請指定秒數:_____)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、資料格式相關問題
|
||||||
|
|
||||||
|
### 18. 日期格式
|
||||||
|
資料庫中日期欄位的格式偏好?
|
||||||
|
|
||||||
|
- [V ] A. DATETIME / TIMESTAMP(資料庫原生格式)
|
||||||
|
- [ ] B. VARCHAR(字串格式,如 '2024-12-22 08:00:00')
|
||||||
|
- [ ] C. DATE(僅日期,不含時間)
|
||||||
|
- [ ] D. 其他(請說明):___________
|
||||||
|
|
||||||
|
### 19. 標籤儲存方式
|
||||||
|
標籤的儲存方式偏好?
|
||||||
|
|
||||||
|
- [ ] A. 正規化設計(tags 表 + article_tags 關聯表,建議)
|
||||||
|
- [V ] B. 非正規化設計(articles 表中以逗號分隔字串儲存)
|
||||||
|
- [ ] C. JSON 格式儲存(如資料庫支援)
|
||||||
|
|
||||||
|
### 20. 內容欄位長度
|
||||||
|
文章內容欄位的長度限制?
|
||||||
|
|
||||||
|
- [V ] A. TEXT 類型(無長度限制)
|
||||||
|
- [ ] B. VARCHAR(N)(請指定最大長度:_____)
|
||||||
|
- [ ] C. MEDIUMTEXT / LONGTEXT(依資料庫而定)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、其他問題
|
||||||
|
|
||||||
|
### 21. 專案部署環境
|
||||||
|
系統將部署在何處?
|
||||||
|
|
||||||
|
- [ ] A. 本地電腦(Windows)
|
||||||
|
- [ ] B. 本地電腦(Linux/Mac)
|
||||||
|
- [ ] C. GitHub Actions(雲端)
|
||||||
|
- [V ] D. 自有伺服器
|
||||||
|
- [ ] E. 雲端服務(AWS、Azure、GCP 等)
|
||||||
|
|
||||||
|
### 22. 資料備份需求
|
||||||
|
是否需要自動備份功能?
|
||||||
|
|
||||||
|
- [V ] A. 否,手動備份即可
|
||||||
|
- [ ] B. 是,定期自動備份資料庫
|
||||||
|
- [ ] C. 是,備份資料庫和 CSV 檔案
|
||||||
|
- [ ] D. 是,備份到雲端儲存
|
||||||
|
|
||||||
|
### 23. 多語言支援
|
||||||
|
是否需要支援多語言?
|
||||||
|
|
||||||
|
- [ ] A. 否,僅支援繁體中文
|
||||||
|
- [V ] B. 是,需要支援多語言(請指定語言:_繁體中文/英文/韓文_________)
|
||||||
|
|
||||||
|
### 24. 資料匯出格式
|
||||||
|
除了 CSV,是否需要其他匯出格式?
|
||||||
|
|
||||||
|
- [ ] A. 僅需 CSV
|
||||||
|
- [ ] B. 需要 JSON 格式
|
||||||
|
- [V ] C. 需要 Excel 格式
|
||||||
|
- [ ] D. 需要多種格式(請指定):___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 請提供以下資訊
|
||||||
|
|
||||||
|
### 資料庫連線資訊(請填寫)
|
||||||
|
|
||||||
|
```
|
||||||
|
資料庫類型:___________
|
||||||
|
主機位址(Host):___________
|
||||||
|
埠號(Port):___________
|
||||||
|
資料庫名稱(Database):___________
|
||||||
|
使用者名稱(Username):___________
|
||||||
|
密碼(Password):___________
|
||||||
|
連線字串(如有):___________
|
||||||
|
其他說明:___________
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**請將回答後的檔案回傳,或直接在文件中標記選項。**
|
||||||
|
|
||||||
|
|
||||||
71
測試Web服務.md
Normal file
71
測試Web服務.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Web 服務測試與除錯指南
|
||||||
|
|
||||||
|
## 問題診斷
|
||||||
|
|
||||||
|
### 1. 無法看到文章列表
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- Web 服務未啟動
|
||||||
|
- API 返回空資料
|
||||||
|
- 前端 JavaScript 錯誤
|
||||||
|
- 資料庫連線問題
|
||||||
|
|
||||||
|
**檢查步驟**:
|
||||||
|
|
||||||
|
1. **確認 Web 服務是否運行**:
|
||||||
|
```bash
|
||||||
|
# 檢查服務是否在運行
|
||||||
|
curl http://localhost:5000/api/articles
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **檢查資料庫中是否有資料**:
|
||||||
|
```bash
|
||||||
|
python check_db_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **檢查瀏覽器控制台**:
|
||||||
|
- 按 F12 開啟開發者工具
|
||||||
|
- 查看 Console 標籤的錯誤訊息
|
||||||
|
- 查看 Network 標籤,檢查 API 請求是否成功
|
||||||
|
|
||||||
|
### 2. 常見錯誤
|
||||||
|
|
||||||
|
#### "Access to storage is not allowed from this context"
|
||||||
|
這是瀏覽器的安全限制,通常不影響功能。如果影響,可以:
|
||||||
|
- 使用 `http://localhost:5000` 而不是 `file://` 協議
|
||||||
|
- 檢查瀏覽器是否允許 localStorage
|
||||||
|
|
||||||
|
#### API 返回空資料
|
||||||
|
檢查:
|
||||||
|
- 資料庫中是否有資料
|
||||||
|
- 資料庫連線是否正常
|
||||||
|
- SQL 查詢是否正確
|
||||||
|
|
||||||
|
## 快速修復
|
||||||
|
|
||||||
|
### 重新啟動 Web 服務
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止現有服務(Ctrl+C)
|
||||||
|
# 然後重新啟動
|
||||||
|
python web_app.py
|
||||||
|
# 或
|
||||||
|
python run_crawler.py --web
|
||||||
|
```
|
||||||
|
|
||||||
|
### 檢查 API 是否正常
|
||||||
|
|
||||||
|
在瀏覽器中訪問:
|
||||||
|
- http://localhost:5000/api/articles
|
||||||
|
- http://localhost:5000/api/statistics
|
||||||
|
|
||||||
|
應該看到 JSON 格式的資料。
|
||||||
|
|
||||||
|
### 檢查資料庫
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python check_db_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
應該顯示資料庫中的文章數量。
|
||||||
|
|
||||||
34
若瑄資安規則.md
Normal file
34
若瑄資安規則.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
你是一位資深全端工程師,請根據目前專案的檔案結構與程式內容,簡述此專案的整體狀態。
|
||||||
|
重點請對照以下檢核項目,逐項說明是否存在與其狀況:
|
||||||
|
|
||||||
|
- 專案結構與依賴檢查
|
||||||
|
1. 是否有入口檔案(如 app.py、main.js、server.js)
|
||||||
|
2. 是否有明確的專案結構(app、routes、static、templates、src 等)
|
||||||
|
3. 是否有 requirements.txt 或 package.json
|
||||||
|
4. 是否可看出使用框架(Flask、FastAPI、Express、Next.js…)
|
||||||
|
5. 是否包含 README.md 且有安裝與啟動說明
|
||||||
|
6. 無多餘或不安全的依賴套件
|
||||||
|
7. 監聽的 port 號碼、主機位址並列出在哪個檔案出現(例如 127.0.0.1:3000、localhost:5000、0.0.0.0:8000…,從環境變數讀取)
|
||||||
|
|
||||||
|
|
||||||
|
- 安全性與環境變數檢核
|
||||||
|
1. 是否存在 .env 或 .env.example
|
||||||
|
2. 是否有 .gitignore 且內容正確(排除 .env、__pycache__、node_modules、logs 等)
|
||||||
|
3. 是否有資料庫連線設定(DB_HOST、SQLAlchemy、Prisma 等)
|
||||||
|
4. DB 連線字串來自 `.env`,無硬編碼敏感資訊(API_KEY、DB 密碼等)
|
||||||
|
5. 使用者輸入有防 SQL Injection / XSS 機制
|
||||||
|
6. 其他明顯缺漏或安全疑慮
|
||||||
|
|
||||||
|
- 程式品質與可維護性
|
||||||
|
1. 錯誤處理(try/except / middleware)完善
|
||||||
|
|
||||||
|
|
||||||
|
- 請用條列方式輸出,例如:
|
||||||
|
- 專案結構與依賴檢查:
|
||||||
|
- ✅ 1. 有 app.py 作為入口
|
||||||
|
- ❌ 7. 無 README.md
|
||||||
|
- 安全性與環境變數檢核:
|
||||||
|
- ❌ 1. 無 .env 檔案
|
||||||
|
- 依據上述的檢核結果給予分數,總分100分
|
||||||
|
- 先列出即可,不要修改程式碼
|
||||||
|
- 將以上檢核項目列出後,產生 Check.md 檔案,不要再產生其他測試文檔
|
||||||
180
資料庫設定說明.md
Normal file
180
資料庫設定說明.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# 資料庫設定說明
|
||||||
|
|
||||||
|
## 資料庫連線資訊
|
||||||
|
|
||||||
|
- **資料庫類型**: MySQL / MariaDB
|
||||||
|
- **主機位址**: mysql.theaken.com
|
||||||
|
- **埠號**: 33306
|
||||||
|
- **資料庫名稱**: HBR_scraper
|
||||||
|
- **使用者名稱**: A101
|
||||||
|
- **密碼**: Aa123456
|
||||||
|
|
||||||
|
## 快速開始
|
||||||
|
|
||||||
|
### 1. 安裝依賴套件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 測試資料庫連線
|
||||||
|
|
||||||
|
執行測試腳本來驗證資料庫連線並建立必要的資料表:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python test_db_connection.py
|
||||||
|
```
|
||||||
|
|
||||||
|
此腳本會執行以下測試:
|
||||||
|
1. 測試基本資料庫連線
|
||||||
|
2. 建立 HBR_scraper 資料庫(如果不存在)
|
||||||
|
3. 測試連接到 HBR_scraper 資料庫
|
||||||
|
4. 建立資料表結構(articles, tags, article_tags)
|
||||||
|
5. 驗證資料表是否建立成功
|
||||||
|
|
||||||
|
### 3. 執行爬蟲
|
||||||
|
|
||||||
|
測試完成後,可以執行爬蟲,資料會自動儲存到資料庫:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd hbr_crawler
|
||||||
|
scrapy crawl hbr
|
||||||
|
```
|
||||||
|
|
||||||
|
## 資料表結構
|
||||||
|
|
||||||
|
### articles(文章主表)
|
||||||
|
|
||||||
|
| 欄位 | 類型 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | BIGINT | 主鍵,自動遞增 |
|
||||||
|
| title | VARCHAR(500) | 文章標題 |
|
||||||
|
| url | VARCHAR(1000) | 文章網址(唯一索引) |
|
||||||
|
| author | VARCHAR(200) | 作者名稱 |
|
||||||
|
| publish_date | DATETIME | 發布日期 |
|
||||||
|
| summary | TEXT | 文章摘要 |
|
||||||
|
| is_paywalled | TINYINT(1) | 是否為付費文章 |
|
||||||
|
| category | VARCHAR(100) | 文章分類 |
|
||||||
|
| content | TEXT | 文章內容 |
|
||||||
|
| created_at | TIMESTAMP | 建立時間 |
|
||||||
|
| updated_at | TIMESTAMP | 更新時間 |
|
||||||
|
| crawled_at | DATETIME | 爬取時間 |
|
||||||
|
|
||||||
|
### tags(標籤表)
|
||||||
|
|
||||||
|
| 欄位 | 類型 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | BIGINT | 主鍵,自動遞增 |
|
||||||
|
| name | VARCHAR(100) | 標籤名稱(唯一索引) |
|
||||||
|
| created_at | TIMESTAMP | 建立時間 |
|
||||||
|
|
||||||
|
### article_tags(文章標籤關聯表)
|
||||||
|
|
||||||
|
| 欄位 | 類型 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | BIGINT | 主鍵,自動遞增 |
|
||||||
|
| article_id | BIGINT | 文章 ID(外鍵) |
|
||||||
|
| tag_id | BIGINT | 標籤 ID(外鍵) |
|
||||||
|
| created_at | TIMESTAMP | 建立時間 |
|
||||||
|
|
||||||
|
## 資料處理邏輯
|
||||||
|
|
||||||
|
### 重複文章處理
|
||||||
|
|
||||||
|
- 系統會依據 URL 檢查文章是否已存在
|
||||||
|
- 如果文章已存在,會更新現有記錄(更新 `crawled_at` 時間戳和所有欄位)
|
||||||
|
- 如果文章不存在,會插入新記錄
|
||||||
|
|
||||||
|
### 標籤處理
|
||||||
|
|
||||||
|
- 標籤採用正規化設計(tags 表 + article_tags 關聯表)
|
||||||
|
- 系統會自動建立新標籤(如果不存在)
|
||||||
|
- 使用標籤快取機制,避免重複查詢資料庫
|
||||||
|
|
||||||
|
## 環境變數設定(可選)
|
||||||
|
|
||||||
|
如果需要透過環境變數設定資料庫連線資訊,可以設定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DB_HOST=mysql.theaken.com
|
||||||
|
export DB_PORT=33306
|
||||||
|
export DB_USER=A101
|
||||||
|
export DB_PASSWORD=Aa123456
|
||||||
|
export DB_NAME=HBR_scraper
|
||||||
|
```
|
||||||
|
|
||||||
|
系統會優先使用環境變數,如果沒有設定則使用 `settings.py` 中的預設值。
|
||||||
|
|
||||||
|
## 查詢資料範例
|
||||||
|
|
||||||
|
### 查詢所有文章
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM articles ORDER BY crawled_at DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查詢特定分類的文章
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM articles WHERE category = 'management' ORDER BY publish_date DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查詢付費文章
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM articles WHERE is_paywalled = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查詢文章及其標籤
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.title,
|
||||||
|
a.url,
|
||||||
|
GROUP_CONCAT(t.name) as tags
|
||||||
|
FROM articles a
|
||||||
|
LEFT JOIN article_tags at ON a.id = at.article_id
|
||||||
|
LEFT JOIN tags t ON at.tag_id = t.id
|
||||||
|
GROUP BY a.id, a.title, a.url;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查詢特定標籤的文章
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT DISTINCT a.*
|
||||||
|
FROM articles a
|
||||||
|
INNER JOIN article_tags at ON a.id = at.article_id
|
||||||
|
INNER JOIN tags t ON at.tag_id = t.id
|
||||||
|
WHERE t.name = '領導力';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 連線失敗
|
||||||
|
|
||||||
|
1. 檢查網路連線
|
||||||
|
2. 確認資料庫主機位址和埠號正確
|
||||||
|
3. 確認使用者名稱和密碼正確
|
||||||
|
4. 檢查防火牆設定
|
||||||
|
|
||||||
|
### 資料表建立失敗
|
||||||
|
|
||||||
|
1. 確認使用者有 CREATE TABLE 權限
|
||||||
|
2. 檢查 SQL 檔案路徑是否正確
|
||||||
|
3. 查看錯誤日誌了解詳細錯誤訊息
|
||||||
|
|
||||||
|
### 資料插入失敗
|
||||||
|
|
||||||
|
1. 檢查資料庫連線是否正常
|
||||||
|
2. 確認資料表結構是否正確建立
|
||||||
|
3. 檢查資料格式是否符合資料表定義
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
|
||||||
|
- 資料庫連線資訊已寫入 `settings.py`,請勿將此檔案提交到公開版本控制系統
|
||||||
|
- 建議使用環境變數或設定檔管理敏感資訊
|
||||||
|
- 定期備份資料庫
|
||||||
|
- 監控資料庫空間使用情況
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user