顯示具有 Linux 標籤的文章。 顯示所有文章
顯示具有 Linux 標籤的文章。 顯示所有文章

2026年8月18日 星期二

高 CP 值的 Debian Linux 微型電腦 MP520-20

前陣子在評估適合跑 OpenClaw 的機器時, 找到下面這塊開發板 MP520-20 :





這是我的 Mapleboard MP-510 的升級版, 性能強大許多 (價格近兩倍), 搭載了 Rockchip RK3588S 八核處理器 (4× Cortex-A76 @ 2.4GHz + 4× Cortex-A55 @ 1.8GHz), 8GB LPDDR4X 與 128GB NVMe SSD, 出廠預裝了中文環境的 Debian Linux, 產品定位主要是做為高效能的微型電腦 (Mini PC/Linux 工作站), 低功耗桌機, 或邊緣伺服器 (配備 26-pin 擴充 I/O 與 UART 介面).

此微型電腦非常適合跑 OpenClaw, 作為開源的個人 AI  代理 (Agent) 網關, CPU 運算力約為 Pi 4 的 3~4 倍, 8GB RAM 對於運行 OpenClaw 及相關工具 (如網頁爬蟲, 檔案讀寫, 自動化腳本) 來說綽綽有餘, 而內建 128GB NVMe SSD 高速低功耗, 很適合作為全年無休 (24/7) 的龍蝦伺服器, 性能上完全輾壓 Pi 4. 雖然 Pi 4 裸板較便宜, 但加購散熱外殼, 變壓器與 128GB 高速 MicroSD 後價差其實不大, 比起一體成型的 MP520-20 來說 CP 值不高. 

總之, 與 Pi 4 相比, MP520-20 強悍的 RK3588S (4大核A76 + 4小核A55) 與板載 128GB NVMe SSD 不僅解決了樹莓派 MicroSD 卡容易因頻繁讀寫損壞的痛點 (NVMe 隨機讀寫速度遠勝 MicroSD,  在 OpenClaw 頻繁讀寫本地記憶時會較順暢且不易讀寫錯誤); 兩者在執行網頁爬蟲, 無頭瀏覽器與多工 Agent 任務時的效能差距非常巨大. 

不過, MP520-20 的 GPU 為 ARM Mali-G610 MP4, 支援 OpenCL / Vulkan / OpenGL 但不支援 CUDA, 而主流的開源 LLM 生態如 vLLM 與 llama.cpp 等 GPU 加速高度依賴 NVIDIA CUDA, 因此 Mali GPU 派不上用場, 無法支援本地大模型推論, 只能使用 CPU 推論, 其 8 核 CPU 跑 llama.cpp 運行 1.5B~3B 的小型量化模型還可以, 跑 7B 模型生成速度會很慢 (約 4~6 tokens/s). 

如果用 MP520-20 來跑 OpenClaw 養龍蝦的話, 最好是將 MP520-20 作為 OpenClaw Agent 閘道與執行端, 大模型思考推理部分就串接雲端 API (例如 OpenAI, Claude, 或 Gemini), 如果用 CPU 去跑本地的小型量化模型效能不佳. 

2025年12月10日 星期三

在樹莓派 P3 A+ 虛擬環境下安裝 Streamlit 的空間不足問題

我的樹莓派 Pi 3A+ 主機由於記憶體僅 512MB, 最近將作業系統升級為 Trixie Lite 後因為 Selenium 與 Chronium 都會吃較多記憶體, 使得執行 Selenium 爬蟲程式時常會用光記憶體而導致爬取失敗, 看來只能用在執行靜態網頁爬蟲程式了. 但這樣一來又覺得沒有充分利用到這台主機, 想說 Lite 版適合當小型伺服器, 那就拿來跑 Streamlit 網站應用程式吧! 

首先建立一個虛擬環境 stremlit_venv, 然後在裡面安裝 Streamlit, 但是執行到一半, 到下載相依的 pyarrow 套件時就出現 OSError 28 錯誤而失敗 : 

pi@pi3aplus:~ $ python -m venv streamlit_venv     
pi@pi3aplus:~ $ source ~/streamlit_venv/bin/activate    
(streamlit_venv) pi@pi3aplus:~ $ pip install streamlit   
Collecting streamlit
  Downloading streamlit-1.52.1-py3-none-any.whl.metadata (9.8 kB)
Collecting altair!=5.4.0,!=5.4.1,<7,>=4.0 (from streamlit)
  Downloading altair-6.0.0-py3-none-any.whl.metadata (11 kB)
Collecting blinker<2,>=1.5.0 (from streamlit)
  Downloading blinker-1.9.0-py3-none-any.whl.metadata (1.6 kB)
Collecting cachetools<7,>=4.0 (from streamlit)
... (略) ...
Downloading jsonschema_specifications-2025.9.1-py3-none-any.whl (18 kB)
Downloading markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl (24 kB)
Downloading narwhals-2.13.0-py3-none-any.whl (426 kB)
Downloading packaging-25.0-py3-none-any.whl (66 kB)
Using cached pyarrow-22.0.0-cp313-cp313-manylinux_2_28_aarch64.whl (45.0 MB)
ERROR: Could not install packages due to an OSError: [Errno 28] 裝置上已無多餘空間

先用 df -h 檢查 SD 卡空間, 還有超大的 18GB 空間 : 

(streamlit_venv) pi@pi3aplus:~ $ df -h   
檔案系統        容量  已用  可用 已用% 掛載點
udev             74M     0   74M    0% /dev
tmpfs            84M  9.0M   75M   11% /run
/dev/mmcblk0p2   29G   11G   18G   38% /
tmpfs           512M     0  512M    0% /dev/shm
tmpfs           5.0M   12K  5.0M    1% /run/lock
tmpfs           1.0M     0  1.0M    0% /run/credentials/systemd-journald.service
tmpfs           209M  138M   71M   67% /tmp
/dev/mmcblk0p1  510M   66M  445M   13% /boot/firmware
tmpfs           1.0M     0  1.0M    0% /run/credentials/getty@tty1.service
tmpfs            42M   16K   42M    1% /run/user/1000

再用 df -i 檢查 inode 使用情形, 發現 inode 只用了 14%, 顯然不是 inode 不足 :

(streamlit_venv) pi@pi3aplus:~ $ df -i   
檔案系統        Inodes  I已用   I可用 I已用% 掛載點
udev             18807    467   18340     3% /dev
tmpfs            53266    827   52439     2% /run
/dev/mmcblk0p2 1890720 259286 1631434    14% /
tmpfs            53266      1   53265     1% /dev/shm
tmpfs            53266      5   53261     1% /run/lock
tmpfs             1024      1    1023     1% /run/credentials/systemd-journald.service
tmpfs          1048576   1673 1046903     1% /tmp
/dev/mmcblk0p1       0      0       0      - /boot/firmware
tmpfs             1024      1    1023     1% /run/credentials/getty@tty1.service
tmpfs            10653     41   10612     1% /run/user/1000

從 SD 卡還有 18GB, inode 使用率僅 14% 可知問題不是 inode 或 SD 卡容量不足, 而是記憶體的 /tmp 不足所致. 通常 /tmp 預設只切 DRAM (512MB) 的一半約 200MB, 但 Streamlit 的最大依賴套件 pyarrow-22.0.0 檔案高達 45.0 MB, 實際需求空間是檔案大小 2~3 倍, 安裝 意即安裝其 wheel 約需要 120–150 MB 的 /tmp 空間, 但目前 /tmp 只剩 71 MB 所以直接觸發 Errno 28. 

Gemini 的建議是在檔案系統中建立一個資料夾做為儲存 pip 下載安裝所需檔案過程中要用到的暫存空間, 然後將環境變數 TMPDIR 指向此資料夾以取代預設的 /tmp 記憶體, 且在用 pip install 安裝時加上 --no-cache-dir 參數, 安裝完即丟棄檔案 :

(streamlit_venv) pi@pi3aplus:~ $ mkdir ~/pip_tmp   
(streamlit_venv) pi@pi3aplus:~ $ TMPDIR=~/pip_tmp    
(streamlit_venv) pi@pi3aplus:~ $ pip install streamlit --no-cache-dir     
Collecting streamlit
  Downloading streamlit-1.52.1-py3-none-any.whl.metadata (9.8 kB)
Collecting altair!=5.4.0,!=5.4.1,<7,>=4.0 (from streamlit)
  Downloading altair-6.0.0-py3-none-any.whl.metadata (11 kB)
... () ...
Downloading narwhals-2.13.0-py3-none-any.whl (426 kB)
Downloading packaging-25.0-py3-none-any.whl (66 kB)
Downloading pyarrow-22.0.0-cp313-cp313-manylinux_2_28_aarch64.whl (45.0 MB)
   ━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━━━━━━━━━━━━ 20.2/45.0 MB 9.0 MB/s eta 0:00:03ERROR: Could not install packages due to an OSError: [Errno 28] 裝置上已無多餘空間

   ━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━━━━━━━━━━━━ 20.2/45.0 MB 9.0 MB/s eta 0:00:03

雖然設定了 TMPDIR, 但 pip 在下載檔案時仍然會先佔用 DRAM 的 /tmp 而失敗, 我轉而詢問 ChatGPT, 它建議除了 TMPDIR 外, 還有 TMP 與 TEMP 這兩個環境變數也要設定為指向下載暫存資料夾 (此處改用 disk_tmp, 上面那個 pip_tmp 可以刪除) : 

(streamlit_venv) pi@pi3aplus:~ $ mkdir -p ~/disk_tmp  
(streamlit_venv) pi@pi3aplus:~ $ TMPDIR=~/disk_tmp 
(streamlit_venv) pi@pi3aplus:~ $ TMP=~/disk_tmp 
(streamlit_venv) pi@pi3aplus:~ $ TEMP=~/disk_tmp 
(streamlit_venv) pi@pi3aplus:~ $ pip install streamlit --no-cache-dir 
Collecting streamlit
  Downloading streamlit-1.52.1-py3-none-any.whl.metadata (9.8 kB)
Collecting altair!=5.4.0,!=5.4.1,<7,>=4.0 (from streamlit)
  Downloading altair-6.0.0-py3-none-any.whl.metadata (11 kB)
Collecting blinker<2,>=1.5.0 (from streamlit)
  Downloading blinker-1.9.0-py3-none-any.whl.metadata (1.6 kB)
Collecting cachetools<7,>=4.0 (from streamlit)
  Downloading cachetools-6.2.2-py3-none-any.whl.metadata (5.6 kB)
Collecting click<9,>=7.0 (from streamlit)
  Downloading click-8.3.1-py3-none-any.whl.metadata (2.6 kB)
Collecting numpy<3,>=1.23 (from streamlit)
  Downloading numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl.metadata (62 kB)
Collecting packaging>=20 (from streamlit)
  Downloading packaging-25.0-py3-none-any.whl.metadata (3.3 kB)
Collecting pandas<3,>=1.4.0 (from streamlit)
  Downloading pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl.metadata (91 kB)
Collecting pillow<13,>=7.1.0 (from streamlit)
  Downloading pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl.metadata (8.8 kB)
Collecting protobuf<7,>=3.20 (from streamlit)
  Downloading protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl.metadata (593 bytes)
Collecting pyarrow>=7.0 (from streamlit)
  Downloading pyarrow-22.0.0-cp313-cp313-manylinux_2_28_aarch64.whl.metadata (3.2 kB)
Collecting requests<3,>=2.27 (from streamlit)
  Downloading requests-2.32.5-py3-none-any.whl.metadata (4.9 kB)
Collecting tenacity<10,>=8.1.0 (from streamlit)
  Downloading tenacity-9.1.2-py3-none-any.whl.metadata (1.2 kB)
Collecting toml<2,>=0.10.1 (from streamlit)
  Downloading toml-0.10.2-py2.py3-none-any.whl.metadata (7.1 kB)
Collecting typing-extensions<5,>=4.4.0 (from streamlit)
  Downloading typing_extensions-4.15.0-py3-none-any.whl.metadata (3.3 kB)
Collecting watchdog<7,>=2.1.5 (from streamlit)
  Downloading watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl.metadata (44 kB)
Collecting gitpython!=3.1.19,<4,>=3.0.7 (from streamlit)
  Downloading gitpython-3.1.45-py3-none-any.whl.metadata (13 kB)
Collecting pydeck<1,>=0.8.0b4 (from streamlit)
  Downloading pydeck-0.9.1-py2.py3-none-any.whl.metadata (4.1 kB)
Collecting tornado!=6.5.0,<7,>=6.0.3 (from streamlit)
  Downloading tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.metadata (2.8 kB)
Collecting jinja2 (from altair!=5.4.0,!=5.4.1,<7,>=4.0->streamlit)
  Downloading jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB)
Collecting jsonschema>=3.0 (from altair!=5.4.0,!=5.4.1,<7,>=4.0->streamlit)
  Downloading jsonschema-4.25.1-py3-none-any.whl.metadata (7.6 kB)
Collecting narwhals>=1.27.1 (from altair!=5.4.0,!=5.4.1,<7,>=4.0->streamlit)
  Downloading narwhals-2.13.0-py3-none-any.whl.metadata (12 kB)
Collecting gitdb<5,>=4.0.1 (from gitpython!=3.1.19,<4,>=3.0.7->streamlit)
  Downloading gitdb-4.0.12-py3-none-any.whl.metadata (1.2 kB)
Collecting smmap<6,>=3.0.1 (from gitdb<5,>=4.0.1->gitpython!=3.1.19,<4,>=3.0.7->streamlit)
  Downloading smmap-5.0.2-py3-none-any.whl.metadata (4.3 kB)
Collecting python-dateutil>=2.8.2 (from pandas<3,>=1.4.0->streamlit)
  Downloading python_dateutil-2.9.0.post0-py2.py3-none-any.whl.metadata (8.4 kB)
Collecting pytz>=2020.1 (from pandas<3,>=1.4.0->streamlit)
  Downloading pytz-2025.2-py2.py3-none-any.whl.metadata (22 kB)
Collecting tzdata>=2022.7 (from pandas<3,>=1.4.0->streamlit)
  Downloading tzdata-2025.2-py2.py3-none-any.whl.metadata (1.4 kB)
Collecting charset_normalizer<4,>=2 (from requests<3,>=2.27->streamlit)
  Downloading charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl.metadata (37 kB)
Collecting idna<4,>=2.5 (from requests<3,>=2.27->streamlit)
  Downloading idna-3.11-py3-none-any.whl.metadata (8.4 kB)
Collecting urllib3<3,>=1.21.1 (from requests<3,>=2.27->streamlit)
  Downloading urllib3-2.6.1-py3-none-any.whl.metadata (6.6 kB)
Collecting certifi>=2017.4.17 (from requests<3,>=2.27->streamlit)
  Downloading certifi-2025.11.12-py3-none-any.whl.metadata (2.5 kB)
Collecting MarkupSafe>=2.0 (from jinja2->altair!=5.4.0,!=5.4.1,<7,>=4.0->streamlit)
  Downloading markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl.metadata (2.7 kB)
Collecting attrs>=22.2.0 (from jsonschema>=3.0->altair!=5.4.0,!=5.4.1,<7,>=4.0->streamlit)
  Downloading attrs-25.4.0-py3-none-any.whl.metadata (10 kB)
Collecting jsonschema-specifications>=2023.03.6 (from jsonschema>=3.0->altair!=5.4.0,!=5.4.1,<7,>=4.0->streamlit)
  Downloading jsonschema_specifications-2025.9.1-py3-none-any.whl.metadata (2.9 kB)
Collecting referencing>=0.28.4 (from jsonschema>=3.0->altair!=5.4.0,!=5.4.1,<7,>=4.0->streamlit)
  Downloading referencing-0.37.0-py3-none-any.whl.metadata (2.8 kB)
Collecting rpds-py>=0.7.1 (from jsonschema>=3.0->altair!=5.4.0,!=5.4.1,<7,>=4.0->streamlit)
  Downloading rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.metadata (4.1 kB)
Collecting six>=1.5 (from python-dateutil>=2.8.2->pandas<3,>=1.4.0->streamlit)
  Downloading six-1.17.0-py2.py3-none-any.whl.metadata (1.7 kB)
Downloading streamlit-1.52.1-py3-none-any.whl (9.0 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 9.0/9.0 MB 9.4 MB/s eta 0:00:00
Downloading altair-6.0.0-py3-none-any.whl (795 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 795.4/795.4 kB 11.4 MB/s eta 0:00:00
Downloading blinker-1.9.0-py3-none-any.whl (8.5 kB)
Downloading cachetools-6.2.2-py3-none-any.whl (11 kB)
Downloading click-8.3.1-py3-none-any.whl (108 kB)
Downloading gitpython-3.1.45-py3-none-any.whl (208 kB)
Downloading gitdb-4.0.12-py3-none-any.whl (62 kB)
Downloading numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl (14.2 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 14.2/14.2 MB 9.6 MB/s eta 0:00:00
Downloading pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl (11.7 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 11.7/11.7 MB 7.2 MB/s eta 0:00:00
Downloading pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl (6.3 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6.3/6.3 MB 10.1 MB/s eta 0:00:00
Downloading protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl (324 kB)
Downloading pydeck-0.9.1-py2.py3-none-any.whl (6.9 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6.9/6.9 MB 10.1 MB/s eta 0:00:00
Downloading requests-2.32.5-py3-none-any.whl (64 kB)
Downloading charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl (147 kB)
Downloading idna-3.11-py3-none-any.whl (71 kB)
Downloading smmap-5.0.2-py3-none-any.whl (24 kB)
Downloading tenacity-9.1.2-py3-none-any.whl (28 kB)
Downloading toml-0.10.2-py2.py3-none-any.whl (16 kB)
Downloading tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (444 kB)
Downloading typing_extensions-4.15.0-py3-none-any.whl (44 kB)
Downloading urllib3-2.6.1-py3-none-any.whl (131 kB)
Downloading watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl (79 kB)
Downloading certifi-2025.11.12-py3-none-any.whl (159 kB)
Downloading jinja2-3.1.6-py3-none-any.whl (134 kB)
Downloading jsonschema-4.25.1-py3-none-any.whl (90 kB)
Downloading attrs-25.4.0-py3-none-any.whl (67 kB)
Downloading jsonschema_specifications-2025.9.1-py3-none-any.whl (18 kB)
Downloading markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl (24 kB)
Downloading narwhals-2.13.0-py3-none-any.whl (426 kB)
Downloading packaging-25.0-py3-none-any.whl (66 kB)
Downloading pyarrow-22.0.0-cp313-cp313-manylinux_2_28_aarch64.whl (45.0 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 45.0/45.0 MB 9.5 MB/s eta 0:00:00
Downloading python_dateutil-2.9.0.post0-py2.py3-none-any.whl (229 kB)
Downloading pytz-2025.2-py2.py3-none-any.whl (509 kB)
Downloading referencing-0.37.0-py3-none-any.whl (26 kB)
Downloading rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (389 kB)
Downloading six-1.17.0-py2.py3-none-any.whl (11 kB)
Downloading tzdata-2025.2-py2.py3-none-any.whl (347 kB)
Installing collected packages: pytz, watchdog, urllib3, tzdata, typing-extensions, tornado, toml, tenacity, smmap, six, rpds-py, pyarrow, protobuf, pillow, packaging, numpy, narwhals, MarkupSafe, idna, click, charset_normalizer, certifi, cachetools, blinker, attrs, requests, referencing, python-dateutil, jinja2, gitdb, pydeck, pandas, jsonschema-specifications, gitpython, jsonschema, altair, streamlit
Successfully installed MarkupSafe-3.0.3 altair-6.0.0 attrs-25.4.0 blinker-1.9.0 cachetools-6.2.2 certifi-2025.11.12 charset_normalizer-3.4.4 click-8.3.1 gitdb-4.0.12 gitpython-3.1.45 idna-3.11 jinja2-3.1.6 jsonschema-4.25.1 jsonschema-specifications-2025.9.1 narwhals-2.13.0 numpy-2.3.5 packaging-25.0 pandas-2.3.3 pillow-12.0.0 protobuf-6.33.2 pyarrow-22.0.0 pydeck-0.9.1 python-dateutil-2.9.0.post0 pytz-2025.2 referencing-0.37.0 requests-2.32.5 rpds-py-0.30.0 six-1.17.0 smmap-5.0.2 streamlit-1.52.1 tenacity-9.1.2 toml-0.10.2 tornado-6.5.2 typing-extensions-4.15.0 tzdata-2025.2 urllib3-2.6.1 watchdog-6.0.0
(streamlit_venv) pi@pi3aplus:~ $

哈哈! 果然成功了! 

原來使用 pip install 套件時並非只有 pip 在工作, 雖然 pip 會遵守規範將下載之檔案暫存在 TMPDIR, 但第三方依賴套件例如 pyarrow 或 C++ 擴展程式可能會用到編譯器或建構腳本, 但依賴套件的編譯器或腳本不一定會遵循, 它們可能使用 TMP 或 TEMP 環境變數, 當這些依賴程式找不到 TMP 或 TEMP 就會 fallback 到 DRAM 的 /tmp, 結果就可能因空間不足而失敗, 所以最妥當的方法就是將 TMP 與 TEMP 與 TMPDIR 一樣指向暫存資料夾, 這樣就幾乎把大部分跑回 /tmp 的可能性都堵死. 這個設定其實就是欺騙 pip, 讓它誤以為擁有 18GB 的可用暫存空間. 

測試是否可載入 streamlit : 

(streamlit_venv) pi@pi3aplus:~ $ python  
Python 3.13.5 (main, Jun 25 2025, 18:55:22) [GCC 14.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import streamlit   
>>> streamlit.__version__   
'1.52.1'

但上面的設定只是暫時的, 當終端機關閉後就消失了. 對於 Pi 3A+ 這種只有 512MB 記憶體的主機而言, 以後每次安裝大一點的套件還是會遇到同樣問題. 如果要徹底解決, 就必須修改 Shell 啟動檔案 ~/.bashrc, 用 export 指令設定這三個環境變數指向暫存資料夾 ~/disk_tmp :

用 nano 開啟Shell 啟動檔 ~/.bashrc : 

pi@pi3aplus:~ $ nano ~/.bashrc    

把下列三個 export 指令寫在檔尾 (較安全) : 

export TMPDIR=~/disk_tmp
export TMP=~/disk_tmp
export TEMP=~/disk_tmp

按 Ctrl+O 存檔後按 Ctrl+X 退出 nano, 用 source 指令叫 Shell 重新讀取啟動檔 :

pi@pi3aplus:~ $ source ~/.bashrc   

用 echo 指令驗證此三個環境變數是否有設定進去 : 

pi@pi3aplus:~ $ echo $TMPDIR  
/home/pi/disk_tmp
pi@pi3aplus:~ $ echo $TMP  
/home/pi/disk_tmp
pi@pi3aplus:~ $ echo $TEMP  
/home/pi/disk_tmp

這樣以後每次開啟終端機進虛擬環境用 pip 安裝套件時就不會再遇到 OSError: [Errno 28] 暫存區記憶體空間不足的錯誤了. 

2025年10月19日 星期日

Mapleboard MP510-50 測試 (四十三) : HTTP API 函式執行平台 (7)

這幾天為了給市圖爬蟲程式改版 (爬取結果儲存於資料庫), 將架在 Mapleboard 與 render.com 上面 serverless 平台添加了 SQLite 資料庫線上管理功能, 本篇旨在紀錄此新增功能. 關於 SQLite 資料庫操作參考 :


在之前的 serverless 專案中, 利用一個 SQLite 資料庫 serverless.db 來儲存各個非系統函式模組被呼叫的次數, 資料表名稱為 call_stats. 現在想用這資料庫來儲存應用函式的資料, 例如圖書館爬蟲之爬取結果, 所以需要一組系統函式來進行資料表的線上管理, 為此我在首頁函式列表底下添加了一個 "資料表列表" 超連結至 /function/list_tables : 



所需模組如下 :
  1. list_tables.py :
    資料表管理之首頁, 以表格顯示非系統資料表, 利用超連結進行顯示 schema, 紀錄, 執行 SQL, 以及刪除資料表等操作. 
  2. add_table.py :
    根據 Schema 新增一個資料表.
  3. drop_table.py :
    刪除指定之資料表.
  4. view_table.py :
    以分頁方式顯示指定之資料表內容.
  5. show_shema.py :
    顯示指定資料表結構.
  6. export_table.py :
    輸出指定資料表內容為 csv 檔. 
  7. execute_sql.py :
    執行 SQL 指令. 
我透過 ChatGPT 協助快速生成上述 7 個資料庫操作的程式碼, 然後依據需求進行微幅調整. 在這過程中也發現了 edit_function.py 的臭蟲, 由於線上操作需要顯示較複雜之 HTML 碼, 原本的 edit_function.py 頁面會出現如下版面崩潰現象 : 




解決之道是使用 html.escape 函式來跳脫 HTML 碼 (例如將 < 字元轉成 &lt;). 注意, 以前 Flask 有實作 escape() 函式, 但新版因 Python 標準模組 html 模組已內建而移除. 修改後的 edit_function.py 程式碼如下 : 

# edit_function.py
# 模組名稱也可更改 -> 相當於新增模組
from flask import render_template_string
import os
from html import escape

def main(request, **kwargs):
    module_name=request.args.get('module_name', '')  # 取得模組名稱
    if not module_name:  # 檢查有無傳入模組名稱
        return '請指定要編輯的模組名稱,格式 ?module_name=hello'
    filename=f'./functions/{module_name}.py'
    if not os.path.isfile(filename):
        return f'找不到函式檔案:{module_name}'
    with open(filename, 'r', encoding='utf-8') as f: 
        content=f.read()  # 讀取模組內容
        content=escape(content)
    html=f'''
    <h2>編輯函式模組:/functions/{module_name}.py</h2>
    <form method="POST" action="/function/update_function">
        <label>模組名稱(請用英數字,不含副檔名 .py):</label><br>    
        <input type="text" name="module_name" size="50" value="{module_name}"><br><br>
        <label>模組內容(請輸入合語法的 Python 程式碼):</label><br>        
        <textarea id="code" name="code" rows="20" cols="100" style="font-family:monospace;">{content}</textarea><br><br>
        <button type="button" onclick="location.href='/function/list_functions'">取消</button>
        <button type="button" onclick="document.getElementById('code').value = '';">清除</button>
        <button type="submit">更新</button>
    </form>
    '''
    return render_template_string(html)

此處改用 flask.render_template_string() 函式來渲染 HTML 程式碼.

以下是經過修正後的程式碼 :


1. list_tables.py :

此模組為資料表管理之首頁, 以表格顯示所有非系統資料表, 利用超連結進行顯示 schema, 紀錄, 執行 SQL, 以及刪除資料表等操作.

# list_tables.py
import os
import sqlite3

def main(request, **kwargs):    
    protected=[]  # 被保護的資料名稱清單 (不顯示)    
    DB_PATH='./serverless.db'  # 資料庫路徑
    # 檢查資料庫檔案是否存在
    if not os.path.exists(DB_PATH):
        html='''
            <p>資料庫檔案 serverless.db 不存在!
            <a href="/function/list_functions">函式列表</a></p>
            '''
        return html
    # 連線資料庫
    try:
        conn=sqlite3.connect(DB_PATH)
        cur=conn.cursor()
        # 取得所有非系統資料表
        cur.execute("""
            SELECT name FROM sqlite_master WHERE type='table'
            AND name NOT LIKE 'sqlite_%' ORDER BY name;
            """)
        rows=cur.fetchall()
        tables=[r[0] for r in rows]
        html='<h2>資料表列表</h2>'
        html += '<table border="1" cellspacing="0" cellpadding="6" '+\
                'style="border-collapse: collapse;">'
        html += '<tr><th>資料表名稱</th><th>記錄筆數</th><th>Schema</th>' +\
                '<th>檢視記錄</th><th>匯出</th><th>刪除</th></tr>'
        if not tables:
            html += '<tr><td colspan="6">目前無任何資料表</td></tr>'
        else:  # 遍歷所有非系統資料表
            for table in tables:
                if table in protected:  # 不顯示被保護資料表                    
                    continue                
                try:  # 取得該資料表的記錄筆數 (若失敗則顯示 '-')
                    cur2=conn.cursor()
                    cur2.execute(f"SELECT COUNT(*) FROM \"{table}\";")
                    cnt=cur2.fetchone()[0]
                except Exception:  # 資料表無紀錄
                    cnt='-'
                html += '<tr>'
                html += f'<td>{table}</td>'
                html += f'<td align="right">{cnt}</td>'
                html += f'<td><a href="/function/show_schema?table={table}">顯示</a></td>'
                html += f'<td><a href="/function/view_table?table={table}">檢視</a></td>'
                html += f'<td><a href="/function/export_table?table={table}">CSV</a></td>'
                html += f'<td><a href="/function/drop_table?table={table}" onclick=' +\
                        f'"return confirm(\'確定要刪除資料表 {table} 嗎?\')">刪除</a></td>'
                html += '</tr>'
        html += '</table>'
        # 管理連結:新增資料表、回到主頁、登出等
        html += '<br><a href="/function/add_table">新增資料表</a> '
        html += '<a href="/function/execute_sql">執行 SQL</a> '
        html += '<a href="/function/list_functions">返回函式列表</a> '
        html += '<a href="/logout">登出</a>'
        conn.close()
        return html
    except sqlite3.Error as e:
        return f'<p>連線資料庫失敗 {str(e)}</p>'
    finally:
        if 'conn' in locals():
            conn.close()    

此處 protected 用來列舉系統資料表, 避免在操作中被誤改, 目前僅統計呼叫次數 call_stats 資料表而已, 為了測試方便先暫時維持空串列, 測試完再把 call_stats 加入串列中. 




所有資料庫操作的功能都在此網頁中. 


2. add_table.py :

此模組根據指定之 Schema 新增一個資料表.

# add_table.py
import os
import sqlite3
from flask import request

def main(request, **kwargs):
    DB_PATH='./serverless.db'
    # 還沒送出表單 : 顯示建立表單頁面
    if request.method == 'GET':
        html=(
            "<h2>新增資料表</h2>"
            "<form method='post' action='/function/add_table'>"
            "<label>資料表名稱:</label><br>"
            "<input type='text' name='table' required><br><br>"
            "<label>欄位定義 (Schema, 以逗號分隔):</label><br>"
            "<textarea name='schema' rows='4' cols='70' "
            "placeholder='例如:id INTEGER PRIMARY KEY, name TEXT, age INTEGER, height REAL' "
            "required></textarea><br><br>"
            "<input type='submit' value='建立資料表'>"
            "</form>"
            "<a href='/function/list_tables'>返回資料表列表</a>"
            )
        return html
    # 處理表單送出
    if request.method == 'POST':
        table=request.form.get('table', '').strip()
        schema=request.form.get('schema', '').strip()
        if not table or not schema:
            return '<p>請輸入資料表名稱與欄位定義!<a href="/function/add_table">返回</a></p>'
        if not os.path.exists(DB_PATH):
            return '''
                <p>資料庫檔案 serverless.db 不存在!
                <a href="/function/list_tables">返回</a></p>
                '''
        try: # 建立資料表
            conn=sqlite3.connect(DB_PATH)
            cur=conn.cursor()
            cur.execute(f'CREATE TABLE IF NOT EXISTS "{table}" ({schema});')
            conn.commit()
            conn.close()
            return f'''
                <p>資料表 <b>{table}</b> 建立成功!</p>
                <a href="/function/list_tables">返回資料表列表</a>
                '''
        except sqlite3.Error as e:
            return f'<p>建立資料表失敗:{str(e)} <a href="/function/add_table">返回</a></p>'
        finally:
            if 'conn' in locals():
                conn.close()            

此模組集輸入表單與建立資料表兩個動作於一身, 依據 HTTP 方法來判定要做哪個動作, 剛進入 /function/add_table 時是 GET 方法, 這時會顯示輸入表單頁面 : 




此處以建立市圖借書預約資訊的資料表 ksml_books 為例, 輸入如下 Schema :

account PRIMARY KEY, borrow_books TEXT, reserve_books TEXT, updated_at TEXT 

按建立資料表按鈕會以 POST 方法再次請求 /function/add_table, 這時會執行 CREATE TABLE 指令建立資料表 : 




按 ksml_books 資料表 Schema 欄的顯示鈕會請求 /function/show_schema 模組, 其程式碼如下 :

# show_schema.py
import os
import sqlite3
from urllib.parse import parse_qs

def main(request, **kwargs):
    # 解析 URL 參數取得資料表名稱
    query=request.query_string.decode('utf-8')
    params=parse_qs(query)
    table=params.get('table', [''])[0]
    DB_PATH='./serverless.db'
    # 檢查資料庫是否存在
    if not os.path.exists(DB_PATH):
        return '''
        <p>資料庫檔案 serverless.db 不存在!
        <a href="/function/list_tables">返回資料表列表</a></p>
        '''
    # 檢查 URL 參數中是否有 table 名稱
    if not table:
        return '''
        <p>缺少 table 參數!
        <a href="/function/list_tables">返回資料表列表</a></p>
        '''
    try:
        conn=sqlite3.connect(DB_PATH)
        cur=conn.cursor()
        # 檢查資料表是否存在
        cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (table,))
        exists=cur.fetchone()
        if not exists:
            conn.close()
            return f'''
            <p>資料表 <b>{table}</b> 不存在!
            <a href="/function/list_tables">返回資料表列表</a></p>
            '''
        # 取得欄位資訊
        cur.execute(f"PRAGMA table_info('{table}');")
        columns=cur.fetchall()
        # 取得 Schema 的 SQL
        cur.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name=?;", (table,))
        create_sql=cur.fetchone()[0]
        conn.close()
        # 產生 HTML
        html=f'<h2>資料表 {table} 結構 (Schema)</h2>'
        html += '<table border="1" cellspacing="0" cellpadding="6" style="border-collapse: collapse;">'
        html += '<tr><th>#</th><th>欄位名稱</th><th>資料型別</th><th>非空</th><th>預設值</th><th>主鍵</th></tr>'
        for col in columns:
            cid, name, ctype, notnull, dflt_value, pk=col
            html += f'<tr>'
            html += f'<td>{cid}</td>'
            html += f'<td>{name}</td>'
            html += f'<td>{ctype}</td>'
            html += f'<td>{"✓" if notnull else ""}</td>'
            html += f'<td>{dflt_value if dflt_value is not None else ""}</td>'
            html += f'<td>{"✓" if pk else ""}</td>'
            html += '</tr>'
        html += '</table>'
        # 顯示 CREATE TABLE 語法
        html += '<h3>CREATE TABLE SQL 語法</h3>'
        html += f'<pre style="background-color:#f4f4f4;padding:10px;">{create_sql}</pre>'
        # 導覽連結
        html += '<a href="/function/list_tables">返回資料表列表</a>'
        return html
    except sqlite3.Error as e:
        return f'<p>讀取資料表結構失敗:{str(e)}</p>'
    finally:
        if 'conn' in locals():
            conn.close()

此程式使用 urllib.parse.parse_qs() 函式來解析請求之 URL 字串以取得 table 參數, 先用 PRAGMA table_info('{table}') 指令取得資料表的欄位資訊, 然後從 SQLite 的系統資料表 sqlite_master 的 sql 欄位中取得建立此資料表之 SQL 指令, 最後利用迴圈顯示欄位資訊與 Schema (SQL 指令) :




接下來實作 /function/execute_sql 並利用它來寫入資料表 :

# execute_sql.py
import os
import sqlite3
from flask import request
from html import escape   

def main(request, **kwargs):
    DB_PATH='./serverless.db'
    # 還沒送出表單 (GET):顯示 SQL 輸入頁面
    if request.method == 'GET':
        html=(
            "<h2>執行 SQL 指令</h2>"
            "<form method='post' action='/function/execute_sql'>"
            "<label>輸入 SQL 指令:</label><br>"
            "<textarea name='sql' rows='6' cols='80' "
            "placeholder='例如:SELECT * FROM call_stats;' required></textarea><br><br>"
            "<input type='submit' value='執行'>"
            "</form>"
            "<a href='/function/list_tables'>返回資料表列表</a>"
        )
        return html  # Removed conn.close() since conn is not defined
    # 處理表單送出 (POST):執行 SQL 指令
    if request.method == 'POST':
        sql=request.form.get('sql', '').strip()  # 取出 sql 欄位
        if not sql:
            return '<p>請輸入 SQL 指令!<a href="/function/execute_sql">返回</a></p>'
        if not os.path.exists(DB_PATH):
            return (
                '<p>資料庫檔案 serverless.db 不存在!'
                '<a href="/function/list_tables">返回</a></p>'
            )
        try:  # 執行 SQL 指令 
            conn=sqlite3.connect(DB_PATH)  # 建立連線與 cursor
            cur=conn.cursor()
            # SELECT 查詢 : 執行查詢後取出所有結果
            if sql.lower().startswith('select'):
                cur.execute(sql)
                rows=cur.fetchall()
                columns=[desc[0] for desc in cur.description]  # 取得欄位名稱
                # 將查詢結果轉成 HTML 表格
                if not rows:
                    result="<p>查無資料。</p>"
                else:
                    result='<table border="1" cellspacing="0" cellpadding="6" style="border-collapse: collapse;">'
                    result += "<tr>" + "".join(f"<th>{escape(col)}</th>" for col in columns) + "</tr>"
                    for row in rows: 
                        result += "<tr>" + "".join(f"<td>{escape(str(v))}</td>" for v in row) + "</tr>"
                    result += "</table>"
                conn.close()  # 關閉資料庫連線
                return (
                    f"<h2>SQL 查詢結果</h2>"
                    f"<p><b>指令:</b> {escape(sql)}</p>"
                    f"{result}"
                    "<br><a href='/function/execute_sql'>返回</a>"
                )
            else:  # 其他非 SELECT 指令 (INSERT, UPDATE, DELETE, DROP/CREATE TABLE) 
                cur.execute(sql)
                conn.commit()
                affected=cur.rowcount
                conn.close()
                return (
                    f"<p>SQL 指令執行成功!(影響 {affected} 筆紀錄)</p>"
                    f"<p><b>指令:</b> {escape(sql)}</p>"
                    "<a href='/function/execute_sql'>返回</a>"
                )
        except sqlite3.Error as e:
            conn.close()  # 關閉資料庫連線
            return f"<p>SQL 執行錯誤:{escape(str(e))}</p><a href='/function/execute_sql'>返回</a>"

此程式與 create_table.py 結構相同, 都是利用判別 HTTP 請求是 GET 或 POST 方法來決定回應方式, 若是 GET 方法就回應一個 SQL 輸入頁面; 若是 POST 方法就執行表單傳來的 SQL 指令 (分成 SELECT 與非 SELECT 指令兩類). 另外, 與上面修改 edit_function.py 的原因一樣, 為了避免網頁版面崩掉與 XSS 注入, 此程式使用 html.escape() 來跳脫 HTML 字元. 

第一次請求 /function/execute_sql 時是 GET 方法, 這時會顯示 SQL 輸入頁面, 測試用的 SQL 寫入指令例如 : 

INSERT OR REPLACE INTO ksml_books (account, borrow_books, reserve_books, updated_at)
VALUES ('tony', '["Python入門", "資料庫設計"]', '["AI導論"]', '2025-10-19 14:39:00')

此處因為 account 是不能重複的主鍵, 所以我們使用 INSERT OR REPLACE 指令, 當然也可以第一次用 INSERT 後續用 UPDATE 指令, 但 用 INSERT OR REPLACE 較單純 :





新增紀錄成功後, 再新增下一筆紀錄 : 

INSERT OR REPLACE INTO ksml_books (account, borrow_books, reserve_books, updated_at)
VALUES ('amy', '["京都時光"]', '["關西地圖"]', '2025-10-19 14:45:00')

這樣回到資料表列表就會看到 ksml_stats 有兩筆紀錄了 :




按檢視紀錄欄中的檢視超連結就會呼叫 /function/view_table 顯示資料表內容, 程式碼如下 :

# view_table.py
import os
import sqlite3
import html
from urllib.parse import parse_qs

def main(request, **kwargs):
    # 解析 URL 查詢參數 (?table=xxx&page=1)
    query=request.query_string.decode('utf-8')
    params=parse_qs(query)
    table=params.get('table', [''])[0]
    try:
        page=int(params.get('page', ['1'])[0])
        if page < 1:
            page=1
    except ValueError:
        page=1    
    DB_PATH='./serverless.db'
    ROWS_PER_PAGE=50  # 每頁顯示筆數
    # 檢查資料庫是否存在
    if not os.path.exists(DB_PATH):
        return '''
        <p>資料庫檔案 serverless.db 不存在!
        <a href="/function/list_tables">返回資料表列表</a></p>
        '''
    # 檢查 URL 參數中是否有 table 名稱
    if not table:
        return '''
        <p>缺少 table 參數!
        <a href="/function/list_tables">返回資料表列表</a></p>
        '''
    try:
        conn=sqlite3.connect(DB_PATH)
        cur=conn.cursor()
        # 安全檢查 (避免惡意表名注入)
        cur.execute("""
            SELECT name FROM sqlite_master
            WHERE type='table' AND name=?
        """, (table,))
        if not cur.fetchone():
            conn.close()
            return f'<p>資料表 "{html.escape(table)}" 不存在!</p>'
        # 取得欄位名稱
        cur.execute(f'SELECT * FROM "{table}" LIMIT 1;')
        colnames=[d[0] for d in cur.description] if cur.description else []
        # 計算總筆數
        cur.execute(f'SELECT COUNT(*) FROM "{table}";')
        total_rows=cur.fetchone()[0]
        total_pages=(total_rows + ROWS_PER_PAGE - 1) // ROWS_PER_PAGE
        # 取得當前頁的資料
        offset=(page - 1) * ROWS_PER_PAGE
        cur.execute(f'SELECT * FROM "{table}" LIMIT ? OFFSET ?;', (ROWS_PER_PAGE, offset))
        rows=cur.fetchall()
        conn.close()
        # 組成 HTML
        text=f'<h2>資料表 {html.escape(table)} 內容</h2>'
        text += '<table border="1" cellspacing="0" cellpadding="6" style="border-collapse: collapse;">'
        # 欄位列
        if colnames:
            text += '<tr>' + ''.join(f'<th>{html.escape(c)}</th>' for c in colnames) + '</tr>'
        else:
            text += '<tr><td colspan="99">(無欄位)</td></tr>'
        # 資料列
        if rows:
            for row in rows:
                text += '<tr>' + ''.join(f'<td>{html.escape(str(v))}</td>' for v in row) + '</tr>'
        else:
            text += '<tr><td colspan="99">(本頁無資料)</td></tr>'
        text += '</table>'
        # 分頁列
        text += f'<p>共 {total_rows} 筆資料,頁 {page}/{total_pages}</p>'
        text += '<div style="margin:10px 0;">'
        if page > 1:
            text += f'<a href="/function/view_table?table={html.escape(table)}&page={page-1}">上一頁</a> '
        if page < total_pages:
            text += f'<a href="/function/view_table?table={html.escape(table)}&page={page+1}">下一頁</a>'
        text += '</div>'
        text += '<a href="/function/list_tables">返回資料表列表</a>'
        return text
    except Exception as e:
        return f'<p>讀取資料時發生錯誤:{html.escape(str(e))}</p>'
    finally:
        if 'conn' in locals():
            conn.close()

此程式使用分頁方式來顯示資料表內的全部紀錄 (預設每頁 50 筆), 首先用 urllib.parse.parse_qs() 函式來解析請求之 URL 字串以取得 table 與 page 參數, 然後用 SELECT COUNT 指令取得紀錄總筆數以便計算要分幾頁來顯示, 結果如下 :




按資料表列表匯出欄的 CSV 超連結會呼叫 /function/export_table 來匯出該資料表內容為 CSV 檔, 程式碼如下 :

# export_table.py
import os
import sqlite3
import csv
from io import StringIO
from flask import Response, request

def main(request, **kwargs):
    # 解析 URL 查詢參數 (?table=xxx)
    table=request.args.get('table')
    if not table:
        return '<p>未指定資料表名稱!<a href="/function/list_tables">返回列表</a></p>'
    DB_PATH='./serverless.db'
    # 檢查資料庫是否存在   
    if not os.path.exists(DB_PATH):
        return '<p>資料庫檔案 serverless.db 不存在!<a href="/function/list_tables">返回列表</a></p>'
    try:
        conn=sqlite3.connect(DB_PATH)
        cur=conn.cursor()
        # 取得欄位名稱
        try:
            cur.execute(f'PRAGMA table_info("{table}")')
            columns_info=cur.fetchall()
            if not columns_info:
                return f'<p>資料表 {table} 不存在!<a href="/function/list_tables">返回列表</a></p>'
            columns=[col[1] for col in columns_info]  # col[1] 是欄位名稱
        except Exception as e:
            return f'<p>取得欄位失敗: {str(e)}</p>'
        # 取得所有資料
        try:
            cur.execute(f'SELECT * FROM "{table}"')
            rows=cur.fetchall()
        except Exception as e:
            return f'<p>讀取資料失敗: {str(e)}</p>'
        # 將資料寫入 CSV
        output=StringIO()
        writer=csv.writer(output)
        writer.writerow(columns)
        writer.writerows(rows)
        csv_data=output.getvalue()
        output.close()
        # 回傳 CSV 給瀏覽器下載
        csv_bytes=csv_data.encode('utf-8-sig')        
        return Response(
            csv_bytes,
            mimetype='text/csv',
            headers={'Content-Disposition': f'attachment; filename="{table}.csv"'}
            )   
    except sqlite3.Error as e:
        return f'<p>連線資料庫失敗 {str(e)}</p>'
    finally:
        if 'conn' in locals():
            conn.close()

此程式使用 io.StringIO 類別來將資料表內容轉成 CSV 資料, 注意, 此處為了解決 Excel 在 Windows 版的 BOM 中文亂碼問題, 此 CSV 資料還呼叫了 encode() 來指定 'utf-8-sig' 編碼格式, 最後將編碼後的 CSV 資料利用 flask.Response() 函式傳給瀏覽器下載 : 




開啟 ksml_books.csv 結果如下 :




最後一個模組是用來刪除資料表的 /function/drop_table, 程式碼如下 :

# drop_table.py
import os
import sqlite3
from flask import request, redirect

def main(request, **kwargs):
    # 解析 URL 參數取得資料表名稱
    query=request.query_string.decode('utf-8')
    params=parse_qs(query)
    table=params.get('table', [''])[0]
    DB_PATH='./serverless.db'
    # 檢查資料庫是否存在
    if not os.path.exists(DB_PATH):
        return '''
            <p>資料庫檔案 serverless.db 不存在!
            <a href="/function/list_tables">返回資料表列表</a></p>
            '''
    # 設定保護清單,避免刪除重要系統表
    protected=['call_stats']
    if table in protected:
        return f'''
            <p>資料表 {table} 為保護表,無法刪除!
            <a href="/function/list_tables">返回列表</a></p>
            '''
    try:
        conn=sqlite3.connect(DB_PATH)
        cur=conn.cursor()
        # 執行刪除資料表
        cur.execute(f'DROP TABLE IF EXISTS "{table}"')
        conn.commit()
        conn.close()
        # 刪除成功後返回資料表列表
        return f'''
            <p>資料表 {table} 已刪除成功!
            <a href="/function/list_tables">返回列表</a></p>
            '''
    except sqlite3.Error as e:
        return f'<p>刪除資料表 {table} 失敗: {str(e)}</p>'
    finally:
        if 'conn' in locals():
            conn.close()

此程式使用 DROP TABLE 指令來刪除指定資料表, 在資料表列表的刪除欄位中按刪除鈕, 經過確認即可刪除資料表 :






可見 ksml_books 資料表已被刪除. 

OK, 這樣就報完工啦!


2025-10-19 補充 : 

剛剛發現似乎缺了刪除紀錄的功能, 所以我又修改了 view_table.py, 在表格最後添加 "刪除" 欄, 按下超連結菁確認後會呼叫 delete_record.py, 攜帶 table 與 pk (帳號主鍵), 刪除完回 view_table.py :

# view_table.py
import os
import sqlite3
import html
from urllib.parse import parse_qs, quote

def main(request, **kwargs):
    # 解析 URL 查詢參數 (?table=xxx&page=1)
    query=request.query_string.decode('utf-8')
    params=parse_qs(query)
    table=params.get('table', [''])[0]
    try:
        page=int(params.get('page', ['1'])[0])
        if page < 1:
            page=1
    except ValueError:
        page=1    
    DB_PATH='./serverless.db'
    ROWS_PER_PAGE=50  # 每頁顯示筆數
    # 檢查資料庫是否存在
    if not os.path.exists(DB_PATH):
        return '''
        <p>資料庫檔案 serverless.db 不存在!<a href="/function/list_tables">返回資料表列表</a></p>
        '''
    if not table:
        return '''
        <p>缺少 table 參數!<a href="/function/list_tables">返回資料表列表</a></p>
        '''
    try:
        conn=sqlite3.connect(DB_PATH)
        cur=conn.cursor()
        # 安全檢查 (避免惡意表名注入)
        cur.execute("""
            SELECT name FROM sqlite_master
            WHERE type='table' AND name=?
        """, (table,))
        if not cur.fetchone():
            conn.close()
            return f'<p>資料表 "{html.escape(table)}" 不存在!</p>'
        # 取得欄位名稱
        cur.execute(f'SELECT * FROM "{table}" LIMIT 1;')
        colnames=[d[0] for d in cur.description] if cur.description else []
        # 計算總筆數
        cur.execute(f'SELECT COUNT(*) FROM "{table}";')
        total_rows=cur.fetchone()[0]
        total_pages=(total_rows + ROWS_PER_PAGE - 1) // ROWS_PER_PAGE
        # 取得當前頁的資料
        offset=(page - 1) * ROWS_PER_PAGE
        cur.execute(f'SELECT * FROM "{table}" LIMIT ? OFFSET ?;', (ROWS_PER_PAGE, offset))
        rows=cur.fetchall()
        conn.close()

        # 組成 HTML
        text=f'<h2>資料表 {html.escape(table)} 內容</h2>'
        text += '<table border="1" cellspacing="0" cellpadding="6" style="border-collapse: collapse;">'
        # 欄位列
        if colnames:
            text += '<tr>'
            text += ''.join(f'<th>{html.escape(c)}</th>' for c in colnames)
            text += '<th>刪除</th>'  # 新增刪除欄位
            text += '</tr>'
        else:
            text += '<tr><td colspan="99">(無欄位)</td></tr>'
        # 資料列
        if rows:
            for row in rows:
                text += '<tr>'
                for v in row:
                    text += f'<td>{html.escape(str(v))}</td>'
                # 假設第一個欄位是主鍵
                pk_value = quote(str(row[0]))
                text += f'<td><a href="/function/delete_record?table={quote(table)}&pk={pk_value}" ' \
                        f'onclick="return confirm(\'確定要刪除此筆紀錄嗎?\')">刪除</a></td>'
                text += '</tr>'
        else:
            text += '<tr><td colspan="99">(本頁無資料)</td></tr>'
        text += '</table>'
        # 分頁列
        text += f'<p>共 {total_rows} 筆資料,頁 {page}/{total_pages}</p>'
        text += '<div style="margin:10px 0;">'
        if page > 1:
            text += f'<a href="/function/view_table?table={quote(table)}&page={page-1}">上一頁</a> '
        if page < total_pages:
            text += f'<a href="/function/view_table?table={quote(table)}&page={page+1}">下一頁</a>'
        text += '</div>'
        text += '<a href="/function/list_tables">返回資料表列表</a>'
        return text
    except Exception as e:
        return f'<p>讀取資料時發生錯誤:{html.escape(str(e))}</p>'
    finally:
        if 'conn' in locals():
            conn.close()





確認刪除會呼叫 delete_record.py :

# delete_record.py
import os
import sqlite3
from flask import redirect, request
from html import escape

def main(request, **kwargs):
    table=request.args.get('table', '').strip()
    pk=request.args.get('pk', '').strip()
    DB_PATH='./serverless.db'
    # 檢查參數
    if not table or not pk:
        return f'<p>缺少 table 或 pk 參數!<a href="/function/list_tables">返回列表</a></p>'
    # 檢查資料庫
    if not os.path.exists(DB_PATH):
        return f'<p>資料庫檔案 serverless.db 不存在!<a href="/function/list_tables">返回列表</a></p>'
    try:  # 刪除紀錄
        conn=sqlite3.connect(DB_PATH)
        cur=conn.cursor()
        # 安全檢查:確認 table 存在
        cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,))
        if not cur.fetchone():
            return f'<p>資料表 "{escape(table)}" 不存在!<a href="/function/list_tables">返回列表</a></p>'
        # 執行刪除
        cur.execute('DELETE FROM ksml_books WHERE account=?', (pk,))
        conn.commit()
        return redirect(f'/function/view_table?table={table}')
    except sqlite3.Error as e:
        return f'<p>刪除紀錄失敗:{escape(str(e))}</p><a href="/function/view_table?table={escape(table)}">返回</a>'
    finally:
        if 'conn' in locals():
            conn.close()

刪除完返回 view_table.py :




雖然手動刪除使用機會不大, 但系統功能還是要完備. 

最後修改 serverless.py, 將上面 8 個與資料庫管理相關的函式列入被保護的系統模組避免被誤改誤刪 : 

# 需要驗證的函式列表
PROTECTED_FUNCTIONS=['list_functions',
                     'add_function',
                     'save_function',
                     'edit_function',
                     'update_function',
                     'delete_function',
                     'show_stats',
                     'clear_stats',
                     'list_tables',
                     'add_table',
                     'drop_table',
                     'view_table',
                     'show_schema',
                     'export_table',
                     'execute_sql',
                     'delete_record'
                     ]

我把此次 v5 改版 zip 放在 GitHub :


而持續小幅修改的則放在 render.com 部署用的 repo :



2025-10-21 補充 :

今天修改了 view_table.py 中顯示資料表內容的程式碼, 將欄位內容中的 \n 跳行改為 <br> 以便在網頁中呈現跳行效果 : 

        # 資料列
        if rows:
            for row in rows:
                text += '<tr>'
                for v in row:
                    html_nl2br=html.escape(str(v)).replace("\n", "<br>")
                    text += f'<td>{html_nl2br}</td>'

它原本只是 html.escape(str(v)) 而已, 這樣 \n 就沒有跳行效果. 注意, 如果在 f 字串中處理 \n 字元會出錯, 必須先處理完再放進 f 字串裡. 

2025年10月15日 星期三

Linux 主機 Crontab 整理

之前在測試 Python 爬蟲時寫了一些程式, 分別部署在三台 Linux 主機上 (兩台 Pi 3, 一台 Pi 3A+, 以及一台 Mapleboard), 時間一久都不清楚到底哪隻爬蟲放在哪, 要維護或改版還得一台一台去檢視 crontab 很花時間, 今天抽空將它們整理如下 :

1. 鄉下老家的 Pi 3 : 

pi@raspberrypi:~ $ sudo crontab -e  
0 * * * * sudo /usr/local/bin/checkwifi.sh
0 * * * * /usr/bin/python3 /home/pi/reportip5.py

pi@raspberrypi:~ $ crontab -e  
*/6 * * * * curl -s https://serverless-fdof.onrender.com/function/hello > /dev/$


2. 高雄家的 Pi 3 : 

pi@raspberrypi:~ $ crontab -e    
0 16 * * 1-5 /usr/bin/python3 /home/pi/twstock_dashboard_update.py
*/31 9-13 * * 1-5 /usr/bin/python3 /home/pi/yahoo_twstock_monitor_table_2.py
0 8,18 * * * /usr/bin/python3 /home/pi/btc_eth_prices_line_notify.py
1 12,17 * * * /usr/bin/python3 /home/pi/technews_3.py
0 9 * * * /usr/bin/python3 /home/pi/books.com.tw_66_telegram.py
0 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py faxxxxxx yyyyyy
4 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py xxxx118 yyyyyy
8 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py xxxx119 yyyyyy
12 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py xxxx120 yyyyyy
16 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py xxxx1792 yyyy
20 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py xxxxxxx587 yyy110
0 13,18 * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py
0 6,16 * * * /usr/bin/python3 /home/pi/nkust_lib_9.py
*/5 * * * * ~/duckdns/duck.sh >/dev/null 2>&1
*/6 * * * * curl -s https://serverless-fdof.onrender.com/function/hello > /dev/$


3. 高雄家的 Pi 3 A+ : (已改為 Trixie Lite OS)

pi@raspberrypi:~ $ crontab -e   
*/3 * * * * curl -s https://serverless-fdof.onrender.com/function/hello
*/3 * * * * curl -s https://serverless-5e6i.onrender.com/function/hello


4. Mapleboard : 

tony1966@LX2438:~/python$ crontab -e   
#0 5,16 * * * /usr/bin/python3 /home/tony1966/python/ksml_books_8.py
#0 10 * * * /usr/bin/python3 /home/tony1966/python/nkust_lib_6.py

舊版爬蟲已停止. 

最近會進行爬蟲改版 (crontab 動態調整中), 爬蟲盡量部署在樹莓派, 蒐集的資料再統一彙整到 Mapleboard, 由 Mapleboard 推送訊息至 LINE 與 Telegram. 

2025年10月13日 星期一

Mapleboard MP510-50 測試 (四十二) : 安裝 OpenAI 與 Gemini API 套件

今天在 Mapleboard 的 Serverless 平台新增 LINE Bot 後端函式模組 linebot_gpt.py 與 linebot_gemini.py 取得 webhook 網址後, 貼到 LINE Messaging 頁面按 Verify 卻出現錯誤 : 






詢問 ChatGPT 答覆要我檢查 Mapleboard 有無安裝串接這兩個 LLM 的 API, 我用 pip3 show 檢查 openai 與 google-generativeai 才發現一直以來都未曾在 Mapleboard 上安裝套件進行串接 LLM 的測試, 難怪 Verify 失敗 : 

tony1966@LX2438:~/flask_apps/serverless$ pip3 show openai   
WARNING: Package(s) not found: openai
tony1966@LX2438:~/flask_apps/serverless$ pip3 show google-generativeai   
WARNING: Package(s) not found: google-generativeai

安裝這兩個套件後 Verify 就成功了 : 

pip3 install openai

tony1966@LX2438:~/flask_apps/serverless$ pip3 install openai   
Defaulting to user installation because normal site-packages is not writeable
Collecting openai
  Downloading openai-2.3.0-py3-none-any.whl (999 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 999.8/999.8 KB 2.8 MB/s eta 0:00:00
Requirement already satisfied: anyio<5,>=3.5.0 in /home/tony1966/.local/lib/python3.10/site-packages (from openai) (4.9.0)
Requirement already satisfied: pydantic<3,>=1.9.0 in /home/tony1966/.local/lib/python3.10/site-packages (from openai) (2.11.7)
Collecting jiter<1,>=0.10.0
  Downloading jiter-0.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (337 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 337.6/337.6 KB 351.7 kB/s eta 0:00:00
Requirement already satisfied: distro<2,>=1.7.0 in /usr/lib/python3/dist-packages (from openai) (1.7.0)
Requirement already satisfied: tqdm>4 in /home/tony1966/.local/lib/python3.10/site-packages (from openai) (4.67.1)
Requirement already satisfied: sniffio in /home/tony1966/.local/lib/python3.10/site-packages (from openai) (1.3.1)
Requirement already satisfied: httpx<1,>=0.23.0 in /home/tony1966/.local/lib/python3.10/site-packages (from openai) (0.28.1)
Requirement already satisfied: typing-extensions<5,>=4.11 in /home/tony1966/.local/lib/python3.10/site-packages (from openai) (4.14.0)
Requirement already satisfied: exceptiongroup>=1.0.2 in /home/tony1966/.local/lib/python3.10/site-packages (from anyio<5,>=3.5.0->openai) (1.2.1)
Requirement already satisfied: idna>=2.8 in /usr/lib/python3/dist-packages (from anyio<5,>=3.5.0->openai) (3.3)
Requirement already satisfied: httpcore==1.* in /home/tony1966/.local/lib/python3.10/site-packages (from httpx<1,>=0.23.0->openai) (1.0.9)
Requirement already satisfied: certifi in /home/tony1966/.local/lib/python3.10/site-packages (from httpx<1,>=0.23.0->openai) (2024.2.2)
Requirement already satisfied: h11>=0.16 in /home/tony1966/.local/lib/python3.10/site-packages (from httpcore==1.*->httpx<1,>=0.23.0->openai) (0.16.0)
Requirement already satisfied: pydantic-core==2.33.2 in /home/tony1966/.local/lib/python3.10/site-packages (from pydantic<3,>=1.9.0->openai) (2.33.2)
Requirement already satisfied: typing-inspection>=0.4.0 in /home/tony1966/.local/lib/python3.10/site-packages (from pydantic<3,>=1.9.0->openai) (0.4.1)
Requirement already satisfied: annotated-types>=0.6.0 in /home/tony1966/.local/lib/python3.10/site-packages (from pydantic<3,>=1.9.0->openai) (0.7.0)
Installing collected packages: jiter, openai
Successfully installed jiter-0.11.0 openai-2.3.0


pip3 show google-generativeai

tony1966@LX2438:~/flask_apps/serverless$  pip3 show google-generativeai    
WARNING: Package(s) not found: google-generativeai
tony1966@LX2438:~/flask_apps/serverless$ pip3 install google-generativeai
Defaulting to user installation because normal site-packages is not writeable
Collecting google-generativeai
  Downloading google_generativeai-0.8.5-py3-none-any.whl (155 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 155.4/155.4 KB 728.1 kB/s eta 0:00:00
Requirement already satisfied: tqdm in /home/tony1966/.local/lib/python3.10/site-packages (from google-generativeai) (4.67.1)
Collecting google-ai-generativelanguage==0.6.15
  Downloading google_ai_generativelanguage-0.6.15-py3-none-any.whl (1.3 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.3/1.3 MB 3.8 MB/s eta 0:00:00
Collecting google-auth>=2.15.0
  Downloading google_auth-2.41.1-py2.py3-none-any.whl (221 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 221.3/221.3 KB 4.5 MB/s eta 0:00:00
Requirement already satisfied: typing-extensions in /home/tony1966/.local/lib/python3.10/site-packages (from google-generativeai) (4.14.0)
Collecting google-api-core
  Downloading google_api_core-2.26.0-py3-none-any.whl (162 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 162.5/162.5 KB 5.1 MB/s eta 0:00:00
Requirement already satisfied: protobuf in /home/tony1966/.local/lib/python3.10/site-packages (from google-generativeai) (6.31.1)
Collecting google-api-python-client
  Downloading google_api_python_client-2.184.0-py3-none-any.whl (14.3 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 14.3/14.3 MB 4.7 MB/s eta 0:00:00
Requirement already satisfied: pydantic in /home/tony1966/.local/lib/python3.10/site-packages (from google-generativeai) (2.11.7)
Collecting proto-plus<2.0.0dev,>=1.22.3
  Downloading proto_plus-1.26.1-py3-none-any.whl (50 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 50.2/50.2 KB 3.0 MB/s eta 0:00:00
Collecting protobuf
  Downloading protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl (319 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 319.8/319.8 KB 2.5 MB/s eta 0:00:00
Collecting pyasn1-modules>=0.2.1
  Downloading pyasn1_modules-0.4.2-py3-none-any.whl (181 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 181.3/181.3 KB 5.7 MB/s eta 0:00:00
Collecting rsa<5,>=3.1.4
  Downloading rsa-4.9.1-py3-none-any.whl (34 kB)
Requirement already satisfied: cachetools<7.0,>=2.0.0 in /home/tony1966/.local/lib/python3.10/site-packages (from google-auth>=2.15.0->google-generativeai) (6.1.0)
Requirement already satisfied: requests<3.0.0,>=2.18.0 in /home/tony1966/.local/lib/python3.10/site-packages (from google-api-core->google-generativeai) (2.32.4)
Collecting googleapis-common-protos<2.0.0,>=1.56.2
  Downloading googleapis_common_protos-1.70.0-py3-none-any.whl (294 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 294.5/294.5 KB 5.0 MB/s eta 0:00:00
Requirement already satisfied: httplib2<1.0.0,>=0.19.0 in /usr/lib/python3/dist-packages (from google-api-python-client->google-generativeai) (0.20.2)
Collecting uritemplate<5,>=3.0.1
  Downloading uritemplate-4.2.0-py3-none-any.whl (11 kB)
Collecting google-auth-httplib2<1.0.0,>=0.2.0
  Downloading google_auth_httplib2-0.2.0-py2.py3-none-any.whl (9.3 kB)
Requirement already satisfied: pydantic-core==2.33.2 in /home/tony1966/.local/lib/python3.10/site-packages (from pydantic->google-generativeai) (2.33.2)
Requirement already satisfied: annotated-types>=0.6.0 in /home/tony1966/.local/lib/python3.10/site-packages (from pydantic->google-generativeai) (0.7.0)
Requirement already satisfied: typing-inspection>=0.4.0 in /home/tony1966/.local/lib/python3.10/site-packages (from pydantic->google-generativeai) (0.4.1)
Collecting grpcio<2.0.0,>=1.33.2
  Downloading grpcio-1.75.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl (6.3 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6.3/6.3 MB 5.4 MB/s eta 0:00:00
Collecting grpcio-status<2.0.0,>=1.33.2
  Downloading grpcio_status-1.75.1-py3-none-any.whl (14 kB)
Requirement already satisfied: pyparsing!=3.0.0,!=3.0.1,!=3.0.2,!=3.0.3,<4,>=2.4.2 in /usr/lib/python3/dist-packages (from httplib2<1.0.0,>=0.19.0->google-api-python-client->google-generativeai) (2.4.7)
Collecting pyasn1<0.7.0,>=0.6.1
  Downloading pyasn1-0.6.1-py3-none-any.whl (83 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 83.1/83.1 KB 2.6 MB/s eta 0:00:00
Requirement already satisfied: urllib3<3,>=1.21.1 in /home/tony1966/.local/lib/python3.10/site-packages (from requests<3.0.0,>=2.18.0->google-api-core->google-generativeai) (2.5.0)
Requirement already satisfied: idna<4,>=2.5 in /usr/lib/python3/dist-packages (from requests<3.0.0,>=2.18.0->google-api-core->google-generativeai) (3.3)
Requirement already satisfied: charset_normalizer<4,>=2 in /home/tony1966/.local/lib/python3.10/site-packages (from requests<3.0.0,>=2.18.0->google-api-core->google-generativeai) (3.4.2)
Requirement already satisfied: certifi>=2017.4.17 in /home/tony1966/.local/lib/python3.10/site-packages (from requests<3.0.0,>=2.18.0->google-api-core->google-generativeai) (2024.2.2)
Collecting grpcio-status<2.0.0,>=1.33.2
  Downloading grpcio_status-1.75.0-py3-none-any.whl (14 kB)
  Downloading grpcio_status-1.74.0-py3-none-any.whl (14 kB)
  Downloading grpcio_status-1.73.1-py3-none-any.whl (14 kB)
  Downloading grpcio_status-1.73.0-py3-none-any.whl (14 kB)
  Downloading grpcio_status-1.72.2-py3-none-any.whl (14 kB)
  Downloading grpcio_status-1.72.1-py3-none-any.whl (14 kB)
  Downloading grpcio_status-1.71.2-py3-none-any.whl (14 kB)
Installing collected packages: uritemplate, pyasn1, protobuf, grpcio, rsa, pyasn1-modules, proto-plus, googleapis-common-protos, grpcio-status, google-auth, google-auth-httplib2, google-api-core, google-api-python-client, google-ai-generativelanguage, google-generativeai
  Attempting uninstall: protobuf
    Found existing installation: protobuf 6.31.1
    Uninstalling protobuf-6.31.1:
      Successfully uninstalled protobuf-6.31.1
Successfully installed google-ai-generativelanguage-0.6.15 google-api-core-2.26.0 google-api-python-client-2.184.0 google-auth-2.41.1 google-auth-httplib2-0.2.0 google-generativeai-0.8.5 googleapis-common-protos-1.70.0 grpcio-1.75.1 grpcio-status-1.71.2 proto-plus-1.26.1 protobuf-5.29.5 pyasn1-0.6.1 pyasn1-modules-0.4.2 rsa-4.9.1 uritemplate-4.2.0

雖然在 render.com 上成功安裝 serverless 與串接 LLM, 但免費帳戶並不保證服務品質, 還是自己維護的 Mapleboard 靠得住啦!