<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://dks50217.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://dks50217.com/" rel="alternate" type="text/html" /><updated>2026-08-31T13:15:47+00:00</updated><id>https://dks50217.com/feed.xml</id><title type="html">Michael’s Blog</title><subtitle>Explore a personal tech blog where I share insights, tutorials, and hands-on experiences on coding, AI, game plugins, and project management.</subtitle><author><name>Michael Chang</name></author><entry><title type="html">Blazor Server 黑箱安全檢查清單</title><link href="https://dks50217.com/blazor-server-security-checklist/" rel="alternate" type="text/html" title="Blazor Server 黑箱安全檢查清單" /><published>2026-08-31T00:00:00+00:00</published><updated>2026-08-31T00:00:00+00:00</updated><id>https://dks50217.com/blazor-server-security-checklist</id><content type="html" xml:base="https://dks50217.com/blazor-server-security-checklist/"><![CDATA[<p>寫給自己的筆記。平常做的幾乎都是 Blazor Server，前陣子有個站要過滲透測試，順手把黑箱檢查的東西整理成清單。<strong>只對自己擁有或已授權的站台做。</strong></p>

<p>Blazor Server 跟一般前端不一樣：C# 全在伺服器，瀏覽器只有 <code class="language-plaintext highlighter-rouge">blazor.server.js</code>、一條 SignalR 連線和畫面 diff，每個互動都是一則 SignalR 事件送回伺服器。好處是傳統網站把 REST API 端點全攤在外面，Blazor Server 只露一條 SignalR，真正打 API、查 DB 的 C# 在伺服器，外面看不到；API 還關在 VNET 裡連不到。</p>

<script type="module">
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
  mermaid.initialize({ startOnLoad: false });
  await mermaid.run({ querySelector: 'pre.mermaid' });
</script>

<pre class="mermaid">
flowchart LR
  subgraph 傳統前後端分離
    direction LR
    B1[瀏覽器] --&gt;|看得到每個 API 端點| A1[REST API] --&gt; D1[(資料庫)]
  end
  subgraph Blazor Server
    direction LR
    B2[瀏覽器] --&gt;|只看得到一條 SignalR| C2[伺服器上的 C#]
    subgraph VN[VNET 私有網路]
      A2[後端 API] --&gt; D2[(資料庫)]
    end
    C2 --&gt; A2
    B2 -. 連不到 .-&gt; A2
  end
</pre>

<p>但這也容易讓人以為就安全了。整份清單只有一句重點：<strong>畫面上看不到，不代表打不到</strong>。按鈕藏起來只是前端不畫，SignalR 事件還在，自己組出來送過去就繞過前端了。</p>

<hr />

<h2 id="stage-0--部署層基本盤">Stage 0 — 部署層基本盤</h2>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">DetailedErrors</code> production 關閉（例外堆疊會回傳到 circuit）</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">ASPNETCORE_ENVIRONMENT</code> 是 <code class="language-plaintext highlighter-rouge">Production</code>，不是誤留 <code class="language-plaintext highlighter-rouge">Development</code></li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />反向代理正確轉發 WebSocket 升級標頭，沒暴露內部埠</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />回應標頭不洩漏框架版本（<code class="language-plaintext highlighter-rouge">Server</code>、<code class="language-plaintext highlighter-rouge">X-Powered-By</code>）</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />HTTPS 強制 + HSTS，無混合內容</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />安全標頭：CSP、<code class="language-plaintext highlighter-rouge">X-Content-Type-Options</code>、<code class="language-plaintext highlighter-rouge">X-Frame-Options</code> / <code class="language-plaintext highlighter-rouge">frame-ancestors</code></li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">_Host.cshtml</code> 沒把設定值、連線字串、內部 URL 寫進註解或 inline script</li>
</ul>

<hr />

<h2 id="stage-1--偵察">Stage 1 — 偵察</h2>

<p>Blazor Server 這邊本來就該很乾淨，挖得到料就是有東西漏了。</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />確認是 Blazor Server（<code class="language-plaintext highlighter-rouge">blazor.server.js</code>、<code class="language-plaintext highlighter-rouge">/_blazor</code> negotiate），這藏不掉</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">/_blazor/negotiate</code> 回應有沒有多餘資訊</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />有沒有混用傳統 REST / Minimal API（<code class="language-plaintext highlighter-rouge">/api/...</code>），有的話一併列入攻擊面</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">wwwroot</code> 有沒有誤放 <code class="language-plaintext highlighter-rouge">.map</code>、備份檔、<code class="language-plaintext highlighter-rouge">appsettings.*.json</code>、匯出檔</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />第三方 SaaS / CDN / 分析服務，確認權限範圍</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />錯誤頁、404、500 有沒有洩漏內部資訊</li>
</ul>

<hr />

<h2 id="stage-2--未登入測試">Stage 2 — 未登入測試</h2>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />匿名建 circuit 後能觸發哪些元件事件，範圍符合預期</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">[Authorize]</code> 是不是只在頁面／元件層，沒落到服務／資料層（關鍵風險，Stage 3）</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">@attribute [Authorize]</code> 跟路由授權一致，沒漏掉的路由</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />匿名可觸發的事件會不會呼叫到需授權的服務方法</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Anti-forgery：negotiate / 登入流程 CSRF 保護完整</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />prerender 有沒有洩漏本該登入才看得到的資料（互動 circuit 前會先跑一次）</li>
</ul>

<hr />

<h2 id="stage-3--登入後測試">Stage 3 — 登入後測試</h2>

<p>最容易出事的一層。授權要做在伺服器端，不能靠 UI 藏按鈕。</p>

<h3 id="授權落點">授權落點</h3>

<p>「構造／重放 SignalR 事件」是指：Blazor 每個互動都是一則訊息（第幾號元件、第幾號事件、參數）。開 DevTools 看得到格式，就能改參數或送隱藏按鈕的事件，伺服器照收。</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />每個敏感操作在服務／資料層都有獨立授權檢查，不是靠使用者看不到按鈕</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />直接構造／重放 SignalR 事件時，伺服器仍拒絕越權（核心測試）</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />IDOR：換 ID / key 能不能存取他人資料，服務層要驗擁有權</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />角色／權限矩陣在伺服器端強制，跟 UI 授權一致</li>
</ul>

<h3 id="狀態與-circuit">狀態與 circuit</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />不同 circuit 的元件狀態、快取、DI scoped 服務有隔離，不會 A 的狀態跑到 B（常見雷：Scoped 誤設 Singleton）</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">AuthenticationStateProvider</code> 沒放進可被跨 circuit 污染的位置</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />token / session 過期後正確失效，權限被撤銷伺服器要即時反映</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />敏感資料（PII）沒長期滯留在元件狀態 / 記憶體</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />token 有沒有塞進 <code class="language-plaintext highlighter-rouge">ProtectedLocalStorage</code>（Server 模式多半不用，見文末）</li>
</ul>

<h3 id="輸入驗證">輸入驗證</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />前端驗證在伺服器端都有對應（輸入經 SignalR 到伺服器，前端驗證可繞過）</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />綁定模型有沒有過度綁定（over-posting）</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">@((MarkupString)...)</code> 使用點逐一檢查 XSS</li>
</ul>

<h3 id="檔案上傳">檔案上傳</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />magic bytes 驗真實檔型，不信任副檔名 / MIME</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />圖片檢查像素上限、ZIP 檢查解壓後大小，擋解壓縮炸彈</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />有大小 / 數量上限，大檔走串流</li>
</ul>

<h3 id="完整流程">完整流程</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />走完整流程找邏輯漏洞（價格、數量、訂單狀態跳關／倒退，例如沒付款跳「已出貨」）</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />付款 callback / webhook 要驗簽、要冪等（重送不會重複扣款）</li>
</ul>

<hr />

<h2 id="stage-4--dos--資源耗盡">Stage 4 — DoS / 資源耗盡</h2>

<p>每條 circuit 都佔伺服器記憶體，這是 Blazor Server 特有的。</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />大量並發 circuit 的資源上限與行為（記憶體、CPU）</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">CircuitOptions</code>：<code class="language-plaintext highlighter-rouge">DisconnectedCircuitMaxRetained</code>、<code class="language-plaintext highlighter-rouge">DisconnectedCircuitRetentionPeriod</code> 合理，避免斷線 circuit 堆積</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><code class="language-plaintext highlighter-rouge">MaximumReceiveMessageSize</code> 有限制，防超大訊息</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />單一 circuit 的昂貴操作有速率限制</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />連線層有連線數、頻率限制</li>
</ul>

<hr />

<h2 id="stage-5--資料層--pii">Stage 5 — 資料層 / PII</h2>

<p>碰到 PII 或受規範資料才需要。</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />加密欄位解密後，明文沒滯留在記憶體或 log</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />audit trail 涵蓋所有敏感操作，無法被越權繞過而不留痕</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />錯誤 / 例外 log 沒誤記 PII</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />測試環境用去識別化資料，不是 production PII</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />第三方傳輸敏感資料有加密、最小權限</li>
</ul>

<hr />

<h2 id="stage-6--poc-驗證與留痕">Stage 6 — PoC 驗證與留痕</h2>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />高風險項目做 PoC，證明可利用，不只是可能有漏洞</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />留證據：時間戳、請求／事件內容、影響範圍</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />每個 finding 標嚴重度、資料類別、修復建議、對應 Stage</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />收尾，沒留下測試帳號或後門</li>
</ul>

<hr />

<h2 id="白箱-vs-黑箱盲區對照">白箱 vs 黑箱：盲區對照</h2>

<p>很多項目光讀源碼看不出來：</p>

<table>
  <thead>
    <tr>
      <th>面向</th>
      <th>白箱（讀 C# 源碼）</th>
      <th>黑箱（打部署後的站）</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>授權邏輯</td>
      <td>看得到 <code class="language-plaintext highlighter-rouge">[Authorize]</code> 寫在哪</td>
      <td>授權有沒有<strong>真的落到服務層</strong>、UI 繞過有效否</td>
    </tr>
    <tr>
      <td>SignalR / circuit</td>
      <td>看得到 <code class="language-plaintext highlighter-rouge">CircuitOptions</code> 值</td>
      <td>實際並發、斷線堆積、訊息大小上限</td>
    </tr>
    <tr>
      <td>DetailedErrors / 環境</td>
      <td>看設定檔</td>
      <td>production 是否真的關閉、例外是否外漏</td>
    </tr>
    <tr>
      <td>反向代理 / WebSocket</td>
      <td>看不到</td>
      <td><strong>只有黑箱能發現</strong>轉發設定與標頭洩漏</td>
    </tr>
    <tr>
      <td>prerender 外漏</td>
      <td>需人工推敲</td>
      <td>從外部直接觀察得到</td>
    </tr>
    <tr>
      <td>第三方整合權限</td>
      <td>看得到呼叫</td>
      <td>實際權限範圍是否過寬</td>
    </tr>
  </tbody>
</table>

<p><strong>兩個都做，盲區互補。</strong></p>

<hr />

<h2 id="實戰front-door--csp-安全標頭">實戰：Front Door + CSP 安全標頭</h2>

<p>範例站台的做法：前面 Azure Front Door 當反向代理，安全標頭由 app 端一支 middleware 統一發。幾個踩過的坑：</p>

<p><strong>CSP nonce</strong>：每個 request 一組隨機 nonce 授權自家 inline script。用 Hex 不用 Base64，因為 Base64 的 <code class="language-plaintext highlighter-rouge">+</code> 會被 Razor 在屬性裡 encode，害 header 跟頁面上的 nonce 對不起來，DAST 會誤報。</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">byte</span><span class="p">[]</span> <span class="n">nonceBytes</span> <span class="p">=</span> <span class="k">new</span> <span class="kt">byte</span><span class="p">[</span><span class="m">32</span><span class="p">];</span>
<span class="n">RandomNumberGenerator</span><span class="p">.</span><span class="nf">Fill</span><span class="p">(</span><span class="n">nonceBytes</span><span class="p">);</span>
<span class="kt">string</span> <span class="n">nonce</span> <span class="p">=</span> <span class="n">Convert</span><span class="p">.</span><span class="nf">ToHexString</span><span class="p">(</span><span class="n">nonceBytes</span><span class="p">);</span>
<span class="n">context</span><span class="p">.</span><span class="n">Items</span><span class="p">[</span><span class="s">"CSPNonce"</span><span class="p">]</span> <span class="p">=</span> <span class="n">nonce</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>SignalR 來源</strong>：<code class="language-plaintext highlighter-rouge">connect-src</code> 少了 <code class="language-plaintext highlighter-rouge">wss://</code> circuit 就連不起來，用 Azure SignalR Service 還要放它的網域。</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">connectSources</span> <span class="p">=</span>
    <span class="s">$"'self' wss://</span><span class="p">{</span><span class="n">host</span><span class="p">}</span><span class="s"> https://</span><span class="p">{</span><span class="n">host</span><span class="p">}</span><span class="s"> "</span> <span class="p">+</span>
    <span class="s">"https://*.service.signalr.net wss://*.service.signalr.net"</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>UI 元件庫</strong>：Telerik / Syncfusion 這類，<code class="language-plaintext highlighter-rouge">style-src</code> 多半得留 <code class="language-plaintext highlighter-rouge">'unsafe-inline'</code>（元件把 <code class="language-plaintext highlighter-rouge">Width</code> / <code class="language-plaintext highlighter-rouge">Height</code> 等 render 成 inline style）；但 <code class="language-plaintext highlighter-rouge">script-src</code> 不一定要 <code class="language-plaintext highlighter-rouge">'unsafe-eval'</code>，別照抄範例。</p>

<p><strong>3xx 跳轉擋快取</strong>：登入跳轉的 Location 常帶敏感 return URL，回應要 <code class="language-plaintext highlighter-rouge">no-store</code>，但排除 304，免得靜態檔快取壞掉。</p>

<p><strong>COEP</strong>：<code class="language-plaintext highlighter-rouge">require-corp</code> 會擋掉沒有 CORP 的第三方腳本（如分析工具），要嘛不裝要嘛降 <code class="language-plaintext highlighter-rouge">credentialless</code>。</p>

<p><strong>HSTS 只在 production 開</strong>：開發環境不加，免得 localhost SSL 出問題把自己鎖死。</p>

<hr />

<h2 id="附protectedlocalstorage-能不能存-token">附：ProtectedLocalStorage 能不能存 token？</h2>

<p><code class="language-plaintext highlighter-rouge">ProtectedLocalStorage</code> 用 ASP.NET Core Data Protection 在伺服器端加密後才寫進瀏覽器 localStorage，比裸存好，客戶端讀不到也改不了。但：</p>

<ul>
  <li><strong>擋不了 XSS</strong>：密文能被整包送回 app 重放，或在 circuit 裡直接動手。要擋 XSS 竊 token 該用 <code class="language-plaintext highlighter-rouge">HttpOnly</code> cookie。</li>
  <li><strong>Server 模式多半不用</strong>：狀態本來就在 circuit，token 放伺服器就好，存 localStorage 是 WASM 的做法。</li>
  <li><strong>金鑰是部署雷</strong>：Data Protection 金鑰環沒持久化 / web farm 沒共用，舊密文就解不開，使用者莫名被登出。</li>
</ul>

<p>結論：<code class="language-plaintext highlighter-rouge">ProtectedLocalStorage</code> 存不敏感、掉了重取即可的狀態；auth token 留伺服器端，要瀏覽器持有用 <code class="language-plaintext highlighter-rouge">HttpOnly</code> cookie。</p>

<hr />

<h2 id="一點心得">一點心得</h2>

<p>最常中的是 Stage 3 授權落點：<code class="language-plaintext highlighter-rouge">[Authorize]</code> 掛在頁面就當做完，服務層沒再檢查，繞過 UI 送事件就進去了。</p>

<p>用自動化工具測的話，prompt 先講清楚前提：Blazor Server（走 SignalR，不是 REST-first）、UI 隱藏不等於授權、有沒有混用 REST API、render mode 是 Server / WASM / Auto。</p>

<hr />

<p>本文僅供授權範圍內的安全測試與學習。所有測試只在自己擁有或已取得書面授權的環境進行；未經授權對他人系統操作屬違法行為。</p>]]></content><author><name>Michael Chang</name></author><category term="Blazor" /><category term="C#" /><category term="Security" /><summary type="html"><![CDATA[整理一份對自己 Blazor Server 網站做黑箱測試的檢查清單，重點在授權落點、circuit 狀態隔離、與部署後才暴露的盲區。]]></summary></entry><entry><title type="html">我寫了一個幫 DJ 排歌單的小程式</title><link href="https://dks50217.com/dj-set-planner/" rel="alternate" type="text/html" title="我寫了一個幫 DJ 排歌單的小程式" /><published>2026-08-16T00:00:00+00:00</published><updated>2026-08-16T00:00:00+00:00</updated><id>https://dks50217.com/dj-set-planner</id><content type="html" xml:base="https://dks50217.com/dj-set-planner/"><![CDATA[<p>放歌最麻煩的其實不是選歌，是順序。同樣那幾首，先放哪首後放哪首，感覺差很多。前一首很嗨、下一首突然變慢，舞池馬上冷掉；兩首調不合，接起來就是怪怪的；整場同一個能量，又平又無聊。</p>

<p>厲害的 DJ 靠手感就搞定了。我想試試能不能讓電腦幫我算，就寫了這個小程式。</p>

<h2 id="先把好不好聽變成分數">先把好不好聽變成分數</h2>

<p>電腦聽不懂音樂，得先把接得順不順拆成幾條它照著就能算的規則。</p>

<p>每首歌先標三個東西：調（DJ 圈用的 Camelot 輪盤編號，像 8A、5A）、BPM（快慢）、能量（有多嗨，我用 1 到 10）。</p>

<p>然後訂三條接歌的規則：</p>

<p>第一條，看調合不合。那些調可以想成時鐘刻度，相鄰的接起來很順，隔壁的關係大小調也行，亂跳就刺耳。順的加分、撞調的扣分。</p>

<p>第二條，比 BPM 差多少。兩首 BPM 越接近越好接，差太多就會斷拍。差個三以內加很多分，差超過十二就直接扣。</p>

<p>第三條，看能量走勢。一路往上堆會越來越嗨，突然大掉場子就冷。所以往上加成、往下打折。</p>

<p>把整場每一次接歌的分數加起來，就是這份歌單的總分。</p>

<h2 id="難的地方在這裡">難的地方在這裡</h2>

<p>假設我要從 20 首裡挑 8 首排順序，總共有幾種排法？答案是幾百億種以上。一種一種試，電腦根本算不完。</p>

<p>而且更煩的是，好根本不只一種。程式同時看三件事：整場多嗨、接歌多順、高潮衝多高，這三個常打架。要接歌超順，可能就衝不高；要炸裂的高潮，接起來可能沒那麼順。沒有唯一答案，只有一堆各有取捨的選擇。</p>

<h2 id="其實是一題作業研究">其實是一題作業研究</h2>

<p>做到一半才發現，排歌單這問題，跟工廠排產線、貨車跑最短路線，骨子裡是同一種東西：一堆限制底下用數學找最好的安排，就是工業工程和作業研究在做的事。</p>

<p>對照起來蠻剛好：決定歌的先後，OR 裡叫排序、排程；從一堆歌先挑再排，叫組合最佳化；上一首會影響下一首順不順，叫順序相依成本，工廠換模、換線算的就是它；同時顧嗨、順、高潮這三件打架的事，是多目標最佳化，最後那組各有取捨的方案，就是 Pareto 效率前緣。</p>

<p>尤其接不接得順要看上一首是什麼，這點讓整題變得很像旅行推銷員問題（TSP）：業務員跑完所有城市、每段路有距離，怎麼跑總路程最短。城市換成歌、距離換成順不順，幾乎同一題。而這種題目是 NP-hard，歌一多就別想又快又保證最佳，所以這東西頂多算到二十幾首，再多電腦就吃不消。</p>

<p>所以我做的，就是把 DJ 靠手感在做的事，拆成一條一條電腦算得動的規則，再拿課本裡的動態規劃跟 Pareto 前緣去解。</p>

<h2 id="動態規劃">動態規劃</h2>

<p>動態規劃聽起來很難，概念其實很簡單：算過的東西記下來，不要每次從頭重算。</p>

<p>放到排歌單就是：只要記住已經放了哪幾首、最後一首是什麼，後面算到同樣狀態就直接拿現成結果，不用一直重算。</p>

<p>沒有唯一答案這件事，我的做法是留一整組互不吃虧的方案：一套方案只要找不到另一套樣樣都贏過它的，就留著。所以程式不是丟一個答案，而是給好幾套風格不同的歌單，今天想順一點還是炸一點自己挑。</p>

<h2 id="實際跑起來長這樣">實際跑起來長這樣</h2>

<p>拿內建的 10 首曲庫、編 6 首來說，程式一次吐出 11 套各有取捨的歌單。挑幾套看就懂取捨是什麼意思：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>最嗨的一套（嗨度 61.2 | 流暢度 4.2 | 高峰 10）:
  Satisfaction → Opus → Insomnia → Levels → One → Strobe
  能量一路 7→8→8→9→9→10 往上堆，但中間 Levels→One 撞了一次調

最順的一套（嗨度 42.7 | 流暢度 7.1 | 高峰 9）:
  Titanium → One More Time → Around the World → Music Sounds Better with You → Opus → Levels
  每一接都順，但少了那記炸裂的高潮，也沒那麼嗨
</code></pre></div></div>

<p>一套嗨到頂但接歌會凸一下，一套從頭順到尾但沒爆點，沒有誰對誰錯，看當下想要哪種。中間那九套是各種程度的折衷。</p>

<h2 id="驗算">驗算</h2>

<p>這個我還蠻在意的，特別寫了一段來驗。</p>

<p>做法是拿一個很小的曲庫，一邊讓聰明的演算法算，另一邊用最笨的方法把每種排法通通列出來也算一次，兩邊對答案。結果一模一樣，才敢相信聰明那版沒偷偷算漏。</p>

<h2 id="實用嗎">實用嗎</h2>

<p>拿來做小範圍精算很好用，要它排整個曲庫還差得遠。</p>

<p>算得動的大概就二十幾首上限，再多會爆（前面說的排列爆炸躲不掉）。真實 DJ 曲庫動不動上百上千首，所以實際做法是先用簡單規則粗篩，砍到十五到二十首候選，再丟給程式精算順序。</p>

<p>還有，電腦再準，也只是照著我訂的那幾條評分規則去最佳化。那些規則到底貼不貼近真正好聽，才是最難的地方，這部分還是得靠人一直去調。</p>

<p>程式碼放在 GitHub：<a href="https://github.com/dks50217/DjSetPlanner">dks50217/DjSetPlanner</a>，用 C# / .NET 8 寫的，<code class="language-plaintext highlighter-rouge">dotnet run</code> 就能跑內建曲庫的 demo，也能餵自己的 CSV。</p>

<hr />

<p>最後推薦一套很有感的現場：<strong>Armin van Buuren F2F Maddix @ A State of Trance 2025</strong>。F2F 是 Armin 搞出來的玩法：兩個 DJ 面對面、各用一套器材，看不到對方點什麼歌，全靠手勢跟耳朵即時對，比共用一台混音器、還能事先套好歌單的 B2B 更即興。整場能量走勢跟每次轉場都是當場喬出來的，我程式想算的那個順，他們靠手感直接做到了。看完再回頭想我那三條規則，特別有感。</p>

<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;margin:1.5em 0;">
  <iframe style="position:absolute;top:0;left:0;width:100%;height:100%;" src="https://www.youtube.com/embed/2-s3hXbDu7Y" title="Armin van Buuren F2F Maddix @ ASOT 2025" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe>
</div>

<p>順手把這場 set 的能量走勢畫成一張圖，五段（P1–P5）都是先掉到低點、再一路爬回高潮，那個呼吸感看圖就懂。</p>

<figure style="margin:1.5em 0;">
<svg viewBox="0 0 720 300" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;display:block;border-radius:12px" role="img" aria-label="ASOT 2025 Armin F2F Maddix 這場 set 的能量隨時間變化，五段各自重置後爬升到高峰。">
<rect x="0" y="0" width="720" height="300" rx="12" fill="#0f0a17" />
<line x1="34.0" y1="26" x2="34.0" y2="266" stroke="#54e0ff" opacity="0.2" />
<text x="39.0" y="37" font-family="monospace" font-size="9" letter-spacing="1.5" fill="#6c6280">P1</text>
<rect x="179.3" y="26" width="149.1" height="240" fill="#fff" opacity="0.03" />
<line x1="179.3" y1="26" x2="179.3" y2="266" stroke="#54e0ff" opacity="0.2" />
<text x="184.3" y="37" font-family="monospace" font-size="9" letter-spacing="1.5" fill="#6c6280">P2</text>
<line x1="328.4" y1="26" x2="328.4" y2="266" stroke="#54e0ff" opacity="0.2" />
<text x="333.4" y="37" font-family="monospace" font-size="9" letter-spacing="1.5" fill="#6c6280">P3</text>
<rect x="469.5" y="26" width="151.1" height="240" fill="#fff" opacity="0.03" />
<line x1="469.5" y1="26" x2="469.5" y2="266" stroke="#54e0ff" opacity="0.2" />
<text x="474.5" y="37" font-family="monospace" font-size="9" letter-spacing="1.5" fill="#6c6280">P4</text>
<line x1="620.6" y1="26" x2="620.6" y2="266" stroke="#54e0ff" opacity="0.2" />
<text x="625.6" y="37" font-family="monospace" font-size="9" letter-spacing="1.5" fill="#6c6280">P5</text>
<line x1="34" y1="146.0" x2="710" y2="146.0" stroke="#2a2036" stroke-dasharray="2 5" />
<text x="28" y="149.0" text-anchor="end" font-family="monospace" font-size="9" fill="#6c6280">E5</text>
<line x1="34" y1="26.0" x2="710" y2="26.0" stroke="#2a2036" stroke-dasharray="2 5" />
<text x="28" y="29.0" text-anchor="end" font-family="monospace" font-size="9" fill="#6c6280">E10</text>
<rect x="35.0" y="122.0" width="47.9" height="144.0" rx="2.5" fill="hsl(330,52%,40%)" />
<rect x="84.9" y="98.0" width="12.3" height="168.0" rx="2.5" fill="hsl(330,63%,47%)" />
<rect x="99.2" y="74.0" width="30.6" height="192.0" rx="2.5" fill="hsl(330,74%,54%)" />
<rect x="131.8" y="98.0" width="25.6" height="168.0" rx="2.5" fill="hsl(330,63%,47%)" />
<rect x="159.4" y="74.0" width="18.9" height="192.0" rx="2.5" fill="hsl(330,74%,54%)" />
<rect x="180.3" y="98.0" width="37.8" height="168.0" rx="2.5" fill="hsl(330,63%,47%)" />
<rect x="220.1" y="74.0" width="29.8" height="192.0" rx="2.5" fill="hsl(330,74%,54%)" />
<rect x="251.9" y="74.0" width="30.8" height="192.0" rx="2.5" fill="hsl(330,74%,54%)" />
<rect x="284.6" y="50.0" width="26.8" height="216.0" rx="2.5" fill="hsl(330,85%,61%)" />
<rect x="313.5" y="50.0" width="13.9" height="216.0" rx="2.5" fill="hsl(330,85%,61%)" />
<rect x="329.4" y="98.0" width="23.8" height="168.0" rx="2.5" fill="hsl(330,63%,47%)" />
<rect x="355.2" y="50.0" width="25.8" height="216.0" rx="2.5" fill="hsl(330,85%,61%)" />
<rect x="383.0" y="74.0" width="21.9" height="192.0" rx="2.5" fill="hsl(330,74%,54%)" />
<rect x="406.9" y="50.0" width="45.7" height="216.0" rx="2.5" fill="hsl(330,85%,61%)" />
<rect x="454.6" y="74.0" width="13.9" height="192.0" rx="2.5" fill="hsl(330,74%,54%)" />
<rect x="470.5" y="74.0" width="20.9" height="192.0" rx="2.5" fill="hsl(330,74%,54%)" />
<rect x="493.4" y="50.0" width="32.8" height="216.0" rx="2.5" fill="hsl(330,85%,61%)" />
<rect x="528.1" y="50.0" width="13.9" height="216.0" rx="2.5" fill="hsl(330,85%,61%)" />
<rect x="544.0" y="26.0" width="25.0" height="240.0" rx="2.5" fill="#ffd24d" filter="url(#g)" />
<rect x="571.1" y="26.0" width="48.5" height="240.0" rx="2.5" fill="#ffd24d" filter="url(#g)" />
<rect x="621.6" y="74.0" width="15.9" height="192.0" rx="2.5" fill="hsl(330,74%,54%)" />
<rect x="639.4" y="74.0" width="21.9" height="192.0" rx="2.5" fill="hsl(330,74%,54%)" />
<rect x="663.3" y="50.0" width="31.8" height="216.0" rx="2.5" fill="hsl(330,85%,61%)" />
<rect x="697.1" y="26.0" width="11.9" height="240.0" rx="2.5" fill="#ffd24d" filter="url(#g)" />
<polyline points="58.9,122.0 91.0,98.0 114.5,74.0 144.6,98.0 168.9,74.0 199.2,98.0 235.0,74.0 267.3,74.0 298.1,50.0 320.4,50.0 341.3,98.0 368.1,50.0 394.0,74.0 429.7,50.0 461.5,74.0 480.9,74.0 509.7,50.0 535.1,50.0 556.6,26.0 595.3,26.0 629.5,74.0 650.4,74.0 679.2,50.0 703.0,26.0" fill="none" stroke="#ff3d8a" stroke-width="1.5" opacity="0.85" />
<text x="30.2" y="288" text-anchor="middle" font-family="monospace" font-size="9" fill="#6c6280">00:00</text>
<text x="149.5" y="288" text-anchor="middle" font-family="monospace" font-size="9" fill="#6c6280">10:00</text>
<text x="268.7" y="288" text-anchor="middle" font-family="monospace" font-size="9" fill="#6c6280">20:00</text>
<text x="388.0" y="288" text-anchor="middle" font-family="monospace" font-size="9" fill="#6c6280">30:00</text>
<text x="507.3" y="288" text-anchor="middle" font-family="monospace" font-size="9" fill="#6c6280">40:00</text>
<text x="626.5" y="288" text-anchor="middle" font-family="monospace" font-size="9" fill="#6c6280">50:00</text>
<defs><filter id="g" x="-40%" y="-40%" width="180%" height="180%"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#ffd24d" flood-opacity="0.7" /></filter></defs>
</svg>
<figcaption style="font-size:13px;color:#888;margin-top:8px;line-height:1.6;">橫軸是演出時間、柱寬是每首歌播放的長度、柱高是能量，粉紅線是能量弧線，琥珀色柱是 E10 的高潮。時間與曲序取自實際 tracklist，能量為依段落結構的估計值。</figcaption>
</figure>]]></content><author><name>Michael Chang</name></author><category term="作業研究" /><category term="動態規劃" /><category term="最佳化" /><category term="DJ" /><category term="C#" /><summary type="html"><![CDATA[把 DJ 的接歌手感拆成一道作業研究題目：用調性、BPM、能量走勢把「接得順不順」變成分數，再拿動態規劃跟 Pareto 前緣算出幾套各有取捨的歌單。]]></summary></entry><entry><title type="html">FFXIV Switch 2 首次啟動選錯 Service Account，怎麼救</title><link href="https://dks50217.com/ffxiv-switch2-service-account/" rel="alternate" type="text/html" title="FFXIV Switch 2 首次啟動選錯 Service Account，怎麼救" /><published>2026-08-16T00:00:00+00:00</published><updated>2026-08-16T00:00:00+00:00</updated><id>https://dks50217.com/ffxiv-switch2-service-account</id><content type="html" xml:base="https://dks50217.com/ffxiv-switch2-service-account/"><![CDATA[<p>8/5 在 eShop 買了 FFXIV Switch 2 版的 Complete Edition，開遊戲登入 Square Enix 帳號。我明明選的是自己的FFXIV角色名，但它一直跳失敗，我在那邊反覆試，結果不知不覺就多建了一個新的 Service Account 出來。等我發現不對，我在 PC 養了好幾年的角色，已經不在遊戲讀到的那個帳號裡了。</p>

<p><img src="../assets/img/FFXIV/ffxiv-sw2-error.jpg" alt="Switch 2 登入後跳出的錯誤畫面" />
<em>就是這個畫面，看到的當下心涼了一半</em></p>

<p>上網查完更慌，一堆媒體都在講「Switch 2 首次啟動的帳號連結是永久、不可逆的」。不過後來發現我踩到的其實不是那個坑，前後花了十天把角色救回來了。這篇記一下過程，給之後同樣手殘的人看。</p>

<h2 id="先搞清楚你踩到的是哪個坑">先搞清楚你踩到的是哪個坑</h2>

<p>有兩件事很容易被混在一起：</p>

<p><strong>Nintendo 帳號 ↔ Square Enix 帳號的綁定，這個才是不可逆的。</strong> 媒體警告的是這件事，如果你登入了錯誤的 SE 帳號，那真的沒救。</p>

<p><strong>Service Account 選錯是另一回事，官方有補救管道。</strong> 一個 SE ID 底下可以開最多 8 個 service account，你的角色、訂閱、平台授權都掛在其中一個上面。我的 SE 帳號從頭到尾都是對的，只是 license 被註冊到新開的那個空帳號去了。</p>

<p><img src="../assets/img/FFXIV/ffxiv-select-service-account.png" alt="Mog Station 的 Select Service Account 畫面" />
<em>上面那兩個分頁就是兩個 service account，角色只掛在 1 底下，2 是空的</em></p>

<p>順帶一提這個坑也不是 Switch 2 才有，之前 PS 轉 Steam 的時候就有一批人中過招了。</p>

<h2 id="表單">表單</h2>

<p>官方名稱叫 <strong>Improperly Registered Service Account</strong>，北美帳號從這裡送：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://support.na.square-enix.com/contact.php?id=5382&amp;la=1&amp;daxx=2
</code></pre></div></div>

<p>（如果是綁錯 SE ID，那是另一張表單，網址結尾改成 <code class="language-plaintext highlighter-rouge">daxx=1</code>）</p>

<p>送出前有幾個條款要先知道：錯誤的那個 service account 會被永久停用，8 個名額就少一個；已經付掉的訂閱費不退；這是一次性服務，只能用一次；官方標示處理時間最長兩週。</p>

<h3 id="registration-code-填-0">Registration Code 填 0</h3>

<p>表單說明只寫「PlayStation Store 或 Microsoft Store 的數位版填 0」，完全沒提 Nintendo，我在這裡卡了很久。</p>

<p>後來想通了，eShop 數位版跟 PS/MS 一樣是 entitlement 綁帳號，根本不發序號，整個啟動流程也沒有哪一步要你輸入 16 碼。所以直接填 0 就對了，不要跟 eShop 儲值卡的序號搞混。</p>

<p>因為沒有序號可以給他們對，我就在說明欄把完整的 eShop 收據資訊貼上去：Transaction ID、購買日期、商品名稱、金額、Nintendo 帳號的 email。</p>

<h3 id="current-和-intended-不要填反">Current 和 Intended 不要填反</h3>

<p>這兩欄很容易搞混：</p>

<ul>
  <li><strong>Current</strong> = 你不小心註冊上去的那個 → Service Account 2</li>
  <li><strong>Intended</strong> = 你本來想註冊的那個 → Service Account 1</li>
</ul>

<p><img src="../assets/img/FFXIV/ffxiv-form-service-account.png" alt="表單上的 Service Account 兩個下拉選單" />
<em>Current 選 2、Intended 選 1，方向反了就是災難</em></p>

<p>Current 欄下面那句「Please select “Service Account 1” if you only have one service account」是給另一種情況的人看的，別被它影響。判斷依據永遠是上面那句：你不小心註冊上去的是哪個。如果填反了，SE 會去停用你有角色的那個帳號，那就慘了。</p>

<h3 id="其他欄位">其他欄位</h3>

<p>姓名、地址、生日要跟 SE 帳號的登錄資料一字不差，這是拿來驗身分的。ZIP+4 如果不知道就留空，不要填一半。</p>

<p>我還主動在說明欄寫了「錯誤的那個帳號上沒有角色、也沒有其他授權」，這樣可以少一次來回問答。另外附上 Mog Station 的 Select Service Account 截圖也有幫助，一張圖就能同時證明兩個帳號存在、以及角色在哪一個。</p>

<p><img src="../assets/img/FFXIV/ffxiv-ticket.png" alt="送出後的 ticket 編號畫面" />
<em>送出後會給一組 ticket number，之後所有往來都靠它</em></p>

<h2 id="實際流程跟我想的不太一樣">實際流程跟我想的不太一樣</h2>

<p>送出當天就收到人工回覆了，比想像中快很多。我回 Yes 之後，第二封信才把實際會怎麼處理講清楚，而且跟我以為的「幫你搬移」完全是兩回事：</p>

<ol>
  <li>SE 會把 license 從錯誤的帳號刪掉（不是搬移，是刪除）</li>
  <li>那個帳號暫時停權幾天</li>
  <li>你下次開遊戲時，註冊畫面會再跳出來一次</li>
  <li>這次由你自己選正確的 service account</li>
</ol>

<p>所以決定成敗的動作其實還在你手上。所謂「一次性」，真正卡的是最後這一步，不是那張表單。</p>

<p>信裡還有一句我覺得應該用紅字寫：註冊畫面有可能在你收到完成通知之前就跳出來。要是你隨手點過去又選錯，那就真的沒了。所以在收到通知前，我完全沒去碰 Switch 2 上的遊戲。</p>

<p>還有，被停權的只是那個錯誤的帳號，PC 版完全不受影響，這段期間我照樣上線。</p>

<h2 id="中間插曲說完成了卻還是進不去">中間插曲：說完成了卻還是進不去</h2>

<p>8/8 收到完成通知，開遊戲登入，結果直接跳錯誤：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Your service account's subscription has expired or FINAL FANTASY XIV
has not yet been registered on this console.
</code></pre></div></div>

<p>註冊畫面根本沒出現。這個錯誤訊息叫你去 Mog Station 完成註冊，但那是給 PC 版寫的通用文字，Switch 2 根本沒有序號可以輸入，照著它繞只是浪費時間。</p>

<p>真正該做的是去 Mog Station 診斷。切到正確的 service account，點 <strong>Service Account Status</strong>：</p>

<ul>
  <li><strong>Subscription</strong> 的 End Date → 我這邊是 9/3 到期、正常續訂，所以不是訂閱的問題</li>
  <li>往下捲到 <strong>Platform</strong> → 這裡 <strong>Nintendo Switch 2 沒有打勾</strong></li>
</ul>

<p>到這邊就定位出來了：移除做完了，但重新發放沒做完。我完整關機重開、也確認 eShop 還是顯示已購買，試過都一樣，於是回報客服，信裡多加了一句：</p>

<blockquote>
  <p>Since the registration screen never appears, I have had no opportunity
to select a Service Account.</p>
</blockquote>

<p>這句我是刻意寫的。註冊畫面沒出現，就代表我根本沒機會選錯，一次性的額度沒被用掉。這點不講清楚，萬一被判定成「你已經用掉機會了」就麻煩了。</p>

<p>客服請我再試一次，這次註冊畫面正常跳出來，角色回來了。</p>

<p><img src="../assets/img/FFXIV/ffxiv-platform-done.png" alt="Platform 清單出現 Nintendo Switch 2 打勾" />
<em>看到這個勾才算真的結束</em></p>

<h2 id="時間軸">時間軸</h2>

<table>
  <thead>
    <tr>
      <th>日期</th>
      <th>事件</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>8/5</td>
      <td>購買，首次啟動選錯</td>
    </tr>
    <tr>
      <td>8/6</td>
      <td>送表單，當天核准，回覆同意</td>
    </tr>
    <tr>
      <td>8/8</td>
      <td>通知完成，但登入仍報錯，回報客服</td>
    </tr>
    <tr>
      <td>8/15</td>
      <td>再試一次，成功</td>
    </tr>
  </tbody>
</table>

<h2 id="給遇到同樣狀況的人">給遇到同樣狀況的人</h2>

<ul>
  <li>別因為看到「不可逆」就放棄，那指的是 Nintendo 帳號綁定，不是這個。</li>
  <li>等待期間不要開通 Switch 2 訂閱、不要課金、也不要在錯誤帳號上創角。條款寫明可能會依訂閱狀態駁回，趁免月費期間送件最乾淨。</li>
  <li>不要重複送表單。想確認進度就用 ticket 編號去問，不要開新單。</li>
  <li>一律用原本那封信回覆。我前後遇到三個不同的專員，他們是靠 thread 歷史接手的，記得每次都帶上 Ticket Number 和 Case ID。</li>
  <li>最後那一步慢慢來。註冊畫面跳出來時，先確認三件事再按：標籤對不對、下面有沒有你的角色、平台清單對不對。不確定就退出遊戲，退出不會消耗任何東西，按錯才會。</li>
  <li>事後回 Mog Station 驗收，Platform 那排看到 Nintendo Switch 2 打勾，才算真的結束。</li>
</ul>

<p>整件事最讓我怕的就是「一次性」這三個字，我填完表單反覆確認了四五次同一個欄位。</p>]]></content><author><name>Michael Chang</name></author><category term="FFXIV" /><summary type="html"><![CDATA[FFXIV Switch 2 首次啟動選錯 Service Account 導致角色不見？實測用官方 Improperly Registered Service Account 表單，十天把角色救回來的完整流程與踩雷筆記。]]></summary></entry><entry><title type="html">NetStone + MCP 架構你的 FFXIV 對話式查詢工具</title><link href="https://dks50217.com/MCPSample/" rel="alternate" type="text/html" title="NetStone + MCP 架構你的 FFXIV 對話式查詢工具" /><published>2025-05-06T00:00:00+00:00</published><updated>2025-05-06T00:00:00+00:00</updated><id>https://dks50217.com/MCPSample</id><content type="html" xml:base="https://dks50217.com/MCPSample/"><![CDATA[<p>本篇文章是從零開始打造一個結合 自然語言處理（MCP Server）、FFXIV 資料查詢（NetStone），並以 WPF (MCP Client) 呈現聊天介面的桌面應用程式。你將學會如何：</p>

<ul>
  <li>使用 MCP 協定(Model Context Protocol)處理聊天語意與工具呼叫</li>
  <li>整合 NetStone 查詢《Final Fantasy XIV》的角色與世界資料</li>
  <li>使用 WPF 呈現 ChatGPT 風格的對話框與 Markdown 回應</li>
</ul>

<p>若你是一位 MMO 玩家、AI 工具開發者，或是想結合遊戲與語言模型的工程師，這篇文章會是你的入門指南。</p>

<h2 id="工具架構概覽">工具架構概覽</h2>

<ul>
  <li>WPF 作為前端（輸入框、對話框）</li>
  <li>MCP Server 處理自然語言與工具匹配</li>
  <li>NetStone 提供 FFXIV 資料查詢</li>
  <li>OpenAI API 作為語言理解後端（可選）</li>
</ul>

<h2 id="建立-mcp-server">建立 MCP Server</h2>

<ol>
  <li>
    <p>建立一個<code class="language-plaintext highlighter-rouge">Server.csproj</code>專案 (MCP 伺服器)，安裝以下套件</p>

    <div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"ModelContextProtocol"</span> <span class="na">Version=</span><span class="s">"0.1.0-preview.8"</span> <span class="nt">/&gt;</span>
 <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"NetStone"</span> <span class="na">Version=</span><span class="s">"1.4.1"</span> <span class="nt">/&gt;</span>
</code></pre></div>    </div>
  </li>
  <li>
    <p>於<code class="language-plaintext highlighter-rouge">Server.csproj</code> 專案的 <code class="language-plaintext highlighter-rouge">Program.cs</code>中加入以下</p>

    <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="n">builder</span><span class="p">.</span><span class="n">Services</span>
     <span class="p">.</span><span class="nf">AddMcpServer</span><span class="p">()</span>
     <span class="p">.</span><span class="nf">WithStdioServerTransport</span><span class="p">()</span>
     <span class="p">.</span><span class="nf">WithToolsFromAssembly</span><span class="p">();</span>
</code></pre></div>    </div>
  </li>
  <li>
    <p>撰寫 NetStone Service <code class="language-plaintext highlighter-rouge">NetStoneService.cs</code>，寫一個讀取FC資訊服務</p>

    <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="k">public</span> <span class="k">interface</span> <span class="nc">INetStoneService</span>
 <span class="p">{</span>
     <span class="n">Task</span> <span class="nf">InitializeAsync</span><span class="p">();</span>
     <span class="n">Task</span><span class="p">&lt;</span><span class="n">FreeCompanySearchEntry</span><span class="p">?&gt;</span> <span class="nf">GetFCInformation</span><span class="p">(</span><span class="kt">string</span> <span class="n">name</span><span class="p">,</span> <span class="kt">string</span> <span class="n">world</span><span class="p">);</span>
 <span class="p">}</span>

 <span class="k">public</span> <span class="k">class</span> <span class="nc">NetStoneService</span> <span class="p">:</span> <span class="n">INetStoneService</span>
 <span class="p">{</span>
     <span class="k">private</span> <span class="n">LodestoneClient</span><span class="p">?</span> <span class="n">_lodestoneClient</span><span class="p">;</span>

     <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">InitializeAsync</span><span class="p">()</span>
     <span class="p">{</span>
         <span class="n">_lodestoneClient</span> <span class="p">=</span> <span class="k">await</span> <span class="n">LodestoneClient</span><span class="p">.</span><span class="nf">GetClientAsync</span><span class="p">();</span>
     <span class="p">}</span>

     <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">CharacterSearchEntry</span><span class="p">?&gt;</span> <span class="nf">GetCharacterID</span><span class="p">(</span><span class="kt">string</span> <span class="n">name</span><span class="p">,</span> <span class="kt">string</span> <span class="n">world</span><span class="p">)</span>
     <span class="p">{</span>
         <span class="k">if</span> <span class="p">(</span><span class="n">_lodestoneClient</span> <span class="p">==</span> <span class="k">null</span><span class="p">)</span>
             <span class="k">throw</span> <span class="k">new</span> <span class="nf">Exception</span><span class="p">(</span><span class="s">"LodestoneClient initialization failed."</span><span class="p">);</span>

         <span class="kt">var</span> <span class="n">searchResponse</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_lodestoneClient</span><span class="p">.</span><span class="nf">SearchCharacter</span><span class="p">(</span><span class="k">new</span> <span class="nf">CharacterSearchQuery</span><span class="p">()</span>
         <span class="p">{</span>
             <span class="n">CharacterName</span> <span class="p">=</span> <span class="n">name</span><span class="p">,</span>
             <span class="n">World</span> <span class="p">=</span> <span class="n">world</span>
         <span class="p">});</span>

         <span class="kt">var</span> <span class="n">lodestoneCharacter</span> <span class="p">=</span>
             <span class="n">searchResponse</span><span class="p">?.</span><span class="n">Results</span>
             <span class="p">.</span><span class="nf">FirstOrDefault</span><span class="p">(</span><span class="n">entry</span> <span class="p">=&gt;</span> <span class="n">entry</span><span class="p">.</span><span class="n">Name</span> <span class="p">==</span> <span class="n">name</span><span class="p">);</span>

         <span class="k">return</span> <span class="n">lodestoneCharacter</span><span class="p">;</span>
     <span class="p">}</span>
 <span class="p">}</span>

</code></pre></div>    </div>
  </li>
  <li>
    <p>撰寫 NetStone Tool <code class="language-plaintext highlighter-rouge">FreeCompanyTool.cs</code>，使用INetStoneService服務</p>

    <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="p">[</span><span class="n">McpServerToolType</span><span class="p">]</span>
 <span class="k">public</span> <span class="k">class</span> <span class="nc">FreeCompanyTool</span><span class="p">(</span><span class="n">INetStoneService</span> <span class="n">netStone</span><span class="p">,</span> <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">CharacterInformationTool</span><span class="p">&gt;</span> <span class="n">logger</span><span class="p">)</span>
 <span class="p">{</span>
     <span class="k">private</span> <span class="k">readonly</span> <span class="n">INetStoneService</span> <span class="n">_netStoneService</span> <span class="p">=</span> <span class="n">netStone</span><span class="p">;</span>
     <span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">CharacterInformationTool</span><span class="p">&gt;</span> <span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>

     <span class="p">[</span><span class="nf">McpServerTool</span><span class="p">(</span><span class="n">Name</span> <span class="p">=</span> <span class="s">"get_fc_information"</span><span class="p">,</span> <span class="n">Title</span> <span class="p">=</span> <span class="s">"Get fc information"</span><span class="p">)]</span>
     <span class="p">[</span><span class="nf">Description</span><span class="p">(</span><span class="s">"Get free Company information"</span><span class="p">)]</span>
     <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">FreeCompanySearchEntry</span><span class="p">?&gt;</span> <span class="nf">GetFCInformation</span><span class="p">(</span>
     <span class="p">[</span><span class="nf">Description</span><span class="p">(</span><span class="s">"The free company name."</span><span class="p">)]</span> <span class="kt">string</span> <span class="n">name</span><span class="p">,</span>
     <span class="p">[</span><span class="nf">Description</span><span class="p">(</span><span class="s">"The data center (world)."</span><span class="p">)]</span> <span class="kt">string</span> <span class="n">world</span>
 <span class="p">)</span>
     <span class="p">{</span>
         <span class="kt">var</span> <span class="n">freeCompany</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_netStoneService</span><span class="p">.</span><span class="nf">GetFCInformation</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="n">world</span><span class="p">);</span>

         <span class="k">return</span> <span class="n">freeCompany</span><span class="p">;</span>
     <span class="p">}</span>
 <span class="p">}</span>
</code></pre></div>    </div>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Program.cs</code>中初始化服務</p>

    <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddSingleton</span><span class="p">&lt;</span><span class="n">INetStoneService</span><span class="p">,</span> <span class="n">NetStoneService</span><span class="p">&gt;();</span>

 <span class="k">static</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">InitNetStone</span><span class="p">(</span><span class="n">IHost</span> <span class="n">host</span><span class="p">)</span>
 <span class="p">{</span>
     <span class="kt">var</span> <span class="n">netStoneService</span> <span class="p">=</span> <span class="n">host</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">GetRequiredService</span><span class="p">&lt;</span><span class="n">INetStoneService</span><span class="p">&gt;();</span>
     <span class="k">await</span> <span class="n">netStoneService</span><span class="p">.</span><span class="nf">InitializeAsync</span><span class="p">();</span>
 <span class="p">}</span>

 <span class="kt">var</span> <span class="n">app</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>

 <span class="k">await</span> <span class="nf">InitNetStone</span><span class="p">(</span><span class="n">app</span><span class="p">);</span>

 <span class="k">await</span> <span class="n">app</span><span class="p">.</span><span class="nf">RunAsync</span><span class="p">();</span>
</code></pre></div>    </div>
  </li>
</ol>

<h2 id="mcp-client">MCP Client</h2>

<ul>
  <li>可使用Claude Desktop 或 自行建立的Client UI</li>
</ul>

<ol>
  <li>以本範例WPF為例，用OpenAI的key來實現</li>
</ol>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">InitializeMCP</span><span class="p">()</span>
<span class="p">{</span>
    <span class="nf">AddUIMessage</span><span class="p">(</span><span class="n">ChatRole</span><span class="p">.</span><span class="n">System</span><span class="p">,</span> <span class="s">"初始化中，請稍候..."</span><span class="p">);</span>

    <span class="kt">var</span> <span class="n">clientTransport</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">StdioClientTransport</span><span class="p">(</span><span class="k">new</span> <span class="n">StdioClientTransportOptions</span>
    <span class="p">{</span>
        <span class="n">Name</span> <span class="p">=</span> <span class="s">"helpTroubleshooter"</span><span class="p">,</span>
        <span class="n">Command</span> <span class="p">=</span> <span class="s">"dotnet"</span><span class="p">,</span>
        <span class="c1">// 指定MCP Server csproj位置</span>
        <span class="n">Arguments</span> <span class="p">=</span> <span class="p">[</span><span class="s">"run"</span><span class="p">,</span> <span class="s">"--project"</span><span class="p">,</span> <span class="s">"../../../../../src/NetStoneMCP.csproj"</span><span class="p">,</span> <span class="s">"--no-build"</span><span class="p">],</span>
    <span class="p">});</span>

    <span class="kt">var</span> <span class="n">client</span> <span class="p">=</span> <span class="k">await</span> <span class="n">McpClientFactory</span><span class="p">.</span><span class="nf">CreateAsync</span><span class="p">(</span><span class="n">clientTransport</span><span class="p">);</span>

    <span class="n">_tools</span> <span class="p">=</span> <span class="k">await</span> <span class="n">client</span><span class="p">.</span><span class="nf">ListToolsAsync</span><span class="p">();</span>

    <span class="n">_apiKey</span> <span class="p">=</span> <span class="n">Environment</span><span class="p">.</span><span class="nf">GetEnvironmentVariable</span><span class="p">(</span><span class="s">"OPENAI_API_KEY"</span><span class="p">)</span> <span class="p">??</span> <span class="kt">string</span><span class="p">.</span><span class="n">Empty</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="kt">string</span><span class="p">.</span><span class="nf">IsNullOrEmpty</span><span class="p">(</span><span class="n">_apiKey</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="n">MessageBox</span><span class="p">.</span><span class="nf">Show</span><span class="p">(</span><span class="s">"API Key is Empty!"</span><span class="p">,</span> <span class="s">"Info"</span><span class="p">);</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">_chatClient</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">OpenAIClient</span><span class="p">(</span><span class="n">_apiKey</span><span class="p">).</span><span class="nf">GetChatClient</span><span class="p">(</span><span class="n">_model</span><span class="p">).</span><span class="nf">AsIChatClient</span><span class="p">()</span>
                        <span class="p">.</span><span class="nf">AsBuilder</span><span class="p">().</span><span class="nf">UseFunctionInvocation</span><span class="p">().</span><span class="nf">Build</span><span class="p">();</span>

    <span class="n">_messages</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="k">new</span><span class="p">(</span><span class="n">ChatRole</span><span class="p">.</span><span class="n">System</span><span class="p">,</span> <span class="s">"請使用繁體中文回答所有問題。"</span><span class="p">));</span>
    <span class="n">_messages</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="k">new</span><span class="p">(</span><span class="n">ChatRole</span><span class="p">.</span><span class="n">System</span><span class="p">,</span> <span class="s">"公司是指公會。"</span><span class="p">));</span>

    <span class="nf">AddUIMessage</span><span class="p">(</span><span class="n">ChatRole</span><span class="p">.</span><span class="n">System</span><span class="p">,</span> <span class="s">"歡迎使用本工具！這是一個結合 NetStone 的自然語言 MCP 伺服器，可用來查詢《Final Fantasy XIV》的角色與世界資料。"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Client程式碼較長，詳細程式碼請參考Repo</strong></p>

<h2 id="實際使用效果">實際使用效果</h2>

<p><img src="../assets/img/MCP/mcp-client.png" /></p>

<p><br /></p>

<p><img src="../assets/img/MCP/sample.png" /></p>

<p>翻譯問題需要在MCP Tool內加入對照字典即可解決</p>

<h2 id="github-專案連結">GitHub 專案連結</h2>

<p><a href="https://github.com/dks50217/NetStoneMCP">查看完整原始碼 on GitHub，陸續增加工具中</a></p>

<p>透過本篇文章，我們成功打造了一個結合 WPF、MCP 協定、NetStone 工具視覺呈現的 FFXIV 對話式資料查詢工具。這不僅是一個實用的小工具，更是一種整合語言模型與遊戲資料的範例：讓開發者能以自然語言，直接與複雜的系統進行互動。</p>

<p>當然，也可以不只利用 NetStone —— MCP Server 是彈性開放的，也可以輕鬆整合其他第三方 API、資料庫查詢工具，甚至你的 AI 模型工具，打造屬於自己的自然語言操作平台。</p>

<p>無論你是《Final Fantasy XIV》的忠實玩家，還是正在探索語言介面與遊戲結合可能性的開發者，這樣的系統都提供了更多創作空間。你可以繼續擴充工具功能，支援更多查詢、角色行為、甚至製作 Discord Bot、網頁前端或語音助理版本。</p>]]></content><author><name>Michael Chang</name></author><category term="MCP" /><category term="AI" /><category term="FFXIV" /><category term="WPF" /><category term="NetStone" /><summary type="html"><![CDATA[利用 NetStone 整合 MCP(Model Context Protocol)，打造 FFXIV 專用的 WPF Chat UI 工具，實現角色資訊、物品資料、任務流程等即時查詢對話式互動功能。]]></summary></entry><entry><title type="html">如何使用Google開源模型SpeciesNet</title><link href="https://dks50217.com/SpeciesNet/" rel="alternate" type="text/html" title="如何使用Google開源模型SpeciesNet" /><published>2025-03-26T00:00:00+00:00</published><updated>2025-03-26T00:00:00+00:00</updated><id>https://dks50217.com/SpeciesNet</id><content type="html" xml:base="https://dks50217.com/SpeciesNet/"><![CDATA[<p>在 AI 技術持續突破的今天，Google 推出了令人興奮的開源模型 SpeciesNet，專為辨識野生動物而設計。這個模型訓練於數千萬張由相機偵測拍攝的實際照片，能夠自動識別超過 2000 種動物物種與相關標籤。對於從事生態研究、環境保護或開發自然觀察應用的開發者來說，SpeciesNet 提供了一個強大的工具，可大量減少人工標記的時間。</p>

<h2 id="模型組合架構ensemble">模型組合架構（Ensemble）</h2>

<p>該系統包含兩個 AI 模型，分別負責不同任務：</p>

<h3 id="1-megadetector目標偵測器">1. MegaDetector（目標偵測器）</h3>
<ul>
  <li>用途：從影像中找出 <strong>動物、人類、車輛</strong> 等目標。</li>
  <li>特點：<strong>不辨識動物的種類</strong>，僅提供「有動物」這類粗略判斷。</li>
  <li>適合用於初步過濾與目標區域偵測。</li>
</ul>

<h3 id="2-speciesnet物種分類器">2. SpeciesNet（物種分類器）</h3>
<ul>
  <li>架構：採用 Google 訓練的 <strong>EfficientNet V2 M</strong> 架構。</li>
  <li>分類能力：可辨識超過 <strong>2000 個標籤</strong>，包含：
    <ul>
      <li>各種動物物種（例如：獅子、長頸鹿）</li>
      <li>高階分類群（如：哺乳類 <em>Mammalia</em>、貓科 <em>Felidae</em>）</li>
      <li>非動物類別（如：「空白」、「車輛」）</li>
    </ul>
  </li>
  <li>訓練資料：
    <ul>
      <li>來自 <a href="https://www.wildlifeinsights.org/">Wildlife Insights</a> 社群的整理影像</li>
      <li>公開資料庫，總數超過 <strong>6,500 萬張相機陷阱影像</strong></li>
    </ul>
  </li>
  <li>功能：提供物種層級的影像分類結果。</li>
</ul>

<h3 id="整合方式">整合方式</h3>
<ul>
  <li>使用<strong>啟發式規則（heuristics）</strong>整合 MegaDetector 和 SpeciesNet 的輸出。</li>
  <li>可選擇使用<strong>地理資訊</strong>以提升分類準確性。</li>
  <li>最終為每張影像提供 <strong>單一分類結果</strong>。</li>
</ul>

<p>這篇文章是紀錄實際應用 &amp; 自己筆記用</p>

<h2 id="安裝python環境">安裝Python環境</h2>

<ul>
  <li>
    <p>安裝 <a href="https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Windows-x86_64.exe">Miniforge</a></p>
  </li>
  <li>
    <p>安裝完成後開啟 Miniforge Prompt</p>

    <p><img src="../assets/img/SpeciesNet/SpeciesNet-1.png" /></p>
  </li>
  <li>輸入以下指令建立環境名稱:speciesnet
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  conda create <span class="nt">-n</span> speciesnet <span class="nv">python</span><span class="o">=</span>3.11 pip <span class="nt">-y</span>
</code></pre></div>    </div>
  </li>
  <li>啟動speciesnet環境
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  conda activate speciesnet
</code></pre></div>    </div>
  </li>
  <li>
    <p>如出現以下代表環境建立成功</p>

    <p><img src="../assets/img/SpeciesNet/SpeciesNet-2.png" /></p>
  </li>
</ul>

<h2 id="安裝speciesnet">安裝SpeciesNet</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>speciesnet
</code></pre></div></div>

<h2 id="簡易測試">簡易測試</h2>

<ul>
  <li>輸入以下指令執行預測</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python <span class="nt">-m</span> speciesnet.scripts.run_model <span class="nt">--folders</span> <span class="s2">"{需要辨識的圖片來源資料夾路徑}"</span> <span class="nt">--predictions_json</span> <span class="s2">"{輸出的json檔案}"</span>
</code></pre></div></div>

<p><img src="../assets/img/SpeciesNet/SpeciesNet-3.png" /></p>

<ul>
  <li>以某張圖為範例結果如下，把結果先丟給ChatGPT分析prediction區塊看來查看其含義</li>
</ul>

<h3 id="分類階層解析從大到小">分類階層解析（從大到小）</h3>

<table>
  <thead>
    <tr>
      <th>分類層級</th>
      <th>名稱</th>
      <th>說明</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>UUID</td>
      <td><code class="language-plaintext highlighter-rouge">436ddfdd-bc43-44c3-a25d-34671d3430a0</code></td>
      <td>此筆分類資料的唯一識別碼</td>
    </tr>
    <tr>
      <td>綱（Class）</td>
      <td><code class="language-plaintext highlighter-rouge">mammalia</code></td>
      <td>哺乳綱，哺乳類動物</td>
    </tr>
    <tr>
      <td>目（Order）</td>
      <td><code class="language-plaintext highlighter-rouge">carnivora</code></td>
      <td>食肉目，例如狗、貓、熊、鼬等</td>
    </tr>
    <tr>
      <td>科（Family）</td>
      <td><code class="language-plaintext highlighter-rouge">ursidae</code></td>
      <td>熊科，包括棕熊、美洲黑熊等</td>
    </tr>
    <tr>
      <td>屬（Genus）</td>
      <td><code class="language-plaintext highlighter-rouge">ursus</code></td>
      <td>熊屬，包括灰熊、美洲黑熊</td>
    </tr>
    <tr>
      <td>種（Species）</td>
      <td><code class="language-plaintext highlighter-rouge">americanus</code></td>
      <td><em>Ursus americanus</em>，美洲黑熊</td>
    </tr>
    <tr>
      <td>常見名稱</td>
      <td><code class="language-plaintext highlighter-rouge">american black bear</code></td>
      <td>中文為「美洲黑熊」</td>
    </tr>
  </tbody>
</table>

<p><br /></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Predictions:</span><span class="w">
</span><span class="p">{</span><span class="w">
    </span><span class="nl">"predictions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="p">{</span><span class="w">
            </span><span class="nl">"filepath"</span><span class="p">:</span><span class="w"> </span><span class="s2">"image/american_black_bear.jpg"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"classifications"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"classes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                    </span><span class="s2">"436ddfdd-bc43-44c3-a25d-34671d3430a0;mammalia;carnivora;ursidae;ursus;americanus;american black bear"</span><span class="p">,</span><span class="w">
                    </span><span class="s2">"febff896-db40-4ac8-bcfe-5bb99a600950;mammalia;cetartiodactyla;cervidae;odocoileus;hemionus;mule deer"</span><span class="p">,</span><span class="w">
                    </span><span class="s2">"5c7ce479-8a45-40b3-ae21-7c97dfae22f5;mammalia;cetartiodactyla;cervidae;odocoileus;virginianus;white-tailed deer"</span><span class="p">,</span><span class="w">
                    </span><span class="s2">"eb3829b0-772e-4088-ae90-f11b9fe38284;mammalia;cetartiodactyla;cervidae;cervus;elaphus;red deer"</span><span class="p">,</span><span class="w">
                    </span><span class="s2">"330bb1e9-84d6-4e41-afa9-938aee17ea29;mammalia;carnivora;ursidae;ursus;arctos;brown bear"</span><span class="w">
                </span><span class="p">],</span><span class="w">
                </span><span class="nl">"scores"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                    </span><span class="mf">0.9895798563957214</span><span class="p">,</span><span class="w">
                    </span><span class="mf">0.003979037050157785</span><span class="p">,</span><span class="w">
                    </span><span class="mf">0.0037781514693051577</span><span class="p">,</span><span class="w">
                    </span><span class="mf">0.0007601650431752205</span><span class="p">,</span><span class="w">
                    </span><span class="mf">0.0005046064616180956</span><span class="w">
                </span><span class="p">]</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"detections"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"label"</span><span class="p">:</span><span class="w"> </span><span class="s2">"animal"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"conf"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9598512053489685</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"bbox"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                        </span><span class="mf">0.43310546875</span><span class="p">,</span><span class="w">
                        </span><span class="mf">0.4283854365348816</span><span class="p">,</span><span class="w">
                        </span><span class="mf">0.3232421875</span><span class="p">,</span><span class="w">
                        </span><span class="mf">0.322265625</span><span class="w">
                    </span><span class="p">]</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">],</span><span class="w">
            </span><span class="nl">"prediction"</span><span class="p">:</span><span class="w"> </span><span class="s2">"436ddfdd-bc43-44c3-a25d-34671d3430a0;mammalia;carnivora;ursidae;ursus;americanus;american black bear"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"prediction_score"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9895798563957214</span><span class="p">,</span><span class="w">
            </span><span class="nl">"prediction_source"</span><span class="p">:</span><span class="w"> </span><span class="s2">"classifier"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"model_version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"4.0.0a"</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>從json內可以看到有bbox(bounding box)標示出動物邊界框，我們以畫面帶出</p>

<p><img src="../assets/img/SpeciesNet/SpeciesNet-4.png" /></p>

<h2 id="微調模型">微調模型</h2>

<p>目前訓練程式碼尚未開源。雖然程式碼基本上是依照論文中的方法進行的，大部分的訓練資料也不是公開的</p>

<p>官方不久後可能會發布微調的範例程式碼</p>

<ul>
  <li>從kaggle下載模型至本地端查看模型結構</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">path</span> <span class="o">=</span> <span class="n">kagglehub</span><span class="p">.</span><span class="n">model_download</span><span class="p">(</span><span class="s">"google/speciesnet/keras/v4.0.0a"</span><span class="p">)</span>

<span class="n">model_file</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="s">"always_crop_99710272_22x8_v12_epoch_00148.keras"</span><span class="p">)</span>

<span class="n">species_model</span> <span class="o">=</span> <span class="n">tf</span><span class="p">.</span><span class="n">keras</span><span class="p">.</span><span class="n">models</span><span class="p">.</span><span class="n">load_model</span><span class="p">(</span><span class="n">model_file</span><span class="p">)</span>

<span class="n">species_model</span><span class="p">.</span><span class="n">summary</span><span class="p">()</span>
</code></pre></div></div>

<p><img src="../assets/img/SpeciesNet/SpeciesNet-5.png" /></p>

<ul>
  <li>模型若有下載會放至<code class="language-plaintext highlighter-rouge">/root/.cache/kagglehub/models/google/speciesnet/keras/v4.0.0a/3</code>
    <ul>
      <li>label 是標籤、keras是模型主檔</li>
    </ul>
  </li>
</ul>

<p><img src="../assets/img/SpeciesNet/SpeciesNet-7.png" /></p>

<ol>
  <li>
    <p>可試著微調模型加入台灣黑熊</p>

    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
 <span class="n">path</span> <span class="o">=</span> <span class="n">kagglehub</span><span class="p">.</span><span class="n">model_download</span><span class="p">(</span><span class="s">"google/speciesnet/keras/v4.0.0a"</span><span class="p">)</span>

 <span class="n">model_file</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="s">"always_crop_99710272_22x8_v12_epoch_00148.keras"</span><span class="p">)</span>

 <span class="k">print</span><span class="p">(</span><span class="n">model_file</span><span class="p">);</span>

 <span class="n">NEW_CLASS_NAME</span> <span class="o">=</span> <span class="s">"taiwan_black_bear"</span>
 <span class="n">LABELS_TXT_PATH</span> <span class="o">=</span>  <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="s">"always_crop_99710272_22x8_v12_epoch_00148.labels.txt"</span><span class="p">)</span>
 <span class="n">NEW_DATASET_DIR</span> <span class="o">=</span> <span class="s">"dataset/taiwan_black_bear"</span> 
 <span class="n">IMAGE_SIZE</span> <span class="o">=</span> <span class="p">(</span><span class="mi">480</span><span class="p">,</span> <span class="mi">480</span><span class="p">)</span>
 <span class="n">BATCH_SIZE</span> <span class="o">=</span> <span class="mi">8</span>
 <span class="n">EPOCHS</span> <span class="o">=</span> <span class="mi">10</span>


 <span class="k">print</span><span class="p">(</span><span class="s">"[INFO] Loading base model..."</span><span class="p">)</span>
 <span class="n">base_model</span> <span class="o">=</span> <span class="n">load_model</span><span class="p">(</span><span class="n">model_file</span><span class="p">)</span>

 <span class="k">print</span><span class="p">(</span><span class="s">"[INFO] Extracting old dense weights..."</span><span class="p">)</span>
 <span class="n">old_dense_layer</span> <span class="o">=</span> <span class="n">base_model</span><span class="p">.</span><span class="n">layers</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
 <span class="n">old_weights</span><span class="p">,</span> <span class="n">old_bias</span> <span class="o">=</span> <span class="n">old_dense_layer</span><span class="p">.</span><span class="n">get_weights</span><span class="p">()</span>

 <span class="n">original_class_count</span> <span class="o">=</span> <span class="n">old_weights</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
 <span class="n">new_class_count</span> <span class="o">=</span> <span class="n">original_class_count</span> <span class="o">+</span> <span class="mi">1</span>

 <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"[INFO] Expanding output layer from </span><span class="si">{</span><span class="n">original_class_count</span><span class="si">}</span><span class="s"> to </span><span class="si">{</span><span class="n">new_class_count</span><span class="si">}</span><span class="s"> classes..."</span><span class="p">)</span>

 <span class="n">new_weights</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">concatenate</span><span class="p">(</span>
     <span class="p">[</span><span class="n">old_weights</span><span class="p">,</span> <span class="n">np</span><span class="p">.</span><span class="n">random</span><span class="p">.</span><span class="n">normal</span><span class="p">(</span><span class="n">size</span><span class="o">=</span><span class="p">(</span><span class="n">old_weights</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="mi">1</span><span class="p">))],</span>
     <span class="n">axis</span><span class="o">=</span><span class="mi">1</span>
 <span class="p">)</span>
 <span class="n">new_bias</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">concatenate</span><span class="p">(</span>
     <span class="p">[</span><span class="n">old_bias</span><span class="p">,</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">([</span><span class="mf">0.0</span><span class="p">])]</span>
 <span class="p">)</span>

 <span class="n">x</span> <span class="o">=</span> <span class="n">base_model</span><span class="p">.</span><span class="n">layers</span><span class="p">[</span><span class="o">-</span><span class="mi">2</span><span class="p">].</span><span class="n">output</span>  <span class="c1"># Dropout 輸出層
</span> <span class="n">new_output</span> <span class="o">=</span> <span class="n">layers</span><span class="p">.</span><span class="n">Dense</span><span class="p">(</span><span class="n">new_class_count</span><span class="p">,</span> <span class="n">activation</span><span class="o">=</span><span class="s">'softmax'</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s">"expanded_output"</span><span class="p">)(</span><span class="n">x</span><span class="p">)</span>

 <span class="n">model</span> <span class="o">=</span> <span class="n">Model</span><span class="p">(</span><span class="n">inputs</span><span class="o">=</span><span class="n">base_model</span><span class="p">.</span><span class="nb">input</span><span class="p">,</span> <span class="n">outputs</span><span class="o">=</span><span class="n">new_output</span><span class="p">)</span>


 <span class="n">model</span><span class="p">.</span><span class="n">get_layer</span><span class="p">(</span><span class="s">"expanded_output"</span><span class="p">).</span><span class="n">set_weights</span><span class="p">([</span><span class="n">new_weights</span><span class="p">,</span> <span class="n">new_bias</span><span class="p">])</span>

 <span class="k">for</span> <span class="n">layer</span> <span class="ow">in</span> <span class="n">model</span><span class="p">.</span><span class="n">layers</span><span class="p">[:</span><span class="o">-</span><span class="mi">1</span><span class="p">]:</span>
     <span class="n">layer</span><span class="p">.</span><span class="n">trainable</span> <span class="o">=</span> <span class="bp">False</span>

 <span class="k">print</span><span class="p">(</span><span class="s">"[INFO] Loading new class images..."</span><span class="p">)</span>
 <span class="n">datagen</span> <span class="o">=</span> <span class="n">ImageDataGenerator</span><span class="p">(</span><span class="n">rescale</span><span class="o">=</span><span class="mf">1.0</span><span class="o">/</span><span class="mi">255</span><span class="p">)</span>

 <span class="k">def</span> <span class="nf">generate_bear_dataset</span><span class="p">(</span><span class="n">path</span><span class="p">):</span>
     <span class="n">class_label</span> <span class="o">=</span> <span class="n">new_class_count</span> <span class="o">-</span> <span class="mi">1</span>  <span class="c1"># taiwan_black_bear = 2498
</span>     <span class="n">filenames</span> <span class="o">=</span> <span class="p">[</span><span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">f</span><span class="p">)</span> <span class="k">for</span> <span class="n">f</span> <span class="ow">in</span> <span class="n">os</span><span class="p">.</span><span class="n">listdir</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="k">if</span> <span class="n">f</span><span class="p">.</span><span class="n">lower</span><span class="p">().</span><span class="n">endswith</span><span class="p">((</span><span class="s">".jpg"</span><span class="p">,</span> <span class="s">".png"</span><span class="p">))]</span>
     <span class="n">images</span> <span class="o">=</span> <span class="p">[]</span>
     <span class="n">labels</span> <span class="o">=</span> <span class="p">[]</span>
     <span class="k">for</span> <span class="n">f</span> <span class="ow">in</span> <span class="n">filenames</span><span class="p">:</span>
         <span class="n">img</span> <span class="o">=</span> <span class="n">tf</span><span class="p">.</span><span class="n">keras</span><span class="p">.</span><span class="n">utils</span><span class="p">.</span><span class="n">load_img</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="n">target_size</span><span class="o">=</span><span class="n">IMAGE_SIZE</span><span class="p">)</span>
         <span class="n">img_array</span> <span class="o">=</span> <span class="n">tf</span><span class="p">.</span><span class="n">keras</span><span class="p">.</span><span class="n">utils</span><span class="p">.</span><span class="n">img_to_array</span><span class="p">(</span><span class="n">img</span><span class="p">)</span> <span class="o">/</span> <span class="mf">255.0</span>
         <span class="n">images</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">img_array</span><span class="p">)</span>
         <span class="n">labels</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">class_label</span><span class="p">)</span>
     <span class="k">return</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">(</span><span class="n">images</span><span class="p">),</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">(</span><span class="n">labels</span><span class="p">)</span>

 <span class="n">X_train</span><span class="p">,</span> <span class="n">y_train</span> <span class="o">=</span> <span class="n">generate_bear_dataset</span><span class="p">(</span><span class="n">NEW_DATASET_DIR</span><span class="p">)</span>
 <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"[INFO] Loaded </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">X_train</span><span class="p">)</span><span class="si">}</span><span class="s"> images for class '</span><span class="si">{</span><span class="n">NEW_CLASS_NAME</span><span class="si">}</span><span class="s">'."</span><span class="p">)</span>

 <span class="n">model</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="n">optimizer</span><span class="o">=</span><span class="n">tf</span><span class="p">.</span><span class="n">keras</span><span class="p">.</span><span class="n">optimizers</span><span class="p">.</span><span class="n">Adam</span><span class="p">(</span><span class="mf">1e-4</span><span class="p">),</span>
             <span class="n">loss</span><span class="o">=</span><span class="s">'sparse_categorical_crossentropy'</span><span class="p">,</span>
             <span class="n">metrics</span><span class="o">=</span><span class="p">[</span><span class="s">'accuracy'</span><span class="p">])</span>

 <span class="k">print</span><span class="p">(</span><span class="s">"[INFO] Training on new class only..."</span><span class="p">)</span>
 <span class="n">model</span><span class="p">.</span><span class="n">fit</span><span class="p">(</span><span class="n">X_train</span><span class="p">,</span> <span class="n">y_train</span><span class="p">,</span> <span class="n">batch_size</span><span class="o">=</span><span class="n">BATCH_SIZE</span><span class="p">,</span> <span class="n">epochs</span><span class="o">=</span><span class="n">EPOCHS</span><span class="p">)</span>

 <span class="k">print</span><span class="p">(</span><span class="s">"[INFO] Saving new model as 'speciesnet_with_bear.keras'..."</span><span class="p">)</span>
 <span class="n">model</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="s">"speciesnet_with_bear.keras"</span><span class="p">)</span>

 <span class="n">new_uuid</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">uuid</span><span class="p">.</span><span class="n">uuid4</span><span class="p">())</span>

 <span class="n">NEW_LABEL_LINE</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">new_uuid</span><span class="si">}</span><span class="s">;mammalia;carnivora;ursidae;ursus;thibetanus;taiwan_black_bear"</span>

 <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">LABELS_TXT_PATH</span><span class="p">,</span> <span class="s">"r"</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
     <span class="n">lines</span> <span class="o">=</span> <span class="n">f</span><span class="p">.</span><span class="n">readlines</span><span class="p">()</span>
     <span class="k">if</span> <span class="nb">any</span><span class="p">(</span><span class="s">"taiwan_black_bear"</span> <span class="ow">in</span> <span class="n">line</span> <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">lines</span><span class="p">):</span>
         <span class="k">print</span><span class="p">(</span><span class="s">"[INFO] Label for 'taiwan_black_bear' already exists in labels.txt, skipping append."</span><span class="p">)</span>
     <span class="k">else</span><span class="p">:</span>
         <span class="n">backup_path</span> <span class="o">=</span> <span class="n">LABELS_TXT_PATH</span> <span class="o">+</span> <span class="s">".backup"</span>
         <span class="k">if</span> <span class="ow">not</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">exists</span><span class="p">(</span><span class="n">backup_path</span><span class="p">):</span>
             <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">backup_path</span><span class="p">,</span> <span class="s">"w"</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">backup_file</span><span class="p">:</span>
                 <span class="n">backup_file</span><span class="p">.</span><span class="n">writelines</span><span class="p">(</span><span class="n">lines</span><span class="p">)</span>
             <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"[INFO] Backup created: </span><span class="si">{</span><span class="n">backup_path</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

         <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">LABELS_TXT_PATH</span><span class="p">,</span> <span class="s">"a"</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f_append</span><span class="p">:</span>
             <span class="n">f_append</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="s">"</span><span class="se">\n</span><span class="s">"</span> <span class="o">+</span> <span class="n">NEW_LABEL_LINE</span><span class="p">)</span>
             <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"[INFO] Appended new class to labels.txt: </span><span class="si">{</span><span class="n">new_uuid</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div>    </div>
  </li>
</ol>

<h2 id="使用gpu-以windows為例">使用GPU (以Windows為例)</h2>

<p>使用GPU可以加外模型預測處理速度，使用以下指令有檢測到GPU即可</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>torch torchvision <span class="nt">--upgrade</span> <span class="nt">--force-reinstall</span> <span class="nt">--index-url</span> https://download.pytorch.org/whl/cu118
python <span class="nt">-m</span> speciesnet.scripts.gpu_test
</code></pre></div></div>

<p><img src="../assets/img/SpeciesNet/SpeciesNet-6.png" /></p>

<h2 id="演示用程式">演示用程式</h2>

<p><a href="https://github.com/dks50217/SpeciesNetDemo">SpeciesNetDemo</a></p>

<h4 id="參考資料">參考資料</h4>
<ul>
  <li>https://github.com/google/cameratrapai?tab=readme-ov-file</li>
  <li>https://github.com/google/cameratrapai/blob/main/installing-python.md</li>
  <li>https://www.inside.com.tw/article/37737-google-opensources-speciesnet</li>
</ul>]]></content><author><name>Michael Chang</name></author><category term="SpeciesNet" /><category term="AI" /><summary type="html"><![CDATA[如何使用Google開源模型SpeciesNet]]></summary></entry><entry><title type="html">WPF Blazor Hybrid 中加入Route</title><link href="https://dks50217.com/WPF-Blazor/" rel="alternate" type="text/html" title="WPF Blazor Hybrid 中加入Route" /><published>2024-09-14T00:00:00+00:00</published><updated>2024-09-14T00:00:00+00:00</updated><id>https://dks50217.com/WPF-Blazor</id><content type="html" xml:base="https://dks50217.com/WPF-Blazor/"><![CDATA[<p>微軟官方文件內有寫WPF如何結合Blazor使用 <a href="https://learn.microsoft.com/en-us/aspnet/core/blazor/hybrid/tutorials/wpf?view=aspnetcore-8.0">blazor hybrid</a>，但後續沒有寫到如何加入Router，本篇將接續官方文件說明如何增加Router。</p>

<ul>
  <li>建立一個App.razor，內容如下，加入Router</li>
</ul>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="n">Router</span> <span class="n">AppAssembly</span><span class="p">=</span><span class="s">"@typeof(App).Assembly"</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="n">Found</span> <span class="n">Context</span><span class="p">=</span><span class="s">"routeData"</span><span class="p">&gt;</span>
        <span class="p">&lt;</span><span class="n">RouteView</span> <span class="n">RouteData</span><span class="p">=</span><span class="s">"@routeData"</span> <span class="p">/&gt;</span>
    <span class="p">&lt;/</span><span class="n">Found</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="n">NotFound</span><span class="p">&gt;</span>
        <span class="p">&lt;</span><span class="n">h1</span><span class="p">&gt;</span><span class="n">Page</span> <span class="n">not</span> <span class="n">found</span><span class="p">&lt;/</span><span class="n">h1</span><span class="p">&gt;</span>
    <span class="p">&lt;/</span><span class="n">NotFound</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="n">Router</span><span class="p">&gt;</span>
</code></pre></div></div>

<ul>
  <li>將RootComponent中的ComponentType改為App</li>
</ul>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="n">Grid</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="n">blazor</span><span class="p">:</span><span class="n">BlazorWebView</span> <span class="n">HostPage</span><span class="p">=</span><span class="s">"wwwroot\index.html"</span> <span class="n">Services</span><span class="p">=</span><span class="s">"{DynamicResource services}"</span><span class="p">&gt;</span>
        <span class="p">&lt;</span><span class="n">blazor</span><span class="p">:</span><span class="n">BlazorWebView</span><span class="p">.</span><span class="n">RootComponents</span><span class="p">&gt;</span>
            <span class="p">&lt;</span><span class="n">blazor</span><span class="p">:</span><span class="n">RootComponent</span> <span class="n">Selector</span><span class="p">=</span><span class="s">"#app"</span> <span class="n">ComponentType</span><span class="p">=</span><span class="s">"{x:Type components:App}"</span> <span class="p">/&gt;</span>
        <span class="p">&lt;/</span><span class="n">blazor</span><span class="p">:</span><span class="n">BlazorWebView</span><span class="p">.</span><span class="n">RootComponents</span><span class="p">&gt;</span>
    <span class="p">&lt;/</span><span class="n">blazor</span><span class="p">:</span><span class="n">BlazorWebView</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="n">Grid</span><span class="p">&gt;</span>
</code></pre></div></div>

<ul>
  <li>建立一個Home.razor，按下按鈕後測試路由導向</li>
</ul>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">@page</span> <span class="s">"/"</span>
<span class="n">@inject</span> <span class="n">NavigationManager</span> <span class="n">navigationManager</span>

<span class="p">&lt;</span><span class="n">button</span> <span class="n">@onclick</span><span class="p">=</span><span class="s">"NavigateToPage"</span><span class="p">&gt;</span><span class="n">Go</span> <span class="n">to</span> <span class="n">Counter</span><span class="p">&lt;/</span><span class="n">button</span><span class="p">&gt;</span>

<span class="n">@code</span> <span class="p">{</span>
    <span class="k">private</span> <span class="k">void</span> <span class="nf">NavigateToPage</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="n">navigationManager</span><span class="p">.</span><span class="nf">NavigateTo</span><span class="p">(</span><span class="s">"/Counter"</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>App.razor就算是主要的頁面，都是由此頁面操作路由</p>]]></content><author><name>Michael Chang</name></author><category term="WPF" /><category term="C#" /><category term="Blazor" /><summary type="html"><![CDATA[如何在WPF Blazor Hybrid中加入Route]]></summary></entry><entry><title type="html">如何開發及引用FFXIV ACT插件</title><link href="https://dks50217.com/ffxiv-act-plugin/" rel="alternate" type="text/html" title="如何開發及引用FFXIV ACT插件" /><published>2022-09-06T00:00:00+00:00</published><updated>2022-09-06T00:00:00+00:00</updated><id>https://dks50217.com/ffxiv-act-plugin</id><content type="html" xml:base="https://dks50217.com/ffxiv-act-plugin/"><![CDATA[<style>
r { color: Red }
</style>

<p>遊戲製作人禁止玩家使用ACT等外部插件，禁止使用副本輔助工具等會影響公平性的工具</p>

<p>若有使用請以不影響其它玩家為主</p>

<p>本篇主要自己紀錄一下如何使用開發ACT插件基底</p>

<h3 id="建立一個c類別庫專案">建立一個C#類別庫專案</h3>

<p><img src="../assets/img/FFXIV/FFXIV2.jpg" /></p>

<p>至Nuget安裝 AdvancedCombatTracker</p>

<p><img src="../assets/img/FFXIV/FFXIV3.jpg" /></p>

<p>在專案內建立一個使用者控制項</p>

<p><img src="../assets/img/FFXIV/FFXIV4.jpg" /></p>

<p>於使用者控制項內實作ACT介面，InitPlugin, DeInitPlugin</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">partial</span> <span class="k">class</span> <span class="nc">UserControl1</span> <span class="p">:</span> <span class="n">UserControl</span><span class="p">,</span> <span class="n">IActPluginV1</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="nf">UserControl1</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="nf">InitializeComponent</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">void</span> <span class="nf">InitPlugin</span><span class="p">(</span><span class="n">TabPage</span> <span class="n">pluginScreenSpace</span><span class="p">,</span> <span class="n">Label</span> <span class="n">pluginStatusText</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="c1">//插件初始化</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">void</span> <span class="nf">DeInitPlugin</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="c1">//插件解除初始化</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="測試讀取遊戲log">測試讀取遊戲Log</h3>

<ul>
  <li>建立一個 lblChatLog 的Label，將遊戲的log印到控制項中</li>
  <li>註冊讀取log事件 Game_LogLineRead</li>
</ul>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">partial</span> <span class="k">class</span> <span class="nc">UserControl1</span> <span class="p">:</span> <span class="n">UserControl</span><span class="p">,</span> <span class="n">IActPluginV1</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="nf">UserControl1</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="nf">InitializeComponent</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">void</span> <span class="nf">InitPlugin</span><span class="p">(</span><span class="n">TabPage</span> <span class="n">pluginScreenSpace</span><span class="p">,</span> <span class="n">Label</span> <span class="n">pluginStatusText</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">pluginScreenSpace</span><span class="p">.</span><span class="n">Controls</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="k">this</span><span class="p">);</span>
        <span class="n">Dock</span> <span class="p">=</span> <span class="n">DockStyle</span><span class="p">.</span><span class="n">Fill</span><span class="p">;</span>
        <span class="n">lblStatus</span> <span class="p">=</span> <span class="n">pluginStatusText</span><span class="p">;</span>
        <span class="n">lblStatus</span><span class="p">.</span><span class="n">Text</span> <span class="p">=</span> <span class="s">"插件初始化"</span><span class="p">;</span>

        <span class="n">ActGlobals</span><span class="p">.</span><span class="n">oFormActMain</span><span class="p">.</span><span class="n">BeforeLogLineRead</span> <span class="p">+=</span> <span class="n">Game_LogLineRead</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="k">void</span> <span class="nf">Game_LogLineRead</span><span class="p">(</span><span class="kt">bool</span> <span class="n">isImport</span><span class="p">,</span> <span class="n">LogLineEventArgs</span> <span class="n">logInfo</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">string</span><span class="p">[]</span> <span class="n">logLine</span> <span class="p">=</span> <span class="n">logInfo</span><span class="p">.</span><span class="n">originalLogLine</span><span class="p">.</span><span class="nf">Split</span><span class="p">(</span><span class="sc">'|'</span><span class="p">);</span>
        <span class="k">this</span><span class="p">.</span><span class="n">lblChatLog</span><span class="p">.</span><span class="n">Text</span> <span class="p">=</span> <span class="kt">string</span><span class="p">.</span><span class="nf">Join</span><span class="p">(</span><span class="s">", "</span><span class="p">,</span> <span class="n">logLine</span><span class="p">.</span><span class="nf">Select</span><span class="p">(</span><span class="n">x</span> <span class="p">=&gt;</span> <span class="s">$"\"</span><span class="p">{</span><span class="n">x</span><span class="p">}</span><span class="s">\""</span><span class="p">));</span>
    <span class="p">}</span>
<span class="p">}</span>

</code></pre></div></div>

<h2 id="編譯專案與加入自製插件">編譯專案與加入自製插件</h2>

<p>將專案以Release Build為DLL檔案後，由ACT引用DLL</p>

<p><img src="../assets/img/FFXIV/FFXIV5.jpg" /></p>

<p>至ACT點擊Plugins頁籤，點擊Browse將剛才Build好的DLL加入</p>

<p><img src="../assets/img/FFXIV/FFXIV6.jpg" /></p>

<p>下方就會出現剛剛自製的DLL了</p>

<p><img src="../assets/img/FFXIV/FFXIV7.jpg" /></p>

<p>點入DLL頁籤，從遊戲中測試Log有正常讀取</p>

<p><img src="../assets/img/FFXIV/FFXIV8.jpg" /></p>

<p>這樣一個簡單的自製ACT插件就完成了，之後就可以利用遊戲中的log或是其它plugin搭配使用開發出好用的插件</p>]]></content><author><name>Michael Chang</name></author><category term="FFXIV" /><category term="C#" /><category term="ACT" /><summary type="html"><![CDATA[如何開發及引用FFXIV ACT插件]]></summary></entry><entry><title type="html">開發簡易影像辨識腳本小工具</title><link href="https://dks50217.com/RepeatTool/" rel="alternate" type="text/html" title="開發簡易影像辨識腳本小工具" /><published>2022-09-05T00:00:00+00:00</published><updated>2022-09-05T00:00:00+00:00</updated><id>https://dks50217.com/RepeatTool</id><content type="html" xml:base="https://dks50217.com/RepeatTool/"><![CDATA[<p>這是一個因為遊戲中的重複作業實在是太麻煩了，又浪費時間，所做出來的小工具 <br />
網路上搜了半天沒有找到一個同時包含腳本、辨識的輕量化小工具，因此想自己開發一個 <br />
Library也非常簡單，主要以下三個就好，辨識 &amp; 控制滑鼠、鍵盤</p>

<ul>
  <li>pyautogui</li>
  <li>cv2</li>
  <li>json</li>
</ul>

<h2 id="設定檔-configjson">設定檔 (config.json)</h2>

<p>為了讓使用者可以自行編輯腳本，規劃設定檔如下</p>
<ol>
  <li>image: 代表此動作要影像辨識</li>
  <li>command: 輸入影像實體路徑</li>
  <li>dir: 對圖片做的動作 (left、right 左右鍵)</li>
</ol>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="s2">"image"</span><span class="p">,</span><span class="w"> </span><span class="nl">"command"</span><span class="p">:</span><span class="s2">"A.jpg"</span><span class="p">,</span><span class="nl">"dir"</span><span class="p">:</span><span class="s2">"left"</span><span class="p">,</span><span class="w"> </span><span class="nl">"alt"</span><span class="p">:</span><span class="s2">"A_alt.jpg"</span><span class="p">}</span><span class="err">,</span><span class="w">
</span></code></pre></div></div>
<ol>
  <li>button: 代表此動作要按下某按鈕</li>
  <li>command: 按下的按鈕</li>
  <li>dir: 留空</li>
</ol>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="s2">"button"</span><span class="p">,</span><span class="nl">"command"</span><span class="p">:</span><span class="s2">"num0"</span><span class="p">,</span><span class="nl">"dir"</span><span class="p">:</span><span class="s2">""</span><span class="p">}</span><span class="err">,</span><span class="w">
</span></code></pre></div></div>

<ol>
  <li>long: 代表此動作要按下滑鼠</li>
  <li>command: 留空</li>
  <li>dir: left、right 左右鍵</li>
</ol>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="s2">"long"</span><span class="p">,</span><span class="nl">"command"</span><span class="p">:</span><span class="s2">""</span><span class="p">,</span><span class="nl">"dir"</span><span class="p">:</span><span class="s2">"left"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>而組合而成後，就代表要先辨識A圖片後按下左鍵 =&gt; 按下數字鍵0 =&gt; 按下滑鼠左鍵</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="s2">"image"</span><span class="p">,</span><span class="w"> </span><span class="nl">"command"</span><span class="p">:</span><span class="s2">"A.jpg"</span><span class="p">,</span><span class="nl">"dir"</span><span class="p">:</span><span class="s2">"left"</span><span class="p">,</span><span class="w"> </span><span class="nl">"alt"</span><span class="p">:</span><span class="s2">"A_alt.jpg"</span><span class="p">}</span><span class="err">,</span><span class="w">
</span><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="s2">"button"</span><span class="p">,</span><span class="nl">"command"</span><span class="p">:</span><span class="s2">"num0"</span><span class="p">,</span><span class="nl">"dir"</span><span class="p">:</span><span class="s2">""</span><span class="p">}</span><span class="err">,</span><span class="w">
</span><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="s2">"long"</span><span class="p">,</span><span class="nl">"command"</span><span class="p">:</span><span class="s2">""</span><span class="p">,</span><span class="nl">"dir"</span><span class="p">:</span><span class="s2">"left"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="程式碼">程式碼</h2>

<p>程式啟用後鍵入的設定</p>

<ol>
  <li>次數: 跑config.json流程的次數</li>
  <li>信度: 辨識圖片的信度，越高越嚴謹</li>
  <li>間格秒數: 每個流程的間隔</li>
  <li>灰度匹配: 先轉成黑白在辨識</li>
</ol>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">runtime</span> <span class="o">=</span> <span class="nb">input</span><span class="p">(</span><span class="s">"次數: "</span><span class="p">)</span>
<span class="n">conf</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="nb">input</span><span class="p">(</span><span class="s">"信度: "</span><span class="p">))</span>
<span class="n">sec</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="nb">input</span><span class="p">(</span><span class="s">"間隔秒數: "</span><span class="p">))</span>
<span class="n">isGray</span> <span class="o">=</span> <span class="nb">input</span><span class="p">(</span><span class="s">"啟用灰度匹配 (Y/N): "</span><span class="p">).</span><span class="n">upper</span><span class="p">()</span>
</code></pre></div></div>

<p>將設定檔轉為JSON物件，讓不同type跑不同的動作</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">jsonObject</span><span class="p">:</span>
   <span class="nb">type</span> <span class="o">=</span> <span class="n">i</span><span class="p">[</span><span class="s">"type"</span><span class="p">]</span>
   <span class="n">commmand</span> <span class="o">=</span> <span class="n">i</span><span class="p">[</span><span class="s">"command"</span><span class="p">]</span>
   <span class="nb">dir</span> <span class="o">=</span> <span class="n">i</span><span class="p">[</span><span class="s">"dir"</span><span class="p">]</span>

   <span class="k">if</span> <span class="nb">type</span> <span class="o">==</span> <span class="s">"button"</span><span class="p">:</span>
      <span class="n">press</span><span class="p">(</span><span class="n">commmand</span><span class="p">)</span>
   <span class="k">elif</span> <span class="nb">type</span> <span class="o">==</span> <span class="s">"image"</span><span class="p">:</span>
      <span class="n">path</span> <span class="o">=</span> <span class="n">img_dir</span> <span class="o">+</span> <span class="n">commmand</span>
      <span class="k">if</span> <span class="ow">not</span> <span class="n">locate_click</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">alt_path</span><span class="p">,</span> <span class="n">t</span><span class="o">=</span><span class="mf">0.35</span><span class="p">,</span> <span class="n">button</span> <span class="o">=</span> <span class="nb">dir</span><span class="p">):</span> 
            <span class="k">break</span><span class="p">;</span>
   <span class="k">elif</span> <span class="nb">type</span> <span class="o">==</span> <span class="s">"long"</span><span class="p">:</span>
      <span class="n">long_click</span><span class="p">(</span><span class="n">button</span><span class="o">=</span><span class="nb">dir</span><span class="p">)</span>
   <span class="k">else</span><span class="p">:</span>
      <span class="k">print</span><span class="p">(</span><span class="s">"not defind"</span><span class="p">)</span>

   <span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="n">sec</span><span class="p">)</span>
</code></pre></div></div>

<p>辨識圖片則是locateOnScreen檢查有沒有圖片在當前螢幕中後點擊</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">box</span> <span class="o">=</span> <span class="n">locateOnScreen</span><span class="p">(</span><span class="n">img</span><span class="p">,</span><span class="n">confidence</span><span class="o">=</span><span class="n">conf</span><span class="p">,</span> <span class="n">grayscale</span><span class="o">=</span><span class="n">isGrayscale</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">imgname</span><span class="p">,</span> <span class="s">"found at:</span><span class="se">\n\t</span><span class="s">"</span><span class="p">,</span> <span class="n">box</span><span class="p">)</span>
<span class="k">if</span> <span class="n">box</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
   <span class="k">if</span> <span class="n">alt</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
      <span class="n">locate_click</span><span class="p">(</span><span class="n">alt</span><span class="p">,</span> <span class="n">button</span><span class="o">=</span><span class="n">button</span><span class="p">)</span>     
   <span class="k">print</span><span class="p">(</span><span class="s">"找不到圖片"</span> <span class="o">+</span> <span class="n">imgname</span><span class="p">)</span>
</code></pre></div></div>

<p>這樣一個簡單的腳本小工具就完成了，
後續只要更改Config.json即可，小工具Repo如以下連結
<a href="https://github.com/dks50217/FFXIV_Cook_Guildleve">FFXIV_Cook_Guildleve</a></p>

<h2 id="附加-直接抓ffxiv的process來執行腳本">附加! 直接抓FFXIV的Process來執行腳本</h2>

<p>為考慮到我僅擁有單一螢幕，且不希望腳本運行佔據我的螢幕空間，我開發了一個新的UI腳本工具。如下圖所示，使用者可以透過UI編輯腳本，然後直接在FFXIV視窗中執行。</p>

<p><img src="../assets/img/FFXIV/demo.gif" /></p>

<ul>
  <li>可透過UI拖曳改變執行順序，儲存腳本
    <ul>
      <li>若所選類型為 [Text]，則訊息將傳送至遊戲對話框。</li>
      <li>若所選類型為 [Button]，則按鈕將被傳送至遊戲視窗。</li>
      <li>設定執行次數</li>
      <li>設定執行N次後中途插入額外腳本</li>
    </ul>
  </li>
</ul>

<p><img src="../assets/img/FFXIV/UI.jpg" /></p>

<p>這樣腳本工具更具有彈性了
後續只要透過UI修改腳本即可，小工具Repo如以下連結
<a href="https://github.com/dks50217/FFXIVMacroController">FFXIVMacroController</a></p>]]></content><author><name>Michael Chang</name></author><category term="python" /><category term="pyautogui" /><summary type="html"><![CDATA[使用pythongui加上簡易設定，讓遊戲重複動作自動完成]]></summary></entry><entry><title type="html">離線環境安裝Jenkins .NET SDK Plugin</title><link href="https://dks50217.com/Jenkins_NET_SDK/" rel="alternate" type="text/html" title="離線環境安裝Jenkins .NET SDK Plugin" /><published>2022-07-23T00:00:00+00:00</published><updated>2022-07-23T00:00:00+00:00</updated><id>https://dks50217.com/Jenkins_NET_SDK</id><content type="html" xml:base="https://dks50217.com/Jenkins_NET_SDK/"><![CDATA[<p>前陣子在公司測試 Jenkins dotnet 插件，本範例是用docker進行</p>

<h4 id="下載net-sdk">下載.NET SDK</h4>

<p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/sdk-6.0.302-linux-x64-binaries">連結</a></p>

<p><img src="../assets/img/CI/dotnet.jpg" /></p>

<h4 id="將sdk放在jenkins容器某個資料夾">將SDK放在Jenkins容器某個資料夾</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker <span class="nb">cp</span> <span class="nv">$sdkLocationPath</span> <span class="nv">$container</span>:<span class="nv">$containerSDKPath</span>
</code></pre></div></div>

<h4 id="安裝net-sdk-support">安裝.NET SDK Support</h4>

<p>至官網下載<a href="https://plugins.jenkins.io/dotnet-sdk/">.NET SDK Support plugin</a></p>

<p><img src="../assets/img/CI/dotnet2.jpg" /></p>

<p>到Jenkins中選擇部屬外掛，選擇剛剛下載的hpi檔</p>

<p><img src="../assets/img/CI/dotnet3.jpg" />
<img src="../assets/img/CI/dotnet4.jpg" /></p>

<h4 id="jenkins設定sdk路徑">Jenkins設定SDK路徑</h4>

<p>到 jenkins Global Tool Configuration 設定  .NET SDK ，設定路徑、NAME</p>

<p><img src="../assets/img/CI/dotnet5.jpg" /></p>

<h4 id="pipeline">pipeline</h4>

<p>在pipeline 加上 tools section，加上 dotnetsdk ‘{NAME}’，NAME為剛剛設定的.NET SDK NAME</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">tools {</span>
        <span class="s">dotnetsdk 'dotnetsdk6'</span>
      <span class="s">}</span>
</code></pre></div></div>

<h4 id="完整使用方式">完整使用方式</h4>

<p>完整pipeline如下</p>

<div class="language-groovy highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pipeline</span> <span class="o">{</span>
  <span class="n">agent</span> <span class="n">any</span>
  <span class="n">environment</span> <span class="o">{</span>

  <span class="o">}</span>
  <span class="n">tools</span> <span class="o">{</span>
    <span class="n">dotnetsdk</span> <span class="s1">'dotnetsdk6'</span>
  <span class="o">}</span>
  <span class="n">stages</span> <span class="o">{</span>
    <span class="n">stage</span><span class="o">(</span><span class="s1">'Clean workspace'</span><span class="o">)</span> <span class="o">{</span>
      <span class="n">steps</span> <span class="o">{</span>
        <span class="n">cleanWs</span><span class="o">()</span>
      <span class="o">}</span>
    <span class="o">}</span>
    <span class="n">stage</span><span class="o">(</span><span class="s1">'Git Checkout'</span><span class="o">)</span> <span class="o">{</span>
      <span class="n">steps</span> <span class="o">{</span>
        <span class="n">git</span> <span class="nl">branch:</span> <span class="s1">'master'</span><span class="o">,</span> <span class="nl">credentialsId:</span> <span class="s1">'credentialsId'</span><span class="o">,</span> <span class="nl">url:</span> <span class="s1">'git_url'</span>
      <span class="o">}</span>
    <span class="o">}</span>
    <span class="n">stage</span><span class="o">(</span><span class="s1">'Restore packages'</span><span class="o">)</span> <span class="o">{</span>
      <span class="n">steps</span> <span class="o">{</span>
        <span class="n">dotnetRestore</span> <span class="nl">project:</span> <span class="s1">'Project_Name'</span><span class="o">,</span> <span class="nl">workDirectory:</span> <span class="s1">'Project_Path'</span>
      <span class="o">}</span>
    <span class="o">}</span>
    <span class="n">stage</span><span class="o">(</span><span class="s1">'Build'</span><span class="o">)</span> <span class="o">{</span>
      <span class="n">steps</span> <span class="o">{</span>
        <span class="n">dotnetBuild</span> <span class="nl">project:</span> <span class="s1">'Project_Name'</span><span class="o">,</span> <span class="nl">workDirectory:</span> <span class="s1">'Project_Path'</span>
      <span class="o">}</span>
    <span class="o">}</span>
    <span class="n">stage</span><span class="o">(</span><span class="s1">'Publish'</span><span class="o">)</span> <span class="o">{</span>
      <span class="n">steps</span> <span class="o">{</span>
        <span class="n">dotnetPublish</span> <span class="nl">configuration:</span> <span class="s1">'Release'</span><span class="o">,</span> <span class="nl">project:</span> <span class="s1">'Project_Name'</span><span class="o">,</span> <span class="nl">workDirectory:</span> <span class="s1">'Project_Path'</span>
      <span class="o">}</span>
    <span class="o">}</span>
    <span class="n">stage</span><span class="o">(</span><span class="s1">'Clean'</span><span class="o">)</span> <span class="o">{</span>
      <span class="n">steps</span> <span class="o">{</span>
        <span class="n">dotnetClean</span> <span class="nl">project:</span> <span class="s1">'Project_Name'</span><span class="o">,</span> <span class="nl">workDirectory:</span> <span class="s1">'Project_Path'</span>
      <span class="o">}</span>
    <span class="o">}</span>
  <span class="o">}</span>
</code></pre></div></div>

<h4 id="參考資料">參考資料</h4>
<ul>
  <li>https://plugins.jenkins.io/dotnet-sdk/#documentation</li>
  <li>https://www.jenkins.io/doc/book/pipeline/syntax/#supported-tools</li>
</ul>]]></content><author><name>Michael Chang</name></author><category term=".NET SDK" /><category term="CI" /><category term="Jenkins" /><category term="docker" /><category term="NAS" /><summary type="html"><![CDATA[在離線 Linux Server 安裝Jenkins .NET SDK Plugin]]></summary></entry><entry><title type="html">在synology NAS用Docker架 .NET 5 MVC網頁(HTTPS)</title><link href="https://dks50217.com/NAS_CORE_MVC/" rel="alternate" type="text/html" title="在synology NAS用Docker架 .NET 5 MVC網頁(HTTPS)" /><published>2021-05-21T00:00:00+00:00</published><updated>2021-05-21T00:00:00+00:00</updated><id>https://dks50217.com/NAS_CORE_MVC</id><content type="html" xml:base="https://dks50217.com/NAS_CORE_MVC/"><![CDATA[<p>最近因為一些需求，買了一台Synology NAS，順便來學習怎麼架MVC網站至NAS</p>

<h2 id="docker安裝">Docker安裝</h2>

<p>在Synology 套件管理中心，將Docker安裝</p>

<p><img src="../assets/img/NAS/NASMVC1.png" /></p>

<h2 id="net-映像檔下載或匯入">.NET 映像檔下載或匯入</h2>

<p>打開Docker，搜尋 .NET CORE，找到如dotnet、aspnetcore下載
但是目前下載的 .net core都是屬於2.0或3.1版本，所以我另外在Docker hub下載
https://hub.docker.com/_/microsoft-dotnet-sdk</p>

<p><img src="../assets/img/NAS/NASMVC2.png" /></p>

<p>將載好的映像檔丟到NAS中，在Docker中選擇從檔案中加入</p>

<p><img src="../assets/img/NAS/NASMVC3.png" /></p>

<p>將剛才載好的映像檔加入Docker中</p>

<p><img src="../assets/img/NAS/NASMVC4.png" /></p>

<p>確認有成功加入</p>

<p><img src="../assets/img/NAS/NASMVC5.png" /></p>

<h2 id="新建並發佈一個-net5-mvc網頁放至nas">新建並發佈一個 .NET5 MVC網頁，放至NAS</h2>

<p>新建一個 .NET5 的MVC專案，並且發佈至資料夾內，如下圖</p>

<p><img src="../assets/img/NAS/NASMVC6.png" /></p>

<p>將發佈的檔案放至NAS中自己指定的路徑</p>

<p><img src="../assets/img/NAS/NASMVC7.png" />
<img src="../assets/img/NAS/NASMVC8.png" /></p>

<h2 id="新建容器">新建容器</h2>

<p>選擇新增容器，打上容器名稱
<img src="../assets/img/NAS/NASMVC9.png" /></p>

<p>儲存空間頁籤內，選擇新增資料夾，選擇剛剛放檔案的位置
<img src="../assets/img/NAS/NASMVC10.png" /></p>

<p>選擇後，容器掛載路徑打入指定路徑，可依照個人喜好輸入，我這裡用/app
<img src="../assets/img/NAS/NASMVC11.png" /></p>

<p>port設定，記得防火牆也要同意相關port有開啟
<img src="../assets/img/NAS/NASMVC12.png" /></p>

<p>最後進階設定，環境變數ASPNETCORE_URLS 中輸入 http://*:80
<img src="../assets/img/NAS/NASMVC13.png" /></p>

<p>建立後立即啟動容器，確認容器已經開啟
<img src="../assets/img/NAS/NASMVC14.png" /></p>

<p>點選已啟動容器，點選終端機頁籤，將 .NET 應用啟動</p>
<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="go">cd app
dotnet familycenter.dll
</span></code></pre></div></div>
<p><img src="../assets/img/NAS/NASMVC15.png" /></p>

<h2 id="建立憑證">建立憑證</h2>

<p>在NAS中的安全性，選擇憑證並建立憑證，選擇從Let’s Encrypt建立憑證</p>

<p><img src="../assets/img/NAS/NASMVC16.png" /></p>

<p>網域名稱輸入 : familycenter.{Domain}.synology.me</p>

<p>輸入信箱後套用</p>

<p><img src="../assets/img/NAS/NASMVC17.png" /></p>

<p>可以確認憑證已成功建立
<img src="../assets/img/NAS/NASMVC18.png" /></p>

<h2 id="反向代理伺服器設定">反向代理伺服器設定</h2>

<p>控制台中搜尋Proxy 並選擇反向代理伺服器頁籤 新增後輸入資料套用</p>

<h3 id="來源">來源</h3>
<ul>
  <li>通訊協定: HTTPS</li>
  <li>主機名稱: familycenter.{Domain}.synology.me</li>
  <li>Port: 443</li>
</ul>

<h3 id="目的地">目的地</h3>
<ul>
  <li>通訊協定: HTTP</li>
  <li>主機名稱: localhost</li>
  <li>Port: 2266</li>
</ul>

<p><img src="../assets/img/NAS/NASMVC19.png" /></p>

<h2 id="連結mapping">連結Mapping</h2>

<p>回到憑證，選擇剛建立好的憑證，將連結Mapping</p>

<p><img src="../assets/img/NAS/NASMVC20.png" /></p>

<p>瀏覽器輸入網址</p>

<p>https://familycenter.{Domain}.synology.me/</p>

<p>就可以用HTTPS開啟網頁了
<img src="../assets/img/NAS/NASMVC21.png" /></p>]]></content><author><name>Michael Chang</name></author><category term=".NET 5" /><category term="Docker" /><category term="HTTPS" /><category term="SSL" /><summary type="html"><![CDATA[在synology NAS用Docker架 .NET 5 MVC網頁]]></summary></entry></feed>