2026-10-13

在 ASUSTOR AS1102TL NAS 跑 kokoro tts(text-to-speech) 生成 mp3 語音檔

參考資料 ----

kokoro


以 mobaXterm 管理員權限登入 AS1102TL

因為 NAS AppCentral 的 FFmpeg 並不具生成 mp3 的能力,所以需要另外下載
 
user$  cd  /volume1/Web/kokoro/
user$  wget  https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz
user$  tar  -xf  ffmpeg-release-arm64-static.tar.xz
 


安裝 python3 的套件管理工具 pip
 
user$  curl  -sS  https://bootstrap.pypa.io/get-pip.py  -o  get-pip.py

# 透過本機 python3 執行安裝(建議加上 --user 避免破壞系統預設權限)
user$  python3  get-pip.py  --user

# 因為有加 --user 參數
# 所以 pip 會安裝在 user 家目錄的 ~/.local/bin
 




安裝輕量版的 kokoro-onnx
 
# 怕將 pip 路徑加入環境變數會對 NAS 造成影響,因此暫不加
# 執行 pip 時就必須輸入完整的 pip 路徑
user$   ~/.local/bin/pip3.12  install  --no-cache-dir  kokoro-onnx  soundfile  pydub

# 因為需中文音素支援,所以還要安裝 misaki 的中文模組
user$  ~/.local/bin/pip3.12  install  --no-cache-dir  kokoro-onnx  "misaki[zh]"
 




下載音色庫
 
# 建立一個目錄存放,在本筆記,我放在 /volume1/Web/kokoro

# 下載 ONNX 模型權重 (約 300MB)
user$  wget  -O  kokoro-v1.0.onnx  https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx

# 下載音色庫 voices-v1.0.bin (約 25MB)
user$  wget  -O  voices-v1.0.bin  https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin
 


想知道有哪些音色
 
user$  python3  -c  "from  kokoro_onnx  import  Kokoro;  k=Kokoro('/volume1/Web/kokoro/kokoro-v1.0.onnx', '/volume1/Web/kokoro/voices-v1.0.bin'); print(k.get_voices())"
 

紅框內就是 kokoro 的角色音色,
第一個字母代表語言:
z:中文 (Zho / Chinese)
a:美式英語 (American English)
b:英式英語 (British English)
j:日語 (Japanese)
e:西班牙語 (Español)
f:法語 (Français)
h:印地語 (Hindi)
i:義大利語 (Italian)
p:巴西葡萄牙語 (Portuguese)

第二個字母代表性別,f 是女性(female),m 是男性(male)。

目前 kokoro 的音色列表:
af_alloy, af_aoede, af_bella, af_heart, af_jessica, af_kore, af_nicole, af_nova, af_river, af_sarah, af_sky, 
am_adam, am_echo, am_eric, am_fenrir, am_liam, am_michael, am_onyx, am_puck, am_santa,
bf_alice, bf_emma, bf_isabella, bf_lily,
bm_daniel, bm_fable, bm_george, bm_lewis,
ef_dora,
em_alex, em_santa,
ff_siwis,
hf_alpha, hf_beta, 
hm_omega, hm_psi,
if_sara,
im_nicola,
jf_alpha, jf_gongitsune, jf_nezumi, jf_tebukuro,
jm_kumo, pf_dora,
pm_alex, pm_santa,
zf_xiaobei, zf_xiaoni, zf_xiaoxiao, zf_xiaoyi,
zm_yunjian, zm_yunxi, zm_yunxia, zm_yunyang


警告訊息可以不理會,因為 joblib(底層用來處理並行運算的套件)在啟動時會嘗試在共享記憶體目錄(通常是 /dev/shm)建立旗標或暫存檔。在 ASUSTOR ADM 系統下,一般使用者帳號預設對 /dev/shm 沒有寫入權限,因此 joblib 自動降級為單線程序列模式(serial mode)運作。

因為在 1GB RAM 的 NAS 上本來就建議以單線程/序列模式執行以防爆記憶體,這個降級對生成語音完全沒有負面影響。


合成中文 tts_zh.py
 
import os
import warnings
import io
import onnxruntime as ort
import soundfile as sf
from kokoro_onnx import Kokoro
from pydub import AudioSegment
from pydub.utils import which
from misaki import zh


# 限制執行緒數量,避免在 1GB RAM NAS 上過載
os.environ["OMP_NUM_THREADS"] = "2"
os.environ["ORT_NUM_THREADS"] = "2"

warnings.filterwarnings("ignore", category=UserWarning, module="joblib")

# 將靜態 ffmpeg 所在目錄放到環境變數 PATH 最前端
FFMPEG_DIR = "/volume1/Web/kokoro/ffmpeg-7.0.2-arm64-static"
os.environ["PATH"] = FFMPEG_DIR + ":" + os.environ.get("PATH", "")

# 明確指定使用支援 libmp3lame 的 ffmpeg 執行檔路徑
AudioSegment.converter = os.path.join(FFMPEG_DIR, "ffmpeg")
AudioSegment.ffmpeg = os.path.join(FFMPEG_DIR, "ffmpeg")


# 檔案路徑設定
MODEL_PATH = "/volume1/Web/kokoro/kokoro-v1.0.onnx"
VOICES_PATH = "/volume1/Web/kokoro/voices-v1.0.bin"
OUTPUT_MP3 = "/volume1/Web/mp3/lee_mood.mp3"


# 目標字串
text_input = "你現在是怎樣的心情呢?是歡喜悲傷,還是一點點不知名的愁?"


# 初始化 Kokoro ONNX
kokoro = Kokoro(model_path=MODEL_PATH, voices_path=VOICES_PATH)

# 初始化中文音素處理器 (Misaki G2P)
g2p = zh.ZHG2P()
res = g2p(text_input)
phonemes = res[0] if isinstance(res, tuple) else res

# 將中文轉為 Kokoro 需要的音素格式
print(f"轉換音素結果: {phonemes}")

# 呼叫 create,傳入音素 (lang 改用 'en-us' 或省略驗證,is_phonemes=True)
print("正在合成語音...")
samples, sample_rate = kokoro.create(
    text=phonemes,
    voice="zm_yunyang",
    speed=1.0,
    is_phonemes=True
)


# 轉存為 MP3
# 先在記憶體中建立輕量 WAV 緩衝區,再由 pydub 調用 ffmpeg 封裝成 MP3
wav_buffer = io.BytesIO()
sf.write(wav_buffer, samples, sample_rate, format="WAV")
wav_buffer.seek(0)

audio_seg = AudioSegment.from_wav(wav_buffer)
audio_seg.export(OUTPUT_MP3, format="mp3", bitrate="192k")

print(f"完成!音訊已輸出至: {OUTPUT_MP3}")
 


合成美語 tts_en.py
 
import os
import warnings
import io
import onnxruntime as ort
import soundfile as sf
from kokoro_onnx import Kokoro
from pydub import AudioSegment
from pydub.utils import which
# from misaki import zh


# 限制執行緒數量,避免在 1GB RAM NAS 上過載
os.environ["OMP_NUM_THREADS"] = "2"
os.environ["ORT_NUM_THREADS"] = "2"

warnings.filterwarnings("ignore", category=UserWarning, module="joblib")

# 將靜態 ffmpeg 所在目錄放到環境變數 PATH 最前端
FFMPEG_DIR = "/volume1/Web/kokoro/ffmpeg-7.0.2-arm64-static"
os.environ["PATH"] = FFMPEG_DIR + ":" + os.environ.get("PATH", "")

# 明確指定使用支援 libmp3lame 的 ffmpeg 執行檔路徑
AudioSegment.converter = os.path.join(FFMPEG_DIR, "ffmpeg")
AudioSegment.ffmpeg = os.path.join(FFMPEG_DIR, "ffmpeg")


# 檔案路徑設定
MODEL_PATH = "/volume1/Web/kokoro/kokoro-v1.0.onnx"
VOICES_PATH = "/volume1/Web/kokoro/voices-v1.0.bin"
OUTPUT_MP3 = "/volume1/Web/mp3/lee_mood.mp3"


# 目標字串
text_input = "你現在是怎樣的心情呢?是歡喜悲傷,還是一點點不知名的愁?"
text_input = "American professors Robert E. Hall and Michael Woodford have won the 2026 Nobel prize in economics for developing new economic models that help governments and central banks stabilize their economies to minimize the impact of external shocks. "


# 初始化 Kokoro ONNX
kokoro = Kokoro(model_path=MODEL_PATH, voices_path=VOICES_PATH)


# 合成美英語音
print("正在合成美英語音...")
samples, sample_rate = kokoro.create(
    text=text_input,
    voice="af_alloy",      # 美式女聲 (亦可更換為 am_adam, af_sarah, af_nicole 等)
    speed=1.0,
    lang="en-us"           # 指定美語
)


# 轉存為 MP3
# 先在記憶體中建立輕量 WAV 緩衝區,再由 pydub 調用 ffmpeg 封裝成 MP3
wav_buffer = io.BytesIO()
sf.write(wav_buffer, samples, sample_rate, format="WAV")
wav_buffer.seek(0)

audio_seg = AudioSegment.from_wav(wav_buffer)
audio_seg.export(OUTPUT_MP3, format="mp3", bitrate="192k")

print(f"完成!音訊已輸出至: {OUTPUT_MP3}")
 



2026-06-25

【Metabase】更新/升級 Metabase

參考資料 ----


本篇筆記環境:
VMware Workstation Pro 26H1
LinuxMint22.2
metabase v0.57.6


備份 metabase 的系統資料庫:因為我的 metabase 是裝在虛擬機 Linuxmint22.2,所以偷懶直接備份虛擬機的整個目錄;萬一 更新/升級 失敗也不怕。😁

目前(2026.06.25) metabase 官網列出的版本,最新版為 v62.3,而我的虛擬機內的版本 v57 的最新版修正版為 v57.20。

官方建議:不要一次貿然的將版本升級到最新,建議先更新至修正版(v57.20),確定沒問題後再升級至最新版本(v62.3)。


登入 Linuxmint
 
# 停止 metabase 服務
user$ sudo  systemctl  stop  metabase.service

# 切換到存放 metabase.jar 的目錄
user$ cd  /usr/share/metabase/

# 將 metabase.jar 改名, 我習慣在原檔名後加上日期 → metabase_20260625.jar
user:/usr/share/metabase$ sudo  mv  metabase.jar  metabase_20260625.jar

# 開啟 Linuxmint 內的瀏覽器,找到自己的 metabase 主版本,本例為 57
# 主版本號 57 的最後一個版本為 57.20,以滑鼠右鍵複製連結網址
# 以指令模式下載最新版本的 metabase.jar
user:/usr/share/metabase$ sudo  wget  -O  metabase.jar  "https://downloads.metabase.com/v0.57.20.x/metabase.jar?_gl=1*1h59pic*_gcl_au*NTExNjgyNTUxLjE3ODIzODIxOTk."

# 變更 metabase.jar 的檔案屬性
user:/usr/share/metabase$ sudo  chown  metabase  metabase.jar
user:/usr/share/metabase$ sudo  chgrp  metabase  metabase.jar


# 重新開機,確定 metabase 能正常運作後,再重複上述的步驟升級至 v62.3
 


2026-05-27

【軟體筆記】群暉(Synology) DS223j 設定 MariaDB + Python3 環境

想要在 DS223j 跑 python 排程,因此摸索寫了這篇筆記;雖然 DS223j 底層也是 Linux,不過算是變形的 Linux,有其獨特性,所以本筆記的做法都是以不影響安全性及效率為前提。

老人家的 DS223j 系統已更新至 DSM7.2。


* 登入 DS223j web 管理系統 DSM,NAS 本身也有一些系統程式是以 python2 或 python3 撰寫,所以在【套件中心】會看到 python2 顯示已安裝,而 python3 雖然不是已安裝的狀態,但是若以 ssh 登入 DS223j 查看就會發現出廠時系統就安裝了 Python3.8.15。

因為我的程式要寫連線 DS223j 的 MariaDB,所以需要安裝【pymysql】套件,所以要與系統的 Python3.8.15 分開,另外再安裝 套件中心 的 Python3.9。

* 點選【套件中心】→【所有套件】→ 找到【Python3.9】並點擊安裝



* 以 ssh 連線進入 DS223j,本筆記採用的軟體為 MobaXterm,注意:NAS 和一般 Linux 不同,只允許系統管理者 ssh 連線。
python3.9 的安裝目錄路徑是 /var/packages/Python3.9/target/usr/bin/,執行檔名稱是 python3.9,在下圖中,可以看到套件中心自動為 python3.9 建立了 2 個捷徑,方便我們呼叫執行 python3.9。 


 
# 切換到家目錄
admin@DS223j: cd ~

# 下載並安裝 pip
admin@DS223j:~$ wget  https://bootstrap.pypa.io/pip/3.9/get-pip.py
admin@DS223j:~$ /var/packages/Python3.9/target/usr/bin/python3.9  get-pip.py

# 安裝 pymysql
admin@DS223j:~$ /var/packages/Python3.9/target/usr/bin/python3.9  -m  pip  install  pymysql
 


test.py
 
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import pymysql


# DS223j 預設關閉了 TCP/IP 連線協定
# 所以改用 socket
def get_db_connection_nas():
    """取得 NAS 資料庫連線"""
    # 請根據您的實際設定修改
    connection = pymysql.connect(
        user='資料庫連線帳號',
        password='密碼',
        database='資料庫名',
        unix_socket='/run/mysqld/mysqld.sock',
        charset='utf8',
        cursorclass=pymysql.cursors.DictCursor
    )
    return connection
    
    
def execute_and_print_query(pdo_nas, sql: str):
    print("\n" + "="*60)
    print(f"執行 SQL: {sql}")
    print("="*60)
    
    try:
        with pdo_nas.cursor() as cursor:
            # 執行 SQL 查詢
            cursor.execute(sql)
            rows = cursor.fetchall()
            if rows and len(rows) > 0:
                field_names = list(rows[0].keys())
                print(f"\n欄位: {', '.join(field_names)}")
                print("-"*60)
            
            # 取得記錄總數
            total_rows = len(rows)
            print(f"\n總共 {total_rows} 筆記錄\n")
            
            # 逐筆 print 出來
            for idx, row in enumerate(rows, 1):
                print(f"第 {idx} 筆記錄:")
                for field, value in row.items():
                    print(f"  {field}: {value}")
                print("-"*40)
            
            # 如果沒有記錄
            if total_rows == 0:
                print("查無任何記錄")
            
            return rows
            
    except Exception as e:
        print(f"執行 SQL 時發生錯誤: {e}")
        return None




def main():
    """主程式"""
    try:
        # 建立 mysql 連線
        pdo_nas = get_db_connection_nas()

        # 查詢記錄
        sql = "SELECT * FROM table1"
        result1 = execute_and_print_query(pdo_nas, sql)

        # 寫入紀錄
        sql_insert = f"""INSERT INTO table1
                        (field1, field2, field3)
                        VALUES
                        ('欄位1值', '欄位2值', '欄位3值')"""
                    
        print(f"sql = {sql_insert}")
        pdo_nas.cursor.execute(sql_insert)
        pdo_nas.commit()
        print("成功寫入")

    except Exception as e:
        print(f"發生錯誤: {e}")
        traceback.print_exc()
    


if __name__ == "__main__":
    main()
 


在儘量不更動 DS223j 的前提下,不改變 NAS 的環境變數,執行 test.py
 
# 須完整路徑,但可以是別名(捷徑),所以下面 3 種呼叫方式都行得通
admin@DS223j:/$ /var/packages/Python3.9/target/usr/bin/python3.9 test.py
admin@DS223j:/$ /var/packages/Python3.9/target/usr/bin/python3 test.py
admin@DS223j:/$ /var/packages/Python3.9/target/usr/bin/python test.py

 


2026-04-06

【Kotlin】自定義 app 結束對話框

本筆記搭配使用 ViewBinding, AdMob, Edge-to-Edge


app  層級 build.gradle
 
android {
    
    compileSdk {
        version = release(36)
    }
...
...

    buildFeatures {
        viewBinding = true
    }
    
    defaultConfig {
        ...
        ...
        minSdk = 26
        targetSdk = 36
    }
}

...
...

dependencies {

    ...
    ...
    
    // adMob
    implementation("com.google.android.gms:play-services-ads:25.1.0")
}
 


strings.xml
 
<resources>
    
    <string name="dialog_title">確定要離開?</string>
    <string name="dialog_message">您確定要關閉應用程式嗎?</string>
    <string name="btn_cancel">取消</string>
    <string name="btn_exit">離開</string>
    
    ...
    ...
 
    <!-- adMob app id 測試 -->
    <string name="admob_app_id">ca-app-pub-3940256099942544~3347511713</string>
    <!-- 橫幅 測試 -->
    <string name="banner_ad_unit_id">ca-app-pub-3940256099942544/6300978111</string>
 
</resources>
 


AndroidManifest.xml
 
<manifest
 
    <!-- 存取網路連線 -->
    <uses-permission android:name="android.permission.INTERNET" />
 
    ...
    ...
 
    <application  ... >
 
        <meta-data
            android:name="com.google.android.gms.version"
            android:value="@integer/google_play_services_version" />
        <meta-data
            android:name="com.google.android.gms.ads.APPLICATION_ID"
            android:value="@string/admob_app_id" />
 
        ...
        ...
 
        <!-- AdMob 的 AdActivity -->
        <activity
            android:name="com.google.android.gms.ads.AdActivity"
            android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|uiMode|screenSize|smallestScreenSize"
            android:theme="@android:style/Theme.Translucent" />
 
        ...
        ...
 
    </application>
 
</manifest>
 


activity_main.xml
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"    ← 注意這裡
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
 


自定義對話框 dialog_exit.xml,因為版面很單純,所以採用 LinearLayout。
 
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:ads="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="24dp">

    <TextView
        android:id="@+id/tv_dialog_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/dialog_title"
        android:textSize="20sp"
        android:textStyle="bold"
        android:textColor="?attr/colorOnSurface"/>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:gravity="end"
        android:orientation="horizontal">

        <Button
            android:id="@+id/btn_cancel"
            style="?attr/borderlessButtonStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/btn_cancel" />

        <Button
            android:id="@+id/btn_exit"
            style="?attr/borderlessButtonStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:text="@string/btn_exit"
            android:textColor="?attr/colorError"/>

    </LinearLayout>

    <!-- 做為 banner 的容器 -->
    <FrameLayout
        android:id="@+id/adContainer"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:minHeight="250dp" />
        
</LinearLayout>
 


MainActivity.kt
 
class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    
    private var preloadedAdView: AdView? = null    // 預載的廣告物件
    

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        // 設定返回鍵攔截(支援新舊所有 Android 版本)
        setupBackPressedListener()
        
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
        
        // 對話框的 AdMob banner
        preloadBannerAd()
    }
override fun onPause() { preloadedAdView?.pause() super.onPause() } override fun onResume() { super.onResume() preloadedAdView?.resume() } override fun onDestroy() { preloadedAdView?.destroy() super.onDestroy() } private fun setupBackPressedListener() { val callback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { // 當使用者按下返回鍵(或手勢返回)時,顯示對話框 showExitCustomDialog() } } // 將 callback 加到 Dispatcher 中,它會自動處理生命週期 onBackPressedDispatcher.addCallback(this, callback) } private fun showExitCustomDialog() { // 初始化 Dialog 的 View Binding val dialogBinding = DialogExitBinding.inflate(layoutInflater) // 將預載好的 AdView 放入對話框容器中 preloadedAdView?.let { adView -> // 避免 AdView 重複被加入不同的 Parent (adView.parent as? ViewGroup)?.removeView(adView) dialogBinding.adContainer.addView(adView) } val dialog = MaterialAlertDialogBuilder(this) .setView(dialogBinding.root) .setCancelable(true) .create() dialogBinding.btnCancel.setOnClickListener { dialog.dismiss() } dialogBinding.btnExit.setOnClickListener { dialog.dismiss() finish() } dialog.show() } private fun preloadBannerAd() { preloadedAdView = AdView(this).apply { adUnitId = getString(R.string.banner_ad_unit_id) setAdSize(AdSize.MEDIUM_RECTANGLE) // 開始非同步載入 loadAd(AdRequest.Builder().build()) } } }  

實際的對話框效果


2026-03-31

【Metabase】套用香港行政區進行分析 -- 區級行政區

參考資料 ----


到 香港開放數據平台 → 點擊【數據集】→ 輸入【地方行政區分界】→ 下面會跳出【地方行政區分界】和【歷史地方行政區分界】→ 點擊【地方行政區分界】

→ 下面列出查詢結果【數據資源】
→ 我們要的就是【地方行政區分界 (英文/繁體中文) (JSON)】 
→ 點擊【下載】鈕


香港的行政區有 18 個,所以下載的檔名為【hksar_18_district_boundary.json】。


2026-03-22

【Metabase】套用台灣地圖進行分析(二) -- 區、里(村)級行政區

參考資料 ----
github.com/g0v/twgeojson


到 github.com/g0v/twgeojson/json,有數個 json 檔,其中 twTown1982.geo.json 是區級行政區,twVillage1982.geo.json 則是 里(村)級。

另有 twTown1982.topo.json 及 twVillage1982.topo.json,是 geo.json 的後繼優化格式,簡略記一下主要的差異:就是相鄰的 2 個區中間的界線不重複記錄。
例如:A 區與 B 區相鄰,geo.json 是完整地記錄 A 區的座標點 [1,2,3,4,5] 及 B 區的座標點 [1,6,7,8,9,2],如此 點 1 及 點 2 就被記錄了 2 次,而 topo.json 則透過拓撲方法只記錄 1 次 點1到點2 的弧線,相較之下,topo.json 的檔案就比較小。





而 Metabase 目前只支援 geojson 格式,所以我們就下載這 2 個檔,以 twTown1982.geo.json 為例
點擊 twTown1982.geo.json
→ 點擊【下載】圖示下載 json 檔

→ 將 twTown1982.geo.json 放置在 /var/www/html/ 下,然後在 Metabase 設定 twTown1982.geo.json 的 url,在本例為 http://localhost/twTown1982.geo.json

瞄一下檔案內容
 
{"type": "FeatureCollection",
 "features": [

 ...
 ...
 
    {"properties":{"TOWNSN":"64000002","TOWNID":"6400003","COUNTYNAME":"高雄市","TOWNNAME":"左營區","name":"高雄市/左營區"},
				   "type":"Feature",
				   "geometry":{"type": "Polygon", 
							   "coordinates": [[[ 120.274766995608132, 22.706593189102705 ], 
                                   ...
                                   ...
                               ]] 
							}
				},
    ...
    ...
 

登入 Metabase,點擊【齒輪】圖示
→ 管理員設定

→ 地圖 → 新增一個地圖


→【URL】輸入 http://localhost/twTown1982.geo.json
→【區域識別符】選擇 TOWNID
→【區域顯示名稱】選擇 TOWNNAME


g0v/twgeojson 的是 2014 年的資料,理論上這幾年並沒有重新劃分區級行政區,所以資料應該仍可信、準確。


g0v/twgeojson/json/twVillage1982.geo.json 則更細, 行政區範圍小至 "村/里",村/里的劃分就可能不定期會變動了,例如老人家居住的里聽說最慢 2027 年就將要拆成 4 個里。


建立區級資料表存放 geojson 的資料,在本筆記,資料表命名為 town_geo
 
CREATE TABLE `town_geo` (
    `id` INT AUTO_INCREMENT PRIMARY KEY,
    `TOWNSN` VARCHAR(20),
    `TOWNID` VARCHAR(20),
    `COUNTYNAME` VARCHAR(50),
    `TOWNNAME` VARCHAR(50),
    `name` VARCHAR(100),
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
 


寫一個 php 讀取 twTown1982.geo.json 並存入 town_geo
 
<?php
ini_set('display_errors', 1);
ini_set('memory_limit', '512M');    // 放寬記憶體限制, 才能讀大檔案

// 資料庫連線設定
$host = 'localhost';
$dbname = '資料庫名稱';
$username = '帳號';
$password = '密碼';

try {
    // 1. 建立 PDO 連線
    $pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $username, $password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    // 2. 讀取並解析 GeoJSON 檔案
    $jsonFile = 'twTown1982.geo.json';
    if (!file_exists($jsonFile)) {
        die("錯誤:找不到檔案 $jsonFile");
    }

    $jsonContent = file_get_contents($jsonFile);
    $data = json_decode($jsonContent, true);

    if ($data === null) {
        die("錯誤:JSON 解析失敗");
    }

    // 3. 準備 SQL 插入語句
    $sql = "INSERT INTO town_geo (TOWNSN, TOWNID, COUNTYNAME, TOWNNAME, name) 
            VALUES (:townsn, :townid, :countyname, :townname, :name)";
    $stmt = $pdo->prepare($sql);

    // 4. 開始交易 (Transaction) 以提升大量寫入的速度
    $pdo->beginTransaction();

    $count = 0;
    foreach ($data['features'] as $feature) {
        $props = $feature['properties'];

        // 綁定參數並執行
        $stmt->execute([
            ':townsn'     => $props['TOWNSN'] ?? null,
            ':townid'     => $props['TOWNID'] ?? null,
            ':countyname' => $props['COUNTYNAME'] ?? null,
            ':townname'   => $props['TOWNNAME'] ?? null,
            ':name'       => $props['name'] ?? null
        ]);
        
        $count++;
    }

    // 5. 提交交易
    $pdo->commit();

    echo "成功!共匯入 $count 筆資料。";

} catch (PDOException $e) {
    // 發生錯誤時回滾
    if (isset($pdo) && $pdo->inTransaction()) {
        $pdo->rollBack();
    }
    echo "資料庫錯誤:" . $e->getMessage();
} catch (Exception $e) {
    echo "一般錯誤:" . $e->getMessage();
}
?>
 

如此就能結合自己的資料表,建立區級行政區的圖表了。




再建立里(村)級資料表,在本例為 village_geo
 
CREATE TABLE `village_geo` (
    `id` INT AUTO_INCREMENT PRIMARY KEY,
    `VILLAGESN` VARCHAR(20),
    `VILLAGEID` VARCHAR(20),
    `COUNTYNAME` VARCHAR(50),
    `TOWNNAME` VARCHAR(50),
    `VILLAGENAM` VARCHAR(50),
    `name` VARCHAR(150),
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
 


寫一個 php 讀取 twVillage1982.geo.json 並存入 village_geo
 
<?php
// 1. 提高記憶體限制(針對較大的村里 GeoJSON 檔案)
ini_set('memory_limit', '512M');
set_time_limit(0); // 取消執行時間限制

// 資料庫連線設定
$host = 'localhost';
$dbname = '資料庫名稱';
$username = '帳號';
$password = '密碼';

try {
    // 2. 建立 PDO 連線
    $pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $username, $password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    // 3. 讀取 GeoJSON 檔案
    $jsonFile = 'twVillage1982.geo.json';
    if (!file_exists($jsonFile)) {
        die("錯誤:找不到檔案 $jsonFile");
    }

    echo "正在讀取檔案...\n";
    $jsonContent = file_get_contents($jsonFile);
    
    echo "正在解析 JSON...\n";
    $data = json_decode($jsonContent, true);
    
    // 解析完立刻釋放字串記憶體
    unset($jsonContent); 

    if ($data === null) {
        die("錯誤:JSON 解析失敗,請檢查檔案格式。");
    }

    // 4. 準備 SQL 插入語句
    $sql = "INSERT INTO village_geo (VILLAGESN, VILLAGEID, COUNTYNAME, TOWNNAME, VILLAGENAM, name) 
            VALUES (:v_sn, :v_id, :c_name, :t_name, :v_name, :full_name)";
    $stmt = $pdo->prepare($sql);

    // 5. 開始交易處理 (大量資料寫入必備)
    $pdo->beginTransaction();

    echo "正在匯入資料...\n";
    $count = 0;
    foreach ($data['features'] as $feature) {
        $props = $feature['properties'];

        $stmt->execute([
            ':v_sn'      => $props['VILLAGESN'] ?? null,
            ':v_id'      => $props['VILLAGEID'] ?? null,
            ':c_name'    => $props['COUNTYNAME'] ?? null,
            ':t_name'    => $props['TOWNNAME'] ?? null,
            ':v_name'    => $props['VILLAGENAM'] ?? null,
            ':full_name' => $props['name'] ?? null
        ]);
        
        $count++;
    }

    // 6. 提交交易
    $pdo->commit();
    echo "匯入完成!成功存入 $count 筆村里資料。";

} catch (PDOException $e) {
    if (isset($pdo) && $pdo->inTransaction()) {
        $pdo->rollBack();
    }
    echo "資料庫錯誤:" . $e->getMessage();
} catch (Exception $e) {
    echo "一般錯誤:" . $e->getMessage();
}
?>
 
匯入的記錄如下圖


如果您需要的圖資精細顆粒度要小到村/里,國土測繪中心在政府資料開放平臺(opendata) 也有提供圖資,感覺有提續在更新,可能會較適合您,但國土測繪中心沒有直接提供 geo.json 格式,我們需要轉換程式幫忙。

到 村里界圖(TWD97經緯度),點擊【資料資源下載網址】,下載的是壓縮檔,找個目錄解壓縮存放,會看到解壓出來的檔案中有副檔名 .shp 的檔案,這是主檔。



到 QGIS 下載格式轉換程式並安裝,藉助程式將 shp 轉換為 geo.json 格式。


選擇 shp 檔,在本例為 VILLAGE_NLSC_1150306.shp



功能表 【Layer】→【Save As】→【Format】欄位選擇 GeoJSON → 輸入檔名,在本例為 town_twd97 → 按下【確定】鈕


開啟檔案,會發現 properties 內的欄位跟上面 g0v 的不同,由此可知,properties 的欄位是各自定義的;所以,若您圖資是選擇國土測繪中心的,需注意匯入資料的欄位也會不同。



相關筆記 ----


2026-03-16

【Metabase】限制/區隔一般 user 的查詢權限

有時,基於工作/職務劃分,公司的資料會進行可視資料權限的控管;例如:行銷部的可以看到有關客戶名單,但不能看業務部有關銷售的業績、人事資料...等,反之亦然。

Metabase 社群版沒有限制使用者可視資料的功能(付費版才有),也就是說,當系統管理員建立了一個資料庫連線,則所有 Metabase 的一般使用者都能看到該連線的所有 table。

不過我們有變通做法 -- 從資料庫分別設定幾個限制權限的帳號,本筆記以 MySQL8 做範例,例如建立帳號 user1(代表業務部),只授予 user1 能查詢檢視 table1,建立帳號 user2(代表行銷部),只能查詢檢視 table2。


以 root 帳號登入 phpMyAdmin 或 登入 Linux 開一個命令列視窗然後建立一個 MySQL 連線,本例示範在 phpMyAdmin 操作
 
-- 建立使用者
CREATE  USER  'user1'@'localhost' IDENTIFIED BY '密碼';

-- 授予 USER1 查詢資料庫 db1 的 table1 的權限
GRANT  SELECT  ON  db1.table1  TO  'user1'@'localhost';

-- 刷新權限(選擇性命令, 通常會自動生效)
FLUSH  PRIVILEGES;
 


驗證:登出 phpMyAdmin,以 user1 登入,就能看到 user1 只能看到 db1 的 table1 的資料。


以管理員帳號登入 Metabase → 設定 → 管理員設定

資料庫 → 新增資料庫(以 MySQL8 為例)


輸入在前面為行銷建立的帳號 user1,並在【顯示名稱】輸入 sales(Linuxmint22.2 的 MySQL8 設定安裝可參考相關筆記),重複步驟建立 user2,【顯示名稱】輸入 marketing。


到【權限管理】→【所有使用者群組】,這個群組預設是可查詢所有資料庫的,而且會高過其他群組的設定,所以要把 marketing、sales 的【建立提問】取消


設定業務部只能查詢 sales 資料庫


而行銷部只能查詢 marketing 資料庫



相關筆記 ----





2026-01-26

【MySQL8】匯入大容量 .sql 檔

phpMyAdmin 只能匯入容量 2M 以內的 .sql 檔,若要匯入大容量 .sql,則需要以命令列操作
 
# 切換到放置 .sql 檔的目錄下,
# 本例中, aa.sql 置於桌面
user$  cd  桌面

# 進入 su 模式
user$  sudo  su

# 匯入檔案
root#  mysql  -u  root  -p  資料庫名 < aa.sql
 


2026-01-25

【Metabase】設定委託 Gmail 定時發送通知信件

如果您設定的儀表板的數據是每日更新的,或是希望在您關心的某個數據發生較大的變化時(例如某日營業額突然暴量)通知您,就需要有台 mail server 寄送通知信件。

若您也沒有自己架設的 mail server,則可以申請 Gmail,設定 Metabase 委託 Gmail 寄送通知信件;Gmail 這項服務基本上是免費的,不過 Google 為了避免被用來惡意寄發垃圾信,所以有每日 500 封、且單封信件大小不可超過 10M 的限制。


登入 Google 帳戶


點擊右上角的帳戶圖示 → 【管理你的 Google 帳戶】


確認帳戶已啟用【兩步驟驗證】 → 在上方的搜尋框輸入【應用程式密碼】


為應用程式命名,在本例中,就叫做 metabase。


系統產生 16 字的密碼,複製/記下來。


以管理員登入 metabase → 點擊右上角的齒輪圖示 → 【管理員設定】


點擊【電子郵件設定】 → 【編輯配置】


參考下圖,填入各個欄位。


寄送測試信,會寄到管理員的信箱。


由於我的管理員 email 是 Gmail(但與申請 smtp.gmail.com 的帳號不同),所以去 Gmail 看有沒有收到信,若有信寄來,就表示設定成功了。


拿 Metabase 現成自帶的範例儀表板實驗


打開 E-commerce Inshights 後,點擊儀表板右上角的 【分享】圖示 → 【訂閱】


可以從 Metabase 的使用者名單選擇要訂閱的人,也可以自己輸入非 Metabase 使用者 → 選擇發送頻率。
若是同一個儀表板,有不同的人在看,在【訂閱】所指定的收信者是各自獨立設定的。
例如:A 已訂閱了 E-commerce Inshights,B 在【訂閱】是看不到有哪些人已經訂閱了 E-commerce Inshights 的。


點擊【現在發送郵件】,確認是否設定正確。


大約過 5 分鐘,去檢查信箱,就可以看到信件內容了。


信件是響應式(RWD) 的,包含了所有分頁的圖表及數據,個人覺得手機看也夠清楚。


2026-01-01

【Metabase】套用台灣地圖進行分析(一) -- 市級行政區

參考資料 ----

simplemaps


Metabase 預設自帶 世界地圖 & 美國地圖,如果需要台灣地圖幫助我們進行資料分析,則要手動新增台灣圖資。


到 simplemaps 下載台灣的 GeoJSON 的檔案 -- 檔名 tw.json,瞄一下 json 檔的內容,包含了一級行政區,其中屬性 id, name 是我們需要的。

 
{
  "type": "FeatureCollection", 
  "features": [
    {
...
...

  "type": "Feature", 
      "properties": {
        "source": "https://simplemaps.com", 
        "id": "TWKIN", 
        "name": "Kinmen"
      }, 
      
...
...
 



因為在【LinuxMint 22.2 安裝設定 MySQL 8.0 + phpMyadmin】這篇筆記中,我們安裝了 phpMyadmin,所以 LinuxMint 一併安裝了 Apache,其網站根目錄位置為 /var/www/html/,

將 tw.json 放置在 /var/www/html/ 下,然後在 Metabase 設定 tw.json 的 url,在本例為 http://localhost/tw.json

登入 Metabase,點擊【齒輪】圖示

→ 管理員設定


→ 地圖 → 新增一個地圖



要注意的是:通常我們的資料庫中 縣/市 欄位並不是遵循 GeoJSON 的 ISO 名稱定義,所以需要做適當的轉換,例如:另外建一個對照用的 table。

 
對照資料表:city_geo
+--------+--------+-------+
|   id   |  英文  |  中文  |
+--------+--------+-------+
| TWKIN  | Kinmen | 金門  | 
+--------+--------+-------+
|  ...   |  ...   |  ...  |
+--------+--------+-------+
 




相關筆記 ----