[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"home-notes":3},[4,327,929,1619],{"id":5,"title":6,"body":7,"category":311,"date":312,"description":313,"draft":314,"extension":315,"meta":316,"navigation":317,"path":318,"readingTime":319,"seo":320,"stem":321,"tags":322,"__hash__":326},"blog\u002Fblog\u002Fdebugging-legacy-code.md","既有系統除錯思路：從重現問題到建立防護",{"type":8,"value":9,"toc":303},"minimark",[10,19,24,27,40,44,47,73,77,80,171,174,256,260,263,275,278,282,293,296,299],[11,12,13,14,18],"p",{},"接手既有系統時，最大的困難往往不是技術，而是「不確定改這裡會影響哪裡」。比起憑感覺修改，用固定的流程除錯更穩定：",[15,16,17],"strong",{},"重現、定位、歸因、修復、防範","，每一步都留下可以驗證的證據。",[20,21,23],"h2",{"id":22},"一重現先讓問題穩定發生","一、重現：先讓問題穩定發生",[11,25,26],{},"無法穩定重現的問題，修好了也無法證明修好。先把條件寫清楚：",[28,29,30,34,37],"ul",{},[31,32,33],"li",{},"哪個頁面、什麼操作順序、什麼資料。",[31,35,36],{},"只發生在特定瀏覽器、螢幕尺寸或登入身分嗎？",[31,38,39],{},"能不能縮小到最小重現範例（Minimal Reproduction）：拿掉不相關的程式碼，問題還在，代表範圍縮對了。",[20,41,43],{"id":42},"二定位用二分法縮小範圍","二、定位：用二分法縮小範圍",[11,45,46],{},"用 DevTools 把範圍切半，再切半：",[28,48,49,55,61,67],{},[31,50,51,54],{},[15,52,53],{},"Console","：先看有沒有錯誤或警告，它們常直接指出方向。",[31,56,57,60],{},[15,58,59],{},"Network","：確認請求有沒有送出、狀態碼、回傳資料是否符合預期。",[31,62,63,66],{},[15,64,65],{},"Elements","：看實際套用的 CSS，以及被劃掉的規則是被誰覆蓋。",[31,68,69,72],{},[15,70,71],{},"Sources","：在可疑位置設中斷點，或用「Event Listener Breakpoints」在特定事件發生時暫停。",[20,74,76],{"id":75},"三歸因用假設驗證不要憑猜測","三、歸因：用假設驗證，不要憑猜測",[11,78,79],{},"介面行為問題常見的原因，可以對照著檢查：",[81,82,83,99],"table",{},[84,85,86],"thead",{},[87,88,89,93,96],"tr",{},[90,91,92],"th",{},"現象",[90,94,95],{},"常見原因",[90,97,98],{},"驗證方式",[100,101,102,114,125,136,160],"tbody",{},[87,103,104,108,111],{},[105,106,107],"td",{},"彈窗點外面會立刻關閉，或關不掉",[105,109,110],{},"事件冒泡或捕獲順序，點擊被上層攔截",[105,112,113],{},"在 capture 階段記錄事件路徑",[87,115,116,119,122],{},[105,117,118],{},"元件值沒有更新",[105,120,121],{},"響應性失效（解構、非響應式物件）",[105,123,124],{},"在 DevTools 檢查該值是否仍是響應式",[87,126,127,130,133],{},[105,128,129],{},"樣式改了沒效果",[105,131,132],{},"選擇器優先權較低，或被框架樣式覆蓋",[105,134,135],{},"在 Elements 面板看被劃掉的規則",[87,137,138,141,149],{},[105,139,140],{},"浮層被遮住",[105,142,143,144,148],{},"stacking context 讓 ",[145,146,147],"code",{},"z-index"," 失效",[105,150,151,152,155,156,159],{},"檢查祖先元素是否有 ",[145,153,154],{},"transform","、",[145,157,158],{},"opacity"," 等",[87,161,162,165,168],{},[105,163,164],{},"偶發錯誤",[105,166,167],{},"非同步時序或請求競態",[105,169,170],{},"加上時間戳記記錄，比對順序",[11,172,173],{},"例如要確認是哪一層攔截了點擊，可以在 capture 階段觀察事件路徑：",[175,176,181],"pre",{"className":177,"code":178,"language":179,"meta":180,"style":180},"language-js shiki shiki-themes github-dark","document.addEventListener('click', (e) => {\n  console.log('capture:', e.target, e.composedPath())\n}, true)\n","js","",[145,182,183,220,243],{"__ignoreMap":180},[184,185,188,192,196,199,203,206,210,213,217],"span",{"class":186,"line":187},"line",1,[184,189,191],{"class":190},"s95oV","document.",[184,193,195],{"class":194},"svObZ","addEventListener",[184,197,198],{"class":190},"(",[184,200,202],{"class":201},"sU2Wk","'click'",[184,204,205],{"class":190},", (",[184,207,209],{"class":208},"s9osk","e",[184,211,212],{"class":190},") ",[184,214,216],{"class":215},"snl16","=>",[184,218,219],{"class":190}," {\n",[184,221,223,226,229,231,234,237,240],{"class":186,"line":222},2,[184,224,225],{"class":190},"  console.",[184,227,228],{"class":194},"log",[184,230,198],{"class":190},[184,232,233],{"class":201},"'capture:'",[184,235,236],{"class":190},", e.target, e.",[184,238,239],{"class":194},"composedPath",[184,241,242],{"class":190},"())\n",[184,244,246,249,253],{"class":186,"line":245},3,[184,247,248],{"class":190},"}, ",[184,250,252],{"class":251},"sDLfK","true",[184,254,255],{"class":190},")\n",[20,257,259],{"id":258},"四修復最小範圍並評估影響面","四、修復：最小範圍，並評估影響面",[11,261,262],{},"修改前先回答三個問題：",[264,265,266,269,272],"ol",{},[31,267,268],{},"這段程式碼還有哪些地方會用到？（全域搜尋、查看引用）",[31,270,271],{},"修改會改變哪些既有行為？",[31,273,274],{},"有沒有更小的改法？例如修元件本身，而不是在每個呼叫端各補一次。",[11,276,277],{},"既有系統通常沒有完整測試，修復後要手動走過「會受影響的流程」，而不只是驗證被回報的那一個情境。",[20,279,281],{"id":280},"五防範讓同樣的問題不再發生","五、防範：讓同樣的問題不再發生",[28,283,284,287,290],{},[31,285,286],{},"補一個能重現原問題的測試；沒有測試環境時，至少把重現步驟寫進 PR 說明。",[31,288,289],{},"能用工具擋下的，就收斂成規則，例如 ESLint 規則或元件的 props 限制。",[31,291,292],{},"把根因記錄在程式碼註解或文件中，寫的是「為什麼這樣做」，而不是「做了什麼」。",[20,294,295],{"id":295},"小結",[11,297,298],{},"除錯的重點是把「感覺」換成「證據」：能穩定重現、能說明原因、能驗證修復、能防止再發。流程固定下來，面對不熟悉的舊程式碼時也能一步一步縮小範圍，而不是到處試改。",[300,301,302],"style",{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":180,"searchDepth":222,"depth":222,"links":304},[305,306,307,308,309,310],{"id":22,"depth":222,"text":23},{"id":42,"depth":222,"text":43},{"id":75,"depth":222,"text":76},{"id":258,"depth":222,"text":259},{"id":280,"depth":222,"text":281},{"id":295,"depth":222,"text":295},"case","2026-10-29","面對非本人撰寫的舊程式碼時，如何透過清晰的最小重現範例、斷點排查與元件解耦逐步解決問題。",false,"md",{},true,"\u002Fblog\u002Fdebugging-legacy-code",7,{"title":6,"description":313},"blog\u002Fdebugging-legacy-code",[323,324,325],"Debugging","Legacy Code","DevTools","dKhjFex4bygbBuYZC85m1yXOyi68cnetrZEScQVjaME",{"id":328,"title":329,"body":330,"category":918,"date":312,"description":919,"draft":314,"extension":315,"meta":920,"navigation":317,"path":921,"readingTime":519,"seo":922,"stem":923,"tags":924,"__hash__":928},"blog\u002Fblog\u002Ffrontend-quality.md","前端工程化與品質：測試策略、無障礙與 Web Vitals",{"type":8,"value":331,"toc":909},[332,335,339,342,404,407,593,598,601,605,612,655,709,716,720,723,773,776,812,872,875,901,903,906],[11,333,334],{},"「品質」不是上線前才補的一道工序，而是讓每次修改都能被驗證、被理解、被安全地回復。這篇整理前端常見的三個面向：測試策略、無障礙，以及效能指標。",[20,336,338],{"id":337},"測試策略測行為不測實作","測試策略：測行為，不測實作",[11,340,341],{},"前端測試常見分三層：",[81,343,344,360],{},[84,345,346],{},[87,347,348,351,354,357],{},[90,349,350],{},"層級",[90,352,353],{},"工具範例",[90,355,356],{},"驗證什麼",[90,358,359],{},"成本",[100,361,362,376,390],{},[87,363,364,367,370,373],{},[105,365,366],{},"單元測試",[105,368,369],{},"Vitest",[105,371,372],{},"純函式、工具函式、狀態邏輯",[105,374,375],{},"低",[87,377,378,381,384,387],{},[105,379,380],{},"元件測試",[105,382,383],{},"Vue Test Utils",[105,385,386],{},"使用者看得到的互動結果",[105,388,389],{},"中",[87,391,392,395,398,401],{},[105,393,394],{},"端對端測試",[105,396,397],{},"Playwright",[105,399,400],{},"完整流程（登入、下單…）",[105,402,403],{},"高",[11,405,406],{},"元件測試的原則是「像使用者一樣操作，檢查使用者看得到的結果」，而不是檢查元件內部的變數名稱。這樣重構內部實作時，測試不會跟著壞。",[175,408,412],{"className":409,"code":410,"language":411,"meta":180,"style":180},"language-ts shiki shiki-themes github-dark","import { mount } from '@vue\u002Ftest-utils'\nimport { describe, expect, it } from 'vitest'\nimport Counter from '.\u002FCounter.vue'\n\ndescribe('Counter', () => {\n  it('點擊按鈕後，顯示的數字加一', async () => {\n    const wrapper = mount(Counter)\n\n    await wrapper.get('button').trigger('click')\n\n    expect(wrapper.text()).toContain('1')\n  })\n})\n","ts",[145,413,414,428,440,452,458,476,500,517,522,551,556,581,587],{"__ignoreMap":180},[184,415,416,419,422,425],{"class":186,"line":187},[184,417,418],{"class":215},"import",[184,420,421],{"class":190}," { mount } ",[184,423,424],{"class":215},"from",[184,426,427],{"class":201}," '@vue\u002Ftest-utils'\n",[184,429,430,432,435,437],{"class":186,"line":222},[184,431,418],{"class":215},[184,433,434],{"class":190}," { describe, expect, it } ",[184,436,424],{"class":215},[184,438,439],{"class":201}," 'vitest'\n",[184,441,442,444,447,449],{"class":186,"line":245},[184,443,418],{"class":215},[184,445,446],{"class":190}," Counter ",[184,448,424],{"class":215},[184,450,451],{"class":201}," '.\u002FCounter.vue'\n",[184,453,455],{"class":186,"line":454},4,[184,456,457],{"emptyLinePlaceholder":317},"\n",[184,459,461,464,466,469,472,474],{"class":186,"line":460},5,[184,462,463],{"class":194},"describe",[184,465,198],{"class":190},[184,467,468],{"class":201},"'Counter'",[184,470,471],{"class":190},", () ",[184,473,216],{"class":215},[184,475,219],{"class":190},[184,477,479,482,484,487,490,493,496,498],{"class":186,"line":478},6,[184,480,481],{"class":194},"  it",[184,483,198],{"class":190},[184,485,486],{"class":201},"'點擊按鈕後，顯示的數字加一'",[184,488,489],{"class":190},", ",[184,491,492],{"class":215},"async",[184,494,495],{"class":190}," () ",[184,497,216],{"class":215},[184,499,219],{"class":190},[184,501,502,505,508,511,514],{"class":186,"line":319},[184,503,504],{"class":215},"    const",[184,506,507],{"class":251}," wrapper",[184,509,510],{"class":215}," =",[184,512,513],{"class":194}," mount",[184,515,516],{"class":190},"(Counter)\n",[184,518,520],{"class":186,"line":519},8,[184,521,457],{"emptyLinePlaceholder":317},[184,523,525,528,531,534,536,539,542,545,547,549],{"class":186,"line":524},9,[184,526,527],{"class":215},"    await",[184,529,530],{"class":190}," wrapper.",[184,532,533],{"class":194},"get",[184,535,198],{"class":190},[184,537,538],{"class":201},"'button'",[184,540,541],{"class":190},").",[184,543,544],{"class":194},"trigger",[184,546,198],{"class":190},[184,548,202],{"class":201},[184,550,255],{"class":190},[184,552,554],{"class":186,"line":553},10,[184,555,457],{"emptyLinePlaceholder":317},[184,557,559,562,565,568,571,574,576,579],{"class":186,"line":558},11,[184,560,561],{"class":194},"    expect",[184,563,564],{"class":190},"(wrapper.",[184,566,567],{"class":194},"text",[184,569,570],{"class":190},"()).",[184,572,573],{"class":194},"toContain",[184,575,198],{"class":190},[184,577,578],{"class":201},"'1'",[184,580,255],{"class":190},[184,582,584],{"class":186,"line":583},12,[184,585,586],{"class":190},"  })\n",[184,588,590],{"class":186,"line":589},13,[184,591,592],{"class":190},"})\n",[594,595,597],"h3",{"id":596},"mock-的邊界","Mock 的邊界",[11,599,600],{},"Mock 的位置越靠近系統邊界越好：mock 網路請求（例如使用 MSW），而不是 mock 自己的內部函式。內部函式被 mock 掉，測試就只是在驗證 mock 本身。",[20,602,604],{"id":603},"無障礙從鍵盤開始","無障礙：從鍵盤開始",[11,606,607,608,611],{},"最容易檢查、效益也最大的一項是",[15,609,610],{},"鍵盤操作","。不用滑鼠，只用鍵盤走過一遍頁面：",[28,613,614,620,631,638],{},[31,615,616,619],{},[145,617,618],{},"Tab"," 能依合理順序走遍所有可互動元素嗎？",[31,621,622,623,626,627,630],{},"目前焦點的位置看得出來嗎？（不要移除 ",[145,624,625],{},"outline","，改用 ",[145,628,629],{},":focus-visible"," 自訂樣式）",[31,632,633,634,637],{},"彈窗打開時，焦點有進入彈窗嗎？按 ",[145,635,636],{},"Esc"," 能關閉嗎？關閉後焦點有回到觸發按鈕嗎？",[31,639,640,641,155,644,155,647,650,651,654],{},"用 ",[145,642,643],{},"\u003Cbutton>",[145,645,646],{},"\u003Ca>",[145,648,649],{},"\u003Clabel>"," 等語意化元素，而不是在 ",[145,652,653],{},"\u003Cdiv>"," 上綁點擊事件。",[175,656,660],{"className":657,"code":658,"language":659,"meta":180,"style":180},"language-css shiki shiki-themes github-dark",":focus-visible {\n  outline: 2px solid #1d4ed8;\n  outline-offset: 2px;\n}\n","css",[145,661,662,668,691,704],{"__ignoreMap":180},[184,663,664,666],{"class":186,"line":187},[184,665,629],{"class":194},[184,667,219],{"class":190},[184,669,670,673,676,679,682,685,688],{"class":186,"line":222},[184,671,672],{"class":251},"  outline",[184,674,675],{"class":190},": ",[184,677,678],{"class":251},"2",[184,680,681],{"class":215},"px",[184,683,684],{"class":251}," solid",[184,686,687],{"class":251}," #1d4ed8",[184,689,690],{"class":190},";\n",[184,692,693,696,698,700,702],{"class":186,"line":245},[184,694,695],{"class":251},"  outline-offset",[184,697,675],{"class":190},[184,699,678],{"class":251},[184,701,681],{"class":215},[184,703,690],{"class":190},[184,705,706],{"class":186,"line":454},[184,707,708],{"class":190},"}\n",[11,710,711,712,715],{},"自動化工具（例如 axe）能抓到缺少 ",[145,713,714],{},"alt","、對比不足、表單欄位沒有標籤等問題，但無法判斷操作流程是否合理，所以仍需要搭配手動檢查。",[20,717,719],{"id":718},"效能core-web-vitals","效能：Core Web Vitals",[11,721,722],{},"Google 的 Core Web Vitals 目前有三項，「良好」的門檻是：",[81,724,725,738],{},[84,726,727],{},[87,728,729,732,735],{},[90,730,731],{},"指標",[90,733,734],{},"衡量",[90,736,737],{},"良好",[100,739,740,751,762],{},[87,741,742,745,748],{},[105,743,744],{},"LCP（Largest Contentful Paint）",[105,746,747],{},"最大內容多快出現",[105,749,750],{},"≤ 2.5 秒",[87,752,753,756,759],{},[105,754,755],{},"INP（Interaction to Next Paint）",[105,757,758],{},"互動後多快有畫面回應",[105,760,761],{},"≤ 200 毫秒",[87,763,764,767,770],{},[105,765,766],{},"CLS（Cumulative Layout Shift）",[105,768,769],{},"版面在載入過程中跳動多少",[105,771,772],{},"≤ 0.1",[11,774,775],{},"常見的改善方向：",[28,777,778,788,794],{},[31,779,780,783,784,787],{},[15,781,782],{},"LCP","：最大的圖片使用適當格式與尺寸，並提高載入優先權（例如 ",[145,785,786],{},"fetchpriority=\"high\"","）；減少阻塞渲染的資源。",[31,789,790,793],{},[15,791,792],{},"INP","：把長時間執行的 JavaScript 拆小，避免佔住主執行緒。",[31,795,796,799,800,803,804,807,808,811],{},[15,797,798],{},"CLS","：圖片與嵌入內容事先指定 ",[145,801,802],{},"width"," \u002F ",[145,805,806],{},"height","（或 ",[145,809,810],{},"aspect-ratio","），讓瀏覽器先保留空間。",[175,813,817],{"className":814,"code":815,"language":816,"meta":180,"style":180},"language-html shiki shiki-themes github-dark","\u003Cimg src=\"\u002Fcover.webp\" width=\"1600\" height=\"728\" alt=\"專案封面\" fetchpriority=\"high\">\n","html",[145,818,819],{"__ignoreMap":180},[184,820,821,824,828,831,834,837,840,842,845,848,850,853,856,858,861,864,866,869],{"class":186,"line":187},[184,822,823],{"class":190},"\u003C",[184,825,827],{"class":826},"s4JwU","img",[184,829,830],{"class":194}," src",[184,832,833],{"class":190},"=",[184,835,836],{"class":201},"\"\u002Fcover.webp\"",[184,838,839],{"class":194}," width",[184,841,833],{"class":190},[184,843,844],{"class":201},"\"1600\"",[184,846,847],{"class":194}," height",[184,849,833],{"class":190},[184,851,852],{"class":201},"\"728\"",[184,854,855],{"class":194}," alt",[184,857,833],{"class":190},[184,859,860],{"class":201},"\"專案封面\"",[184,862,863],{"class":194}," fetchpriority",[184,865,833],{"class":190},[184,867,868],{"class":201},"\"high\"",[184,870,871],{"class":190},">\n",[20,873,874],{"id":874},"大型專案的維護思維",[28,876,877,883,889,895],{},[31,878,879,882],{},[15,880,881],{},"讓錯誤盡早出現","：TypeScript、ESLint、型別化的 API 回傳，把錯誤擋在編譯與提交階段。",[31,884,885,888],{},[15,886,887],{},"小步修改、可回復","：每次變更範圍小，才容易 review，也容易回退。",[31,890,891,894],{},[15,892,893],{},"把規則寫成工具","：能由 CI 自動檢查的（格式、型別、測試），就不要靠人記得。",[31,896,897,900],{},[15,898,899],{},"留下「為什麼」","：註解與文件記錄決策原因，接手的人才能判斷何時可以改。",[20,902,295],{"id":295},[11,904,905],{},"測試守住「行為」、無障礙守住「每個人都能用」、Web Vitals 守住「用起來順不順」。三者都可以從很小的習慣開始：寫一個行為測試、用鍵盤走一遍頁面、替圖片加上尺寸。",[300,907,908],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":180,"searchDepth":222,"depth":222,"links":910},[911,914,915,916,917],{"id":337,"depth":222,"text":338,"children":912},[913],{"id":596,"depth":245,"text":597},{"id":603,"depth":222,"text":604},{"id":718,"depth":222,"text":719},{"id":874,"depth":222,"text":874},{"id":295,"depth":222,"text":295},"quality","測試、自動化驗證、無障礙設計、效能優化與大型專案的維護思維。",{},"\u002Fblog\u002Ffrontend-quality",{"title":329,"description":919},"blog\u002Ffrontend-quality",[925,926,927],"Testing","Accessibility","Web Vitals","NxcOha3EK8H-KqYX2pKqLo1EFXTqQ8zz5I7qR9j2saY",{"id":930,"title":931,"body":932,"category":918,"date":1609,"description":1610,"draft":314,"extension":315,"meta":1611,"navigation":317,"path":1612,"readingTime":524,"seo":1613,"stem":1614,"tags":1615,"__hash__":1618},"blog\u002Fblog\u002Fjwt-storage-security.md","JWT 要存哪裡：從 localStorage 到 Cookie + CSRF Token",{"type":8,"value":933,"toc":1598},[934,941,944,948,955,958,986,989,993,1000,1103,1110,1113,1117,1120,1123,1133,1137,1148,1155,1166,1170,1177,1248,1251,1377,1383,1387,1390,1393,1413,1416,1420,1426,1429,1432,1519,1522,1525,1528,1578,1581,1595],[11,935,936,937,940],{},"我的第一個全端專案「遊戲揪團平台」，是把 JWT 存在 Pinia，再持久化到 localStorage。做法簡單，每次請求放進 ",[145,938,939],{},"Authorization"," header 就好。當時我對權限這塊的理解還比較薄弱，沒有想太多。",[11,942,943],{},"後來做 LINE OA 商家管理平台，後台可以管理多間店家的內容和帳號權限，登入憑證被偷的影響大很多，所以我改成 HttpOnly Cookie 加 CSRF Token。這篇整理兩種做法的風險，以及實作時遇到的問題。",[20,945,947],{"id":946},"jwt-localstorage-的風險xss","JWT + localStorage 的風險：XSS",[11,949,950,951,954],{},"localStorage 的優點是簡單，缺點是",[15,952,953],{},"任何在頁面上執行的 JavaScript 都讀得到","。",[11,956,957],{},"XSS（跨站腳本攻擊）是指攻擊者讓自己的腳本在你的網站上執行，例如透過沒有處理好的使用者輸入，或被植入惡意程式的第三方套件。一旦發生，偷走 token 只要一行：",[175,959,961],{"className":177,"code":960,"language":179,"meta":180,"style":180},"const token = localStorage.getItem('token')\n",[145,962,963],{"__ignoreMap":180},[184,964,965,968,971,973,976,979,981,984],{"class":186,"line":187},[184,966,967],{"class":215},"const",[184,969,970],{"class":251}," token",[184,972,510],{"class":215},[184,974,975],{"class":190}," localStorage.",[184,977,978],{"class":194},"getItem",[184,980,198],{"class":190},[184,982,983],{"class":201},"'token'",[184,985,255],{"class":190},[11,987,988],{},"攻擊者把 token 傳回自己的伺服器後，就能在別的地方假冒使用者，直到 token 過期。JWT 通常是無狀態的，簽發後伺服器很難提前作廢，所以有效期越長，風險越高。",[20,990,992],{"id":991},"改放-httponly-cookie","改放 HttpOnly Cookie",[11,994,995,996,999],{},"加上 ",[145,997,998],{},"HttpOnly"," 的 Cookie，JavaScript 讀不到，瀏覽器會在請求時自動帶上：",[175,1001,1003],{"className":177,"code":1002,"language":179,"meta":180,"style":180},"export const accessTokenCookieConfig = {\n  httpOnly: true, \u002F\u002F JavaScript 讀不到\n  secure: process.env.NODE_ENV === 'production', \u002F\u002F 只走 HTTPS\n  sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',\n  maxAge: 15 * 60 * 1000,\n}\n",[145,1004,1005,1020,1033,1052,1078,1099],{"__ignoreMap":180},[184,1006,1007,1010,1013,1016,1018],{"class":186,"line":187},[184,1008,1009],{"class":215},"export",[184,1011,1012],{"class":215}," const",[184,1014,1015],{"class":251}," accessTokenCookieConfig",[184,1017,510],{"class":215},[184,1019,219],{"class":190},[184,1021,1022,1025,1027,1029],{"class":186,"line":222},[184,1023,1024],{"class":190},"  httpOnly: ",[184,1026,252],{"class":251},[184,1028,489],{"class":190},[184,1030,1032],{"class":1031},"sAwPA","\u002F\u002F JavaScript 讀不到\n",[184,1034,1035,1038,1041,1044,1047,1049],{"class":186,"line":245},[184,1036,1037],{"class":190},"  secure: process.env.",[184,1039,1040],{"class":251},"NODE_ENV",[184,1042,1043],{"class":215}," ===",[184,1045,1046],{"class":201}," 'production'",[184,1048,489],{"class":190},[184,1050,1051],{"class":1031},"\u002F\u002F 只走 HTTPS\n",[184,1053,1054,1057,1059,1061,1063,1066,1069,1072,1075],{"class":186,"line":454},[184,1055,1056],{"class":190},"  sameSite: process.env.",[184,1058,1040],{"class":251},[184,1060,1043],{"class":215},[184,1062,1046],{"class":201},[184,1064,1065],{"class":215}," ?",[184,1067,1068],{"class":201}," 'none'",[184,1070,1071],{"class":215}," :",[184,1073,1074],{"class":201}," 'lax'",[184,1076,1077],{"class":190},",\n",[184,1079,1080,1083,1086,1089,1092,1094,1097],{"class":186,"line":460},[184,1081,1082],{"class":190},"  maxAge: ",[184,1084,1085],{"class":251},"15",[184,1087,1088],{"class":215}," *",[184,1090,1091],{"class":251}," 60",[184,1093,1088],{"class":215},[184,1095,1096],{"class":251}," 1000",[184,1098,1077],{"class":190},[184,1100,1101],{"class":186,"line":478},[184,1102,708],{"class":190},[11,1104,1105,1106,1109],{},"前端的 axios 要開啟 ",[145,1107,1108],{},"withCredentials: true","，跨網域請求才會帶上 Cookie。",[11,1111,1112],{},"這樣 XSS 就偷不走 token 了。但「瀏覽器會自動帶上」這個特性，帶來了另一個問題。",[20,1114,1116],{"id":1115},"cookie-的風險csrf","Cookie 的風險：CSRF",[11,1118,1119],{},"CSRF（跨站請求偽造）是使用者已經登入你的網站時，瀏覽惡意網站，對方偷偷發出一個請求到你的 API。因為 Cookie 會自動帶上，伺服器會以為是使用者本人操作。",[11,1121,1122],{},"攻擊者不需要讀到 Cookie，只要讓瀏覽器幫忙送出就好。",[11,1124,1125,1128,1129,1132],{},[145,1126,1127],{},"SameSite"," 可以擋掉大部分情況，但我的 Cookie 在正式環境設成 ",[145,1130,1131],{},"SameSite=None","，這個設定不會限制跨站請求，所以還需要 CSRF Token。",[20,1134,1136],{"id":1135},"safari前後端要放在同一個網域","Safari：前後端要放在同一個網域",[11,1138,1139,1140,1143,1144,1147],{},"這個問題是我用手機測試時才發現的。前後端在完全不同的網域時，例如前端在 ",[145,1141,1142],{},"xxx.pages.dev","、API 在 ",[145,1145,1146],{},"xxx.onrender.com","，對瀏覽器來說這就是第三方 Cookie。",[11,1149,1150,1151,1154],{},"Safari 預設會封鎖第三方 Cookie，就算設了 ",[145,1152,1153],{},"SameSite=None; Secure"," 也一樣。結果是登入 API 回傳成功，Cookie 卻沒有被存下來，一直是未登入狀態。",[11,1156,1157,1158,1161,1162,1165],{},"後來我把網域改掉，讓前後端使用同一個主網域，例如 ",[145,1159,1160],{},"admin.example.com"," 和 ",[145,1163,1164],{},"api.example.com","。這樣 Cookie 就不算第三方，Safari 也能正常運作。",[20,1167,1169],{"id":1168},"csrf-token-怎麼運作","CSRF Token 怎麼運作",[11,1171,1172,1173,1176],{},"登入時後端產生一組隨機值，放在一個",[15,1174,1175],{},"前端可以取得","的地方，之後每次修改資料的請求都要放進自訂 header：",[175,1178,1180],{"className":177,"code":1179,"language":179,"meta":180,"style":180},"\u002F\u002F 前端：每個請求帶上 CSRF Token\napi.interceptors.request.use((config) => {\n  if (authStore.csrfToken) {\n    config.headers['x-csrf-token'] = authStore.csrfToken\n  }\n  return config\n})\n",[145,1181,1182,1187,1207,1215,1231,1236,1244],{"__ignoreMap":180},[184,1183,1184],{"class":186,"line":187},[184,1185,1186],{"class":1031},"\u002F\u002F 前端：每個請求帶上 CSRF Token\n",[184,1188,1189,1192,1195,1198,1201,1203,1205],{"class":186,"line":222},[184,1190,1191],{"class":190},"api.interceptors.request.",[184,1193,1194],{"class":194},"use",[184,1196,1197],{"class":190},"((",[184,1199,1200],{"class":208},"config",[184,1202,212],{"class":190},[184,1204,216],{"class":215},[184,1206,219],{"class":190},[184,1208,1209,1212],{"class":186,"line":245},[184,1210,1211],{"class":215},"  if",[184,1213,1214],{"class":190}," (authStore.csrfToken) {\n",[184,1216,1217,1220,1223,1226,1228],{"class":186,"line":454},[184,1218,1219],{"class":190},"    config.headers[",[184,1221,1222],{"class":201},"'x-csrf-token'",[184,1224,1225],{"class":190},"] ",[184,1227,833],{"class":215},[184,1229,1230],{"class":190}," authStore.csrfToken\n",[184,1232,1233],{"class":186,"line":460},[184,1234,1235],{"class":190},"  }\n",[184,1237,1238,1241],{"class":186,"line":478},[184,1239,1240],{"class":215},"  return",[184,1242,1243],{"class":190}," config\n",[184,1245,1246],{"class":186,"line":319},[184,1247,592],{"class":190},[11,1249,1250],{},"後端比對 Cookie 和 header 裡的值是否相同：",[175,1252,1254],{"className":177,"code":1253,"language":179,"meta":180,"style":180},"const a = Buffer.from(String(req.cookies.csrf_token))\nconst b = Buffer.from(String(req.headers['x-csrf-token']))\n\nif (a.length !== b.length || !timingSafeEqual(a, b)) {\n  return res.status(403).json({ success: false, message: 'CSRF invalid' })\n}\n",[145,1255,1256,1278,1303,1307,1338,1373],{"__ignoreMap":180},[184,1257,1258,1260,1263,1265,1268,1270,1272,1275],{"class":186,"line":187},[184,1259,967],{"class":215},[184,1261,1262],{"class":251}," a",[184,1264,510],{"class":215},[184,1266,1267],{"class":190}," Buffer.",[184,1269,424],{"class":194},[184,1271,198],{"class":190},[184,1273,1274],{"class":194},"String",[184,1276,1277],{"class":190},"(req.cookies.csrf_token))\n",[184,1279,1280,1282,1285,1287,1289,1291,1293,1295,1298,1300],{"class":186,"line":222},[184,1281,967],{"class":215},[184,1283,1284],{"class":251}," b",[184,1286,510],{"class":215},[184,1288,1267],{"class":190},[184,1290,424],{"class":194},[184,1292,198],{"class":190},[184,1294,1274],{"class":194},[184,1296,1297],{"class":190},"(req.headers[",[184,1299,1222],{"class":201},[184,1301,1302],{"class":190},"]))\n",[184,1304,1305],{"class":186,"line":245},[184,1306,457],{"emptyLinePlaceholder":317},[184,1308,1309,1312,1315,1318,1321,1324,1326,1329,1332,1335],{"class":186,"line":454},[184,1310,1311],{"class":215},"if",[184,1313,1314],{"class":190}," (a.",[184,1316,1317],{"class":251},"length",[184,1319,1320],{"class":215}," !==",[184,1322,1323],{"class":190}," b.",[184,1325,1317],{"class":251},[184,1327,1328],{"class":215}," ||",[184,1330,1331],{"class":215}," !",[184,1333,1334],{"class":194},"timingSafeEqual",[184,1336,1337],{"class":190},"(a, b)) {\n",[184,1339,1340,1342,1345,1348,1350,1353,1355,1358,1361,1364,1367,1370],{"class":186,"line":460},[184,1341,1240],{"class":215},[184,1343,1344],{"class":190}," res.",[184,1346,1347],{"class":194},"status",[184,1349,198],{"class":190},[184,1351,1352],{"class":251},"403",[184,1354,541],{"class":190},[184,1356,1357],{"class":194},"json",[184,1359,1360],{"class":190},"({ success: ",[184,1362,1363],{"class":251},"false",[184,1365,1366],{"class":190},", message: ",[184,1368,1369],{"class":201},"'CSRF invalid'",[184,1371,1372],{"class":190}," })\n",[184,1374,1375],{"class":186,"line":478},[184,1376,708],{"class":190},[11,1378,1379,1380,1382],{},"惡意網站雖然能讓瀏覽器帶上 Cookie，但讀不到 Token，也就寫不進自訂 header。這裡用 ",[145,1381,1334],{}," 比對，避免從回應時間猜出內容。",[20,1384,1386],{"id":1385},"access-token-與-refresh-token","Access Token 與 Refresh Token",[11,1388,1389],{},"Access Token 我設定 15 分鐘，就算外洩，影響時間也短。為了不讓使用者一直重新登入，再搭配 7 天的 Refresh Token。",[11,1391,1392],{},"Refresh Token 我多做了幾件事：",[28,1394,1395,1401,1407],{},[31,1396,1397,1400],{},[15,1398,1399],{},"資料庫只存 SHA-256 雜湊","：一開始是明碼存入，後來才改掉。就算資料庫外洩，也拿不到能用的 token。",[31,1402,1403,1406],{},[15,1404,1405],{},"每次更新都輪替","：舊的 Refresh Token 用過就失效。",[31,1408,1409,1412],{},[15,1410,1411],{},"限制同時登入的數量","：超過上限時移除最舊的一筆。",[11,1414,1415],{},"JWT 無法提前作廢的問題，也因此得到緩解：把資料庫裡的 Refresh Token 刪掉，使用者最多 15 分鐘後就要重新登入。",[20,1417,1419],{"id":1418},"踩到的坑多支請求同時-401","踩到的坑：多支請求同時 401",[11,1421,1422,1423,954],{},"頁面通常會同時發出多支 API。Access Token 過期時，這些請求會同時收到 401，然後同時呼叫 ",[145,1424,1425],{},"\u002Frefresh",[11,1427,1428],{},"因為 Refresh Token 每次都會輪替，第一支成功後，其他請求拿的是舊 token，就會被拒絕，接著觸發登出，把 CSRF Token 也清掉，後面的請求全部失敗。",[11,1430,1431],{},"解法是讓同一時間只有一次 refresh，其他請求共用結果：",[175,1433,1435],{"className":177,"code":1434,"language":179,"meta":180,"style":180},"let refreshPromise = null\n\nrefreshPromise ??= authApi.post('\u002Frefresh').finally(() => {\n  refreshPromise = null\n})\nconst { data } = await refreshPromise\n",[145,1436,1437,1450,1454,1485,1494,1498],{"__ignoreMap":180},[184,1438,1439,1442,1445,1447],{"class":186,"line":187},[184,1440,1441],{"class":215},"let",[184,1443,1444],{"class":190}," refreshPromise ",[184,1446,833],{"class":215},[184,1448,1449],{"class":251}," null\n",[184,1451,1452],{"class":186,"line":222},[184,1453,457],{"emptyLinePlaceholder":317},[184,1455,1456,1459,1462,1465,1468,1470,1473,1475,1478,1481,1483],{"class":186,"line":245},[184,1457,1458],{"class":190},"refreshPromise ",[184,1460,1461],{"class":215},"??=",[184,1463,1464],{"class":190}," authApi.",[184,1466,1467],{"class":194},"post",[184,1469,198],{"class":190},[184,1471,1472],{"class":201},"'\u002Frefresh'",[184,1474,541],{"class":190},[184,1476,1477],{"class":194},"finally",[184,1479,1480],{"class":190},"(() ",[184,1482,216],{"class":215},[184,1484,219],{"class":190},[184,1486,1487,1490,1492],{"class":186,"line":454},[184,1488,1489],{"class":190},"  refreshPromise ",[184,1491,833],{"class":215},[184,1493,1449],{"class":251},[184,1495,1496],{"class":186,"line":460},[184,1497,592],{"class":190},[184,1499,1500,1502,1505,1508,1511,1513,1516],{"class":186,"line":478},[184,1501,967],{"class":215},[184,1503,1504],{"class":190}," { ",[184,1506,1507],{"class":251},"data",[184,1509,1510],{"class":190}," } ",[184,1512,833],{"class":215},[184,1514,1515],{"class":215}," await",[184,1517,1518],{"class":190}," refreshPromise\n",[11,1520,1521],{},"另一個坑是多分頁。如果每次登入都產生新的 CSRF Token，其他分頁手上的值就對不上了，所以我改成已經有的話就沿用。",[20,1523,1524],{"id":1524},"這樣就安全了嗎",[11,1526,1527],{},"沒有絕對安全的做法。HttpOnly Cookie 只是讓 token 不會被偷走，如果網站真的有 XSS，攻擊者還是能在頁面上直接發送請求。所以輸入處理、依賴套件管理這些基本功還是要做。",[81,1529,1530,1543],{},[84,1531,1532],{},[87,1533,1534,1537,1540],{},[90,1535,1536],{},"做法",[90,1538,1539],{},"主要風險",[90,1541,1542],{},"需要的防護",[100,1544,1545,1556,1567],{},[87,1546,1547,1550,1553],{},[105,1548,1549],{},"JWT + localStorage",[105,1551,1552],{},"XSS 偷走 token",[105,1554,1555],{},"防 XSS、縮短有效期",[87,1557,1558,1561,1564],{},[105,1559,1560],{},"JWT + HttpOnly Cookie",[105,1562,1563],{},"CSRF",[105,1565,1566],{},"SameSite、CSRF Token",[87,1568,1569,1572,1575],{},[105,1570,1571],{},"再加 Refresh Token 機制",[105,1573,1574],{},"實作較複雜",[105,1576,1577],{},"輪替、雜湊儲存、處理並發",[20,1579,1580],{"id":1580},"這次學到的事",[28,1582,1583,1586,1589,1592],{},[31,1584,1585],{},"沒有最好的存法，只有不同的風險。localStorage 防不了 XSS，Cookie 要處理 CSRF。",[31,1587,1588],{},"我做揪團平台時對權限這塊還比較薄弱，那個專案也沒有改回來。後來做管理多間店家資料的後台，才真正開始思考憑證被偷的影響。",[31,1590,1591],{},"只在電腦上測試不夠。這個問題是拿手機實測才發現的，Cookie 這類行為要連 Safari 一起測。",[31,1593,1594],{},"機制越多，邊界情況越多。真正花時間的不是加上 Cookie，而是處理並發和多分頁。",[300,1596,1597],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":180,"searchDepth":222,"depth":222,"links":1599},[1600,1601,1602,1603,1604,1605,1606,1607,1608],{"id":946,"depth":222,"text":947},{"id":991,"depth":222,"text":992},{"id":1115,"depth":222,"text":1116},{"id":1135,"depth":222,"text":1136},{"id":1168,"depth":222,"text":1169},{"id":1385,"depth":222,"text":1386},{"id":1418,"depth":222,"text":1419},{"id":1524,"depth":222,"text":1524},{"id":1580,"depth":222,"text":1580},"2026-09-30","比較 JWT 存在 localStorage 與 HttpOnly Cookie 的差異，說明 XSS 與 CSRF 的風險，並記錄實際專案改用 Cookie 加 CSRF Token 的做法與踩坑。",{},"\u002Fblog\u002Fjwt-storage-security",{"title":931,"description":1610},"blog\u002Fjwt-storage-security",[1616,1563,1617],"JWT","XSS","0MSTWsXPii5S3XFzL8Gw6vNQJYLiOqaFkmfljY_sG_0",{"id":1620,"title":1621,"body":1622,"category":311,"date":3232,"description":3233,"draft":314,"extension":315,"meta":3234,"navigation":317,"path":3235,"readingTime":553,"seo":3236,"stem":3237,"tags":3238,"__hash__":3242},"blog\u002Fblog\u002Fnotion-as-cms.md","用 Notion API 當 CMS：東京自由行旅遊網站的實作紀錄",{"type":8,"value":1623,"toc":3216},[1624,1627,1631,1634,1637,1644,1647,1654,1728,1735,1739,1746,1749,1877,1880,2013,2020,2024,2031,2174,2177,2181,2184,2254,2257,2328,2331,2334,2345,2352,2355,2362,2445,2449,2452,2455,2545,2548,2810,2814,2817,2820,2823,2930,2934,2952,2955,2966,3055,3077,3081,3084,3087,3185,3188,3200,3202,3213],[11,1625,1626],{},"為了 9 月去日本東京自由行，我用 Nuxt 4 做了一個旅遊行程網站，出門時可以直接用手機查看。行程、行李清單、購物清單和票券這些內容會一直調整，所以我不想另外做一套後台，而是把 Notion 當成 CMS：內容都在 Notion 編輯，網站只負責讀取和顯示。",[20,1628,1630],{"id":1629},"為什麼選-notion","為什麼選 Notion",[11,1632,1633],{},"行程內容本來就適合用表格管理，日期、順序、地區、交通方式都是欄位。Notion 的資料庫可以直接當後台使用，在手機上也能編輯，我不用自己做登入、表單和資料庫。",[11,1635,1636],{},"整體流程是這樣：",[175,1638,1642],{"className":1639,"code":1641,"language":567,"meta":180},[1640],"language-text","Notion 資料庫 → Nuxt 伺服器路由 → transformer → 前端頁面\n",[145,1643,1641],{"__ignoreMap":180},[20,1645,1646],{"id":1646},"金鑰只留在伺服器",[11,1648,1649,1650,1653],{},"Notion API 需要金鑰，所以不能從瀏覽器直接呼叫。我把金鑰放在 ",[145,1651,1652],{},"runtimeConfig"," 的私有區塊，在伺服器建立共用的 client：",[175,1655,1657],{"className":409,"code":1656,"language":411,"meta":180,"style":180},"\u002F\u002F server\u002Futils\u002Fnotion.ts\nimport { Client } from '@notionhq\u002Fclient'\n\nconst config = useRuntimeConfig()\n\nexport const notion = new Client({\n  auth: config.notionApiKey,\n})\n",[145,1658,1659,1664,1676,1680,1695,1699,1719,1724],{"__ignoreMap":180},[184,1660,1661],{"class":186,"line":187},[184,1662,1663],{"class":1031},"\u002F\u002F server\u002Futils\u002Fnotion.ts\n",[184,1665,1666,1668,1671,1673],{"class":186,"line":222},[184,1667,418],{"class":215},[184,1669,1670],{"class":190}," { Client } ",[184,1672,424],{"class":215},[184,1674,1675],{"class":201}," '@notionhq\u002Fclient'\n",[184,1677,1678],{"class":186,"line":245},[184,1679,457],{"emptyLinePlaceholder":317},[184,1681,1682,1684,1687,1689,1692],{"class":186,"line":454},[184,1683,967],{"class":215},[184,1685,1686],{"class":251}," config",[184,1688,510],{"class":215},[184,1690,1691],{"class":194}," useRuntimeConfig",[184,1693,1694],{"class":190},"()\n",[184,1696,1697],{"class":186,"line":460},[184,1698,457],{"emptyLinePlaceholder":317},[184,1700,1701,1703,1705,1708,1710,1713,1716],{"class":186,"line":478},[184,1702,1009],{"class":215},[184,1704,1012],{"class":215},[184,1706,1707],{"class":251}," notion",[184,1709,510],{"class":215},[184,1711,1712],{"class":215}," new",[184,1714,1715],{"class":194}," Client",[184,1717,1718],{"class":190},"({\n",[184,1720,1721],{"class":186,"line":319},[184,1722,1723],{"class":190},"  auth: config.notionApiKey,\n",[184,1725,1726],{"class":186,"line":519},[184,1727,592],{"class":190},[11,1729,1730,1731,1734],{},"前端只呼叫自己的 ",[145,1732,1733],{},"\u002Fapi\u002Fnotion\u002F*"," 路由，看不到金鑰，也不會直接接觸 Notion。",[20,1736,1738],{"id":1737},"用-transformer-隔開-notion-的資料結構","用 transformer 隔開 Notion 的資料結構",[11,1740,1741,1742,1745],{},"Notion 回傳的資料巢狀很深，例如讀一個標題要寫到 ",[145,1743,1744],{},"page.properties[key].title[0].plain_text","。如果前端直接用，Notion 的結構就會散落在每個元件裡。",[11,1747,1748],{},"所以我先寫一組取值函式，處理空值：",[175,1750,1752],{"className":409,"code":1751,"language":411,"meta":180,"style":180},"export function getTitle(page: any, key: string) {\n  return page.properties[key]?.title?.[0]?.plain_text ?? ''\n}\n\nexport function getMultiSelect(page: any, key: string) {\n  return page.properties[key]?.multi_select?.map((item: any) => item.name) ?? []\n}\n",[145,1753,1754,1788,1807,1811,1815,1842,1873],{"__ignoreMap":180},[184,1755,1756,1758,1761,1764,1766,1769,1772,1775,1777,1780,1782,1785],{"class":186,"line":187},[184,1757,1009],{"class":215},[184,1759,1760],{"class":215}," function",[184,1762,1763],{"class":194}," getTitle",[184,1765,198],{"class":190},[184,1767,1768],{"class":208},"page",[184,1770,1771],{"class":215},":",[184,1773,1774],{"class":251}," any",[184,1776,489],{"class":190},[184,1778,1779],{"class":208},"key",[184,1781,1771],{"class":215},[184,1783,1784],{"class":251}," string",[184,1786,1787],{"class":190},") {\n",[184,1789,1790,1792,1795,1798,1801,1804],{"class":186,"line":222},[184,1791,1240],{"class":215},[184,1793,1794],{"class":190}," page.properties[key]?.title?.[",[184,1796,1797],{"class":251},"0",[184,1799,1800],{"class":190},"]?.plain_text ",[184,1802,1803],{"class":215},"??",[184,1805,1806],{"class":201}," ''\n",[184,1808,1809],{"class":186,"line":245},[184,1810,708],{"class":190},[184,1812,1813],{"class":186,"line":454},[184,1814,457],{"emptyLinePlaceholder":317},[184,1816,1817,1819,1821,1824,1826,1828,1830,1832,1834,1836,1838,1840],{"class":186,"line":460},[184,1818,1009],{"class":215},[184,1820,1760],{"class":215},[184,1822,1823],{"class":194}," getMultiSelect",[184,1825,198],{"class":190},[184,1827,1768],{"class":208},[184,1829,1771],{"class":215},[184,1831,1774],{"class":251},[184,1833,489],{"class":190},[184,1835,1779],{"class":208},[184,1837,1771],{"class":215},[184,1839,1784],{"class":251},[184,1841,1787],{"class":190},[184,1843,1844,1846,1849,1852,1854,1857,1859,1861,1863,1865,1868,1870],{"class":186,"line":478},[184,1845,1240],{"class":215},[184,1847,1848],{"class":190}," page.properties[key]?.multi_select?.",[184,1850,1851],{"class":194},"map",[184,1853,1197],{"class":190},[184,1855,1856],{"class":208},"item",[184,1858,1771],{"class":215},[184,1860,1774],{"class":251},[184,1862,212],{"class":190},[184,1864,216],{"class":215},[184,1866,1867],{"class":190}," item.name) ",[184,1869,1803],{"class":215},[184,1871,1872],{"class":190}," []\n",[184,1874,1875],{"class":186,"line":319},[184,1876,708],{"class":190},[11,1878,1879],{},"再用 transformer 把 Notion 的頁面轉成網站自己的型別：",[175,1881,1883],{"className":409,"code":1882,"language":411,"meta":180,"style":180},"export function transformItinerary(page: any): Itinerary {\n  return {\n    id: page.id,\n    title: getTitle(page, 'Trip Name'),\n    date: getDate(page, 'Date'),\n    day: getSelect(page, 'Day'),\n    order: getNumber(page, 'Order'),\n    category: getMultiSelect(page, 'Category'),\n    \u002F\u002F ...\n  }\n}\n",[145,1884,1885,1912,1918,1923,1940,1955,1970,1985,2000,2005,2009],{"__ignoreMap":180},[184,1886,1887,1889,1891,1894,1896,1898,1900,1902,1905,1907,1910],{"class":186,"line":187},[184,1888,1009],{"class":215},[184,1890,1760],{"class":215},[184,1892,1893],{"class":194}," transformItinerary",[184,1895,198],{"class":190},[184,1897,1768],{"class":208},[184,1899,1771],{"class":215},[184,1901,1774],{"class":251},[184,1903,1904],{"class":190},")",[184,1906,1771],{"class":215},[184,1908,1909],{"class":194}," Itinerary",[184,1911,219],{"class":190},[184,1913,1914,1916],{"class":186,"line":222},[184,1915,1240],{"class":215},[184,1917,219],{"class":190},[184,1919,1920],{"class":186,"line":245},[184,1921,1922],{"class":190},"    id: page.id,\n",[184,1924,1925,1928,1931,1934,1937],{"class":186,"line":454},[184,1926,1927],{"class":190},"    title: ",[184,1929,1930],{"class":194},"getTitle",[184,1932,1933],{"class":190},"(page, ",[184,1935,1936],{"class":201},"'Trip Name'",[184,1938,1939],{"class":190},"),\n",[184,1941,1942,1945,1948,1950,1953],{"class":186,"line":460},[184,1943,1944],{"class":190},"    date: ",[184,1946,1947],{"class":194},"getDate",[184,1949,1933],{"class":190},[184,1951,1952],{"class":201},"'Date'",[184,1954,1939],{"class":190},[184,1956,1957,1960,1963,1965,1968],{"class":186,"line":478},[184,1958,1959],{"class":190},"    day: ",[184,1961,1962],{"class":194},"getSelect",[184,1964,1933],{"class":190},[184,1966,1967],{"class":201},"'Day'",[184,1969,1939],{"class":190},[184,1971,1972,1975,1978,1980,1983],{"class":186,"line":319},[184,1973,1974],{"class":190},"    order: ",[184,1976,1977],{"class":194},"getNumber",[184,1979,1933],{"class":190},[184,1981,1982],{"class":201},"'Order'",[184,1984,1939],{"class":190},[184,1986,1987,1990,1993,1995,1998],{"class":186,"line":519},[184,1988,1989],{"class":190},"    category: ",[184,1991,1992],{"class":194},"getMultiSelect",[184,1994,1933],{"class":190},[184,1996,1997],{"class":201},"'Category'",[184,1999,1939],{"class":190},[184,2001,2002],{"class":186,"line":524},[184,2003,2004],{"class":1031},"    \u002F\u002F ...\n",[184,2006,2007],{"class":186,"line":553},[184,2008,1235],{"class":190},[184,2010,2011],{"class":186,"line":558},[184,2012,708],{"class":190},[11,2014,2015,2016,2019],{},"這樣前端只認識 ",[145,2017,2018],{},"Itinerary","。Notion 的欄位改名時，只要改 transformer 這一個地方。",[20,2021,2023],{"id":2022},"快取不要每次請求都打-notion","快取：不要每次請求都打 Notion",[11,2025,2026,2027,2030],{},"Notion API 的回應不算快，也有請求速率限制，而行程內容不會每秒變動。所以我用 Nitro 的 ",[145,2028,2029],{},"defineCachedEventHandler"," 包住路由，快取 60 秒：",[175,2032,2034],{"className":409,"code":2033,"language":411,"meta":180,"style":180},"export default defineCachedEventHandler(async () => {\n  const response = await notion.dataSources.query({\n    data_source_id: config.notionDataSourceId,\n  })\n\n  return response.results\n    .map(transformItinerary)\n    .sort((a, b) => a.date !== b.date\n      ? a.date.localeCompare(b.date)\n      : a.order - b.order)\n}, { maxAge: 60 })\n",[145,2035,2036,2056,2076,2081,2085,2089,2096,2106,2136,2150,2164],{"__ignoreMap":180},[184,2037,2038,2040,2043,2046,2048,2050,2052,2054],{"class":186,"line":187},[184,2039,1009],{"class":215},[184,2041,2042],{"class":215}," default",[184,2044,2045],{"class":194}," defineCachedEventHandler",[184,2047,198],{"class":190},[184,2049,492],{"class":215},[184,2051,495],{"class":190},[184,2053,216],{"class":215},[184,2055,219],{"class":190},[184,2057,2058,2061,2064,2066,2068,2071,2074],{"class":186,"line":222},[184,2059,2060],{"class":215},"  const",[184,2062,2063],{"class":251}," response",[184,2065,510],{"class":215},[184,2067,1515],{"class":215},[184,2069,2070],{"class":190}," notion.dataSources.",[184,2072,2073],{"class":194},"query",[184,2075,1718],{"class":190},[184,2077,2078],{"class":186,"line":245},[184,2079,2080],{"class":190},"    data_source_id: config.notionDataSourceId,\n",[184,2082,2083],{"class":186,"line":454},[184,2084,586],{"class":190},[184,2086,2087],{"class":186,"line":460},[184,2088,457],{"emptyLinePlaceholder":317},[184,2090,2091,2093],{"class":186,"line":478},[184,2092,1240],{"class":215},[184,2094,2095],{"class":190}," response.results\n",[184,2097,2098,2101,2103],{"class":186,"line":319},[184,2099,2100],{"class":190},"    .",[184,2102,1851],{"class":194},[184,2104,2105],{"class":190},"(transformItinerary)\n",[184,2107,2108,2110,2113,2115,2118,2120,2123,2125,2127,2130,2133],{"class":186,"line":519},[184,2109,2100],{"class":190},[184,2111,2112],{"class":194},"sort",[184,2114,1197],{"class":190},[184,2116,2117],{"class":208},"a",[184,2119,489],{"class":190},[184,2121,2122],{"class":208},"b",[184,2124,212],{"class":190},[184,2126,216],{"class":215},[184,2128,2129],{"class":190}," a.date ",[184,2131,2132],{"class":215},"!==",[184,2134,2135],{"class":190}," b.date\n",[184,2137,2138,2141,2144,2147],{"class":186,"line":524},[184,2139,2140],{"class":215},"      ?",[184,2142,2143],{"class":190}," a.date.",[184,2145,2146],{"class":194},"localeCompare",[184,2148,2149],{"class":190},"(b.date)\n",[184,2151,2152,2155,2158,2161],{"class":186,"line":553},[184,2153,2154],{"class":215},"      :",[184,2156,2157],{"class":190}," a.order ",[184,2159,2160],{"class":215},"-",[184,2162,2163],{"class":190}," b.order)\n",[184,2165,2166,2169,2172],{"class":186,"line":558},[184,2167,2168],{"class":190},"}, { maxAge: ",[184,2170,2171],{"class":251},"60",[184,2173,1372],{"class":190},[11,2175,2176],{},"代價是在 Notion 改完內容後，最多要等一分鐘才會更新。對行程網站來說完全可以接受。",[20,2178,2180],{"id":2179},"踩到的坑slug-不一定唯一","踩到的坑：slug 不一定唯一",[11,2182,2183],{},"行程詳情頁一開始只用 slug 查詢，後來發現同一間飯店住兩次，就會有兩筆相同的 slug。Notion 不會幫欄位檢查唯一值，所以我再用第幾天來區分：",[175,2185,2187],{"className":409,"code":2186,"language":411,"meta":180,"style":180},"const trip = (day ? matches.find(item => item.day === `Day ${day}`) : null)\n  ?? matches[0]\n",[145,2188,2189,2241],{"__ignoreMap":180},[184,2190,2191,2193,2196,2198,2201,2204,2207,2210,2212,2214,2217,2220,2223,2226,2229,2232,2234,2236,2239],{"class":186,"line":187},[184,2192,967],{"class":215},[184,2194,2195],{"class":251}," trip",[184,2197,510],{"class":215},[184,2199,2200],{"class":190}," (day ",[184,2202,2203],{"class":215},"?",[184,2205,2206],{"class":190}," matches.",[184,2208,2209],{"class":194},"find",[184,2211,198],{"class":190},[184,2213,1856],{"class":208},[184,2215,2216],{"class":215}," =>",[184,2218,2219],{"class":190}," item.day ",[184,2221,2222],{"class":215},"===",[184,2224,2225],{"class":201}," `Day ${",[184,2227,2228],{"class":190},"day",[184,2230,2231],{"class":201},"}`",[184,2233,212],{"class":190},[184,2235,1771],{"class":215},[184,2237,2238],{"class":251}," null",[184,2240,255],{"class":190},[184,2242,2243,2246,2249,2251],{"class":186,"line":222},[184,2244,2245],{"class":215},"  ??",[184,2247,2248],{"class":190}," matches[",[184,2250,1797],{"class":251},[184,2252,2253],{"class":190},"]\n",[11,2255,2256],{},"路由帶了查詢參數之後，快取的 key 也要跟著改，否則不同天會拿到同一份快取：",[175,2258,2260],{"className":409,"code":2259,"language":411,"meta":180,"style":180},"{ maxAge: 60, getKey: event => `${getRouterParam(event, 'slug') ?? ''}:${getQuery(event).day ?? ''}` }\n",[145,2261,2262],{"__ignoreMap":180},[184,2263,2264,2267,2270,2272,2274,2276,2279,2281,2284,2286,2289,2292,2294,2296,2298,2301,2303,2305,2308,2311,2313,2315,2317,2319,2322,2325],{"class":186,"line":187},[184,2265,2266],{"class":190},"{ ",[184,2268,2269],{"class":194},"maxAge",[184,2271,675],{"class":190},[184,2273,2171],{"class":251},[184,2275,489],{"class":190},[184,2277,2278],{"class":194},"getKey",[184,2280,675],{"class":190},[184,2282,2283],{"class":208},"event",[184,2285,2216],{"class":215},[184,2287,2288],{"class":201}," `${",[184,2290,2291],{"class":194},"getRouterParam",[184,2293,198],{"class":201},[184,2295,2283],{"class":190},[184,2297,489],{"class":201},[184,2299,2300],{"class":201},"'slug'",[184,2302,212],{"class":201},[184,2304,1803],{"class":215},[184,2306,2307],{"class":201}," ''}:${",[184,2309,2310],{"class":194},"getQuery",[184,2312,198],{"class":201},[184,2314,2283],{"class":190},[184,2316,541],{"class":201},[184,2318,2228],{"class":190},[184,2320,2321],{"class":215}," ??",[184,2323,2324],{"class":201}," ''}`",[184,2326,2327],{"class":190}," }\n",[11,2329,2330],{},"這也是用 Notion 當 CMS 要注意的事：它是編輯工具，不是資料庫，資料的正確性要由程式自己把關。",[20,2332,2333],{"id":2333},"讓內容維持好編輯",[11,2335,2336,2337,2340,2341,2344],{},"交通方式是一段多步驟的說明，如果拆成很多欄位，編輯起來很麻煩。所以我讓它在 Notion 裡維持一段純文字，用 ",[145,2338,2339],{},"#"," 當標題、",[145,2342,2343],{},"⏱"," 當時間，再寫一個 parser 轉成結構化資料給前端顯示。",[11,2346,2347,2348,2351],{},"票券則是放在一般的 Notion 頁面，由 ",[145,2349,2350],{},"blocks.children.list"," 讀取標題、圖片和檔案。這部分比較敏感，所以另外加了密碼驗證，通過後才能讀取。",[20,2353,2354],{"id":2354},"內容和使用者狀態分開",[11,2356,2357,2358,2361],{},"行李清單的項目來自 Notion，但「打勾」是每個使用者自己的狀態，不該寫回 Notion。所以打勾狀態和自己新增的項目都存在 ",[145,2359,2360],{},"localStorage","，顯示時再合併：",[175,2363,2365],{"className":409,"code":2364,"language":411,"meta":180,"style":180},"const items = computed(() =>\n  [...(data.value ?? []), ...customItems.value].map(item => ({\n    ...item,\n    checked: checkedMap.value[item.id] ?? false,\n  })),\n)\n",[145,2366,2367,2384,2416,2424,2436,2441],{"__ignoreMap":180},[184,2368,2369,2371,2374,2376,2379,2381],{"class":186,"line":187},[184,2370,967],{"class":215},[184,2372,2373],{"class":251}," items",[184,2375,510],{"class":215},[184,2377,2378],{"class":194}," computed",[184,2380,1480],{"class":190},[184,2382,2383],{"class":215},"=>\n",[184,2385,2386,2389,2392,2395,2397,2400,2402,2405,2407,2409,2411,2413],{"class":186,"line":222},[184,2387,2388],{"class":190},"  [",[184,2390,2391],{"class":215},"...",[184,2393,2394],{"class":190},"(data.value ",[184,2396,1803],{"class":215},[184,2398,2399],{"class":190}," []), ",[184,2401,2391],{"class":215},[184,2403,2404],{"class":190},"customItems.value].",[184,2406,1851],{"class":194},[184,2408,198],{"class":190},[184,2410,1856],{"class":208},[184,2412,2216],{"class":215},[184,2414,2415],{"class":190}," ({\n",[184,2417,2418,2421],{"class":186,"line":245},[184,2419,2420],{"class":215},"    ...",[184,2422,2423],{"class":190},"item,\n",[184,2425,2426,2429,2431,2434],{"class":186,"line":454},[184,2427,2428],{"class":190},"    checked: checkedMap.value[item.id] ",[184,2430,1803],{"class":215},[184,2432,2433],{"class":251}," false",[184,2435,1077],{"class":190},[184,2437,2438],{"class":186,"line":460},[184,2439,2440],{"class":190},"  })),\n",[184,2442,2443],{"class":186,"line":478},[184,2444,255],{"class":190},[20,2446,2448],{"id":2447},"串接-google-maps","串接 Google Maps",[11,2450,2451],{},"每個行程都有 Notion 裡維護的 Google Maps 連結，在詳情頁點一下就能開啟地圖。",[11,2453,2454],{},"迪士尼樂園的規劃則用 Google Maps JavaScript API，在地圖上標出每個項目，再用線連起來，看得出當天的動線。點擊標記可以聚焦到對應的項目：",[175,2456,2458],{"className":409,"code":2457,"language":411,"meta":180,"style":180},"markers = props.items.map((item, index) => {\n  const marker = new g.maps.Marker({ \u002F* 位置與標籤 *\u002F })\n  marker.addListener('click', () => focusItem(index))\n  return marker\n})\n",[145,2459,2460,2487,2512,2534,2541],{"__ignoreMap":180},[184,2461,2462,2465,2467,2470,2472,2474,2476,2478,2481,2483,2485],{"class":186,"line":187},[184,2463,2464],{"class":190},"markers ",[184,2466,833],{"class":215},[184,2468,2469],{"class":190}," props.items.",[184,2471,1851],{"class":194},[184,2473,1197],{"class":190},[184,2475,1856],{"class":208},[184,2477,489],{"class":190},[184,2479,2480],{"class":208},"index",[184,2482,212],{"class":190},[184,2484,216],{"class":215},[184,2486,219],{"class":190},[184,2488,2489,2491,2494,2496,2498,2501,2504,2507,2510],{"class":186,"line":222},[184,2490,2060],{"class":215},[184,2492,2493],{"class":251}," marker",[184,2495,510],{"class":215},[184,2497,1712],{"class":215},[184,2499,2500],{"class":190}," g.maps.",[184,2502,2503],{"class":194},"Marker",[184,2505,2506],{"class":190},"({ ",[184,2508,2509],{"class":1031},"\u002F* 位置與標籤 *\u002F",[184,2511,1372],{"class":190},[184,2513,2514,2517,2520,2522,2524,2526,2528,2531],{"class":186,"line":245},[184,2515,2516],{"class":190},"  marker.",[184,2518,2519],{"class":194},"addListener",[184,2521,198],{"class":190},[184,2523,202],{"class":201},[184,2525,471],{"class":190},[184,2527,216],{"class":215},[184,2529,2530],{"class":194}," focusItem",[184,2532,2533],{"class":190},"(index))\n",[184,2535,2536,2538],{"class":186,"line":454},[184,2537,1240],{"class":215},[184,2539,2540],{"class":190}," marker\n",[184,2542,2543],{"class":186,"line":460},[184,2544,592],{"class":190},[11,2546,2547],{},"地圖腳本不小，也不是每頁都需要，所以只在要用時才載入。我把載入的 Promise 存起來，多個地方同時呼叫也只會載入一次：",[175,2549,2551],{"className":409,"code":2550,"language":411,"meta":180,"style":180},"let loaderPromise: Promise\u003Cvoid> | null = null\n\nexport function loadGoogleMaps(apiKey: string): Promise\u003Cvoid> {\n  if (window.google?.maps) return Promise.resolve()\n  if (loaderPromise) return loaderPromise\n\n  loaderPromise = new Promise((resolve, reject) => {\n    const script = document.createElement('script')\n    script.src = `https:\u002F\u002Fmaps.googleapis.com\u002Fmaps\u002Fapi\u002Fjs?key=${apiKey}`\n    script.async = true\n    script.onload = () => resolve()\n    script.onerror = () => reject(new Error('Google Maps 載入失敗'))\n    document.head.appendChild(script)\n  })\n\n  return loaderPromise\n}\n",[145,2552,2553,2582,2586,2617,2637,2649,2653,2679,2701,2716,2726,2745,2777,2788,2793,2798,2805],{"__ignoreMap":180},[184,2554,2555,2557,2560,2562,2565,2567,2570,2573,2576,2578,2580],{"class":186,"line":187},[184,2556,1441],{"class":215},[184,2558,2559],{"class":190}," loaderPromise",[184,2561,1771],{"class":215},[184,2563,2564],{"class":194}," Promise",[184,2566,823],{"class":190},[184,2568,2569],{"class":251},"void",[184,2571,2572],{"class":190},"> ",[184,2574,2575],{"class":215},"|",[184,2577,2238],{"class":251},[184,2579,510],{"class":215},[184,2581,1449],{"class":251},[184,2583,2584],{"class":186,"line":222},[184,2585,457],{"emptyLinePlaceholder":317},[184,2587,2588,2590,2592,2595,2597,2600,2602,2604,2606,2608,2610,2612,2614],{"class":186,"line":245},[184,2589,1009],{"class":215},[184,2591,1760],{"class":215},[184,2593,2594],{"class":194}," loadGoogleMaps",[184,2596,198],{"class":190},[184,2598,2599],{"class":208},"apiKey",[184,2601,1771],{"class":215},[184,2603,1784],{"class":251},[184,2605,1904],{"class":190},[184,2607,1771],{"class":215},[184,2609,2564],{"class":194},[184,2611,823],{"class":190},[184,2613,2569],{"class":251},[184,2615,2616],{"class":190},"> {\n",[184,2618,2619,2621,2624,2627,2629,2632,2635],{"class":186,"line":454},[184,2620,1211],{"class":215},[184,2622,2623],{"class":190}," (window.google?.maps) ",[184,2625,2626],{"class":215},"return",[184,2628,2564],{"class":251},[184,2630,2631],{"class":190},".",[184,2633,2634],{"class":194},"resolve",[184,2636,1694],{"class":190},[184,2638,2639,2641,2644,2646],{"class":186,"line":460},[184,2640,1211],{"class":215},[184,2642,2643],{"class":190}," (loaderPromise) ",[184,2645,2626],{"class":215},[184,2647,2648],{"class":190}," loaderPromise\n",[184,2650,2651],{"class":186,"line":478},[184,2652,457],{"emptyLinePlaceholder":317},[184,2654,2655,2658,2660,2662,2664,2666,2668,2670,2673,2675,2677],{"class":186,"line":319},[184,2656,2657],{"class":190},"  loaderPromise ",[184,2659,833],{"class":215},[184,2661,1712],{"class":215},[184,2663,2564],{"class":251},[184,2665,1197],{"class":190},[184,2667,2634],{"class":208},[184,2669,489],{"class":190},[184,2671,2672],{"class":208},"reject",[184,2674,212],{"class":190},[184,2676,216],{"class":215},[184,2678,219],{"class":190},[184,2680,2681,2683,2686,2688,2691,2694,2696,2699],{"class":186,"line":519},[184,2682,504],{"class":215},[184,2684,2685],{"class":251}," script",[184,2687,510],{"class":215},[184,2689,2690],{"class":190}," document.",[184,2692,2693],{"class":194},"createElement",[184,2695,198],{"class":190},[184,2697,2698],{"class":201},"'script'",[184,2700,255],{"class":190},[184,2702,2703,2706,2708,2711,2713],{"class":186,"line":524},[184,2704,2705],{"class":190},"    script.src ",[184,2707,833],{"class":215},[184,2709,2710],{"class":201}," `https:\u002F\u002Fmaps.googleapis.com\u002Fmaps\u002Fapi\u002Fjs?key=${",[184,2712,2599],{"class":190},[184,2714,2715],{"class":201},"}`\n",[184,2717,2718,2721,2723],{"class":186,"line":553},[184,2719,2720],{"class":190},"    script.async ",[184,2722,833],{"class":215},[184,2724,2725],{"class":251}," true\n",[184,2727,2728,2731,2734,2736,2738,2740,2743],{"class":186,"line":558},[184,2729,2730],{"class":190},"    script.",[184,2732,2733],{"class":194},"onload",[184,2735,510],{"class":215},[184,2737,495],{"class":190},[184,2739,216],{"class":215},[184,2741,2742],{"class":194}," resolve",[184,2744,1694],{"class":190},[184,2746,2747,2749,2752,2754,2756,2758,2761,2763,2766,2769,2771,2774],{"class":186,"line":583},[184,2748,2730],{"class":190},[184,2750,2751],{"class":194},"onerror",[184,2753,510],{"class":215},[184,2755,495],{"class":190},[184,2757,216],{"class":215},[184,2759,2760],{"class":194}," reject",[184,2762,198],{"class":190},[184,2764,2765],{"class":215},"new",[184,2767,2768],{"class":194}," Error",[184,2770,198],{"class":190},[184,2772,2773],{"class":201},"'Google Maps 載入失敗'",[184,2775,2776],{"class":190},"))\n",[184,2778,2779,2782,2785],{"class":186,"line":589},[184,2780,2781],{"class":190},"    document.head.",[184,2783,2784],{"class":194},"appendChild",[184,2786,2787],{"class":190},"(script)\n",[184,2789,2791],{"class":186,"line":2790},14,[184,2792,586],{"class":190},[184,2794,2796],{"class":186,"line":2795},15,[184,2797,457],{"emptyLinePlaceholder":317},[184,2799,2801,2803],{"class":186,"line":2800},16,[184,2802,1240],{"class":215},[184,2804,2648],{"class":190},[184,2806,2808],{"class":186,"line":2807},17,[184,2809,708],{"class":190},[20,2811,2813],{"id":2812},"不是所有資料都放-notion迪士尼餐廳資料","不是所有資料都放 Notion：迪士尼餐廳資料",[11,2815,2816],{},"規劃東京迪士尼時，我想直接看到各餐廳的菜單，但官網沒有繁體中文，一間一間查也很花時間。這類資料量大、又幾乎不會改，不適合在 Notion 手動維護，所以我用爬蟲把資料整理下來。",[11,2818,2819],{},"我寫了在瀏覽器裡執行的腳本，逐間讀取官網的餐廳頁面，總共整理出 154 間餐廳，其中 122 間有菜單，合計 1,842 個品項，再用 GPT 翻譯成繁體中文。這批資料只供個人旅遊使用，放在密碼驗證之後，沒有公開。",[11,2821,2822],{},"每間餐廳存成一個 JSON 檔，並保留原文和繁體中文，也記下來源網址和取得日期，之後要核對或更新才有依據：",[175,2824,2827],{"className":2825,"code":2826,"language":1357,"meta":180,"style":180},"language-json shiki shiki-themes github-dark","{\n  \"source\": {\n    \"official_url\": \"...\",\n    \"retrieved_date\": \"2026-09-10\",\n    \"language_original\": \"en\"\n  },\n  \"restaurant\": {\n    \"name\": {\n      \"original\": \"La Taverne de Gaston\",\n      \"zh_tw\": \"加斯頓餐館\"\n    }\n  }\n}\n",[145,2828,2829,2834,2842,2854,2866,2876,2881,2888,2895,2907,2917,2922,2926],{"__ignoreMap":180},[184,2830,2831],{"class":186,"line":187},[184,2832,2833],{"class":190},"{\n",[184,2835,2836,2839],{"class":186,"line":222},[184,2837,2838],{"class":251},"  \"source\"",[184,2840,2841],{"class":190},": {\n",[184,2843,2844,2847,2849,2852],{"class":186,"line":245},[184,2845,2846],{"class":251},"    \"official_url\"",[184,2848,675],{"class":190},[184,2850,2851],{"class":201},"\"...\"",[184,2853,1077],{"class":190},[184,2855,2856,2859,2861,2864],{"class":186,"line":454},[184,2857,2858],{"class":251},"    \"retrieved_date\"",[184,2860,675],{"class":190},[184,2862,2863],{"class":201},"\"2026-09-10\"",[184,2865,1077],{"class":190},[184,2867,2868,2871,2873],{"class":186,"line":460},[184,2869,2870],{"class":251},"    \"language_original\"",[184,2872,675],{"class":190},[184,2874,2875],{"class":201},"\"en\"\n",[184,2877,2878],{"class":186,"line":478},[184,2879,2880],{"class":190},"  },\n",[184,2882,2883,2886],{"class":186,"line":319},[184,2884,2885],{"class":251},"  \"restaurant\"",[184,2887,2841],{"class":190},[184,2889,2890,2893],{"class":186,"line":519},[184,2891,2892],{"class":251},"    \"name\"",[184,2894,2841],{"class":190},[184,2896,2897,2900,2902,2905],{"class":186,"line":524},[184,2898,2899],{"class":251},"      \"original\"",[184,2901,675],{"class":190},[184,2903,2904],{"class":201},"\"La Taverne de Gaston\"",[184,2906,1077],{"class":190},[184,2908,2909,2912,2914],{"class":186,"line":553},[184,2910,2911],{"class":251},"      \"zh_tw\"",[184,2913,675],{"class":190},[184,2915,2916],{"class":201},"\"加斯頓餐館\"\n",[184,2918,2919],{"class":186,"line":558},[184,2920,2921],{"class":190},"    }\n",[184,2923,2924],{"class":186,"line":583},[184,2925,1235],{"class":190},[184,2927,2928],{"class":186,"line":589},[184,2929,708],{"class":190},[594,2931,2933],{"id":2932},"本機正常部署到-vercel-卻找不到檔案","本機正常，部署到 Vercel 卻找不到檔案",[11,2935,2936,2937,2940,2941,1161,2944,2947,2948,2951],{},"一開始 API 用 ",[145,2938,2939],{},"node:fs"," 的 ",[145,2942,2943],{},"readdirSync",[145,2945,2946],{},"readFileSync"," 搭配 ",[145,2949,2950],{},"process.cwd()"," 讀取這些 JSON，本機完全正常，部署到 Vercel 後卻找不到檔案。",[11,2953,2954],{},"原因是這種在執行時才動態決定路徑的讀檔方式，Vercel 的 serverless function 打包器無法靜態追蹤，這批檔案根本沒有被包進部署結果裡。",[11,2956,2957,2958,2961,2962,2965],{},"解法是改用 Nitro 的 ",[145,2959,2960],{},"serverAssets","，把 JSON 內嵌進 server build，再用 ",[145,2963,2964],{},"useStorage()"," 讀取：",[175,2967,2969],{"className":409,"code":2968,"language":411,"meta":180,"style":180},"\u002F\u002F nuxt.config.ts\nnitro: {\n  serverAssets: [{ baseName: 'tdr-menu', dir: 'data\u002Ftdr-menu' }],\n}\n\n\u002F\u002F API 路由\nconst storage = useStorage('assets:tdr-menu')\nconst keys = await storage.getKeys(park)\n",[145,2970,2971,2976,2983,3003,3007,3011,3016,3035],{"__ignoreMap":180},[184,2972,2973],{"class":186,"line":187},[184,2974,2975],{"class":1031},"\u002F\u002F nuxt.config.ts\n",[184,2977,2978,2981],{"class":186,"line":222},[184,2979,2980],{"class":194},"nitro",[184,2982,2841],{"class":190},[184,2984,2985,2988,2991,2994,2997,3000],{"class":186,"line":245},[184,2986,2987],{"class":194},"  serverAssets",[184,2989,2990],{"class":190},": [{ baseName: ",[184,2992,2993],{"class":201},"'tdr-menu'",[184,2995,2996],{"class":190},", dir: ",[184,2998,2999],{"class":201},"'data\u002Ftdr-menu'",[184,3001,3002],{"class":190}," }],\n",[184,3004,3005],{"class":186,"line":454},[184,3006,708],{"class":190},[184,3008,3009],{"class":186,"line":460},[184,3010,457],{"emptyLinePlaceholder":317},[184,3012,3013],{"class":186,"line":478},[184,3014,3015],{"class":1031},"\u002F\u002F API 路由\n",[184,3017,3018,3020,3023,3025,3028,3030,3033],{"class":186,"line":319},[184,3019,967],{"class":215},[184,3021,3022],{"class":251}," storage",[184,3024,510],{"class":215},[184,3026,3027],{"class":194}," useStorage",[184,3029,198],{"class":190},[184,3031,3032],{"class":201},"'assets:tdr-menu'",[184,3034,255],{"class":190},[184,3036,3037,3039,3042,3044,3046,3049,3052],{"class":186,"line":519},[184,3038,967],{"class":215},[184,3040,3041],{"class":251}," keys",[184,3043,510],{"class":215},[184,3045,1515],{"class":215},[184,3047,3048],{"class":190}," storage.",[184,3050,3051],{"class":194},"getKeys",[184,3053,3054],{"class":190},"(park)\n",[11,3056,3057,3058,3061,3062,2940,3065,3068,3069,3072,3073,3076],{},"這裡還有一個小坑：",[145,3059,3060],{},"dir"," 要寫相對於 ",[145,3063,3064],{},"server\u002F",[145,3066,3067],{},"data\u002Ftdr-menu","。如果寫成 ",[145,3070,3071],{},"server\u002Fdata\u002Ftdr-menu","，路徑會疊成 ",[145,3074,3075],{},"server\u002Fserver\u002Fdata\u002Ftdr-menu","，一樣找不到。",[20,3078,3080],{"id":3079},"另外串接-gpt-與-gemini","另外串接 GPT 與 Gemini",[11,3082,3083],{},"旅途中常會遇到臨時想問的問題，所以網站裡也做了 AI 聊天，同樣由伺服器路由呼叫，金鑰不會出現在前端。可以選擇 GPT 或 Gemini，也可以上傳圖片一起詢問。",[11,3085,3086],{},"Gemini 遇到 429（請求過多）時，會自動改用 OpenAI 回答，使用者不用自己切換：",[175,3088,3090],{"className":409,"code":3089,"language":411,"meta":180,"style":180},"try {\n  return { message: await askGemini({ history, images }) }\n} catch (error: any) {\n  if (error.status === 429) {\n    return { provider: 'openai', message: await askOpenAI({ history, images }) }\n  }\n  throw error\n}\n",[145,3091,3092,3099,3115,3135,3149,3169,3173,3181],{"__ignoreMap":180},[184,3093,3094,3097],{"class":186,"line":187},[184,3095,3096],{"class":215},"try",[184,3098,219],{"class":190},[184,3100,3101,3103,3106,3109,3112],{"class":186,"line":222},[184,3102,1240],{"class":215},[184,3104,3105],{"class":190}," { message: ",[184,3107,3108],{"class":215},"await",[184,3110,3111],{"class":194}," askGemini",[184,3113,3114],{"class":190},"({ history, images }) }\n",[184,3116,3117,3120,3123,3126,3129,3131,3133],{"class":186,"line":245},[184,3118,3119],{"class":190},"} ",[184,3121,3122],{"class":215},"catch",[184,3124,3125],{"class":190}," (",[184,3127,3128],{"class":208},"error",[184,3130,1771],{"class":215},[184,3132,1774],{"class":251},[184,3134,1787],{"class":190},[184,3136,3137,3139,3142,3144,3147],{"class":186,"line":454},[184,3138,1211],{"class":215},[184,3140,3141],{"class":190}," (error.status ",[184,3143,2222],{"class":215},[184,3145,3146],{"class":251}," 429",[184,3148,1787],{"class":190},[184,3150,3151,3154,3157,3160,3162,3164,3167],{"class":186,"line":460},[184,3152,3153],{"class":215},"    return",[184,3155,3156],{"class":190}," { provider: ",[184,3158,3159],{"class":201},"'openai'",[184,3161,1366],{"class":190},[184,3163,3108],{"class":215},[184,3165,3166],{"class":194}," askOpenAI",[184,3168,3114],{"class":190},[184,3170,3171],{"class":186,"line":478},[184,3172,1235],{"class":190},[184,3174,3175,3178],{"class":186,"line":319},[184,3176,3177],{"class":215},"  throw",[184,3179,3180],{"class":190}," error\n",[184,3182,3183],{"class":186,"line":519},[184,3184,708],{"class":190},[20,3186,3187],{"id":3187},"目前的限制",[28,3189,3190,3197],{},[31,3191,3192,3193,3196],{},"查詢沒有處理分頁。Notion 每次查詢最多回傳 100 筆，資料超過之後要用 ",[145,3194,3195],{},"next_cursor"," 繼續讀取。",[31,3198,3199],{},"Notion 欄位是用名稱取值，有人在後台改欄位名稱，資料就會變成空值，不會報錯。",[20,3201,1580],{"id":1580},[28,3203,3204,3207,3210],{},[31,3205,3206],{},"不是每個專案都需要自己做後台，先想清楚內容由誰維護、多常更新。",[31,3208,3209],{},"第三方資料要在邊界轉換成自己的型別，不要讓外部結構滲進元件。",[31,3211,3212],{},"快取的 key 要包含所有會影響結果的參數。",[300,3214,3215],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":180,"searchDepth":222,"depth":222,"links":3217},[3218,3219,3220,3221,3222,3223,3224,3225,3226,3229,3230,3231],{"id":1629,"depth":222,"text":1630},{"id":1646,"depth":222,"text":1646},{"id":1737,"depth":222,"text":1738},{"id":2022,"depth":222,"text":2023},{"id":2179,"depth":222,"text":2180},{"id":2333,"depth":222,"text":2333},{"id":2354,"depth":222,"text":2354},{"id":2447,"depth":222,"text":2448},{"id":2812,"depth":222,"text":2813,"children":3227},[3228],{"id":2932,"depth":245,"text":2933},{"id":3079,"depth":222,"text":3080},{"id":3187,"depth":222,"text":3187},{"id":1580,"depth":222,"text":1580},"2026-09-11","為了 9 月的東京自由行，用 Nuxt 4 做了一個旅遊行程網站，把 Notion 當成內容後台，並用伺服器路由、transformer 與快取隔開 Notion 的資料結構。",{},"\u002Fblog\u002Fnotion-as-cms",{"title":1621,"description":3233},"blog\u002Fnotion-as-cms",[3239,3240,3241],"Notion API","CMS","Nuxt","XFUHA8oaWUBKIXrewXcfF_21qt4KZI7agXhRe42-Evs"]