<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Let's Write</title>
    <description>The latest articles on DEV Community by Let's Write (@letswrite).</description>
    <link>https://dev.to/letswrite</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F879108%2F12d6ce35-abca-4678-a503-fc4600997729.png</url>
      <title>DEV Community: Let's Write</title>
      <link>https://dev.to/letswrite</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/letswrite"/>
    <language>en</language>
    <item>
      <title>OpenClaw：安裝教學，在 macOS 用虛擬機 (Ubuntu) 安全部署龍蝦 AI</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Tue, 03 Mar 2026 12:39:26 +0000</pubDate>
      <link>https://dev.to/letswrite/openclawan-zhuang-jiao-xue-zai-macos-yong-xu-ni-ji-ubuntu-an-quan-bu-shu-long-xia-ai-4b00</link>
      <guid>https://dev.to/letswrite/openclawan-zhuang-jiao-xue-zai-macos-yong-xu-ni-ji-ubuntu-an-quan-bu-shu-long-xia-ai-4b00</guid>
      <description>&lt;h1&gt;
  
  
  OpenClaw：安裝教學，在 macOS 用虛擬機 (Ubuntu) 安全部署龍蝦 AI
&lt;/h1&gt;

&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;自從 OpenClaw 橫空出世後，三不五時就會看到相關的文章和影音介紹，看起來是個跨時代的產物。不過伴隨而來的，也有不少資安方面的疑慮。&lt;/p&gt;

&lt;p&gt;相信很多人都想試試龍蝦的威力，但說實話，安裝起來沒那麼簡單。&lt;/p&gt;

&lt;p&gt;本篇主要筆記怎麼安裝 OpenClaw。為了安全考量，選擇先在本機架設虛擬機，再於虛擬機上安裝 OpenClaw。這樣萬一出事了，大不了直接把虛擬機砍掉就好。&lt;/p&gt;

&lt;p&gt;本篇注意事項：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;本篇安裝 OpenClaw 時，官方的版本是 2026.3.2。官方更新速度很快，也許會遇到不同的安裝設定，如果遇到有疑問或不知道是什麼的狀況，建議詢問 AI。&lt;/li&gt;
&lt;li&gt;本篇是使用 macOS，虛擬機是使用 Ubuntu-24.04.4。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;以下將 OpenClaw 簡稱龍蝦。&lt;/p&gt;




&lt;h2&gt;
  
  
  安裝虛擬機
&lt;/h2&gt;

&lt;p&gt;下載並安裝 VirtualBuddy：&lt;a href="https://github.com/insidegui/VirtualBuddy/releases" rel="noopener noreferrer"&gt;https://github.com/insidegui/VirtualBuddy/releases&lt;/a&gt;。&lt;/p&gt;

&lt;p&gt;安裝完 VirtualBuddy 後，打開來，選擇 Linux &amp;gt; 左下角的 Custom Link。&lt;/p&gt;

&lt;p&gt;輸入：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://cdimage.ubuntu.com/releases/24.04.4/release/ubuntu-24.04.4-live-server-arm64.iso
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;或者手動下載 iso：&lt;a href="https://cdimage.ubuntu.com/releases/24.04.4/release/" rel="noopener noreferrer"&gt;https://cdimage.ubuntu.com/releases/24.04.4/release/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;點擊「64-bit ARM (ARMv8/AArch64) server install image」。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj3cmd0bc4r3jy7awh4ku.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj3cmd0bc4r3jy7awh4ku.png" alt="VirtualBuddy 安裝 Ubuntu 24.04 ARM 架構伺服器版本映像檔選擇畫面" width="725" height="696"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;在 VirtualBuddy 的介面，選擇 Linux &amp;gt; 左下角的 Custom File，選擇剛剛下載的檔案。&lt;/p&gt;

&lt;p&gt;安裝的設定可以如下：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Virtual CPUs：3 或 4&lt;/li&gt;
&lt;li&gt;Memory：4&lt;/li&gt;
&lt;li&gt;Display Width / Height：都拉到最小值&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;安裝時，遇到「Install OpenSSH server」，記得這選項要打勾。&lt;/p&gt;

&lt;p&gt;安裝完並重開機後，建議後續改用 Mac 的終端機 &lt;code&gt;ssh&lt;/code&gt; 的方式來操作。&lt;/p&gt;

&lt;p&gt;比方登入後，會看到這段訊息：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqzk4rkco4ctjb0bjfy4g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqzk4rkco4ctjb0bjfy4g.png" alt="Ubuntu VM 啟動後顯示的 IPv4 位址資訊，用於 SSH 遠端連線" width="800" height="195"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;我們只需要在終端機上輸入：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="o"&gt;[&lt;/span&gt;你的帳號]@192.168.64.13
範例：
ssh letswrite@192.168.64.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;接著輸入帳號，就可以遠端登入了。&lt;/p&gt;




&lt;h2&gt;
  
  
  安裝 Node.js
&lt;/h2&gt;

&lt;p&gt;本篇透過 npm 來安裝 OpenClaw，因此需要先安裝 Node.js。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;下載並執行 NVM 安裝腳本&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-o-&lt;/span&gt; https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;更新環境設定&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc

&lt;span class="c"&gt;# 如果是用 Zsh&lt;/span&gt;
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.zshrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;安裝 Node.js（npm 會一併安裝）&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nvm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--lts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  取得 Telegram Bot Token
&lt;/h2&gt;

&lt;p&gt;龍蝦可以在多個平台使用，在看了幾個官方的 &lt;a href="https://github.com/openclaw/openclaw/releases" rel="noopener noreferrer"&gt;Releases&lt;/a&gt;，會發現每次更版都有針對 Telegram 的部分，看起來對 Telegram 的支援度較高，因此本篇使用 Telegram。&lt;/p&gt;

&lt;p&gt;註冊了 Telegram 會員後，登入，我們要先建立一個機器人用的帳號。&lt;/p&gt;

&lt;p&gt;Telegram 上要新增一個機器人很容易，一樣跟機器人對話就可以建立。&lt;/p&gt;

&lt;p&gt;點擊這個網址加入 BotFather 為好友：&lt;a href="https://telegram.me/BotFather" rel="noopener noreferrer"&gt;https://telegram.me/BotFather&lt;/a&gt;。&lt;/p&gt;

&lt;p&gt;接著對話框中傳送這個給 BotFather：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/newbot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;再按照 BotFather 需要我們回應的訊息，一步步回應後，就可以得到 Telegram Bot 的 Token，如下圖：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F16cfv6oanzfv6f0kg0sh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F16cfv6oanzfv6f0kg0sh.png" alt="Telegram BotFather 申請機器人後取得的 HTTP API Token 範例" width="618" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;這個 Token 要存下來，之後安裝龍蝦時，就可以在安裝過程中直接輸入。&lt;/p&gt;




&lt;h2&gt;
  
  
  安裝 OpenClaw：正常版
&lt;/h2&gt;

&lt;p&gt;以下是平常有扶老婆婆過馬路，有積功德，可以一路順順走下去的 Happy Path 版本。&lt;/p&gt;

&lt;p&gt;輸入指令：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-g&lt;/span&gt; openclaw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;安裝完後，接著輸入以下指令，開始進行設定：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw onboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Onboarding mode&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;直接使用 &lt;code&gt;QuickStart&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model/auth provider&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;根據網路上看到的災情 +&lt;br&gt;
August 本人的 Google Antigravity 被封殺過的人體實驗證明 +&lt;br&gt;
各家 API 的花費如果要到 Pro 等級的很燒 $$ 經驗……&lt;/p&gt;

&lt;p&gt;那個，對，August 曾經選擇 Gemini Pro 3 的 API，安裝個 gog 跟一些 skills 就燒掉了 3 美金，所以除非口袋很深，不然不建議用 API 的方式。&lt;/p&gt;

&lt;p&gt;建議二種：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;ChatGPT 是 Plus 以上方案的，就選 &lt;code&gt;OpenAI&lt;/code&gt; 的 &lt;code&gt;OpenAI Codex (ChatGPT OAuth)&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;如果有訂閱 GitHub Copilot，就選 &lt;code&gt;Copilot&lt;/code&gt; 的 &lt;code&gt;GitHub Copilot (GitHub device login)&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;如果是選 OpenAI Codex，選了以後，就會看到一個網址，貼到瀏覽器上後，執行登入，接著會看到「無法連上這個網站」的畫面，莫驚慌莫害怕，我們只要把登入結果的 URL 回貼到終端機上就可以了。&lt;/p&gt;

&lt;p&gt;如果是選 GitHub Copilot，選了以後，會看到一個網址，用 GitHub 帳號授權登入後，填寫介面提供的代碼，就完成了。&lt;/p&gt;

&lt;p&gt;其他使用模型的方式 August 沒試過，大家可以抱著探索自己口袋深度的心態勇敢按下去。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Select channel&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;選擇 &lt;code&gt;Telegram (Bot API)&lt;/code&gt;，再選 &lt;code&gt;Enter Telegram bot token&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;然後把我們在前一段從 Telegram 上取得的 Token 貼上去，就可以了。&lt;/p&gt;

&lt;p&gt;接著會問要不要安裝 skill，在這邊可裝可不裝，看個人需求，這個階段跳過也無所謂，後續再跟 Agent 說要裝 XX skill 就行。&lt;/p&gt;

&lt;p&gt;下一步會看到問要不要再輸入「GOOGLE_PLACES_API_KEY」、「GEMINI_API_KEY」、「OPENAI_API_KEY」、「ELEVENLABS_API_KEY」，可以都選「No」，因為凡是 Key、Token……都是要發動魔法小卡的。&lt;/p&gt;

&lt;p&gt;「NOTION_API_KEY」，如果想讓 Agent 控制 Notion，可以填，在 Notion 的 &lt;a href="https://www.notion.so/profile/integrations/public" rel="noopener noreferrer"&gt;integrations&lt;/a&gt; 頁面上可以取得 Key。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enable hooks&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這一步一開始沒看懂，問了 Gemini 後才懂意思，如下：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;boot-md：當系統（Gateway）啟動時，會自動讀取並執行工作區中的 BOOT.md 檔案。這適合用來初始化環境變數或設定當次啟動的特殊邏輯。&lt;/li&gt;
&lt;li&gt;bootstrap-extra-files：允許系統在啟動時載入額外的設定檔案（如 USER.md 或 SOUL.md）。這對於維持 AI 對你（使用者）的身份認知非常重要。&lt;/li&gt;
&lt;li&gt;command-logger：（推薦勾選）將你下達的所有指令記錄到審計日誌（Audit log）中。重視 ISO 27001 與安全稽核的作業習慣。&lt;/li&gt;
&lt;li&gt;session-memory：（核心功能）當執行 /new 或 /reset 指令開新對話時，會自動將之前的上下文（Context）存入記憶。這能確保 AI 即使重啟對話，也能維持開發進度的連續性。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;勾不勾選就看個人需求，後續也都可以再更改。&lt;/p&gt;

&lt;p&gt;最後，就安裝成功了，之後只要在 Telegram 建的機器人上隨便傳一句訊息，就會收到要配對的指令：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh9puccdpi9dlkleetkmq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh9puccdpi9dlkleetkmq.png" alt="OpenClaw 終端機配對指令與 Telegram 成功授權畫面" width="437" height="227"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;把最後一行的指令在終端機上執行，會看到收到以下的回傳訊息：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Approved telegram sender xxxxxxxxxx.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;代表配對完成，可以在 Telegram Bot 上使用 OpenClaw 了。&lt;/p&gt;




&lt;h2&gt;
  
  
  安裝 OpenClaw：遇到神奇錯誤版
&lt;/h2&gt;

&lt;p&gt;過年前 August 安裝龍蝦，都是上一段的正常版本。&lt;/p&gt;

&lt;p&gt;但遇到 Google Antigravity 用到被停權，前幾天帳號終於解封後，再安裝時就出現了錯誤。&lt;/p&gt;

&lt;p&gt;如果進行到最後一步，看到以下錯誤，就代表恭喜你，要走這段的版本：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: systemctl is-enabled unavailable: Command failed: systemctl --user is-enabled openclaw-gateway.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;解決方式如下，就照著一步步輸入指令就可以，以下都是來自於 Claude 提供的解方。&lt;/p&gt;

&lt;h3&gt;
  
  
  第一步：前置準備（安裝前先做）
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;loginctl enable-linger &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export XDG_RUNTIME_DIR=/run/user/$(id -u)'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export DBUS_SESSION_BUS_ADDRESS=unix:path=${XDG_RUNTIME_DIR}/bus'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  第二步：正常安裝 OpenClaw
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw onboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;遇到 systemctl 相關錯誤直接忽略，繼續完成設定。&lt;/p&gt;

&lt;h3&gt;
  
  
  第三步：手動建立 Gateway Service
&lt;/h3&gt;

&lt;p&gt;安裝完成後執（一次性的複製貼上即可）：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.config/systemd/user

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.config/systemd/user/openclaw-gateway.service &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
[Unit]
Description=OpenClaw Gateway
After=network.target

[Service]
Type=simple
Environment="PATH=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;which openclaw&lt;span class="si"&gt;))&lt;/span&gt;&lt;span class="sh"&gt;:/usr/local/bin:/usr/bin:/bin"
ExecStart=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;which openclaw&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt; gateway run
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; daemon-reload
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nb"&gt;enable &lt;/span&gt;openclaw-gateway
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; start openclaw-gateway
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  第四步：確認正常運作
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; status openclaw-gateway
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; is-enabled openclaw-gateway
openclaw status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;is-enabled&lt;/code&gt; 應顯示 &lt;code&gt;enabled&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;openclaw status&lt;/code&gt; 裡 gateway 應顯示 &lt;code&gt;reachable&lt;/code&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  重新安裝 gateway
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw gateway &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;接著重啟：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw gateway restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  常用管理指令
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; stop openclaw-gateway      &lt;span class="c"&gt;# 停止&lt;/span&gt;
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; restart openclaw-gateway   &lt;span class="c"&gt;# 重啟&lt;/span&gt;
journalctl &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; openclaw-gateway &lt;span class="nt"&gt;-f&lt;/span&gt;    &lt;span class="c"&gt;# 即時 log&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;按照上面的步驟執行後，OpenClaw 就安裝成功了。&lt;/p&gt;




&lt;h2&gt;
  
  
  後續想要修改設定
&lt;/h2&gt;

&lt;p&gt;比方在安裝時，選了使用 GitHub Copilot，之後如果想換用 Openai Codex，或是一些設定值想要修改，要怎麼做呢？&lt;/p&gt;

&lt;p&gt;很簡單，執行以下命令：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;修改完成後，要再重啟 gateway：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw gateway restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>openclaw</category>
      <category>ai</category>
      <category>ubuntu</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>站內搜尋加上 AI：使用 Google Vertex AI Search（RAG）打造智慧問答型搜尋</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Tue, 20 Jan 2026 14:40:02 +0000</pubDate>
      <link>https://dev.to/letswrite/zhan-nei-sou-xun-jia-shang-aishi-yong-google-vertex-ai-search-rag-da-zao-zhi-hui-wen-da-xing-sou-xun-302c</link>
      <guid>https://dev.to/letswrite/zhan-nei-sou-xun-jia-shang-aishi-yong-google-vertex-ai-search-rag-da-zao-zhi-hui-wen-da-xing-sou-xun-302c</guid>
      <description>&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;之前就一直想試試看 &lt;strong&gt;RAG（Retrieval-Augmented Generation）&lt;/strong&gt; 要怎麼應用在實際場景，但卡在自建 RAG 架構的門檻不低（像是要處理向量資料庫、轉檔、索引……bla bla bla），又想說成本會不會很高？直到這幾天發現了 Google 有 Vertex AI Search 這功能，讓 Gemini 判斷了一下可能成本，發現，意外的蠻便宜的耶，就決定來應用一下。&lt;/p&gt;

&lt;p&gt;簡單說明 RAG（以下為 Gemini 提供）：&lt;/p&gt;

&lt;p&gt;RAG 就像是為 AI 掛載了一份「即時參考資料」。它並非單一資料庫，而是包含三個核心動作，而 Vertex AI Search 已經把這些複雜的底層邏輯都封裝好了：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;檢索（Retrieval）：當使用者問問題時，系統去你的網站（資料庫）找出最相關的幾段文字。&lt;/li&gt;
&lt;li&gt;增強（Augmented）：把這些找出來的文字，連同原本的問題，一起塞進給 AI 的指令（Prompt）裡。&lt;/li&gt;
&lt;li&gt;生成（Generation）：AI 閱讀了你給它的「臨時參考資料」，最後產出正確的回答。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;因為本站重點都在實作，因此本篇的使用情境設定如下：&lt;/p&gt;

&lt;p&gt;當網站內容累積到一定程度後，傳統的關鍵字搜尋往往無法精準回應使用者的需求。透過 &lt;strong&gt;Vertex AI Search&lt;/strong&gt;，可以實作 RAG 架構，讓站內搜尋不只是「找關鍵字」，而是能「閱讀」我們的網站內容並整理出答案。&lt;/p&gt;

&lt;p&gt;而在打不過 AI 就加入它的時代，我們可以達成：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;自然語言提問&lt;/strong&gt;：使用者問「怎麼實作 code review」，AI 能從多篇文章中彙整答案。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;低開發成本&lt;/strong&gt;：不需自行維護向量資料庫 (Vector DB) 或處理複雜的資料清洗。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;高精準度&lt;/strong&gt;：結合 Google 的語意搜尋與生成能力。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;本篇實作成果，已經放上了首頁，以及每篇筆記文的文末，會看到一個「Vertex AI 搜尋」的輸入框，大家可以試一試喔，覺得好用或不好用，都歡迎留言。&lt;/p&gt;




&lt;h2&gt;
  
  
  建立 Google Vertex AI Search (RAG) 實作流程：資料匯入
&lt;/h2&gt;

&lt;p&gt;首先，必須要有一個 Google Cloud 的帳號，有了帳號後新增專案，而這個專案必須是「付費帳戶」，就是要付 $$ 的。&lt;/p&gt;

&lt;p&gt;有了一個付費帳戶的專案，接著開始以下幾個步驟。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;進入 Vertex AI 後台&lt;/strong&gt;：從 Google Cloud 控制台進入 Vertex AI 介面。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmdke7khnimaa7hvairn2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmdke7khnimaa7hvairn2.png" alt="Google Cloud Vertex AI 控制台首頁介面" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;啟用 API&lt;/strong&gt;：點擊「啟用所有建議的 API」以確保功能完整。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;因為啟用不用 $$，之後有使用到才要，為了防止後續步驟陣亡的莫名其妙，這邊就給它全部打開。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2burwsih9ycj5rskvvws.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2burwsih9ycj5rskvvws.png" alt="啟用 Vertex AI 相關建議 API 畫面" width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;選擇應用程式類型&lt;/strong&gt;：點擊「運用 AI 模式打造站內搜尋服務」。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvtqw9icoh6i1i1ase17m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvtqw9icoh6i1i1ase17m.png" alt="選擇 Vertex AI Search 搜尋應用程式類型" width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;填寫應用程式資訊&lt;/strong&gt;：輸入名稱並選取區域（建議選 global）。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fow7mdz6eqnl0khv2zt7k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fow7mdz6eqnl0khv2zt7k.png" alt="填寫應用程式基本資訊與地理位置" width="800" height="867"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;建立資料儲存庫 (Data Store)&lt;/strong&gt;：點擊建立按鈕。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl3ahhpjfdtb79xgityn9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl3ahhpjfdtb79xgityn9.png" alt="點擊建立資料儲存庫按鈕" width="800" height="212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;選取資料來源&lt;/strong&gt;：選擇「網站內容」。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;因為本篇是應用在站內搜尋，因此是選「網站內容」，如果大家使用時目的不同，可以詢問 AI 用哪一個省成本。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5jogwsumyjo9urccphtf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5jogwsumyjo9urccphtf.png" alt="選擇資料來源為網站內容" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;指定索引路徑&lt;/strong&gt;：輸入網址模式，如 &lt;code&gt;www.letswrite.tw/*&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;「自動檢索網址並持續更新」：建議打勾，之後網站有更新，Vertex 就會自動更新。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzcpvntw0dq0ysev3d943.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzcpvntw0dq0ysev3d943.png" alt="設定要建立索引的網址路徑模式" width="800" height="803"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;啟用強化功能&lt;/strong&gt;：設定儲存庫名稱並建議勾選「文件處理設定」。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;下面截圖中的選項要不要勾看網站的內容，一樣可以跟 AI 討論是否都勾。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcg6okzngst28cta13rg6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcg6okzngst28cta13rg6.png" alt="設定資料儲存庫名稱與啟用文件處理設定" width="800" height="857"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;選取計費模式&lt;/strong&gt;：初期選擇「一般計費模式」。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9mb2elx1ulakuq2uisyt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9mb2elx1ulakuq2uisyt.png" alt="選擇搜尋應用程式的計費模式" width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;連結 Data Store&lt;/strong&gt;：勾選剛建好的儲存庫並點擊繼續。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyeud6t3nn9d3jhlvipv4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyeud6t3nn9d3jhlvipv4.png" alt="將資料儲存庫連結至搜尋應用程式" width="800" height="202"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;監控進度&lt;/strong&gt;：在資料頁面查看「正在建立初始索引」。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frs95hnibr08e4z6g8q0m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frs95hnibr08e4z6g8q0m.png" alt="在後台查看初始索引建立進度" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;增加 Sitemap&lt;/strong&gt;：手動新增 Sitemap 網址以確保文章完整收錄。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmvrri6dde7igrl3fr9hg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmvrri6dde7igrl3fr9hg.png" alt="手動新增 Sitemap 網址以加速索引" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;索引完成&lt;/strong&gt;：當狀態顯示「初始索引建立完成」時，可以看到目前文件的總體積與數量（本站約 340 MB，共 696 份文件）。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbpe6ootqp1r18pp79p5o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbpe6ootqp1r18pp79p5o.png" alt="資料索引完成狀態，顯示 696 份文件與 339.84 MiB" width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  將 Vertex AI Search 放上網站
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;調整回覆風格&lt;/strong&gt;：在「設定」中將搜尋類型改為「搜尋答案」，並在操作說明加入「簡明扼要、不超過 100 字、繁體中文」等指令。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F41dllcps2hjwfpago6o9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F41dllcps2hjwfpago6o9.png" alt="調整搜尋小工具 UI 設定為搜尋答案，並輸入自訂操作說明" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;整合權限設定&lt;/strong&gt;：選取「公開存取權」並填入允許的網域。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fobky2jqpweh082ry0955.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fobky2jqpweh082ry0955.png" alt="設定公開存取網域並複製搜尋小工具程式碼" width="800" height="655"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  實作：iframe 與加到 WordPress
&lt;/h2&gt;

&lt;p&gt;本站是用 WordPress 架站的，照著上圖直接照放程式碼，發現樣式會被影響，因此把 Vertex Search 的頁面做一個 HTML 檔案，然後在 WordPress 上嵌入。&lt;/p&gt;

&lt;h3&gt;
  
  
  iframe.html
&lt;/h3&gt;

&lt;p&gt;我們將 Widget 放在新增的 HTML 檔案中，並處理 Shadow DOM 的 Enter 事件以動態撐開高度。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"zh-TW"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"UTF-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1.0"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Vertex AI Search - Let's Write&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;#searchWidgetTrigger&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;box-sizing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;border-box&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#ccc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;-webkit-appearance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* 移除 iOS 預設樣式 */&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nt"&gt;gen-search-widget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;margin-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"searchWidgetTrigger"&lt;/span&gt; &lt;span class="na"&gt;inputmode=&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;gen-search-widget&lt;/span&gt;
      &lt;span class="na"&gt;configId=&lt;/span&gt;&lt;span class="s"&gt;"9a35bc9d-f398-4b2b-bfeb-28091c39e3f6"&lt;/span&gt;
      &lt;span class="na"&gt;triggerId=&lt;/span&gt;&lt;span class="s"&gt;"searchWidgetTrigger"&lt;/span&gt;
      &lt;span class="na"&gt;anchorsTarget=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt;
      &lt;span class="na"&gt;alwaysOpened&lt;/span&gt;
      &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Vertex AI 搜尋"&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;gt;&amp;lt;/gen-search-widget&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cloud.google.com/ai/gen-app-builder/client?hl=zh_TW"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;widget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gen-search-widget&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;searchInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;searchWidgetTrigger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;triggered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// 處理 Enter 按下時的邏輯&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onEnterPressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 防止重複觸發&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;triggered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;triggered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// 通知父頁面調整 iframe 高度&lt;/span&gt;
        &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;setHeight&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.letswrite.tw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 2 秒後重置觸發狀態，允許下次搜尋&lt;/span&gt;
        &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;triggered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

      &lt;span class="c1"&gt;// 深度遍歷 shadow DOM，為所有 input 和 button 添加事件監聽&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setupDeepListener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 防止無限遞迴&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buttons&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 為所有 input 元素添加事件監聽&lt;/span&gt;
        &lt;span class="nx"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// 避免重複添加監聽器&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listenerAdded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listenerAdded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

          &lt;span class="c1"&gt;// 桌機：Enter 鍵 (keyup)&lt;/span&gt;
          &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keyup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nf"&gt;onEnterPressed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;

          &lt;span class="c1"&gt;// 手機：Enter 鍵 (keypress)&lt;/span&gt;
          &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keypress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nf"&gt;onEnterPressed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;

          &lt;span class="c1"&gt;// 手機：輸入變化時觸發 (失焦或完成輸入)&lt;/span&gt;
          &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onEnterPressed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// 為所有 button 元素添加點擊監聽 (手機端主要觸發方式)&lt;/span&gt;
        &lt;span class="nx"&gt;buttons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listenerAdded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listenerAdded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

          &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;onEnterPressed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// 遞迴檢查嵌套的 shadow DOM&lt;/span&gt;
        &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;setupDeepListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

      &lt;span class="c1"&gt;// 設置 shadow DOM 監聽器&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setupShadowListener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shadow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

          &lt;span class="c1"&gt;// 初始化深度監聽&lt;/span&gt;
          &lt;span class="nf"&gt;setupDeepListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

          &lt;span class="c1"&gt;// 使用 MutationObserver 監聽動態添加的元素&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MutationObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addedNodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodeType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                  &lt;span class="c1"&gt;// 如果新增節點有 shadow root，遞迴設置監聽&lt;/span&gt;
                  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;setupDeepListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                  &lt;span class="p"&gt;}&lt;/span&gt;
                  &lt;span class="c1"&gt;// 如果新增 input 或 button，重新掃描整個 shadow DOM&lt;/span&gt;
                  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INPUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BUTTON&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;setupDeepListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                  &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
              &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;

          &lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;childList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;subtree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;

          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

      &lt;span class="c1"&gt;// 等待 widget 定義並設置監聽&lt;/span&gt;
      &lt;span class="nx"&gt;customElements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;whenDefined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gen-search-widget&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// 每 500ms 嘗試設置監聽，直到成功&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;setupShadowListener&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

          &lt;span class="c1"&gt;// 15 秒後停止嘗試&lt;/span&gt;
          &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="c1"&gt;// 外層 input 的 Enter 鍵監聽 (keyup)&lt;/span&gt;
      &lt;span class="nx"&gt;searchInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keyup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;onEnterPressed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="c1"&gt;// 外層 input 的 Enter 鍵監聽 (keypress)&lt;/span&gt;
      &lt;span class="nx"&gt;searchInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keypress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;onEnterPressed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="c1"&gt;// 監聽整個文檔的 Enter 鍵 (keyup,捕獲階段)&lt;/span&gt;
      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keyup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INPUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;onEnterPressed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// 監聽整個文檔的 Enter 鍵 (keypress 捕獲階段)&lt;/span&gt;
      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keypress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INPUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;onEnterPressed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// 監聽所有按鈕點擊 (捕獲階段 主要針對手機端)&lt;/span&gt;
      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BUTTON&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onEnterPressed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  WordPress 裡嵌入 iframe 頁面
&lt;/h3&gt;

&lt;p&gt;透過 WP Code 等插件，可以選擇在標題上方，或各文章頁底部自動插入 iframe。&lt;/p&gt;

&lt;p&gt;以下是範例。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.bwp-section-header-separator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vertexIframe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;htmlString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
            &amp;lt;div class="vertex-ai-container" style="width: 100%; margin: 20px 0;"&amp;gt;
                &amp;lt;iframe id="vertexIframe" 
                        src="/iframe.html" 
                        style="width: 100%; border: none; overflow: hidden; transition: height 0.3s ease; height: 85px;" 
                        scrolling="no"&amp;gt;
                &amp;lt;/iframe&amp;gt;
            &amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertAdjacentHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beforebegin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;htmlString&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 建議加入來源網域檢查（因 iframe 同源可省略，但若跨網域則必須）&lt;/span&gt;
        &lt;span class="c1"&gt;// if (event.origin !== "https://YOUR_IFRAME_DOMAIN") return;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setHeight&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iframe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vertexIframe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iframe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;iframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  成本分析預估
&lt;/h2&gt;

&lt;p&gt;基於目前的網站規模（約 700 份文件），August 預計在官網測試一個月多來評估，主要是成本考量。&lt;/p&gt;

&lt;p&gt;以下是「Gemini 3 思考型」做的成本預估：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;項目&lt;/th&gt;
&lt;th&gt;估計單價 (USD)&lt;/th&gt;
&lt;th&gt;說明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;資料儲存 (Storage)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;約 $1.00 / GB / 月&lt;/td&gt;
&lt;td&gt;340 MB 體積產生的月費微乎其微。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;企業版查詢 (Enterprise)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$4.00 / 1,000 次查詢&lt;/td&gt;
&lt;td&gt;包含 RAG 生成式回答與語意搜尋。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gemini 2.5 Flash Token&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;依流量計費&lt;/td&gt;
&lt;td&gt;由於 Flash 價格極低，主要成本集中在查詢次數費。&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;如果每月有 1,000 次有效查詢，預估成本約為 &lt;strong&gt;$4 ~ $10 美金&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;一個月 5 美金以下，就還可以接受，超過 5 美金……就要看本站能不能帶來一些收入了，不然一直造成支出也不是辦法。&lt;/p&gt;

</description>
      <category>google</category>
      <category>vertexai</category>
      <category>rag</category>
      <category>gemini</category>
    </item>
    <item>
      <title>CodiumAI PR-Agent，在 Gitea 上用 AI 來 Code Review</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Sat, 10 Jan 2026 10:18:06 +0000</pubDate>
      <link>https://dev.to/letswrite/codiumai-pr-agentzai-gitea-shang-yong-ai-lai-code-review-m6l</link>
      <guid>https://dev.to/letswrite/codiumai-pr-agentzai-gitea-shang-yong-ai-lai-code-review-m6l</guid>
      <description>&lt;h1&gt;
  
  
  CodiumAI PR-Agent，在 Gitea 上用 AI 來 Code Review
&lt;/h1&gt;

&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;之前有寫過一篇：〈&lt;a href="https://www.letswrite.tw/gitea-ai-code-review/" rel="noopener noreferrer"&gt;使用 Gitea Actions 與 OpenAI 實現自動化 PR Code Review&lt;/a&gt;〉。&lt;/p&gt;

&lt;p&gt;底下有人留言說，Gitea 還有其他 Code Review 的方式，研究了以後發現，哎哎哎，這不就是 CodiumAI 的 PR-Agent 嗎？之前寫過在 GitHub、GitLab 上使用的，沒想到 Gitea 也有：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.letswrite.tw/github-ai-code-review/" rel="noopener noreferrer"&gt;CodiumAI PR-Agent，在 GitHub 上用 AI 來 Code Review&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.letswrite.tw/gitlab-ai-code-review/" rel="noopener noreferrer"&gt;CodiumAI PR-Agent，在 GitLab 上用 AI 來 Code Review&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;再加上本篇，本站對於 CodiumAI PR-Agent Code Review 這塊就湊滿 Git 三神獸了，看能不能解開什麼封印之門（咦？）&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CodiumAI PR-Agent&lt;/strong&gt; 是一個開源的 AI 工具，能夠自動分析 Pull Request 的變更內容。&lt;/p&gt;

&lt;p&gt;本篇將用 Docker 來部署並整合到用 Docker 自架的 &lt;strong&gt;Gitea&lt;/strong&gt;，實現以下自動化流程：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;自動審查&lt;/strong&gt;：當開發者建立或更新 PR 時，自動觸發 AI 進行分析。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;自動描述&lt;/strong&gt;：AI 自動生成 PR 的摘要修改點，減少工程師撰寫文件的時間。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;品質建議&lt;/strong&gt;：提供程式碼改進建議、偵測潛在 Bug。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;繁體中文友善&lt;/strong&gt;：設定 AI 以繁體中文回應，好吸收好閱讀。&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Docker 安裝 Gitea
&lt;/h2&gt;

&lt;p&gt;架設 Gitea 伺服器，可以參考前一篇 Gitea 的文章，就邊就不重覆寫：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.letswrite.tw/gitea-ai-code-review/#%e4%b8%80%e3%80%81%e5%ae%89%e8%a3%9d-gitea" rel="noopener noreferrer"&gt;Docker 安裝 Gitea&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;本文假設我們已經有一個運行中的 Gitea，並且擁有管理員權限。&lt;/p&gt;




&lt;h2&gt;
  
  
  取得需要的 Token 與 Webhook Secret
&lt;/h2&gt;

&lt;p&gt;在部署 CodiumAI PR-Agent 前，我們需要先準備好 Gitea 的 Token 與 OpenAI API Key。&lt;/p&gt;

&lt;h3&gt;
  
  
  取得 Gitea Personal Access Token
&lt;/h3&gt;

&lt;p&gt;Token 是為了讓 PR-Agent 可以讀取專案，並進行留言。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;登入 Gitea 伺服器。&lt;/li&gt;
&lt;li&gt;點擊右上角頭像，進入「設定」。&lt;/li&gt;
&lt;li&gt;選擇左側選單的「應用程式」。&lt;/li&gt;
&lt;li&gt;在「管理存取權杖」區塊中，點擊「生成新權杖」。&lt;/li&gt;
&lt;li&gt;填寫權杖名稱（例如：&lt;code&gt;pr-agent&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;權限設定（重要）&lt;/strong&gt;：請務必勾選以下權限：&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;issue&lt;/code&gt;：讀取和寫入&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;repository&lt;/code&gt;：讀取和寫入&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;點擊「產生 Token」並 &lt;strong&gt;立即複製&lt;/strong&gt;（離開頁面後將無法再次查看）。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu08m9k7cvm4nwq55vwf7.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu08m9k7cvm4nwq55vwf7.jpeg" alt="Gitea 使用者設定頁面中生成應用程式存取權杖 (Token) 的權限勾選畫面"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  生成 Webhook Secret
&lt;/h3&gt;

&lt;p&gt;為了確保安全性，我們需要設定一個 Secret 來驗證 Webhook 請求。&lt;/p&gt;

&lt;p&gt;產生 Secret 的方式有二種。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;在終端機使用 &lt;code&gt;openssl&lt;/code&gt; 產生一組高強度的隨機字串：
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;直接跟 AI 要一組，比方在 ChatGPT 或 Gemini 上，輸入：提供我一組 16 碼金鑰。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;產生的 Secret 要記下來，之後會填到設定檔中。&lt;/p&gt;

&lt;h3&gt;
  
  
  準備 OpenAI API Key
&lt;/h3&gt;

&lt;p&gt;前往 &lt;a href="https://platform.openai.com/api-keys" rel="noopener noreferrer"&gt;OpenAI Platform&lt;/a&gt; 建立一組 API Key。&lt;/p&gt;

&lt;p&gt;建立 Key，必須要先存一筆金額才能使用。&lt;/p&gt;

&lt;p&gt;這功能是要 $$ 的，記得選用 CP 高的模型，才不會幾次 Code Review 下來，荷包大失血。&lt;/p&gt;




&lt;h2&gt;
  
  
  Docker Compose 與環境變數設定
&lt;/h2&gt;

&lt;h3&gt;
  
  
  .env
&lt;/h3&gt;

&lt;p&gt;建立一個專案用資料夾，取名「pr-agent-gitea」。&lt;/p&gt;

&lt;p&gt;資料夾裡新增 &lt;code&gt;.env&lt;/code&gt; 的檔案，把敏感的資料，如我們前二步取得的 Token，都寫進去，方便管理與保密。&lt;/p&gt;

&lt;p&gt;複製貼上以下到 .env：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# ==================== Gitea 連線設定 ====================
# 若 Gitea 在另一台主機，請填寫該主機的固定 IP 或網域
&lt;/span&gt;&lt;span class="n"&gt;GITEA_URL&lt;/span&gt;=&lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="n"&gt;xx&lt;/span&gt;.&lt;span class="n"&gt;xx&lt;/span&gt;:&lt;span class="m"&gt;3000&lt;/span&gt;
&lt;span class="n"&gt;GITEA_TOKEN&lt;/span&gt;=在此貼上在 &lt;span class="n"&gt;Gitea&lt;/span&gt; 取得的&lt;span class="n"&gt;Token&lt;/span&gt;
&lt;span class="n"&gt;GITEA_WEBHOOK_SECRET&lt;/span&gt;=在此貼上 &lt;span class="n"&gt;Secret&lt;/span&gt;

&lt;span class="c"&gt;# ==================== LLM 模型設定 ====================
&lt;/span&gt;&lt;span class="n"&gt;OPENAI_KEY&lt;/span&gt;=在此貼上 &lt;span class="n"&gt;Openai&lt;/span&gt; 的 &lt;span class="n"&gt;Key&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  docker-compose.yml
&lt;/h3&gt;

&lt;p&gt;專案裡新增一個 docker-compose.yml 檔，複製貼上以下內容：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pr-agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;codiumai/pr-agent:0.31-gitea_app&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pr-agent&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;

    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# ==================== Gitea 連線設定 ====================&lt;/span&gt;
      &lt;span class="na"&gt;CONFIG__GIT_PROVIDER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gitea"&lt;/span&gt;
      &lt;span class="na"&gt;GITEA__URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${GITEA_URL}&lt;/span&gt;
      &lt;span class="na"&gt;GITEA__PERSONAL_ACCESS_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${GITEA_TOKEN}&lt;/span&gt;
      &lt;span class="na"&gt;GITEA__WEBHOOK_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${GITEA_WEBHOOK_SECRET}&lt;/span&gt;
      &lt;span class="na"&gt;GITEA__SKIP_SSL_VERIFICATION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;

      &lt;span class="c1"&gt;# ==================== OpenAI 模型設定 ====================&lt;/span&gt;
      &lt;span class="na"&gt;CONFIG__MODEL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4.1-mini"&lt;/span&gt;
      &lt;span class="na"&gt;OPENAI__KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${OPENAI_KEY}&lt;/span&gt;

      &lt;span class="c1"&gt;# ==================== PR 觸發設定 ====================&lt;/span&gt;
      &lt;span class="c1"&gt;# 設定自動執行的指令：描述 (/describe)、審查 (/review)、優化 (/improve)&lt;/span&gt;
      &lt;span class="na"&gt;GITEA__PR_COMMANDS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;["/describe",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/review",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/improve"]'&lt;/span&gt;
      &lt;span class="c1"&gt;# 設定觸發動作：開啟 PR 或 同步更新代碼時&lt;/span&gt;
      &lt;span class="na"&gt;GITEA__HANDLE_PR_ACTIONS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;["opened",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"synchronize"]'&lt;/span&gt;

      &lt;span class="c1"&gt;# ==================== 語言與輸出設定 ====================&lt;/span&gt;
      &lt;span class="na"&gt;CONFIG__RESPONSE_LANGUAGE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zh-TW"&lt;/span&gt;
      &lt;span class="na"&gt;CONFIG__PUBLISH_OUTPUT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;CONFIG__PUBLISH_OUTPUT_PROGRESS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;

      &lt;span class="c1"&gt;# ==================== 留言行為設定 ====================&lt;/span&gt;
      &lt;span class="c1"&gt;# 強制使用新留言模式 (false)，不覆蓋舊留言，保留審查歷史&lt;/span&gt;
      &lt;span class="na"&gt;PR_DESCRIPTION__PERSISTENT_COMMENT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="na"&gt;PR_REVIEW__PERSISTENT_COMMENT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="c1"&gt;# 關閉行內程式碼註解 (依喜好開啟)&lt;/span&gt;
      &lt;span class="na"&gt;PR_REVIEW__INLINE_CODE_COMMENTS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;

      &lt;span class="c1"&gt;# ==================== 功能開關設定 ====================&lt;/span&gt;
      &lt;span class="c1"&gt;# 簡化輸出，關閉額外的分類標籤&lt;/span&gt;
      &lt;span class="na"&gt;PR_DESCRIPTION__ENABLE_SEMANTIC_FILES_TYPES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="na"&gt;PR_REVIEW__ENABLE_REVIEW_LABELS_EFFORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="na"&gt;PR_REVIEW__ENABLE_REVIEW_LABELS_SECURITY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="na"&gt;GITEA__PUBLISH_LABELS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;

      &lt;span class="c1"&gt;# ==================== 檔案忽略清單 (Cost Saving) ====================&lt;/span&gt;
      &lt;span class="c1"&gt;# 忽略鎖定檔、靜態資源等不需要分析的檔案，大幅節省 Token 費用&lt;/span&gt;
      &lt;span class="na"&gt;CONFIG__IGNORE__GLOB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;['dist/**',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'build/**',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'out/**',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'.next/**',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'coverage/**',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'node_modules/**',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'package-lock.json',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'pnpm-lock.yaml',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'yarn.lock',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.md',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.txt',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.log',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.min.js',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.min.css',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.map',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.svg',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.png',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.jpg',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.jpeg',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.gif',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.ico',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.woff',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.woff2',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.ttf',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.eot',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'.env*',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'.gitignore',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'.eslintrc*',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'.prettierrc*',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'tsconfig.json',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'jest.config.*',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'vite.config.*',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'webpack.config.*',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.spec.ts',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.spec.js',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.test.ts',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'*.test.js']"&lt;/span&gt;

    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3001:3000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;內容說明：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;模型選擇 (&lt;code&gt;CONFIG__MODEL&lt;/code&gt;)&lt;/strong&gt;：使用 &lt;code&gt;gpt-4.1-mini&lt;/code&gt;，這是 CP 值高的選擇。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;語言設定 (&lt;code&gt;CONFIG__RESPONSE_LANGUAGE&lt;/code&gt;)&lt;/strong&gt;：指定 &lt;code&gt;zh-TW&lt;/code&gt;，讓 AI 輸出時用正體中文。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;忽略清單 (&lt;code&gt;CONFIG__IGNORE__GLOB&lt;/code&gt;)&lt;/strong&gt;：如果想省 $$，這點很重要！要排除 &lt;code&gt;node_modules&lt;/code&gt;、圖片檔、lock 檔等，避免 AI 浪費 Token 去讀取這些非程式碼檔案。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ports&lt;/strong&gt;：這邊指定 3001，主要是 Gitea 預設是 3000，如果 PR-Agent 一樣用 3000 會啟動失敗，因此改用 3001。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;docker-compose.yml 檔完成後，專案內開啟終端機，執行：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Container 上看到有 pr-agent，代表啟動成功。&lt;/p&gt;




&lt;h2&gt;
  
  
  實際執行 Code Review
&lt;/h2&gt;

&lt;h3&gt;
  
  
  設定 Gitea Repository Webhook
&lt;/h3&gt;

&lt;p&gt;最後一步，我們要告訴 Gitea 當有 PR 發生時，要把資料送給誰。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;進入要進行 Code Review 的專案，點擊「設定」 &amp;gt; 「Webhook」 &amp;gt; 「新增 Webhook」 &amp;gt; 「Gitea」。&lt;/li&gt;
&lt;li&gt;Webhook 填寫以下資訊：&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;目標 URL&lt;/strong&gt;：&lt;code&gt;http://&amp;lt;我們的_PR_AGENT_IP&amp;gt;:3001/api/v1/gitea_webhooks&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP 方法&lt;/strong&gt;：POST&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;POST Content Type&lt;/strong&gt;：application/json&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;密鑰&lt;/strong&gt;：填入 &lt;code&gt;.env&lt;/code&gt; 中的 &lt;code&gt;GITEA_WEBHOOK_SECRET&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;觸發條件&lt;/strong&gt;：選取「自訂事件」，建議勾選：&lt;/li&gt;
&lt;li&gt;問題留言（Issue Comment）&lt;/li&gt;
&lt;li&gt;合併請求（Pull Request）&lt;/li&gt;
&lt;li&gt;合併請求留言（Pull Request Comment）&lt;/li&gt;
&lt;li&gt;合併請求同步（Pull Request Synchronize）&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;點擊「新增 Webhook」。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhxz36mzm4frox0yqryv4.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhxz36mzm4frox0yqryv4.jpeg" alt="在 Gitea 儲存庫設定中新增 Webhook 的詳細參數填寫範例"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  驗證成果
&lt;/h3&gt;

&lt;p&gt;我們可以建一個小專案，故意寫錯出幾個 bug，然後發一個 Pull Request。&lt;/p&gt;

&lt;p&gt;等待約 10~30 秒，我們會看到：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AI 自動留言 (Describe)&lt;/strong&gt;：AI 會摘要這個 PR 做了什麼改動。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI 自動審查 (Review)&lt;/strong&gt;：AI 會列出「主要變更」、「潛在問題」以及「改進建議」。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmg5yvcdwbn1okwd9nn5h.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmg5yvcdwbn1okwd9nn5h.jpeg" alt="CodiumAI PR-Agent 在 Gitea Pull Request 頁面自動產生的繁體中文審查報告與摘要"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  進階：手動觸發指令
&lt;/h3&gt;

&lt;p&gt;除了 PR 時自動觸發，我也可以在 PR 的留言區輸入指令與 AI 互動：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/describe&lt;/code&gt;：重新生成 PR 描述。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/review&lt;/code&gt;：重新執行程式碼審查。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/ask "這段改動有 SQL Injection 風險嗎？"&lt;/code&gt;：針對特定問題詢問 AI。&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  總結
&lt;/h2&gt;

&lt;p&gt;透過 PR-Agent 與 Gitea 的整合，我們可以用極低的成本搭建 Code Review 功能。&lt;/p&gt;

&lt;p&gt;雖然 AI 無法 100% 取代人工，但它能幫忙過濾低級錯誤、自動撰寫文件，讓攻城獅能將精力集中在怎麼攻城（欸這什麼結論）。&lt;/p&gt;

</description>
      <category>gitea</category>
      <category>ai</category>
      <category>codereview</category>
      <category>codiumai</category>
    </item>
    <item>
      <title>在本機安裝 Mattermost：打造自主控制的團隊通訊平台</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Wed, 10 Dec 2025 12:19:20 +0000</pubDate>
      <link>https://dev.to/letswrite/zai-ben-ji-an-zhuang-mattermostda-zao-zi-zhu-kong-zhi-de-tuan-dui-tong-xun-ping-tai-4hk2</link>
      <guid>https://dev.to/letswrite/zai-ben-ji-an-zhuang-mattermostda-zao-zi-zhu-kong-zhi-de-tuan-dui-tong-xun-ping-tai-4hk2</guid>
      <description>&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;工作上，各家公司用的通訊軟體，常聽到的是以下三種：LINE、Teams、Slack。&lt;/p&gt;

&lt;p&gt;如果稍微比較一下，用起來的經驗如下（以下為 August 個人體感，不代表本台立場 XD）：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LINE、Telegram：適合閒聊&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這兩個工具雖然方便，尤其台灣人幾乎都有 LINE，但主要設計給日常聊天使用。&lt;/p&gt;

&lt;p&gt;面對工程團隊需要更結構化的溝通方式時，比如：頻道分類、討論串、檔案管理、發送程式碼……等功能就不太行。&lt;/p&gt;

&lt;p&gt;還容易讓生活 + 工作的訊息都摻在一起作灑尿牛丸，重要資訊很容易被淹沒。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slack、Teams：貴&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Slack 夠強大，也是專門針對開發團隊用，但免費版只能保留最近 90 天的訊息記錄，而且發送訊息的數量每個月有上限，想要完整保存對話歷史就必須付費。&lt;/p&gt;

&lt;p&gt;對小團隊或個人專案來說，魔法小卡沒辦法說發動就發動。&lt;/p&gt;

&lt;p&gt;Microsoft Teams 需要企業或教育機構的授權才能充分使用，個人或小團隊要獨立使用並不方便，比如想要用 Webhook，家庭方案還無法使用。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discord：資料及傳檔限制&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;雖然 Discord 免費且功能豐富，但所有資料都存放在 Discord 的伺服器上，對於重視資料隱私或需要完全掌控資料的人來說，這是一個重要的考量點。&lt;/p&gt;

&lt;p&gt;傳檔案時，如果是免費方案有 10 MB 的限制，想傳大一點的檔案就會被擋下來。&lt;/p&gt;

&lt;p&gt;因為使用上述幾個通訊軟體的體感，在詢問了 Gemini 3 Pro 有沒有更適合的工具後，才知道了今天要筆記的這個 Mattermost。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mattermost 優勢&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;開源，可以完全安裝在自己的電腦或伺服器上。&lt;/li&gt;
&lt;li&gt;類似 Slack 的使用方式，支援頻道分類、討論串、檔案分享等功能，還能透過外掛擴充更多能力。&lt;/li&gt;
&lt;li&gt;可以整合 Webhook、機器人帳號。&lt;/li&gt;
&lt;li&gt;所有資料都在本地，不用擔心第三方服務終止或資料外洩的問題。&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  本機安裝 Mattermost
&lt;/h2&gt;

&lt;h3&gt;
  
  
  事前準備
&lt;/h3&gt;

&lt;p&gt;在開始之前，請確認電腦已經安裝：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker Desktop（包含 Docker Compose）&lt;/li&gt;
&lt;li&gt;一個行動硬碟（選用，用來存放圖片和檔案）&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Docker Compose 設定檔
&lt;/h3&gt;

&lt;p&gt;建立一個工作目錄，在裡面新增 &lt;code&gt;docker-compose.yml&lt;/code&gt; 檔案。&lt;/p&gt;

&lt;p&gt;可以直接複製貼上以下：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/db:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=mattermost&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_USER=mmuser&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=mmuser_password&lt;/span&gt;

  &lt;span class="na"&gt;mattermost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mattermost/mattermost-team-edition:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no"&lt;/span&gt;
    &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;
    &lt;span class="na"&gt;pids_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/config:/mattermost/config:rw&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/logs:/mattermost/logs:rw&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/plugins:/mattermost/plugins:rw&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/client/plugins:/mattermost/client/plugins:rw&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/Volumes/backup/mattermost_data:/mattermost/data:rw&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_SQLSETTINGS_DRIVERNAME=postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mmuser_password@db:5432/mattermost?sslmode=disable&amp;amp;connect_timeout=10&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_SERVICESETTINGS_SITEURL=http://localhost:8065&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_PLUGINSETTINGS_ENABLEUPLOADS=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_PLUGINSETTINGS_ENABLEMARKETPLACE=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_SERVICESETTINGS_ALLOWCORSFROM=*&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_WEBSERVER_WEBSOCKETSECUREPORT=8065&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_SERVICESETTINGS_ENABLEINSECUREOUTGOINGCONNECTIONS=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_EMAILSETTINGS_SENDPUSHNOTIFICATIONS=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_EMAILSETTINGS_PUSHNOTIFICATIONSERVER=https://push-test.mattermost.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_EMAILSETTINGS_PUSHNOTIFICATIONCONTENTS=full&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_LOGSETTINGS_FILEMAXAGEDAYS=30&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_LOGSETTINGS_FILECOMPRESS=true&lt;/span&gt;

    &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json-file"&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;max-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;
        &lt;span class="na"&gt;max-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5"&lt;/span&gt;

    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8065:8065"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;以下逐段說明設定內容：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;資料庫服務&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no"&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/db:/var/lib/postgresql/data&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=mattermost&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_USER=mmuser&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=mmuser_password&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這段設定建立了 PostgreSQL 16 資料庫容器。使用輕量的 Alpine Linux 版本可以減少映像檔大小。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;restart: "no"&lt;/code&gt; 表示電腦重開機後不會自動啟動，需要手動執行啟動指令，主要是避免重新開機時，行動硬碟沒有插在主機上。&lt;/p&gt;

&lt;p&gt;資料庫檔案存放在 &lt;code&gt;./volumes/db&lt;/code&gt; 目錄，這樣即使容器被刪除，資料也不會遺失。&lt;/p&gt;

&lt;p&gt;環境變數設定了資料庫名稱、使用者名稱和密碼，這些資訊稍後會被 Mattermost 用來連線。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mattermost 服務&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;mattermost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mattermost/mattermost-team-edition:latest&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no"&lt;/span&gt;
  &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;
  &lt;span class="na"&gt;pids_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mattermost 服務依賴資料庫，Docker 會確保資料庫先啟動。&lt;/p&gt;

&lt;p&gt;使用 Team Edition 免費版本，已經足夠小型團隊使用。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;security_opt&lt;/code&gt; 和 &lt;code&gt;pids_limit&lt;/code&gt; 是安全性設定，限制容器的權限提升和程序數量。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;儲存策略&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/config:/mattermost/config:rw&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/logs:/mattermost/logs:rw&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/plugins:/mattermost/plugins:rw&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/client/plugins:/mattermost/client/plugins:rw&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/Volumes/backup/mattermost_data:/mattermost/data:rw&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這裡採用混合儲存策略，解決 Mattermost 不刪檔特性帶來的空間問題：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;存放在本機的檔案&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;設定檔（config）、日誌（logs）、外掛（plugins）都存在本機的 &lt;code&gt;./volumes/&lt;/code&gt; 目錄。這些檔案體積小且需要頻繁讀取，放在 Volume 可以確保系統反應速度，避免操作介面卡頓。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;存放在行動硬碟的靜態資源&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;圖片和檔案（data）存在行動硬碟的 &lt;code&gt;/Volumes/backup/mattermost_data&lt;/code&gt; 路徑。這是整個設定的關鍵考量點：&lt;strong&gt;Mattermost 預設不會刪除任何上傳的檔案&lt;/strong&gt;，即使訊息被刪除，檔案依然保留在伺服器上。&lt;/p&gt;

&lt;p&gt;隨著時間累積，照片、文件、影片……會越來越多，如果全部存在本機，很快就會面臨空間不足的問題。&lt;/p&gt;

&lt;p&gt;將這些靜態資源檔案改存到行動硬碟，既能完整保留所有歷史檔案，又不會壓縮主機空間。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;請記得修改硬碟路徑&lt;/strong&gt;：將 &lt;code&gt;/Volumes/backup&lt;/code&gt; 改成行動硬碟在 Mac 上的實際掛載位置。&lt;/p&gt;

&lt;p&gt;在 Finder 中打開「前往」→「電腦」就能看到硬碟名稱。如果你的行動硬碟名稱是「My Passport」，路徑就是 &lt;code&gt;/Volumes/My Passport/mattermost_data&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;最後一行掛載系統時間檔案，確保容器內的時間與主機同步。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;資料庫連線設定&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_SQLSETTINGS_DRIVERNAME=postgres&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mmuser_password@db:5432/mattermost?sslmode=disable&amp;amp;connect_timeout=10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;指定使用 PostgreSQL 資料庫，連線字串包含使用者名稱、密碼、主機名稱（db 就是前面定義的資料庫服務名稱）、Port 和資料庫名稱。在區域網路環境下，為了簡化設定，這裡停用了 SSL 加密。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;網站網址設定&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_SERVICESETTINGS_SITEURL=http://localhost:8065&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這是 Mattermost 對外的網址。如果你只在本機使用，就維持 &lt;code&gt;http://localhost:8065&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;如果要讓區域網路內其他裝置存取，可以設定為你電腦的區域網路 IP，例如 &lt;code&gt;http://192.168.1.100:8065&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;如果想用 Cloudflare Tunnel 進行內網穿透，可以設定為對外的網址，例如 &lt;code&gt;https://mattermost.yourdomain.com&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;外掛市集功能&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_PLUGINSETTINGS_ENABLEUPLOADS=true&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_PLUGINSETTINGS_ENABLEMARKETPLACE=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;開啟外掛上傳和市集功能，這樣才能安裝 Boards、Todo 等擴充功能。&lt;/p&gt;

&lt;p&gt;預設情況下這些功能可能被關閉，需要明確啟用。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebSocket 連線設定&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_SERVICESETTINGS_ALLOWCORSFROM=*&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_WEBSERVER_WEBSOCKETSECUREPORT=8065&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_SERVICESETTINGS_ENABLEINSECUREOUTGOINGCONNECTIONS=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WebSocket 是實現即時訊息的關鍵技術，而且後續使用 Boards 看版功能時也需要。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ALLOWCORSFROM=*&lt;/code&gt; 允許所有來源的跨域請求，方便在不同裝置上存取。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ENABLEINSECUREOUTGOINGCONNECTIONS&lt;/code&gt; 允許 Mattermost 連接到沒有 HTTPS 的外部服務，這在測試環境中很實用。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;推播通知設定&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_EMAILSETTINGS_SENDPUSHNOTIFICATIONS=true&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_EMAILSETTINGS_PUSHNOTIFICATIONSERVER=https://push-test.mattermost.com&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_EMAILSETTINGS_PUSHNOTIFICATIONCONTENTS=full&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;開啟推播通知功能，使用 Mattermost 官方提供的測試推播伺服器（TPNS）。這是免費服務，讓你的手機 App 能收到即時通知。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PUSHNOTIFICATIONCONTENTS=full&lt;/code&gt; 表示通知會顯示發訊者姓名和訊息內容；如果改成 &lt;code&gt;generic&lt;/code&gt; 則只顯示「您有一則新訊息」。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;日誌管理&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_LOGSETTINGS_FILEMAXAGEDAYS=30&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MM_LOGSETTINGS_FILECOMPRESS=true&lt;/span&gt;

&lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json-file"&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;max-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;
    &lt;span class="na"&gt;max-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mattermost 內部的日誌檔案會保留 30 天，超過後自動壓縮舊檔案。&lt;/p&gt;

&lt;p&gt;Docker 容器輸出的日誌則限制每個檔案最大 10MB，最多保留 5 個檔案，避免日誌無限膨脹佔用硬碟空間。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;對外 Port&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8065:8065"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;將容器內的 8065 Port 對應到主機的 8065 Port。&lt;/p&gt;

&lt;p&gt;啟動後可以透過 &lt;code&gt;http://localhost:8065&lt;/code&gt; 存取 Mattermost。&lt;/p&gt;

&lt;h3&gt;
  
  
  啟動服務
&lt;/h3&gt;

&lt;p&gt;在 &lt;code&gt;docker-compose.yml&lt;/code&gt; 所在目錄執行：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-d&lt;/code&gt; 參數表示在背景執行。第一次啟動會需要下載映像檔，可能需要幾分鐘時間。&lt;/p&gt;

&lt;p&gt;啟動完成後，打開瀏覽器前往 &lt;code&gt;http://localhost:8065&lt;/code&gt;，就會看到 Mattermost 的初始設定頁面。按照指示建立管理員帳號、設定團隊名稱，就能開始使用了。&lt;/p&gt;

&lt;h3&gt;
  
  
  停止服務
&lt;/h3&gt;

&lt;p&gt;當不需要使用時，可以執行：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這會停止並移除容器，但所有資料都保留在 volumes 目錄中，下次啟動時會自動載入。&lt;/p&gt;




&lt;h2&gt;
  
  
  切換介面語言為繁體中文
&lt;/h2&gt;

&lt;p&gt;預設安裝後，Mattermost 的介面是英文。如果想改成繁體中文，只需要簡單幾個步驟：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;點擊畫面右上角的齒輪圖示（Settings）&lt;/li&gt;
&lt;li&gt;在設定選單中找到「Display」（顯示設定）&lt;/li&gt;
&lt;li&gt;往下捲動到底部，會看到「Language」（語言）選項&lt;/li&gt;
&lt;li&gt;點擊「Edit」，從下拉選單中選擇「繁體中文（台灣）」&lt;/li&gt;
&lt;li&gt;點擊「Save」儲存&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;介面會立即切換成繁體中文，所有選單、按鈕、提示訊息都會變成中文顯示。&lt;/p&gt;




&lt;h2&gt;
  
  
  安裝擴充功能
&lt;/h2&gt;

&lt;p&gt;Mattermost 跟 LINE 很不同的地方，在於可以安裝另外的擴充功能。&lt;/p&gt;

&lt;p&gt;以下介紹兩個實用的擴充功能。&lt;/p&gt;

&lt;h3&gt;
  
  
  Focalboard
&lt;/h3&gt;

&lt;p&gt;Focalboard 是內建的看板工具，類似 Jira 或 Notion，可以用來管理專案任務、追蹤進度。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;安裝步驟：&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;進到 Focalboard 的 &lt;a href="https://github.com/mattermost-community/focalboard/releases" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt; 頁面。&lt;/li&gt;
&lt;li&gt;下載最新版本的檔案，因為 Docker 是 Linux，要選擇 mattermost-plugin-focalboard-vxxxx-linux-xxx.tar.gz 的檔案。&lt;/li&gt;
&lt;li&gt;Mattermost 點擊左上角的 Mattermost Logo，選擇「System Console」（系統控制台）。&lt;/li&gt;
&lt;li&gt;在左側選單找到「Plugins」（擴充程式）→「Plugin Management」（擴充程式管理）。&lt;/li&gt;
&lt;li&gt;在上傳擴充程式中，點擊「選擇檔案」，接著選擇剛剛下載的檔案後，點擊「上傳」。&lt;/li&gt;
&lt;li&gt;安裝完成後，左側選單會出現「Mattermost Boards」的選項，點擊進去，再啟用擴充程式功能。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;使用 Boards：&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;回到主畫面，點擊左上角的 Mattermost Logo 會出現「Boards」圖示。點擊後可以建立新的看板。&lt;/p&gt;

&lt;p&gt;選擇範本（如待辦清單、專案追蹤、內容行事曆等），或從空白看板開始。&lt;/p&gt;

&lt;p&gt;看板支援卡片拖曳、自訂欄位、篩選排序等功能。&lt;/p&gt;

&lt;h3&gt;
  
  
  Todo
&lt;/h3&gt;

&lt;p&gt;Todo 外掛可以讓你在對話中直接建立待辦事項，並在側邊欄追蹤所有未完成的任務。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;安裝步驟：&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;安裝方式跟上面的 Boards 相同。&lt;/p&gt;

&lt;p&gt;Todo 的 &lt;a href="https://github.com/mattermost-community/mattermost-plugin-todo/releases" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;使用 Todo：&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;在任何頻道或私訊中，右側會出現更多 Todo 的圖示（三個點 + 三條線）。&lt;/p&gt;

&lt;p&gt;點擊後選擇「Add Todo」。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;實用技巧：&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;你可以在任何頻道輸入 &lt;code&gt;/todo add [任務內容]&lt;/code&gt; 快速建立待辦事項，不需要依附在特定訊息上。例如：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/todo add 下週一前完成專案提案
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  4 使用整合的機器人帳號
&lt;/h2&gt;

&lt;p&gt;機器人帳號可以讓 Mattermost 與其他系統整合，執行像是自動化通知等的訊息發送。&lt;/p&gt;

&lt;h3&gt;
  
  
  建立機器人帳號
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;步驟：&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;進入「System Console」（系統控製台）→「Integrations」（整合）→「Bot Accounts」（機器人帳號）。&lt;/li&gt;
&lt;li&gt;點擊「Add Bot Account」（新增機器人帳號）。&lt;/li&gt;
&lt;li&gt;填寫機器人資訊：

&lt;ul&gt;
&lt;li&gt;Username：例如 &lt;code&gt;notification-bot&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Bot Icon：上傳機器人的頭像圖片（選用）&lt;/li&gt;
&lt;li&gt;Display Name：顯示名稱，例如「通知機器人」&lt;/li&gt;
&lt;li&gt;Description：說明這個機器人的用途&lt;/li&gt;
&lt;li&gt;Role：選擇「Member」即可&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;點擊「Create Bot Account」&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;建立完成後，頁面會顯示一個 Token（存取權杖）。&lt;strong&gt;請務必複製並妥善保存這個 Token&lt;/strong&gt;，它只會顯示一次。如果遺失，需要重新產生新的 Token。&lt;/p&gt;

&lt;h3&gt;
  
  
  用 n8n 發送訊息
&lt;/h3&gt;

&lt;p&gt;n8n 是一個開源的工作流程自動化工具，可以串接各種服務。&lt;/p&gt;

&lt;p&gt;以下示範如何讓 n8n 透過機器人帳號發送訊息到 Mattermost。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;前置作業：&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;首先需要在 Mattermost 建立一個接收訊息的頻道，例如「系統通知」。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;在 n8n 中設定：&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;在 n8n 工作流程中新增「Mattermost」節點&lt;/li&gt;
&lt;li&gt;建立 Credential（憑證）：

&lt;ul&gt;
&lt;li&gt;Base URL：填入你的 Mattermost 網址，例如 &lt;code&gt;http://localhost:8065&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;Access Token：貼上前面複製的機器人 Token。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;設定要執行的動作：

&lt;ul&gt;
&lt;li&gt;Operation：選擇「Post Message」。&lt;/li&gt;
&lt;li&gt;Channel ID：下拉式選單選取。&lt;/li&gt;
&lt;li&gt;Message：輸入要發送的訊息內容。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;簡單的測試範例：&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;建立一個「Schedule Trigger」節點，設定每天早上 9 點執行，連接到 Mattermost 節點發送「早安！今天也要加油」的訊息。這樣就完成了一個簡單的每日問候機器人。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;進階應用：&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;你可以組合更多節點，例如：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;監控網站是否正常運作，異常時發送警告訊息&lt;/li&gt;
&lt;li&gt;從 Google Sheets 讀取資料，定期發送報表摘要&lt;/li&gt;
&lt;li&gt;串接 Webhook，當特定事件發生時自動通知團隊&lt;/li&gt;
&lt;li&gt;整合 RSS 閱讀器，自動分享新文章到頻道&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;機器人帳號讓 Mattermost 不只是通訊軟體，還能執行自動化流程的訊息中心。&lt;/p&gt;




&lt;h2&gt;
  
  
  結語
&lt;/h2&gt;

&lt;p&gt;透過 Docker Compose，我們可以在本機快速部署 Mattermost，擁有完整的團隊協作功能，同時保有資料的完全控制權。合理的儲存策略配置（本機設定、行動硬碟存檔）讓系統既快速又節省空間。&lt;/p&gt;

&lt;p&gt;搭配 Boards 和 Todo 等外掛，以及機器人帳號的自動化整合，Mattermost 可以滿足從團隊溝通到專案管理的各種需求。&lt;/p&gt;

&lt;p&gt;而且，以上這些功能都是免費且開源的，不用擔心訂閱費用或資料隱私問題。&lt;/p&gt;

&lt;p&gt;最後，August 其實也還用不到一個月，但覺得好用，又能保有隱私，所以先寫了這篇筆記，紀錄一下安裝、使用的過程。&lt;/p&gt;

&lt;p&gt;如果有更好的通訊軟體，歡迎留言提供。&lt;/p&gt;

</description>
      <category>mattermost</category>
      <category>docker</category>
      <category>selfhost</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>使用 Gitea Actions 與 OpenAI 實現自動化 PR Code Review</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Sun, 23 Nov 2025 04:24:05 +0000</pubDate>
      <link>https://dev.to/letswrite/shi-yong-gitea-actions-yu-openai-shi-xian-zi-dong-hua-pr-code-review-2380</link>
      <guid>https://dev.to/letswrite/shi-yong-gitea-actions-yu-openai-shi-xian-zi-dong-hua-pr-code-review-2380</guid>
      <description>&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;在團隊協作開發中，Code Review 是確保程式碼品質的重要環節。&lt;/p&gt;

&lt;p&gt;但很常要審核的人，自己也在趕案子，要落實很難，如果可以每次都自動由 AI 來審，至少可以讓開發工程師藉由 AI 回饋，來知道自己哪些部份需要注意。&lt;/p&gt;

&lt;p&gt;之前有寫過 GitHub、GitLab 的版本：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.letswrite.tw/github-ai-code-review/" rel="noopener noreferrer"&gt;CodiumAI PR-Agent，在 GitHub 上用 AI 來 Code Review&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.letswrite.tw/gitlab-ai-code-review/" rel="noopener noreferrer"&gt;CodiumAI PR-Agent，在 GitLab 上用 AI 來 Code Review&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;但不是每間公司都會使用外部雲端服務，也有公司是自行架設 Git 服務。&lt;/p&gt;

&lt;p&gt;自行架設 Git 服務時，一般會採用 GitLab 或 Gitea。&lt;/p&gt;

&lt;p&gt;本筆記文將示範如何使用 Gitea Actions 搭配 OpenAI API，建立自動化的 PR Code Review 流程。當開發者提交 Pull Request 時，系統會自動：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;分析 PR 中的程式碼變更&lt;/li&gt;
&lt;li&gt;透過 OpenAI 識別潛在的錯誤、安全問題和可維護性問題&lt;/li&gt;
&lt;li&gt;在 PR 中自動留言，提供詳細的改善建議&lt;/li&gt;
&lt;li&gt;發送 Discord 通知（可選）&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;這套自動化流程能夠：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;提早發現程式碼問題，減少 bug 進入主分支的機會&lt;/li&gt;
&lt;li&gt;節省人工審查時間，讓團隊專注於更複雜的邏輯檢查&lt;/li&gt;
&lt;li&gt;提供一致性的審查標準&lt;/li&gt;
&lt;li&gt;幫助新手開發者學習最佳實踐&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  一、安裝 Gitea
&lt;/h2&gt;

&lt;h3&gt;
  
  
  準備 docker-compose.yml
&lt;/h3&gt;

&lt;p&gt;建立一個專案目錄，並建立 &lt;code&gt;docker-compose.yml&lt;/code&gt; 檔案：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/gitea:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_UID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_GID=1000&lt;/span&gt;

      &lt;span class="c1"&gt;# Database config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__DB_TYPE=postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__HOST=db:5432&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__NAME=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__USER=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__PASSWD=gitea123&lt;/span&gt;

      &lt;span class="c1"&gt;# Actions config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__actions__ENABLED=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__actions__DEFAULT_ACTIONS_URL=https://github.com&lt;/span&gt;

    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8123:3000"&lt;/span&gt; &lt;span class="c1"&gt;# Web UI&lt;/span&gt;

    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./gitea:/data&lt;/span&gt;

    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea-postgres&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_USER=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=gitea123&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./postgres:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;gitea&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;gitea"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  關鍵設定說明
&lt;/h3&gt;

&lt;p&gt;在這個 docker-compose 設定中，我們已經透過環境變數啟用了 Gitea Actions 功能：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GITEA__actions__ENABLED=true&lt;/code&gt;：啟用 Actions 功能&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GITEA__actions__DEFAULT_ACTIONS_URL=https://github.com&lt;/code&gt;：設定 Actions 的預設來源為 GitHub&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這樣就不需要在 UI 介面另外設定，Gitea 啟動後即可直接使用 Actions 功能。&lt;/p&gt;

&lt;h3&gt;
  
  
  啟動 Gitea
&lt;/h3&gt;

&lt;p&gt;在專案目錄下執行：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;等待容器啟動完成後，開啟瀏覽器訪問 &lt;code&gt;http://192.168.xx.xxx:8123&lt;/code&gt;（請將 IP 改為我們的實際 IP 位址）。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgkzzy1vaijrmd8bqcfcw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgkzzy1vaijrmd8bqcfcw.png" alt="Gitea 介面首頁畫面" width="800" height="2514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;完成 Gitea 的初始設定（建立管理員帳號等），即可開始使用。&lt;/p&gt;




&lt;h2&gt;
  
  
  二、安裝 Gitea Runner
&lt;/h2&gt;

&lt;h3&gt;
  
  
  取得 Runner Registration Token
&lt;/h3&gt;

&lt;p&gt;首先需要從 Gitea 後台取得 Runner 的註冊 Token。&lt;/p&gt;

&lt;p&gt;登入 Gitea 後，進入管理後台：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fub6888zw10cooh8fnttm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fub6888zw10cooh8fnttm.png" alt="Gitea 管理後台選單畫面" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;在「站點管理」→「Actions」→「Runner」頁面，點選「建立新的 Runner」或查看註冊 Token：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4c6xsmyn4i7bx8oxk9xa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4c6xsmyn4i7bx8oxk9xa.png" alt="Gitea Actions Runner 註冊 Token 取得畫面" width="800" height="578"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;複製這個 Token，稍後會用到。&lt;/p&gt;

&lt;h3&gt;
  
  
  建立 Runner 的 docker-compose.yml
&lt;/h3&gt;

&lt;p&gt;在另一個目錄，建立 Runner 的 &lt;code&gt;docker-compose.yml&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;runner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io/gitea/act_runner:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea-runner&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;GITEA_INSTANCE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://192.168.68.61:8123"&lt;/span&gt;
      &lt;span class="na"&gt;GITEA_RUNNER_REGISTRATION_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;請替換為我們的&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Token"&lt;/span&gt;
      &lt;span class="na"&gt;GITEA_RUNNER_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code_review"&lt;/span&gt;

    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;重要提醒：&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;將 &lt;code&gt;GITEA_INSTANCE_URL&lt;/code&gt; 中的 IP 改為我們的實際 IP&lt;/li&gt;
&lt;li&gt;將 &lt;code&gt;GITEA_RUNNER_REGISTRATION_TOKEN&lt;/code&gt; 替換為剛才複製的 Token&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  啟動 Runner
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  確認 Runner 連線狀態
&lt;/h3&gt;

&lt;p&gt;回到 Gitea 管理後台的 Runner 頁面，應該可以看到剛才啟動的 Runner 已經成功註冊並處於「閒置」狀態：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffee0exyzfx5gup1e0e4n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffee0exyzfx5gup1e0e4n.png" alt="Gitea Runner 已成功連線並顯示閒置狀態" width="800" height="208"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;看到綠色的「閒置」狀態，表示 Runner 已經準備好接受任務。&lt;/p&gt;




&lt;h2&gt;
  
  
  三、建立測試專案
&lt;/h2&gt;

&lt;h3&gt;
  
  
  建立新專案並設定 Secrets
&lt;/h3&gt;

&lt;p&gt;在 Gitea 上建立一個新的專案（或使用現有專案）：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpp1xld554maadufu5njg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpp1xld554maadufu5njg.png" alt="Gitea 介面中的建立新專案畫面" width="800" height="653"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;進入專案後，前往「設定」→「Actions」→「Secrets」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhgifbswqoc17dx0kh0fc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhgifbswqoc17dx0kh0fc.png" alt="Gitea 專案設定中的 Secrets 管理頁面" width="800" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;新增以下三個 Secrets：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OPENAIAPIKEY&lt;/strong&gt;：我們的 OpenAI API Key&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw53s25u0y78rcxxihxa0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw53s25u0y78rcxxihxa0.png" alt="Gitea Secrets 新增 OPENAIAPIKEY 介面" width="800" height="836"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;GITEATOKEN&lt;/strong&gt;：Gitea 的 Access Token（用於自動留言）&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;在「設定」→「應用程式」→「管理存取令牌」中產生。&lt;/p&gt;

&lt;p&gt;設定令牌權限時：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;issue：讀取和寫入&lt;/li&gt;
&lt;li&gt;repository：讀取&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;再把產生的 Token 存到 Secrets 裡。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6h9i26cq7mrg65bfil27.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6h9i26cq7mrg65bfil27.png" alt="Gitea Access Token 產生畫面" width="800" height="595"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DISCORDWEBHOOK&lt;/strong&gt;：Discord Webhook URL（可選）&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;新增完後，Secrets 管理裡最多就會看到三組設定好的清單：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd8sbozjb1pamzpj7kxl4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd8sbozjb1pamzpj7kxl4.png" alt="Gitea 專案內已設定的 Secrets 清單" width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  新增 Workflow 檔案
&lt;/h3&gt;

&lt;p&gt;在要執行 Code Review 的專案根目錄建立 &lt;code&gt;.gitea/workflows/&lt;/code&gt; 目錄結構，並在其中新增 &lt;code&gt;openai-pr-review.yml&lt;/code&gt; 檔案：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your-project/
├── .gitea/
│   └── workflows/
│       └── openai-pr-review.yml
└── (其他專案檔案)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;openai-pr-review.yml&lt;/code&gt; 內容如下：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OpenAI PR Code Review&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pr-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate PR diff&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git fetch origin pull/${{ github.event.pull_request.number }}/head:pr_head&lt;/span&gt;
          &lt;span class="s"&gt;git diff --diff-filter=AM origin/${{ github.event.pull_request.base.ref }}...pr_head &amp;gt; diff.txt&lt;/span&gt;
          &lt;span class="s"&gt;echo "Diff generated."&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Review PR with OpenAI and post results&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/github-script@v7&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OPENAIAPIKEY }}&lt;/span&gt;
          &lt;span class="na"&gt;GITEA_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITEATOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;DISCORD_WEBHOOK&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DISCORDWEBHOOK }}&lt;/span&gt;
          &lt;span class="na"&gt;SERVER_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.server_url }}&lt;/span&gt;
          &lt;span class="na"&gt;REPO&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.repository }}&lt;/span&gt;
          &lt;span class="na"&gt;PR_NUMBER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.number }}&lt;/span&gt;
          &lt;span class="na"&gt;PR_TITLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.title }}&lt;/span&gt;
          &lt;span class="na"&gt;PR_AUTHOR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.user.login }}&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;const fs = require("fs");&lt;/span&gt;

            &lt;span class="s"&gt;const apiKey = process.env.OPENAI_API_KEY;&lt;/span&gt;
            &lt;span class="s"&gt;const giteaToken = process.env.GITEA_TOKEN;&lt;/span&gt;
            &lt;span class="s"&gt;const discordWebhook = process.env.DISCORD_WEBHOOK;&lt;/span&gt;
            &lt;span class="s"&gt;const serverUrl = process.env.SERVER_URL;&lt;/span&gt;
            &lt;span class="s"&gt;const repo = process.env.REPO;&lt;/span&gt;
            &lt;span class="s"&gt;const prNumber = process.env.PR_NUMBER;&lt;/span&gt;
            &lt;span class="s"&gt;const prTitle = process.env.PR_TITLE;&lt;/span&gt;
            &lt;span class="s"&gt;const prAuthor = process.env.PR_AUTHOR;&lt;/span&gt;

            &lt;span class="s"&gt;if (!apiKey) {&lt;/span&gt;
              &lt;span class="s"&gt;core.setFailed("缺少 OPENAI_API_KEY");&lt;/span&gt;
              &lt;span class="s"&gt;return;&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;

            &lt;span class="s"&gt;// 讀取 diff&lt;/span&gt;
            &lt;span class="s"&gt;let diff = "";&lt;/span&gt;
            &lt;span class="s"&gt;try {&lt;/span&gt;
              &lt;span class="s"&gt;diff = fs.readFileSync("diff.txt", "utf8");&lt;/span&gt;
            &lt;span class="s"&gt;} catch (e) {&lt;/span&gt;
              &lt;span class="s"&gt;core.info("無法讀取 diff.txt");&lt;/span&gt;
              &lt;span class="s"&gt;diff = "";&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;
            &lt;span class="s"&gt;diff = diff.slice(0, 12000);&lt;/span&gt;

            &lt;span class="s"&gt;if (!diff.trim()) {&lt;/span&gt;
              &lt;span class="s"&gt;core.info("No diff to review.");&lt;/span&gt;
              &lt;span class="s"&gt;return;&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;

            &lt;span class="s"&gt;// 精簡版 system prompt&lt;/span&gt;
            &lt;span class="s"&gt;const systemPrompt = [&lt;/span&gt;
              &lt;span class="s"&gt;"你是專業 code reviewer，請根據 PR diff 找出『最重要的 5 項問題』，依優先順序：1. 錯誤 2. 資安 3. 可維護性。",&lt;/span&gt;
              &lt;span class="s"&gt;"找不到重要問題時，只回覆：**No major issues found.**(Markdown)。",&lt;/span&gt;
              &lt;span class="s"&gt;"",&lt;/span&gt;
              &lt;span class="s"&gt;"# Findings Summary (Top 5)",&lt;/span&gt;
              &lt;span class="s"&gt;"請產生一份 Markdown 表格：",&lt;/span&gt;
              &lt;span class="s"&gt;"| # | 分類 | 問題摘要 | Risk | 建議摘要 |",&lt;/span&gt;
              &lt;span class="s"&gt;"|---|------|----------|------|-----------|",&lt;/span&gt;
              &lt;span class="s"&gt;"Risk = High / Medium / Low。",&lt;/span&gt;
              &lt;span class="s"&gt;"",&lt;/span&gt;
              &lt;span class="s"&gt;"# Detailed Review",&lt;/span&gt;
              &lt;span class="s"&gt;"依序對每項輸出：",&lt;/span&gt;
              &lt;span class="s"&gt;"## 問題 X(分類)",&lt;/span&gt;
              &lt;span class="s"&gt;"### 問題摘要",&lt;/span&gt;
              &lt;span class="s"&gt;"- 1~2 句說明",&lt;/span&gt;
              &lt;span class="s"&gt;"### Risk Score",&lt;/span&gt;
              &lt;span class="s"&gt;"- High / Medium / Low",&lt;/span&gt;
              &lt;span class="s"&gt;"### 為何重要",&lt;/span&gt;
              &lt;span class="s"&gt;"- 說明風險與影響",&lt;/span&gt;
              &lt;span class="s"&gt;"### 改善建議",&lt;/span&gt;
              &lt;span class="s"&gt;"若有程式碼，請提供『原始程式碼』與『建議後程式碼』兩段 Markdown 程式碼區塊，使用 diff 語法。",&lt;/span&gt;
              &lt;span class="s"&gt;"若無程式碼,提供可執行條列建議。",&lt;/span&gt;
              &lt;span class="s"&gt;"",&lt;/span&gt;
              &lt;span class="s"&gt;"請：",&lt;/span&gt;
              &lt;span class="s"&gt;"- 只輸出最重要的五項(不足五項則寫實際數量)",&lt;/span&gt;
              &lt;span class="s"&gt;"- 嚴格使用 Markdown",&lt;/span&gt;
              &lt;span class="s"&gt;"- 不要產生前言、後記、寒暄或多餘敘述",&lt;/span&gt;
              &lt;span class="s"&gt;"- 回覆簡潔、可採取行動"&lt;/span&gt;
            &lt;span class="s"&gt;].join("\n");&lt;/span&gt;

            &lt;span class="s"&gt;const payload = {&lt;/span&gt;
              &lt;span class="s"&gt;model: "gpt-5-nano",&lt;/span&gt;
              &lt;span class="s"&gt;reasoning_effort: "low",&lt;/span&gt;
              &lt;span class="s"&gt;messages: [&lt;/span&gt;
                &lt;span class="s"&gt;{&lt;/span&gt;
                  &lt;span class="s"&gt;role: "system",&lt;/span&gt;
                  &lt;span class="s"&gt;content: systemPrompt&lt;/span&gt;
                &lt;span class="s"&gt;},&lt;/span&gt;
                &lt;span class="s"&gt;{&lt;/span&gt;
                  &lt;span class="s"&gt;role: "user",&lt;/span&gt;
                  &lt;span class="s"&gt;content: `請 review 以下 PR diff：\n\n${diff}`&lt;/span&gt;
                &lt;span class="s"&gt;}&lt;/span&gt;
              &lt;span class="s"&gt;]&lt;/span&gt;
            &lt;span class="s"&gt;};&lt;/span&gt;

            &lt;span class="s"&gt;core.info("Calling OpenAI...");&lt;/span&gt;

            &lt;span class="s"&gt;const openaiRes = await fetch("https://api.openai.com/v1/chat/completions", {&lt;/span&gt;
              &lt;span class="s"&gt;method: "POST",&lt;/span&gt;
              &lt;span class="s"&gt;headers: {&lt;/span&gt;
                &lt;span class="s"&gt;"Authorization": `Bearer ${apiKey}`,&lt;/span&gt;
                &lt;span class="s"&gt;"Content-Type": "application/json"&lt;/span&gt;
              &lt;span class="s"&gt;},&lt;/span&gt;
              &lt;span class="s"&gt;body: JSON.stringify(payload)&lt;/span&gt;
            &lt;span class="s"&gt;});&lt;/span&gt;

            &lt;span class="s"&gt;if (!openaiRes.ok) {&lt;/span&gt;
              &lt;span class="s"&gt;const text = await openaiRes.text();&lt;/span&gt;
              &lt;span class="s"&gt;core.setFailed(`OpenAI API Error: ${openaiRes.status} ${text}`);&lt;/span&gt;
              &lt;span class="s"&gt;return;&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;

            &lt;span class="s"&gt;const data = await openaiRes.json();&lt;/span&gt;
            &lt;span class="s"&gt;const reviewContent =&lt;/span&gt;
              &lt;span class="s"&gt;data.choices?.[0]?.message?.content?.trim() || "無法取得回應";&lt;/span&gt;

            &lt;span class="s"&gt;core.info("OpenAI review completed.");&lt;/span&gt;
            &lt;span class="s"&gt;core.info("Preview (first 200 chars):");&lt;/span&gt;
            &lt;span class="s"&gt;core.info(reviewContent.slice(0, 200));&lt;/span&gt;

            &lt;span class="s"&gt;// ===== 把結果貼到 Gitea PR comment =====&lt;/span&gt;
            &lt;span class="s"&gt;if (giteaToken &amp;amp;&amp;amp; serverUrl &amp;amp;&amp;amp; repo &amp;amp;&amp;amp; prNumber) {&lt;/span&gt;
              &lt;span class="s"&gt;const apiUrl = `${serverUrl}/api/v1/repos/${repo}/issues/${prNumber}/comments`;&lt;/span&gt;
              &lt;span class="s"&gt;const body = {&lt;/span&gt;
                &lt;span class="s"&gt;body: `### 🤖 OpenAI Code Review\n\n${reviewContent}`&lt;/span&gt;
              &lt;span class="s"&gt;};&lt;/span&gt;

              &lt;span class="s"&gt;core.info(`Posting review comment to: ${apiUrl}`);&lt;/span&gt;

              &lt;span class="s"&gt;const res = await fetch(apiUrl, {&lt;/span&gt;
                &lt;span class="s"&gt;method: "POST",&lt;/span&gt;
                &lt;span class="s"&gt;headers: {&lt;/span&gt;
                  &lt;span class="s"&gt;"Authorization": `token ${giteaToken}`,&lt;/span&gt;
                  &lt;span class="s"&gt;"Content-Type": "application/json"&lt;/span&gt;
                &lt;span class="s"&gt;},&lt;/span&gt;
                &lt;span class="s"&gt;body: JSON.stringify(body)&lt;/span&gt;
              &lt;span class="s"&gt;});&lt;/span&gt;

              &lt;span class="s"&gt;if (!res.ok) {&lt;/span&gt;
                &lt;span class="s"&gt;const text = await res.text();&lt;/span&gt;
                &lt;span class="s"&gt;core.setFailed(`Failed to post comment: ${res.status} ${text}`);&lt;/span&gt;
                &lt;span class="s"&gt;return;&lt;/span&gt;
              &lt;span class="s"&gt;} else {&lt;/span&gt;
                &lt;span class="s"&gt;core.info("Comment posted successfully.");&lt;/span&gt;
              &lt;span class="s"&gt;}&lt;/span&gt;
            &lt;span class="s"&gt;} else {&lt;/span&gt;
              &lt;span class="s"&gt;core.info("GITEA_TOKEN / SERVER_URL / REPO / PR_NUMBER 不完整，略過貼 PR comment。");&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;

            &lt;span class="s"&gt;// ===== 發 Discord 通知 =====&lt;/span&gt;
            &lt;span class="s"&gt;if (discordWebhook) {&lt;/span&gt;
              &lt;span class="s"&gt;const message =&lt;/span&gt;
                &lt;span class="s"&gt;`🤖 **Code Review 完成**\n\n` +&lt;/span&gt;
                &lt;span class="s"&gt;`OpenAI 已完成程式碼審查，請前往查看建議。\n\n` +&lt;/span&gt;
                &lt;span class="s"&gt;`標題：${prTitle}\n` +&lt;/span&gt;
                &lt;span class="s"&gt;`作者：${prAuthor}\n` +&lt;/span&gt;
                &lt;span class="s"&gt;`連結：${serverUrl}/${repo}/pulls/${prNumber}`;&lt;/span&gt;

              &lt;span class="s"&gt;const discordRes = await fetch(discordWebhook, {&lt;/span&gt;
                &lt;span class="s"&gt;method: "POST",&lt;/span&gt;
                &lt;span class="s"&gt;headers: {&lt;/span&gt;
                  &lt;span class="s"&gt;"Content-Type": "application/json"&lt;/span&gt;
                &lt;span class="s"&gt;},&lt;/span&gt;
                &lt;span class="s"&gt;body: JSON.stringify({ content: message })&lt;/span&gt;
              &lt;span class="s"&gt;});&lt;/span&gt;

              &lt;span class="s"&gt;if (!discordRes.ok) {&lt;/span&gt;
                &lt;span class="s"&gt;const text = await discordRes.text();&lt;/span&gt;
                &lt;span class="s"&gt;core.setFailed(`Failed to send Discord notification: ${discordRes.status} ${text}`);&lt;/span&gt;
              &lt;span class="s"&gt;} else {&lt;/span&gt;
                &lt;span class="s"&gt;core.info("Discord notification sent.");&lt;/span&gt;
              &lt;span class="s"&gt;}&lt;/span&gt;
            &lt;span class="s"&gt;} else {&lt;/span&gt;
              &lt;span class="s"&gt;core.info("No DISCORD_WEBHOOK provided, skip Discord notification.");&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;將此 workflow 檔案 commit 到主分支（main 或 master）。&lt;/p&gt;

&lt;h3&gt;
  
  
  新增分支
&lt;/h3&gt;

&lt;p&gt;建立一個新的開發分支來進行測試，可以用介面操作，也可以下指令：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; feature/test-review
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  建立測試用 JS 檔
&lt;/h3&gt;

&lt;p&gt;在專案根目錄建立 &lt;code&gt;index.js&lt;/code&gt;，並故意寫入一些常見的錯誤：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 測試用程式碼 - 包含一些常見問題&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 問題 1：缺少參數驗證&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 問題 2：使用 var 而非 let/const&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;discount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 問題 3：可能的精度問題&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 問題 4：未處理的 Promise&lt;/span&gt;
&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// 問題 5：使用 == 而非 ===&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;calculateTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;計算正確&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這個範例包含了多個常見問題，OpenAI 應該能夠識別並提供改善建議。&lt;/p&gt;

&lt;h3&gt;
  
  
  Commit 並建立 Pull Request
&lt;/h3&gt;

&lt;p&gt;將變更 commit 並推送到遠端，可以用介面操作，也可以下指令：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add index.js
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add test file for code review"&lt;/span&gt;
git push origin feature/test-review
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;回到 Gitea 介面，建立一個從 &lt;code&gt;feature/test-review&lt;/code&gt; 到 &lt;code&gt;main&lt;/code&gt; 的 Pull Request：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpxc4hb05n34z1t5r1nf2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpxc4hb05n34z1t5r1nf2.png" alt="Gitea 介面中的建立 Pull Request 畫面" width="800" height="581"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;填寫 PR 標題和描述後，點選「建立合併請求」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa9qrm5oykysdhs9slpzc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa9qrm5oykysdhs9slpzc.png" alt="建立合併請求按鈕畫面" width="800" height="724"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  在 Gitea 上查看結果
&lt;/h3&gt;

&lt;p&gt;PR 建立後，Gitea Actions 會自動觸發。我們可以在以下位置查看執行狀態：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Actions 執行狀態&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;在 PR 頁面下方可以看到 Actions 的執行狀態：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffrfi06034pedaw0w679z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffrfi06034pedaw0w679z.png" alt="Gitea Actions 在 Pull Request 中的執行狀態" width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;點選可以查看詳細的執行 log：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu1m9jcoi9h5xv7svxjvt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu1m9jcoi9h5xv7svxjvt.png" alt="Gitea Actions 執行 log 詳細畫面" width="800" height="577"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;第一次執行會比較久，後續再執行就會比較快。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. OpenAI 的 Review 結果&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;當 Actions 執行完成後，OpenAI 會在 PR 中自動留言，提供詳細的程式碼審查報告：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxlgvfodoazjvs4lgfh10.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxlgvfodoazjvs4lgfh10.png" alt="OpenAI 自動在 Pull Request 中留言審查結果" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Review 報告會包含：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Findings Summary 表格&lt;/strong&gt;：列出前 5 大問題的摘要。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detailed Review&lt;/strong&gt;：每個問題的詳細說明，包括風險評估和改善建議。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;程式碼範例&lt;/strong&gt;：使用 diff 格式展示建議的修改。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Discord 通知（若有設定）&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;如果有設定 Discord Webhook，團隊成員也會在 Discord 頻道收到通知：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs9o3t8bk10kzs5cdy3yv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs9o3t8bk10kzs5cdy3yv.jpg" alt="Discord 收到來自 Gitea PR Code Review 的通知訊息" width="800" height="553"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  結語
&lt;/h2&gt;

&lt;p&gt;透過這套自動化流程，我們可以在每次 Pull Request 時獲得即時的程式碼審查建議。&lt;/p&gt;

&lt;p&gt;雖然 AI 無法完全取代人工審查，但它能有效地：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;捕捉常見的程式碼問題和 anti-patterns。&lt;/li&gt;
&lt;li&gt;提供一致性的審查標準。&lt;/li&gt;
&lt;li&gt;節省人工審查的時間。&lt;/li&gt;
&lt;li&gt;作為程式碼品質的第一道防線。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果將 AI 審查作為輔助工具，在重要的 PR 中搭配人工審查，確保程式碼品質和業務邏輯的正確性。&lt;/p&gt;

</description>
      <category>gitea</category>
      <category>openai</category>
      <category>codereview</category>
      <category>ai</category>
    </item>
    <item>
      <title>圖片壓縮：用 Compressor.js 自動調整品質壓縮至指定大小</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Sat, 04 Oct 2025 03:49:43 +0000</pubDate>
      <link>https://dev.to/letswrite/tu-pian-ya-suo-yong-compressorjs-zi-dong-diao-zheng-pin-zhi-ya-suo-zhi-zhi-ding-da-xiao-2j18</link>
      <guid>https://dev.to/letswrite/tu-pian-ya-suo-yong-compressorjs-zi-dong-diao-zheng-pin-zhi-ya-suo-zhi-zhi-ding-da-xiao-2j18</guid>
      <description>&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;很多網站功能會需要處理使用者上傳的圖片，比方讓使用者上傳會員照片。&lt;/p&gt;

&lt;p&gt;但隨著手機相機愈做愈好，拍出來的照片隨便都是幾 MB，直接上傳的話，耗時也佔空間。&lt;/p&gt;

&lt;p&gt;雖然網路上搜尋有許多圖片壓縮工具，但大多只能設定固定的壓縮的品質，無法保證壓縮後的檔案大小符合需求。&lt;/p&gt;

&lt;p&gt;本筆記文將使用 Compressor.js 套件，實作一個圖片壓縮功能，符合以下需求：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;自動嘗試不同的壓縮品質，直到檔案小於指定大小（ex: 600KB）為止。&lt;/li&gt;
&lt;li&gt;將圖片轉換為 WebP 格式。&lt;/li&gt;
&lt;li&gt;長、寬限制最大尺寸。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這樣就可以確保壓縮後的圖片既符合檔案大小限制，又保有良好的質感。&lt;/p&gt;




&lt;h2&gt;
  
  
  核心概念：遞迴壓縮
&lt;/h2&gt;

&lt;p&gt;一般網路上的圖片壓縮只能一次性設定品質參數，本篇用了 comporess.js 後，採用 &lt;strong&gt;遞迴嘗試&lt;/strong&gt; 的方式，執行方式如下：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;從最高品質(&lt;code&gt;quality = 1.0&lt;/code&gt;)開始壓縮。&lt;/li&gt;
&lt;li&gt;檢查壓縮後的檔案大小。&lt;/li&gt;
&lt;li&gt;如果超過目標大小 600KB，則降低品質（減少 0.05）後重新壓縮。&lt;/li&gt;
&lt;li&gt;重複步驟 2-3，直到檔案符合大小要求，或品質降到下限（0.40）。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;這種方法能夠在保證檔案大小的前提下，盡可能保留圖片品質。&lt;/p&gt;




&lt;h2&gt;
  
  
  引入 Compressor.js
&lt;/h2&gt;

&lt;p&gt;首先，我們需要引入 Compressor.js 這個圖片壓縮套件。&lt;/p&gt;

&lt;p&gt;它可以在瀏覽器端直接處理圖片：無需後端支援:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/compressorjs@1.2.1/dist/compressor.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;上面是直接引用 CDN 的方式，&lt;a href="https://github.com/fengyuanchen/compressorjs" rel="noopener noreferrer"&gt;官方文件&lt;/a&gt; 也有其他引用方式，可以自己的需求引用。&lt;/p&gt;




&lt;h2&gt;
  
  
  建立 HTML
&lt;/h2&gt;

&lt;p&gt;我們需要一個檔案上傳的 file input，以及一個用來預覽壓縮結果的 div：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"upload"&lt;/span&gt; &lt;span class="na"&gt;accept=&lt;/span&gt;&lt;span class="s"&gt;"image/*"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"preview"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"max-width:300px"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  實作智慧壓縮函式
&lt;/h2&gt;

&lt;p&gt;這是整個方案的核心函式，接受四個參數:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;file&lt;/code&gt;：要壓縮的原始檔案。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;targetSize&lt;/code&gt;：目標檔案大小，預設 600KB。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;floor&lt;/code&gt;：品質下限，預設 0.40。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;step&lt;/code&gt;：每次品質遞減幅度，預設 0.05。
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compressWithFloor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;floor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Compressor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 強制轉換為 WebP 格式&lt;/span&gt;
        &lt;span class="na"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2560&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 最大寬度限制&lt;/span&gt;
        &lt;span class="na"&gt;maxHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 最大高度限制&lt;/span&gt;
        &lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;`q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;  size=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;KB`&lt;/span&gt;
          &lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 繼續壓縮&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 符合大小或到達品質下限&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  參數說明
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. 強制輸出 WebP 格式&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mimeType: 'image/webp'&lt;/code&gt; 會將所有圖片（包括 PNG、JPEG）都轉換為 WebP。&lt;/p&gt;

&lt;p&gt;WebP 是 Google 開發的現代圖片格式。在相同品質下。檔案大小通常比 JPEG 小 25-35%。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. 尺寸限制&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;maxWidth: 2560&lt;/code&gt; 和 &lt;code&gt;maxHeight: 1440&lt;/code&gt; 確保圖片不會超過 2K 解析度。&lt;/p&gt;

&lt;p&gt;如果原圖尺寸較小，不會被放大;&lt;/p&gt;

&lt;p&gt;如果較大，會等比例縮小。&lt;/p&gt;

&lt;p&gt;這對於手機拍攝的 4K、8K 照片特別有用。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. 遞迴壓縮邏輯&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 繼續壓縮&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這段程式碼檢查兩個條件：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;檔案是否仍大於目標大小？&lt;/li&gt;
&lt;li&gt;品質是否還有下降空間？&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;只要 1、2 都符合，就會降低品質並重新嘗試壓縮。&lt;/p&gt;




&lt;h2&gt;
  
  
  Blob 轉 Base64 輔助函式
&lt;/h2&gt;

&lt;p&gt;為了在瀏覽器中預覽和下載圖片，或是調用 API 傳到後端，我們需要將壓縮後的 Blob 物件轉換為 Base64 格式：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;blobToBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FileReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onloadend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readAsDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  整合上傳與下載功能
&lt;/h2&gt;

&lt;p&gt;最後，我們監聽檔案上傳事件，執行壓縮流程，並自動觸發下載:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;upload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;compressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;compressWithFloor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;final size:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compressed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;blobToBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compressed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;preview&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 取出原始檔名(去掉副檔名)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originalName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.[^/&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newFileName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;originalName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-compress.webp`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 建立下載連結並自動觸發&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newFileName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;壓縮失敗:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  檔名處理邏輯
&lt;/h3&gt;

&lt;p&gt;程式會自動處理檔名:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;移除原始副檔名：&lt;code&gt;file.name.replace(/\.[^/.]+$/, "")&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;加上 &lt;code&gt;-compress.webp&lt;/code&gt; 後綴。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;例如：&lt;code&gt;vacation.jpg&lt;/code&gt; → &lt;code&gt;vacation-compress.webp&lt;/code&gt;。&lt;/p&gt;




&lt;h2&gt;
  
  
  壓縮流程示例
&lt;/h2&gt;

&lt;p&gt;假設上傳一張 5MB 的照片，壓縮過程可能如下:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;q=1.00  size=1200KB  → 超過 600KB，繼續
q=0.95  size=950KB   → 超過 600KB，繼續
q=0.90  size=750KB   → 超過 600KB，繼續
q=0.85  size=580KB   → 符合要求，完成！
final size: 580 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;如果原圖品質很差，即使降到最低品質仍超過 600KB，程式也會在 &lt;code&gt;q=0.40&lt;/code&gt; 時停止，避免過度壓縮導致圖片難以辨識。&lt;/p&gt;




&lt;h2&gt;
  
  
  完整程式碼
&lt;/h2&gt;

&lt;p&gt;以下是完整的可執行範例：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"zh-TW"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"UTF-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1.0"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Let's write - 圖片壓縮&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Let's write - 圖片壓縮工具&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;筆記文：&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://www.letswrite.tw/compressjs/"&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"upload"&lt;/span&gt; &lt;span class="na"&gt;accept=&lt;/span&gt;&lt;span class="s"&gt;"image/*"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"preview"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"max-width:300px; margin-top:20px;"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/compressorjs@1.2.1/dist/compressor.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
      &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compressWithFloor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;floor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Compressor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2560&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;maxHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                  &lt;span class="s2"&gt;`q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;  size=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;KB`&lt;/span&gt;
                &lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                  &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                  &lt;span class="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                  &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
              &lt;span class="p"&gt;},&lt;/span&gt;
              &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
              &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
          &lt;span class="p"&gt;};&lt;/span&gt;

          &lt;span class="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;blobToBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FileReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onloadend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readAsDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c1"&gt;// 依序嘗試 q=1.00, 0.95, 0.90, ... ，直到 &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="nx"&gt;KB&lt;/span&gt; &lt;span class="nx"&gt;或&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mf"&gt;0.40&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compressWithFloor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;floor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Compressor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 強制轉 WebP&lt;/span&gt;
            &lt;span class="na"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2560&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 最大寬度限制&lt;/span&gt;
            &lt;span class="na"&gt;maxHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 最大高度限制&lt;/span&gt;
            &lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s2"&gt;`q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;  size=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;KB`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="p"&gt;);&lt;/span&gt;
              &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 繼續壓縮&lt;/span&gt;
              &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 符合大小或到達品質下限&lt;/span&gt;
              &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;

        &lt;span class="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Blob -&amp;gt; base64&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;blobToBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FileReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onloadend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readAsDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;upload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// 檢查原始檔案大小&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;targetSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s2"&gt;`原始檔案大小 &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;KB，已小於目標大小 &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;KB，不需壓縮`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`圖片大小已符合要求 (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;KB)，無需壓縮`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 仍然顯示預覽&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;blobToBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;preview&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;compressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;compressWithFloor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;final size:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compressed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;blobToBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compressed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;preview&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originalName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.[^/&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newFileName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;originalName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-compress.webp`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newFileName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;壓縮失敗:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Demo 與原始碼
&lt;/h2&gt;

&lt;p&gt;想要實際體驗圖片壓縮工具的話，以下是完整的 Demo 頁面和原始碼供大家使用。&lt;/p&gt;

&lt;p&gt;使用前，如果這個工具對你有幫助，歡迎到 GitHub 給我一顆星星 ⭐，你的小小動作，對本站都是大大的鼓勵。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Demo&lt;/strong&gt;：&lt;a href="https://letswritetw.github.io/letswrite-compressjs/" rel="noopener noreferrer"&gt;Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub 原始碼&lt;/strong&gt;：&lt;a href="https://github.com/letswritetw/letswrite-compressjs" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>javascript</category>
    </item>
    <item>
      <title>使用 pm2.web 建立免費 PM2 監控系統</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Fri, 26 Sep 2025 13:16:21 +0000</pubDate>
      <link>https://dev.to/letswrite/shi-yong-pm2web-jian-li-mian-fei-pm2-jian-kong-xi-tong-5dim</link>
      <guid>https://dev.to/letswrite/shi-yong-pm2web-jian-li-mian-fei-pm2-jian-kong-xi-tong-5dim</guid>
      <description>&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;PM2 是 Node.js 裡常用的 process manager，一般如果是透過網頁監控、重啟，大概會使用官方的 &lt;a href="https://app.pm2.io/" rel="noopener noreferrer"&gt;Keymetrics&lt;/a&gt;。&lt;/p&gt;

&lt;p&gt;但，But！就是這個 But！免費版最多只能監控 4 個 Process，再多就要掏出魔法小卡了。&lt;/p&gt;

&lt;p&gt;問了 ChatGPT 後，發現有一個開源的替代方案：&lt;a href="https://github.com/oxdev03/pm2.web" rel="noopener noreferrer"&gt;pm2.web&lt;/a&gt;，可以自己架設，不管幾個 process 都完全免費。&lt;/p&gt;

&lt;p&gt;以下筆記如何使用 Vercel + MongoDB Atlas 部署 pm2.web，免費監控我們的 PM2。&lt;/p&gt;




&lt;h2&gt;
  
  
  架構概念
&lt;/h2&gt;

&lt;p&gt;pm2.web 分成兩部分，如下。&lt;/p&gt;

&lt;h3&gt;
  
  
  Dashboard
&lt;/h3&gt;

&lt;p&gt;Next.js 寫前端 + API。&lt;/p&gt;

&lt;p&gt;本篇選擇放在 Vercel 執行。&lt;/p&gt;

&lt;h3&gt;
  
  
  Backend
&lt;/h3&gt;

&lt;p&gt;官方文件裡叫 Backend，其實它就是跑在目標伺服器上的 Agent。&lt;/p&gt;

&lt;p&gt;Agent 在我們要監控的伺服器上，透過 PM2 BUS API 讀取 process 狀態，再把資料寫到 MongoDB。&lt;/p&gt;

&lt;p&gt;Dashboard 與 Backend 之間是透過 &lt;strong&gt;MongoDB Atlas&lt;/strong&gt; 溝通，不需要開防火牆或暴露 192.168.* 內網。&lt;/p&gt;




&lt;h2&gt;
  
  
  步驟一：MongoDB Atlas 設定
&lt;/h2&gt;

&lt;p&gt;前往 &lt;a href="https://www.mongodb.com/cloud/atlas" rel="noopener noreferrer"&gt;MongoDB Atlas&lt;/a&gt; 建一個免費 cluster。&lt;/p&gt;

&lt;p&gt;複製連線字串，待會會用到：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fodyt53khwuc7o1o8bafc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fodyt53khwuc7o1o8bafc.png" alt="MongoDB Atlas 連線字串複製畫面" width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;在 &lt;strong&gt;Network Access&lt;/strong&gt; 加上 &lt;code&gt;0.0.0.0/0&lt;/code&gt;（因為 Vercel 是浮動 IP，否則無法連線）：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F10rt78fl2ioa7xi433vi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F10rt78fl2ioa7xi433vi.png" alt="MongoDB Atlas Network Access 設定介面" width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0c9gtn0lxnxgu6opq6lm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0c9gtn0lxnxgu6opq6lm.png" alt="允許所有 IP 存取 MongoDB Atlas" width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  步驟二：Vercel 部署 Dashboard
&lt;/h2&gt;

&lt;p&gt;點擊 &lt;a href="https://github.com/oxdev03/pm2.web/blob/master/apps/dashboard/README.md#setup-1" rel="noopener noreferrer"&gt;官方文件&lt;/a&gt; 上的「Deploy」按鈕：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa69cte0f2c4bz6t2b28a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa69cte0f2c4bz6t2b28a.png" alt="Vercel 部署 pm2.web 的 Deploy 按鈕" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;設定環境變數：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;MONGODB_URI&lt;/code&gt; = 我們的 Atlas 連線字串&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NEXTAUTH_SECRET&lt;/code&gt; = 自己生成的隨機字串（可用 &lt;code&gt;openssl rand -base64 32&lt;/code&gt;，或直接開啟 &lt;a href="https://generate-secret.vercel.app/32" rel="noopener noreferrer"&gt;https://generate-secret.vercel.app/32&lt;/a&gt;）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frjegn6xj4hswqn6xbuhd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frjegn6xj4hswqn6xbuhd.png" alt="在 Vercel 設定 pm2.web 所需的環境變數" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;第一次部署後，一定會出現失敗，免緊張、免害怕，選單點擊 Settings → Build &amp;amp; Development Settings → Framework Preset，選 &lt;strong&gt;Next.js&lt;/strong&gt;，之後再重新 Redeploy 一次，就 OK 了。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fluzq0mm06qop210nvlol.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fluzq0mm06qop210nvlol.png" alt="選擇 Next.js 作為 Vercel 的 Framework" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs6luot1w8x4go9mw4ohg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs6luot1w8x4go9mw4ohg.png" alt="重新部署 Vercel 專案" width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;部署完成後，打開網址會進入註冊頁。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1kpz7il1lg0xny458wql.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1kpz7il1lg0xny458wql.png" alt="pm2.web Dashboard 的使用者註冊頁面" width="800" height="755"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;重要提醒&lt;/strong&gt;：第一個註冊的帳號會自動成為 owner，之後就能在 UI 裡管理權限。&lt;/p&gt;




&lt;h2&gt;
  
  
  步驟三：安裝 Backend Agent
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;官方文件&lt;/strong&gt;：&lt;a href="https://github.com/oxdev03/pm2.web/blob/master/apps/backend/README.md" rel="noopener noreferrer"&gt;pm2.web - Backend&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;假設內網伺服器 IP 是 &lt;code&gt;192.168.1.2&lt;/code&gt;，進入主機後：&lt;/p&gt;

&lt;h3&gt;
  
  
  下載與安裝
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/oxdev03/pm2.web.git
&lt;span class="nb"&gt;cd &lt;/span&gt;pm2.web

&lt;span class="c"&gt;# 根目錄先安裝（避免 pm2 報錯）&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="c"&gt;# 進入 backend 目錄&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;apps/backend
npm &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="c"&gt;# 複製環境變數範例&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  設定環境變數
&lt;/h3&gt;

&lt;p&gt;編輯 &lt;code&gt;.env&lt;/code&gt; 檔：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MONGODB_URI=我們的 Atlas 連線字串
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  啟動 Agent
&lt;/h3&gt;

&lt;p&gt;接著，終端機要回到專案的根目錄，不能停留在 backend 的資料夾中。&lt;/p&gt;

&lt;p&gt;執行以下：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 start npm &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"pm2.web"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; run &lt;span class="s2"&gt;"start:apps:backend"&lt;/span&gt;
pm2 save
pm2 startup  &lt;span class="c"&gt;# 重開機後會自動啟動&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;接著瀏覽器打開 Vercel 提供的網址，就會看到 Dashboard 上出現資料了：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy0ccgs5uyli2cpcued24.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy0ccgs5uyli2cpcued24.png" alt="pm2.web Dashboard 成功顯示 PM2 資料" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;這樣 Backend 就會定期把 PM2 狀態推送到 Atlas，Dashboard 會自動讀取。&lt;/p&gt;




&lt;h2&gt;
  
  
  效果展示
&lt;/h2&gt;

&lt;p&gt;在 Dashboard 上我們可以：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;查看所有 process 的狀態（Running、CPU、Memory 使用率）&lt;/li&gt;
&lt;li&gt;遠端重啟、停止 process&lt;/li&gt;
&lt;li&gt;監控多台伺服器的 PM2 狀態&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fav5rgdp9vz798qwpkkc7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fav5rgdp9vz798qwpkkc7.png" alt="pm2.web 顯示多個 Process 狀態的畫面" width="800" height="527"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  總結
&lt;/h2&gt;

&lt;p&gt;照著本筆記文的方式依步驟執行後，我們就能用 Vercel + MongoDB Atlas + pm2.web，打造一個：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ 完全免費&lt;/li&gt;
&lt;li&gt;✅ 介面美觀，有深色模式&lt;/li&gt;
&lt;li&gt;✅ 可控管使用者權限&lt;/li&gt;
&lt;li&gt;✅ 透過網站進行多機管理&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;的 PM2 監控系統，不用因為沒有魔法小卡，被 4 個 process 給限制。&lt;/p&gt;




&lt;h2&gt;
  
  
  參考資源
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/oxdev03/pm2.web" rel="noopener noreferrer"&gt;pm2.web GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.mongodb.com/cloud/atlas" rel="noopener noreferrer"&gt;MongoDB Atlas&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vercel.com/" rel="noopener noreferrer"&gt;Vercel&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>vercel</category>
      <category>pm2</category>
      <category>mongodb</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>使用 Docker 搭配 Node.js 快速建置 Redis 快取系統</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Tue, 10 Jun 2025 12:54:09 +0000</pubDate>
      <link>https://dev.to/letswrite/shi-yong-docker-da-pei-nodejs-kuai-su-jian-zhi-redis-kuai-qu-xi-tong-26jl</link>
      <guid>https://dev.to/letswrite/shi-yong-docker-da-pei-nodejs-kuai-su-jian-zhi-redis-kuai-qu-xi-tong-26jl</guid>
      <description>&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;我們在開發時，常常會遇到這樣的情況：&lt;/p&gt;

&lt;p&gt;同一個頁面一進來，取得的資料很多都是相同的，比如一個商品的基本資訊、公告訊息的內文。&lt;/p&gt;

&lt;p&gt;而大部份資料是由前端調用 API，API 再去資料庫查詢資料，所以會明明資料都是相同的，但每次都要耗掉效能去運算，流量多了，會大量消耗資料庫資源，接著讓系統變慢，使用者等待時間變長。&lt;/p&gt;

&lt;p&gt;Redis 的橫空出世，就可以解決這個問題。&lt;/p&gt;

&lt;p&gt;Redis 是一個快取資料庫，它的工作就像是一個超快速的暫存櫃。&lt;/p&gt;

&lt;p&gt;比方我們去買早餐，如果同一個三明治，早餐店店員都是客人來了才現包，那就要花很多時間準備，客人就要等很久，久了生意就會不好，生意不好就沒有酷 $$ 買烏薩奇寶寶娃娃。&lt;/p&gt;

&lt;p&gt;Redis 快取就像是台子上先放好大家常買的三明治，客人來了就直接拿取然後結帳，不用一直等店員製作，速度快很多，這樣子 $$ 進來的也快。&lt;/p&gt;

&lt;p&gt;情境換成網站上，Redis 就是把常用的資料放在記憶體裡，這樣 API 就能快速回應，加強使用者體驗。&lt;/p&gt;

&lt;p&gt;本篇筆記文會示範如何用 Redis 來做快取機制，並用 Docker 簡單快速地部署 Redis 環境，搭配 Node.js 實作。&lt;/p&gt;




&lt;h2&gt;
  
  
  用 Docker 安裝 Redis
&lt;/h2&gt;

&lt;p&gt;使用 Docker 來安裝 Redis，可以直接複製貼上以下的 &lt;strong&gt;docker-compose.yml&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;以下 docker-compose.yml 的內容，會同時啟動兩個服務：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;redis-server&lt;/strong&gt;：Redis 伺服器。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redis-insight&lt;/strong&gt;：Redis 官方提供的視覺化管理工具，方便查看和管理 Redis 資料。
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.8"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:8.0.2-alpine&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-server&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6379:6379"&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;redis-server --maxmemory 2gb --maxmemory-policy allkeys-lru&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data:/data&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;redisinsight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis/redisinsight:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-insight&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5540:5540"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redis-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  重要說明
&lt;/h3&gt;

&lt;p&gt;Redis 使用 &lt;code&gt;redis:8.0.2-alpine&lt;/code&gt; 這個輕量級的映像檔，是這篇文章寫作時，截至 2025/6 為止的最新版本。&lt;/p&gt;

&lt;p&gt;設定 &lt;code&gt;--maxmemory 2gb&lt;/code&gt;，是限制 Redis 最多使用 2GB 記憶體，超過時用 &lt;code&gt;allkeys-lru&lt;/code&gt; 策略自動淘汰最少使用的資料。避免塞太多資料讓記憶體先炸掉。&lt;/p&gt;

&lt;p&gt;RedisInsight 會開在本機的 5540 PORT，可以用瀏覽器打開 &lt;code&gt;http://localhost:5540&lt;/code&gt; 來管理 Redis。&lt;/p&gt;

&lt;p&gt;啟動指令：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這樣就會在背景啟動 Redis 服務和 RedisInsight。&lt;/p&gt;




&lt;h2&gt;
  
  
  Node.js 執行 Redis
&lt;/h2&gt;

&lt;p&gt;在 Node.js 裡，我們用 &lt;code&gt;ioredis&lt;/code&gt; 這個套件來連接 Redis，操作起來很簡單。&lt;/p&gt;

&lt;p&gt;以下示範基本的快取讀寫範例，並附上幾個常用的 Redis 操作。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ioredis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 連到預設 localhost:6379&lt;/span&gt;

&lt;span class="c1"&gt;// 範例資料&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello August&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// 寫入快取，並設定 60 秒過期時間&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api:result&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 讀取快取&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api:result&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;讀到快取資料：&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;快取不存在或已過期&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 常用 Redis 操作示範：&lt;/span&gt;

&lt;span class="c1"&gt;// 1. 設定鍵值並設定過期時間（EX: 秒，PX: 毫秒）&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user:123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;August&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. 取得鍵值&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userStr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user:123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. 刪除鍵&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user:123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 4. 檢查鍵是否存在，回傳 1 或 0&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api:result&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 5. 自增計數器&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;page:view&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 6. 取得自增後的值&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;views&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;page:view&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`頁面瀏覽數：&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;views&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這些指令是常見的快取或計數器操作，可以依照需求再自行翻閱 &lt;a href="https://github.com/redis/ioredis" rel="noopener noreferrer"&gt;官方文件&lt;/a&gt;，&lt;del&gt;或是問 ChatGPT&lt;/del&gt;。&lt;/p&gt;




&lt;h2&gt;
  
  
  用 RedisInsight 查看資料
&lt;/h2&gt;

&lt;p&gt;RedisInsight 是 Redis 官方的 GUI 管理工具，在前述的 docker-copmose.yml 就一併安裝好了，在瀏覽器輸入以下網址就可以看到：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:5540
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;連接預設的 Redis 伺服器（&lt;code&gt;localhost:6379&lt;/code&gt;），就可以用圖形介面：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;查詢所有快取鍵值&lt;/li&gt;
&lt;li&gt;查看資料內容（支援 JSON、String、List、Set、Hash 等類型）&lt;/li&gt;
&lt;li&gt;監控 Redis 記憶體使用狀況&lt;/li&gt;
&lt;li&gt;設定過期時間與刪除資料&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;實作時，覺得有個介面看，比裝厲害下指令查要舒服多了。&lt;/p&gt;

</description>
      <category>docker</category>
      <category>redis</category>
      <category>node</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>用 Docker 快速架設 Node.js 靜態網站服務</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Sat, 07 Jun 2025 02:34:35 +0000</pubDate>
      <link>https://dev.to/letswrite/yong-docker-kuai-su-jia-she-nodejs-jing-tai-wang-zhan-fu-wu-5k0</link>
      <guid>https://dev.to/letswrite/yong-docker-kuai-su-jia-she-nodejs-jing-tai-wang-zhan-fu-wu-5k0</guid>
      <description>&lt;h2&gt;
  
  
  本篇要解決什麼問題
&lt;/h2&gt;

&lt;p&gt;對前端工程師來說，有時只是單純要一個靜態網站，讓主管或業者看到 Demo，卻因為後端、Nginx、Linux 等設定卡關半天。這時候，其實可以用 Node.js 搭配 Express，再加上 Docker，簡單幾個指令，就能快速建起一個 Demo 網站了。&lt;/p&gt;




&lt;h2&gt;
  
  
  使用 Node.js + Express 快速提供靜態檔案服務
&lt;/h2&gt;

&lt;p&gt;本篇使用了以下技術與工具：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js + Express：提供簡易的靜態檔服務。&lt;/li&gt;
&lt;li&gt;Docker：容器化應用，讓執行環境一致、方便搬移與佈署。&lt;/li&gt;
&lt;li&gt;docker-compose：簡化啟動流程與指令。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;以下檔案，新增在我們想要架起 Demo 的專案資料夾中。&lt;/p&gt;

&lt;h3&gt;
  
  
  server.js
&lt;/h3&gt;

&lt;p&gt;server.js 檔裡，主要是讓我們使用 Express 來讓 Node.js 能提供靜態檔案的服務，程式碼如下：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// PORT 要修改成自己需要的&lt;/span&gt;

&lt;span class="c1"&gt;// 提供靜態檔案（例如 index.html、images、js、css）&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;static&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Server is running on http://localhost:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這段程式會讓 Node.js 在本機的 3001 PORT 上開啟一個伺服器，並提供當前目錄底下的所有檔案給瀏覽器存取。&lt;/p&gt;

&lt;p&gt;PORT 請自行修改想要的，這邊寫 3001 是要避開常用到的 3000。&lt;/p&gt;

&lt;h3&gt;
  
  
  package.json
&lt;/h3&gt;

&lt;p&gt;server.js 裡有使用 express，因此 package.json 需要安裝。&lt;/p&gt;

&lt;p&gt;如果專案裡本來就有 package.json，那就直接執行：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;express
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;如果還沒有 package.json，那就執行以下：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;express
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  docker-compose.yml
&lt;/h3&gt;

&lt;p&gt;這是讓整個 Node.js 專案能在 Docker 裡運行的設定檔，使用官方 node image 搭配 volume 掛載當前資料夾，並自動執行 server：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.8"&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:23-alpine3.20&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3001:3001"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.:/usr/src/app&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/src/app&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node server.js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這邊的 ports，要跟 server.js 裡寫得一樣。&lt;/p&gt;

&lt;h3&gt;
  
  
  .dockerignore
&lt;/h3&gt;

&lt;p&gt;讓 Docker 避免將不必要的檔案打包進去，例如 &lt;code&gt;.git&lt;/code&gt;、&lt;code&gt;node_modules&lt;/code&gt; 等：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node_modules
npm-debug.log
Dockerfile
docker-compose.yml
.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;有了以上四個檔案，就可以準備用 Docker 架起 Demo 網站了。&lt;/p&gt;

&lt;p&gt;四個檔案都在同一專案資料夾中。&lt;/p&gt;




&lt;h2&gt;
  
  
  啟動
&lt;/h2&gt;

&lt;p&gt;本機必須要先安裝好 Docker：&lt;a href="https://www.docker.com/products/docker-desktop/" rel="noopener noreferrer"&gt;https://www.docker.com/products/docker-desktop/&lt;/a&gt;。&lt;/p&gt;

&lt;p&gt;有了 Docker 後，在專案目錄下執行以下指令：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;接著打開瀏覽器，輸入 &lt;a href="http://localhost:3001%EF%BC%8C%E5%B0%B1%E5%8F%AF%E4%BB%A5%E7%9C%8B%E5%88%B0%E4%BD%A0%E7%9A%84%E9%9D%9C%E6%85%8B%E7%B6%B2%E7%AB%99%E5%85%A7%E5%AE%B9%E4%BA%86%EF%BC%81" rel="noopener noreferrer"&gt;http://localhost:3001，就可以看到你的靜態網站內容了！&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  專案原始碼連結
&lt;/h2&gt;

&lt;p&gt;本篇說明的檔案，有放在 GitHub 上，取用前請先點擊星星，你的小小動作，對本站都是大大的鼓勵：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/letswritetw/letswrite-docker-node-static-site" rel="noopener noreferrer"&gt;https://github.com/letswritetw/letswrite-docker-node-static-site&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;如果你也常常需要快速測試一份靜態網頁，這個方法可以省下很多時間。&lt;/p&gt;

</description>
      <category>docker</category>
      <category>node</category>
      <category>frontend</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>iPhone 的語音辨識功能：語音備忘錄，自動標點分段</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Thu, 22 May 2025 11:51:00 +0000</pubDate>
      <link>https://dev.to/letswrite/iphone-de-yu-yin-bian-shi-gong-neng-yu-yin-bei-wang-lu-zi-dong-biao-dian-fen-duan-23c7</link>
      <guid>https://dev.to/letswrite/iphone-de-yu-yin-bian-shi-gong-neng-yu-yin-bei-wang-lu-zi-dong-biao-dian-fen-duan-23c7</guid>
      <description>&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;之前寫過幾篇，是用 OpenAI 的 Whisper API 來語音辨識的功能，都是免費可以使用的：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.letswrite.tw/colab-faster-whisper/" rel="noopener noreferrer"&gt;Google Colab + Faster Whisper&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.letswrite.tw/colab-whisper-large-v3/" rel="noopener noreferrer"&gt;Google Colab + Whisper large v3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.letswrite.tw/cloudflare-workers-ai-whisper/" rel="noopener noreferrer"&gt;Cloudflare Workers AI + Whisper&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;前陣子驚為天人的發現，原來 iPhone 有這功能了，還會自動加上標點符號以及分段，辨識的速度也很快，看來語音轉文字的功能，市場的需求量很大呀。&lt;/p&gt;

&lt;p&gt;語音備忘錄 App 的樣子：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd5bjbky7yadk25hwrnyz.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd5bjbky7yadk25hwrnyz.jpeg" alt="iPhone 語音備忘錄應用程式介面截圖" width="252" height="284"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  硬體要求
&lt;/h2&gt;

&lt;h3&gt;
  
  
  iPhone
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;iPhone 12 以上。&lt;/li&gt;
&lt;li&gt;iOS 18.0 或以上版本。&lt;/li&gt;
&lt;li&gt;支援英文、西班牙文、葡萄牙文、義大利文、法文、德文、日文、韓文、繁體中文、簡體中文。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Mac
&lt;/h3&gt;

&lt;p&gt;Mac 也可以，但要是 M 晶片的才行，作業系統是 macOS Sequoia 或以上版本。&lt;/p&gt;




&lt;h2&gt;
  
  
  使用 iPhone 語音轉文字
&lt;/h2&gt;

&lt;p&gt;語音備忘錄裡的音檔，副檔名必須是「.m4a」。&lt;/p&gt;

&lt;p&gt;如果直接在語音備忘錄裡點擊錄音，那就會是這個格式，如果是一般的音檔如 .mp3，就必須要進行轉檔，轉檔方式將在下一段說明，這段先寫怎麼用語音備忘錄來轉成文字。&lt;/p&gt;

&lt;p&gt;在語音備忘錄裡點擊音檔後，會看到一個正常的播放器，接著點擊左下角一個很像聲紋的按鈕：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcngsyzs2xb1ttcyte4m5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcngsyzs2xb1ttcyte4m5.jpg" alt="iPhone 語音備忘錄辨識按鈕位置說明圖" width="800" height="1562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;會看到音檔的聲紋，接著再點擊左下角有個「’’」的按鈕：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faamjlfe4tl3yo2i02oj8.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faamjlfe4tl3yo2i02oj8.jpg" alt="點擊 ’’ 按鈕" width="800" height="1578"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;點擊後，就會進行辨識，會看到畫面上呈現「正在轉錄…」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftjqhz0vy8cczbamq2dmt.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftjqhz0vy8cczbamq2dmt.jpg" alt="語音備忘錄進行語音辨識時顯示正在轉錄的畫面" width="800" height="1566"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;iPhone 的辨識速度很快，同樣一個音檔，竟然可以比 Faster Whisper 還快。&lt;/p&gt;

&lt;p&gt;辨識完後，就會看到畫面上呈現辨識結果：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4so0unnu237irhtn165a.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4so0unnu237irhtn165a.jpg" alt="語音備忘錄辨識完成後的文字顯示結果" width="800" height="1577"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;更讚的是，會還自動加上標點符，並將文字內容分段，這是 Whisper API 無法一次完成的功能。&lt;/p&gt;

&lt;p&gt;想要複製辨識的文字很簡單，點擊右上角「…」符號，就會出現「拷貝逐字稿」的選項，點擊後就複製了：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpvb9wpkifaaq76soiwga.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpvb9wpkifaaq76soiwga.jpg" alt="點擊「…」取得逐字稿的功能操作圖" width="800" height="1574"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  音檔轉成 m4a
&lt;/h2&gt;

&lt;h3&gt;
  
  
  非 Mac
&lt;/h3&gt;

&lt;p&gt;不是用 Mac 的朋友，直接搜尋「xx to m4a」，就會看到很多網站都有提供轉檔的服務。&lt;/p&gt;

&lt;p&gt;比方搜尋「mp3 to m4a」，出現的搜尋結果就一大串。&lt;/p&gt;

&lt;h3&gt;
  
  
  Mac
&lt;/h3&gt;

&lt;p&gt;有 Mac 的朋友，轉檔不用靠線上工具，因為 Mac 本身就有 App 可以轉，就是「QuickTime Player」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fod50p6jgqp7g4p9we3qz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fod50p6jgqp7g4p9we3qz.png" alt="QuickTime Player App" width="316" height="226"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;把音檔用 QuickTime Player 打開，接著依次點擊「檔案 &amp;gt; 輸出為 &amp;gt; 只限音訊」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzjxwhf8m4un18vcfcn5b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzjxwhf8m4un18vcfcn5b.png" alt="Mac 使用 QuickTime Player 開啟音檔介面" width="800" height="715"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;輸出的檔案就會是 .m4a 的檔案了。&lt;/p&gt;

&lt;p&gt;如果已經在 Mac 作業，又是 M 晶片的話，可以直接在 Mac 上打開「語音備忘錄」，把轉好的音檔拖拉進去，點擊音檔後，右上角就會看到「’’」的按鈕，點擊後就會開始進行辨識：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3y8osvx8q1aufhmol1mj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3y8osvx8q1aufhmol1mj.png" alt="Mac 上語音備忘錄 App 的語音辨識操作圖" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  參考資源
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/zh-tw/guide/iphone/iph00953a982/ios" rel="noopener noreferrer"&gt;在 iPhone 上檢視「語音備忘錄」逐字稿&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/zh-tw/guide/voice-memos/vm4a03609f0d/mac" rel="noopener noreferrer"&gt;在 Mac 上檢視「語音備忘錄」逐字稿&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/zh-hk/guide/iphone/iphbe11247b5/ios" rel="noopener noreferrer"&gt;在 iPhone 上的「備忘錄」中錄音和轉寫音訊&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ios</category>
      <category>whisper</category>
      <category>tutorial</category>
      <category>ai</category>
    </item>
    <item>
      <title>GitHub Copilot + Figma MCP Server 實戰：用 AI 快速切版教學</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Sat, 12 Apr 2025 12:57:28 +0000</pubDate>
      <link>https://dev.to/letswrite/github-copilot-figma-mcp-server-shi-zhan-yong-ai-kuai-su-qie-ban-jiao-xue-286f</link>
      <guid>https://dev.to/letswrite/github-copilot-figma-mcp-server-shi-zhan-yong-ai-kuai-su-qie-ban-jiao-xue-286f</guid>
      <description>&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;最近在研究 MCP，剛好在 Threads 上看到有人實測 Figma MCP，想試看看是否真的能透過 AI 進行切版。&lt;/p&gt;

&lt;p&gt;實作了一下後，還真的可以，不過目前僅針對簡單設計稿進行測試，結果略有跑版現象，整體效果尚可接受。&lt;/p&gt;

&lt;p&gt;但目前就有這成果覺得厲害，再給它一段時間，也許前端工程師可以省掉切版的時間，把心力放到別的地方。&lt;/p&gt;

&lt;p&gt;前提是，客戶要很明確的知道自己要什麼 XD，不然靠 AI 微調，還不如人工直接改程式還比較快。&lt;/p&gt;

&lt;h2&gt;
  
  
  用到的資源
&lt;/h2&gt;

&lt;p&gt;以下是要實作用 Figma MCP 來切版，需要的資源：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem" rel="noopener noreferrer"&gt;Filesystem&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/GLips/Figma-Context-MCP" rel="noopener noreferrer"&gt;Figma-Context-MCP&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Demo 直接搜尋一個有人分享的設計稿：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.figma.com/community/file/1222060007934600841" rel="noopener noreferrer"&gt;Responsive Landing Page Design&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;本篇使用的是 GitHub Copilot，要使用 Agent Mode 必需要是付費的方案，免費的無法使用。&lt;/p&gt;

&lt;p&gt;Agent Mode 聽說會逐步自動安裝到 VS Code 的 GitHub Copilot 上，沒有的話可以參照官方文件自行打開：&lt;a href="https://code.visualstudio.com/docs/copilot/chat/chat-agent-mode" rel="noopener noreferrer"&gt;Use agent mode in VS Code&lt;/a&gt;。&lt;/p&gt;




&lt;h2&gt;
  
  
  取得 Figma access token
&lt;/h2&gt;

&lt;p&gt;Figma access token 可以在網頁版上取得，但因為抓設計稿的連結，要是 Figma Desktop 上的才有用，所以建議是直接下載 Desktop，安裝後取得：&lt;a href="https://www.figma.com/downloads/" rel="noopener noreferrer"&gt;Figma downloads&lt;/a&gt;。&lt;/p&gt;

&lt;p&gt;安裝好並登入帳號後，點擊自己的大頭貼 &amp;gt; Settings &amp;gt; Security，中間區塊就會有一個 Personal access tokens，點擊 Generate new token，就可以新增一組。&lt;/p&gt;

&lt;p&gt;Token 要存下來，下一步要使用。&lt;/p&gt;




&lt;h2&gt;
  
  
  VS Code MCP 安裝 Figma MCP
&lt;/h2&gt;

&lt;h3&gt;
  
  
  安裝 Filesystem
&lt;/h3&gt;

&lt;p&gt;要讓 Agent 可以新增、讀取我們本機檔案，要裝上 MCP 的 Filesystem。&lt;/p&gt;

&lt;p&gt;打開 VS Code 後，按下 &lt;code&gt;shift + cmd + p&lt;/code&gt;，輸入 &lt;code&gt;mcp&lt;/code&gt;，然後點擊「MCP: 新增伺服器」&amp;gt; 「NPM 套件」，輸入 &lt;code&gt;@modelcontextprotocol/server-filesystem&lt;/code&gt; 後按下 enter。&lt;/p&gt;

&lt;p&gt;中間會再問一次是不是允設安裝，點擊允許。&lt;/p&gt;

&lt;p&gt;接著會要我們填讓 MCP 可以有權限的資料夾路徑，這邊，請，不要，填整台電腦的根目錄，主要是安全性的考量，我們可以開一個資料夾給 MCP 用，然後這邊就填上資料夾的路徑。&lt;/p&gt;

&lt;p&gt;記得，不要給整台電腦的根目錄，或是有什麼敏感資料的資料夾路徑。&lt;/p&gt;

&lt;h3&gt;
  
  
  安裝 Figma MCP
&lt;/h3&gt;

&lt;p&gt;打開 VS Code 後，按下 cammnd + p，就會出現搜尋檔案的輸入框。&lt;/p&gt;

&lt;p&gt;搜尋「settings.json」會打開我們的設定檔，這邊會是全域設定，只要 VS Code 有登入自己的帳號，就會同步設定。&lt;/p&gt;

&lt;p&gt;因為我們已經安裝了 Filesystem，這邊會看到有一段的設定就是 mcp：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F47gdlh2doirrcpu8v7ob.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F47gdlh2doirrcpu8v7ob.png" alt="VS Code 設定檔中的 MCP 設定範例"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;在 &lt;code&gt;"filesystem"&lt;/code&gt; 同一層的地方，貼上以下：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Framelink Figma MCP"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"figma-developer-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"--figma-api-key=Figma_access_token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"--stdio"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Figma_access_token&lt;/code&gt; 記得換成上一段中取得的 Figma access token。&lt;/p&gt;




&lt;h2&gt;
  
  
  取得 Figma 設計稿的連結
&lt;/h2&gt;

&lt;p&gt;記得，一定要是從 Figma Desktop 上取得的才有效，不然 MCP 的權限判斷會失敗。&lt;/p&gt;

&lt;p&gt;對著想要讓 Agent 寫程式的區塊，點右鍵 &amp;gt; Copy/Paste as &amp;gt; Copy link to selection：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgn7betf2dhmoxlxqrksr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgn7betf2dhmoxlxqrksr.png" alt="從 Figma Desktop 介面複製設計稿區塊連結的操作畫面"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  VS Code GitHub Copilot Agent Mode 的 prompt
&lt;/h2&gt;

&lt;p&gt;GitHub Copilot 要先切換成「代理程式」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftuc2pu656un66bquxftw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftuc2pu656un66bquxftw.png" alt="在 VS Code 中將 GitHub Copilot 切換為代理模式的畫面"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;之後貼上以下：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;以下設計稿，幫我製作成 html、css，所有的圖片檔，都轉成 png，存進 img 資料夾中：
{Figma 設計區塊的連結}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{Figma 設計區塊的連結}&lt;/code&gt; 換成上一步中取得的設計稿連結。&lt;/p&gt;

&lt;p&gt;按下 enter 後，Agent 會逐步要授權，都給予後就會產生 HTML、CSS、images 檔案。&lt;/p&gt;

&lt;p&gt;這邊之所以多一句「所有的圖片檔，都轉成 png」，是因為 August 測試時，發現 SVG 圖檔會常常遇到問題，不是載不下來，不然就是載很久但還是錯誤的，用 PNG 檔這個問題就少很多。&lt;/p&gt;

&lt;p&gt;以下影片，是 August 測試時的 Agent 畫面：&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/c0jDjPptmek"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;最後 Agent 產生的檔案，打開來後如下：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://letswritetw.github.io/Figma-Context-MCP/" rel="noopener noreferrer"&gt;https://letswritetw.github.io/Figma-Context-MCP/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;原始碼：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/letswritetw/Figma-Context-MCP/tree/main/docs" rel="noopener noreferrer"&gt;https://github.com/letswritetw/Figma-Context-MCP/tree/main/docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;可以看到除了圖片有些很怪以外，大致上算正常，前端工程師接手後，稍微調整一下，就可以當一個頁面使用了。&lt;/p&gt;




&lt;h2&gt;
  
  
  心得
&lt;/h2&gt;

&lt;p&gt;雖然現在的 Figma MCP 切出來的頁面，看上去還是有很怪的地方，但 ChatGPT 爆紅後才過了多久就有了 MCP 呢？再給它一段時間，它會愈來愈能符合需求。&lt;/p&gt;

&lt;p&gt;AI 會就這樣取代前端工程師嗎？還很難，因為工程師又不是只有切版而已。&lt;/p&gt;

&lt;p&gt;把需求做到 120 分讓客戶買單，需要工程師的經驗。&lt;/p&gt;

&lt;p&gt;跟設計師對談理解哪邊要哪些效果，跟 PM &lt;del&gt;吵架&lt;/del&gt; 溝通，跟後端喬 API 的規格，都需要經驗，AI 目前還是給一動做一動的方式，人類很多東西無法被取代。&lt;/p&gt;

&lt;p&gt;但，有了 AI 幫我們先簡單切版，假設 PM 或企劃，需要臨時跟客戶開會，想要有個東西看的話，就可以拿這個去討論，節省很多工程師的時間。&lt;/p&gt;

&lt;p&gt;目前 August 的心態是，不要把 AI 當敵人，把它當會不斷幫助我們，而且還不會覺得累的隨身助理。&lt;/p&gt;

&lt;p&gt;所以，一直覺得「Copilot 副駕駛」這個詞用得真好。&lt;/p&gt;

</description>
      <category>githubcopilot</category>
      <category>figma</category>
      <category>mcp</category>
      <category>frontend</category>
    </item>
    <item>
      <title>使用 Google Apps Script 串接 Google Analytics API，整合多站數據</title>
      <dc:creator>Let's Write</dc:creator>
      <pubDate>Sat, 29 Mar 2025 09:09:54 +0000</pubDate>
      <link>https://dev.to/letswrite/shi-yong-google-apps-script-chuan-jie-google-analytics-apizheng-he-duo-zhan-shu-ju-4leh</link>
      <guid>https://dev.to/letswrite/shi-yong-google-apps-script-chuan-jie-google-analytics-apizheng-he-duo-zhan-shu-ju-4leh</guid>
      <description>&lt;h2&gt;
  
  
  本篇要解決的問題
&lt;/h2&gt;

&lt;p&gt;一間公司裡可能旗下會有多個網站，想同時查看所有網站的 GA 數據，通常需要開啟多個瀏覽器視窗並排顯示，操作較為繁瑣。&lt;/p&gt;

&lt;p&gt;如果可以改由 API 來取得 GA 的數據，工程師就可以把各站的資料顯示在一個頁面上，而不用同時開多個 GA 來看。&lt;/p&gt;




&lt;h2&gt;
  
  
  開通 GA API
&lt;/h2&gt;

&lt;h3&gt;
  
  
  取得 GCP 專案編號
&lt;/h3&gt;

&lt;p&gt;要先有 Google Cloud Platform（GCP）的專案，沒有的話登入自己的 Google 帳號，就可以先增一個。&lt;/p&gt;

&lt;p&gt;專案編號就在 &lt;a href="https://console.cloud.google.com/home/dashboard" rel="noopener noreferrer"&gt;資訊主頁&lt;/a&gt; 上：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftnwgm6pugn5gvo5holbn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftnwgm6pugn5gvo5holbn.png" alt="GCP 主控台首頁，顯示專案編號位置"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  開通 GA API 功能
&lt;/h3&gt;

&lt;p&gt;在使用 API 前，必須先於 GCP 開通對應的功能。&lt;/p&gt;

&lt;p&gt;GCP 的專案點擊選單中的「API 和服務」&amp;gt; 「程式庫」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3d4l0a8qebh0x7yva8qf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3d4l0a8qebh0x7yva8qf.png" alt="GCP 中 API 與服務 &amp;gt; 程式庫選單頁面"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;搜尋框上搜尋「google analytics data api」，會看到結果清單裡會出現「Google Analytics Data API」，點進去後，再點擊啟用，就完成了：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbf4sisheo2kbhbi1t3d3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbf4sisheo2kbhbi1t3d3.png" alt="在程式庫搜尋 Google Analytics Data API"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F87gagenuxzacoirz3ev4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F87gagenuxzacoirz3ev4.png" alt="點擊啟用 Google Analytics Data API"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  取得 GA 的資源 ID
&lt;/h2&gt;

&lt;p&gt;看要抓的是哪一個 GA 的資料，進到 GA 後台，進到「管理」，點擊「資源詳細資料」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftdgdhzr9hrr4kwtq9toq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftdgdhzr9hrr4kwtq9toq.png" alt="GA 後台管理介面"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;接著右上角就會看到資源編號：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqmtqfkwr9vibke74l1vp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqmtqfkwr9vibke74l1vp.png" alt="GA 資源詳細頁面，顯示資源 ID"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  調用 GA API 程式碼部份
&lt;/h2&gt;

&lt;p&gt;因為調用 Google 的 API，要先經過認證的程序，但如果是寫在跟 GA 相同帳號的 Google Apps Script，就可以省掉這一段。&lt;/p&gt;

&lt;h3&gt;
  
  
  Google Apps Script 上新增專案
&lt;/h3&gt;

&lt;p&gt;到 &lt;a href="https://script.google.com/home" rel="noopener noreferrer"&gt;Google Apps Script&lt;/a&gt; 的頁面上，新增一個專案：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnhuz67idyycexdz6101y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnhuz67idyycexdz6101y.png" alt="Google Apps Script 新增專案畫面"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;接著可以直接複製貼上以下的程式碼。&lt;/p&gt;

&lt;h3&gt;
  
  
  瀏覽量、活躍人數
&lt;/h3&gt;

&lt;p&gt;以下程式碼是抓瀏覽量、活躍人數的：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;propertyId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xxxxxx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="err"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 替換成 GA 的資源編號&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-01-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 替換成想要從哪一天開始抓的日期&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getGA4Data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;apiUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://analyticsdata.googleapis.com/v1beta/properties/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;propertyId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:runReport`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dateRanges&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;startDate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;endDate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;today&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;metrics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;screenPageViews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// 瀏覽數&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// 活躍用戶數&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;method&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contentType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ScriptApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOAuthToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;muteHttpExceptions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UrlFetchApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContentText&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="c1"&gt;// 檢查回應中是否有 rows 資料&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// 取得瀏覽數和活躍用戶數&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;screenPageViews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;metricValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;activeUsers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;metricValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 回傳資料&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;totalPageViews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;screenPageViews&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;activeUsers&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 若無符合資料，則回傳 0 作為預設值&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;totalPageViews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  瀏覽量、活躍人數：指定頁面標題
&lt;/h3&gt;

&lt;p&gt;除了抓全站的資料，可以抓指定網頁的 title 是什麼結尾，比如這邊抓的是「會員中心」為結尾的頁面：&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;propertyId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xxxxxx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="err"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 替換成 GA 的資源編號&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-01-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 替換成想要從哪一天開始抓的日期&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;pageTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;會員中心&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 替換成想要篩選的頁面標題文字&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getGA4DataPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;apiUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://analyticsdata.googleapis.com/v1beta/properties/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;propertyId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:runReport`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dateRanges&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;startDate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;endDate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;today&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;metrics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;screenPageViews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// 瀏覽數&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// 活躍用戶數&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dimensionFilter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;filter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fieldName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unifiedScreenName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stringFilter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pageTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;matchType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENDS_WITH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;method&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contentType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ScriptApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOAuthToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;muteHttpExceptions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UrlFetchApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContentText&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;screenPageViews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;metricValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;activeUsers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;metricValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;totalPageViews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;screenPageViews&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;activeUsers&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;totalPageViews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  即時人數
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;propertyId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xxxxxx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="err"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 替換成 GA 的資源編號&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getGA4RealtimeData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;apiUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://analyticsdata.googleapis.com/v1beta/properties/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;propertyId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:runRealtimeReport`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;metrics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;method&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contentType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ScriptApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOAuthToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;muteHttpExceptions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UrlFetchApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContentText&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="c1"&gt;// 取得即時人數&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;metricValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  即時人數：指定頁面標題
&lt;/h3&gt;

&lt;p&gt;這邊一樣是抓「會員中心」為結尾的頁面：&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;propertyId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xxxxxx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="err"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 替換成 GA 的資源編號&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;pageTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;會員中心&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 替換成想要篩選的頁面標題文字&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getGA4RealtimeDataPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;apiUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://analyticsdata.googleapis.com/v1beta/properties/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;propertyId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:runRealtimeReport`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 在 payload 中新增 dimensions 和 dimensionFilter&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;metrics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dimensions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unifiedScreenName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dimensionFilter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;filter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fieldName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unifiedScreenName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stringFilter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pageTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;matchType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENDS_WITH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;method&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contentType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ScriptApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOAuthToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;muteHttpExceptions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UrlFetchApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContentText&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;totalActiveUsers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 累加所有匹配頁面的人數&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;totalActiveUsers&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metricValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;totalActiveUsers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 返回所有匹配頁面的總人數&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  以 POST 的參數，判斷要給哪種資料
&lt;/h3&gt;

&lt;p&gt;上面一共寫了四個函式，我們需要一個參數，讓 API 知道要回應的是哪個資料。&lt;/p&gt;

&lt;p&gt;這邊用的參數是 &lt;code&gt;type&lt;/code&gt;，&lt;code&gt;type&lt;/code&gt; 不同值，就給不同資料，共有 4 個值可以寫：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;data&lt;/code&gt;：瀏覽量、活躍人數。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dataPage&lt;/code&gt;：瀏覽量、活躍人數：指定頁面標題。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;realtimeData&lt;/code&gt;：即時人數。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;realtimeDataPage&lt;/code&gt;：即時人數：指定頁面標題。
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 回應錯誤訊息&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createErrorResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;errorResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;jsonOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ContentService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTextOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errorResponse&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;jsonOutput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMimeType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ContentService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MimeType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;jsonOutput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 處理 POST&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;doPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 確認有傳入內容&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postData&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createErrorResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;無效請求：未收到 POST 資料。&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 將 POST 資料解析成 JSON&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;requestData&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;requestData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createErrorResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;JSON 格式無效。&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 檢查是否有 type 參數&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;requestData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createErrorResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;缺少「type」參數。&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 根據 type 參數執行不同的邏輯&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 瀏覽量、活躍人數&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;reportData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getGA4Data&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;totalPageViews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reportData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalPageViews&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reportData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeUsers&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// 瀏覽量、活躍人數：指定頁面標題&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dataPage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;reportData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getGA4DataPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;totalPageViews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reportData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalPageViews&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reportData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeUsers&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// 即時人數&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;realtimeData&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;reportData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getGA4RealtimeData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reportData&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// 即時人數：指定頁面標題&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;realtimeDataPage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;reportData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getGA4RealtimeDataPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activeUsers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reportData&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createErrorResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type 錯誤&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 回傳 JSON 格式的結果&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;jsonOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ContentService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTextOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;jsonOutput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMimeType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ContentService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MimeType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;jsonOutput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Google Apps Script 連結到 GCP 專案
&lt;/h2&gt;

&lt;p&gt;左側選單點擊「設定」，接著頁面往下滑，有一項「 Google Cloud Platform (GCP) 專案」，點擊這項的「變更專案」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5pz78cb5fww50q0be1ih.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5pz78cb5fww50q0be1ih.png" alt="GAS 設定頁面，點擊變更 GCP 專案"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;接著輸入框就填上第一步在 GCP 上取得的編號，再點擊「設定專案」就可以了：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkuci6d7crvapdd85bwyl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkuci6d7crvapdd85bwyl.png" alt="填入 GCP 專案編號並點選設定"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;如果專案存在，連結就會成功，成功的畫面像這樣：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmzecq6ipd79tmkij1usm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmzecq6ipd79tmkij1usm.png" alt="顯示成功連結到 GCP 專案的畫面"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;設定完後，這份 GAS 檔就可以使用 GCP 上開啟的 GA API。&lt;/p&gt;


&lt;h2&gt;
  
  
  部署 GAS
&lt;/h2&gt;

&lt;p&gt;Google Apps Script 的程式如果要能對外，要部署出去才行。&lt;/p&gt;

&lt;p&gt;點擊右上角的「部署」&amp;gt; 「管理部署作業」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0vzqd3knw2uucrgbms2i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0vzqd3knw2uucrgbms2i.png" alt="GAS 部署選單，點選管理部署作業"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;部署的類型是「網頁應用程式」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu1htp803hsu8f4tetlwm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu1htp803hsu8f4tetlwm.png" alt="選擇部署類型為網頁應用程式"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;「誰可以存取」要選擇「所有人」：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frja4ymkd1rn6r32qzyde.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frja4ymkd1rn6r32qzyde.png" alt="設定所有人皆可存取 API"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;選好後，點擊「部署」，需要授權時就直接授權。&lt;/p&gt;

&lt;p&gt;部署成功，就會取得一個網址，這個網址就是對外的 API 網址了：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fozoo447ofgl9ba9t3ul2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fozoo447ofgl9ba9t3ul2.png" alt="部署成功後顯示 API 網址"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;最後，只要 POST 到這個網址，帶上指定的參數 &lt;code&gt;type&lt;/code&gt;，就可以取得我們想要的資料。&lt;/p&gt;


&lt;h2&gt;
  
  
  手動補權限
&lt;/h2&gt;

&lt;p&gt;如果 POST 後，取到的資料一直是 0，代表部署時提供的權限不完整，就要手動補授權。&lt;/p&gt;

&lt;p&gt;點擊左側的「專案設定」，&lt;strong&gt;在編輯器中顯示「appsscript.json」資訊清單檔案&lt;/strong&gt; 打勾：&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn51opgu1ymae6uw7b9o1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn51opgu1ymae6uw7b9o1.png" alt="GAS 設定中勾選顯示 JSON 設定檔"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;接著回到編輯器，會看到 appsscript.json 這個檔案，補上以下：&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"oauthScopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://www.googleapis.com/auth/script.external_request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://www.googleapis.com/auth/analytics.readonly"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft99j8cp9901ofnjk97k8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft99j8cp9901ofnjk97k8.png" alt="appsscript.json 中新增 oauthScopes 權限設定"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;最後再次部署，會出現要補權限的授權，一律給過就行，就可以取得 GA 上的資料了。&lt;/p&gt;


&lt;h2&gt;
  
  
  完整程式碼
&lt;/h2&gt;

&lt;p&gt;完整的 Google Apps Script 程式碼如下，可以直接複製貼上：&lt;/p&gt;


&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>googleanalytics</category>
      <category>google</category>
      <category>api</category>
    </item>
  </channel>
</rss>
