[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"post-\u002Fblog\u002Fnotion-as-cms":3},{"id":4,"title":5,"body":6,"category":1691,"date":1692,"description":1693,"draft":1694,"extension":1695,"meta":1696,"navigation":84,"path":1697,"readingTime":435,"seo":1698,"stem":1699,"tags":1700,"__hash__":1704},"blog\u002Fblog\u002Fnotion-as-cms.md","用 Notion API 當 CMS：東京自由行旅遊網站的實作紀錄",{"type":7,"value":8,"toc":1675},"minimark",[9,13,18,21,24,35,38,45,146,153,157,164,167,303,306,444,451,455,462,610,613,617,620,691,694,767,770,773,784,791,794,801,886,890,893,896,989,992,1261,1265,1268,1271,1274,1382,1387,1406,1409,1420,1509,1531,1535,1538,1541,1640,1643,1657,1660,1671],[10,11,12],"p",{},"為了 9 月去日本東京自由行，我用 Nuxt 4 做了一個旅遊行程網站，出門時可以直接用手機查看。行程、行李清單、購物清單和票券這些內容會一直調整，所以我不想另外做一套後台，而是把 Notion 當成 CMS：內容都在 Notion 編輯，網站只負責讀取和顯示。",[14,15,17],"h2",{"id":16},"為什麼選-notion","為什麼選 Notion",[10,19,20],{},"行程內容本來就適合用表格管理，日期、順序、地區、交通方式都是欄位。Notion 的資料庫可以直接當後台使用，在手機上也能編輯，我不用自己做登入、表單和資料庫。",[10,22,23],{},"整體流程是這樣：",[25,26,32],"pre",{"className":27,"code":29,"language":30,"meta":31},[28],"language-text","Notion 資料庫 → Nuxt 伺服器路由 → transformer → 前端頁面\n","text","",[33,34,29],"code",{"__ignoreMap":31},[14,36,37],{"id":37},"金鑰只留在伺服器",[10,39,40,41,44],{},"Notion API 需要金鑰，所以不能從瀏覽器直接呼叫。我把金鑰放在 ",[33,42,43],{},"runtimeConfig"," 的私有區塊，在伺服器建立共用的 client：",[25,46,50],{"className":47,"code":48,"language":49,"meta":31,"style":31},"language-ts shiki shiki-themes github-dark","\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","ts",[33,51,52,61,79,86,106,111,134,140],{"__ignoreMap":31},[53,54,57],"span",{"class":55,"line":56},"line",1,[53,58,60],{"class":59},"sAwPA","\u002F\u002F server\u002Futils\u002Fnotion.ts\n",[53,62,64,68,72,75],{"class":55,"line":63},2,[53,65,67],{"class":66},"snl16","import",[53,69,71],{"class":70},"s95oV"," { Client } ",[53,73,74],{"class":66},"from",[53,76,78],{"class":77},"sU2Wk"," '@notionhq\u002Fclient'\n",[53,80,82],{"class":55,"line":81},3,[53,83,85],{"emptyLinePlaceholder":84},true,"\n",[53,87,89,92,96,99,103],{"class":55,"line":88},4,[53,90,91],{"class":66},"const",[53,93,95],{"class":94},"sDLfK"," config",[53,97,98],{"class":66}," =",[53,100,102],{"class":101},"svObZ"," useRuntimeConfig",[53,104,105],{"class":70},"()\n",[53,107,109],{"class":55,"line":108},5,[53,110,85],{"emptyLinePlaceholder":84},[53,112,114,117,120,123,125,128,131],{"class":55,"line":113},6,[53,115,116],{"class":66},"export",[53,118,119],{"class":66}," const",[53,121,122],{"class":94}," notion",[53,124,98],{"class":66},[53,126,127],{"class":66}," new",[53,129,130],{"class":101}," Client",[53,132,133],{"class":70},"({\n",[53,135,137],{"class":55,"line":136},7,[53,138,139],{"class":70},"  auth: config.notionApiKey,\n",[53,141,143],{"class":55,"line":142},8,[53,144,145],{"class":70},"})\n",[10,147,148,149,152],{},"前端只呼叫自己的 ",[33,150,151],{},"\u002Fapi\u002Fnotion\u002F*"," 路由，看不到金鑰，也不會直接接觸 Notion。",[14,154,156],{"id":155},"用-transformer-隔開-notion-的資料結構","用 transformer 隔開 Notion 的資料結構",[10,158,159,160,163],{},"Notion 回傳的資料巢狀很深，例如讀一個標題要寫到 ",[33,161,162],{},"page.properties[key].title[0].plain_text","。如果前端直接用，Notion 的結構就會散落在每個元件裡。",[10,165,166],{},"所以我先寫一組取值函式，處理空值：",[25,168,170],{"className":47,"code":169,"language":49,"meta":31,"style":31},"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",[33,171,172,209,229,234,238,265,299],{"__ignoreMap":31},[53,173,174,176,179,182,185,189,192,195,198,201,203,206],{"class":55,"line":56},[53,175,116],{"class":66},[53,177,178],{"class":66}," function",[53,180,181],{"class":101}," getTitle",[53,183,184],{"class":70},"(",[53,186,188],{"class":187},"s9osk","page",[53,190,191],{"class":66},":",[53,193,194],{"class":94}," any",[53,196,197],{"class":70},", ",[53,199,200],{"class":187},"key",[53,202,191],{"class":66},[53,204,205],{"class":94}," string",[53,207,208],{"class":70},") {\n",[53,210,211,214,217,220,223,226],{"class":55,"line":63},[53,212,213],{"class":66},"  return",[53,215,216],{"class":70}," page.properties[key]?.title?.[",[53,218,219],{"class":94},"0",[53,221,222],{"class":70},"]?.plain_text ",[53,224,225],{"class":66},"??",[53,227,228],{"class":77}," ''\n",[53,230,231],{"class":55,"line":81},[53,232,233],{"class":70},"}\n",[53,235,236],{"class":55,"line":88},[53,237,85],{"emptyLinePlaceholder":84},[53,239,240,242,244,247,249,251,253,255,257,259,261,263],{"class":55,"line":108},[53,241,116],{"class":66},[53,243,178],{"class":66},[53,245,246],{"class":101}," getMultiSelect",[53,248,184],{"class":70},[53,250,188],{"class":187},[53,252,191],{"class":66},[53,254,194],{"class":94},[53,256,197],{"class":70},[53,258,200],{"class":187},[53,260,191],{"class":66},[53,262,205],{"class":94},[53,264,208],{"class":70},[53,266,267,269,272,275,278,281,283,285,288,291,294,296],{"class":55,"line":113},[53,268,213],{"class":66},[53,270,271],{"class":70}," page.properties[key]?.multi_select?.",[53,273,274],{"class":101},"map",[53,276,277],{"class":70},"((",[53,279,280],{"class":187},"item",[53,282,191],{"class":66},[53,284,194],{"class":94},[53,286,287],{"class":70},") ",[53,289,290],{"class":66},"=>",[53,292,293],{"class":70}," item.name) ",[53,295,225],{"class":66},[53,297,298],{"class":70}," []\n",[53,300,301],{"class":55,"line":136},[53,302,233],{"class":70},[10,304,305],{},"再用 transformer 把 Notion 的頁面轉成網站自己的型別：",[25,307,309],{"className":47,"code":308,"language":49,"meta":31,"style":31},"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",[33,310,311,339,345,350,367,382,397,412,427,433,439],{"__ignoreMap":31},[53,312,313,315,317,320,322,324,326,328,331,333,336],{"class":55,"line":56},[53,314,116],{"class":66},[53,316,178],{"class":66},[53,318,319],{"class":101}," transformItinerary",[53,321,184],{"class":70},[53,323,188],{"class":187},[53,325,191],{"class":66},[53,327,194],{"class":94},[53,329,330],{"class":70},")",[53,332,191],{"class":66},[53,334,335],{"class":101}," Itinerary",[53,337,338],{"class":70}," {\n",[53,340,341,343],{"class":55,"line":63},[53,342,213],{"class":66},[53,344,338],{"class":70},[53,346,347],{"class":55,"line":81},[53,348,349],{"class":70},"    id: page.id,\n",[53,351,352,355,358,361,364],{"class":55,"line":88},[53,353,354],{"class":70},"    title: ",[53,356,357],{"class":101},"getTitle",[53,359,360],{"class":70},"(page, ",[53,362,363],{"class":77},"'Trip Name'",[53,365,366],{"class":70},"),\n",[53,368,369,372,375,377,380],{"class":55,"line":108},[53,370,371],{"class":70},"    date: ",[53,373,374],{"class":101},"getDate",[53,376,360],{"class":70},[53,378,379],{"class":77},"'Date'",[53,381,366],{"class":70},[53,383,384,387,390,392,395],{"class":55,"line":113},[53,385,386],{"class":70},"    day: ",[53,388,389],{"class":101},"getSelect",[53,391,360],{"class":70},[53,393,394],{"class":77},"'Day'",[53,396,366],{"class":70},[53,398,399,402,405,407,410],{"class":55,"line":136},[53,400,401],{"class":70},"    order: ",[53,403,404],{"class":101},"getNumber",[53,406,360],{"class":70},[53,408,409],{"class":77},"'Order'",[53,411,366],{"class":70},[53,413,414,417,420,422,425],{"class":55,"line":142},[53,415,416],{"class":70},"    category: ",[53,418,419],{"class":101},"getMultiSelect",[53,421,360],{"class":70},[53,423,424],{"class":77},"'Category'",[53,426,366],{"class":70},[53,428,430],{"class":55,"line":429},9,[53,431,432],{"class":59},"    \u002F\u002F ...\n",[53,434,436],{"class":55,"line":435},10,[53,437,438],{"class":70},"  }\n",[53,440,442],{"class":55,"line":441},11,[53,443,233],{"class":70},[10,445,446,447,450],{},"這樣前端只認識 ",[33,448,449],{},"Itinerary","。Notion 的欄位改名時，只要改 transformer 這一個地方。",[14,452,454],{"id":453},"快取不要每次請求都打-notion","快取：不要每次請求都打 Notion",[10,456,457,458,461],{},"Notion API 的回應不算快，也有請求速率限制，而行程內容不會每秒變動。所以我用 Nitro 的 ",[33,459,460],{},"defineCachedEventHandler"," 包住路由，快取 60 秒：",[25,463,465],{"className":47,"code":464,"language":49,"meta":31,"style":31},"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",[33,466,467,489,510,515,520,524,531,541,571,585,599],{"__ignoreMap":31},[53,468,469,471,474,477,479,482,485,487],{"class":55,"line":56},[53,470,116],{"class":66},[53,472,473],{"class":66}," default",[53,475,476],{"class":101}," defineCachedEventHandler",[53,478,184],{"class":70},[53,480,481],{"class":66},"async",[53,483,484],{"class":70}," () ",[53,486,290],{"class":66},[53,488,338],{"class":70},[53,490,491,494,497,499,502,505,508],{"class":55,"line":63},[53,492,493],{"class":66},"  const",[53,495,496],{"class":94}," response",[53,498,98],{"class":66},[53,500,501],{"class":66}," await",[53,503,504],{"class":70}," notion.dataSources.",[53,506,507],{"class":101},"query",[53,509,133],{"class":70},[53,511,512],{"class":55,"line":81},[53,513,514],{"class":70},"    data_source_id: config.notionDataSourceId,\n",[53,516,517],{"class":55,"line":88},[53,518,519],{"class":70},"  })\n",[53,521,522],{"class":55,"line":108},[53,523,85],{"emptyLinePlaceholder":84},[53,525,526,528],{"class":55,"line":113},[53,527,213],{"class":66},[53,529,530],{"class":70}," response.results\n",[53,532,533,536,538],{"class":55,"line":136},[53,534,535],{"class":70},"    .",[53,537,274],{"class":101},[53,539,540],{"class":70},"(transformItinerary)\n",[53,542,543,545,548,550,553,555,558,560,562,565,568],{"class":55,"line":142},[53,544,535],{"class":70},[53,546,547],{"class":101},"sort",[53,549,277],{"class":70},[53,551,552],{"class":187},"a",[53,554,197],{"class":70},[53,556,557],{"class":187},"b",[53,559,287],{"class":70},[53,561,290],{"class":66},[53,563,564],{"class":70}," a.date ",[53,566,567],{"class":66},"!==",[53,569,570],{"class":70}," b.date\n",[53,572,573,576,579,582],{"class":55,"line":429},[53,574,575],{"class":66},"      ?",[53,577,578],{"class":70}," a.date.",[53,580,581],{"class":101},"localeCompare",[53,583,584],{"class":70},"(b.date)\n",[53,586,587,590,593,596],{"class":55,"line":435},[53,588,589],{"class":66},"      :",[53,591,592],{"class":70}," a.order ",[53,594,595],{"class":66},"-",[53,597,598],{"class":70}," b.order)\n",[53,600,601,604,607],{"class":55,"line":441},[53,602,603],{"class":70},"}, { maxAge: ",[53,605,606],{"class":94},"60",[53,608,609],{"class":70}," })\n",[10,611,612],{},"代價是在 Notion 改完內容後，最多要等一分鐘才會更新。對行程網站來說完全可以接受。",[14,614,616],{"id":615},"踩到的坑slug-不一定唯一","踩到的坑：slug 不一定唯一",[10,618,619],{},"行程詳情頁一開始只用 slug 查詢，後來發現同一間飯店住兩次，就會有兩筆相同的 slug。Notion 不會幫欄位檢查唯一值，所以我再用第幾天來區分：",[25,621,623],{"className":47,"code":622,"language":49,"meta":31,"style":31},"const trip = (day ? matches.find(item => item.day === `Day ${day}`) : null)\n  ?? matches[0]\n",[33,624,625,678],{"__ignoreMap":31},[53,626,627,629,632,634,637,640,643,646,648,650,653,656,659,662,665,668,670,672,675],{"class":55,"line":56},[53,628,91],{"class":66},[53,630,631],{"class":94}," trip",[53,633,98],{"class":66},[53,635,636],{"class":70}," (day ",[53,638,639],{"class":66},"?",[53,641,642],{"class":70}," matches.",[53,644,645],{"class":101},"find",[53,647,184],{"class":70},[53,649,280],{"class":187},[53,651,652],{"class":66}," =>",[53,654,655],{"class":70}," item.day ",[53,657,658],{"class":66},"===",[53,660,661],{"class":77}," `Day ${",[53,663,664],{"class":70},"day",[53,666,667],{"class":77},"}`",[53,669,287],{"class":70},[53,671,191],{"class":66},[53,673,674],{"class":94}," null",[53,676,677],{"class":70},")\n",[53,679,680,683,686,688],{"class":55,"line":63},[53,681,682],{"class":66},"  ??",[53,684,685],{"class":70}," matches[",[53,687,219],{"class":94},[53,689,690],{"class":70},"]\n",[10,692,693],{},"路由帶了查詢參數之後，快取的 key 也要跟著改，否則不同天會拿到同一份快取：",[25,695,697],{"className":47,"code":696,"language":49,"meta":31,"style":31},"{ maxAge: 60, getKey: event => `${getRouterParam(event, 'slug') ?? ''}:${getQuery(event).day ?? ''}` }\n",[33,698,699],{"__ignoreMap":31},[53,700,701,704,707,710,712,714,717,719,722,724,727,730,732,734,736,739,741,743,746,749,751,753,756,758,761,764],{"class":55,"line":56},[53,702,703],{"class":70},"{ ",[53,705,706],{"class":101},"maxAge",[53,708,709],{"class":70},": ",[53,711,606],{"class":94},[53,713,197],{"class":70},[53,715,716],{"class":101},"getKey",[53,718,709],{"class":70},[53,720,721],{"class":187},"event",[53,723,652],{"class":66},[53,725,726],{"class":77}," `${",[53,728,729],{"class":101},"getRouterParam",[53,731,184],{"class":77},[53,733,721],{"class":70},[53,735,197],{"class":77},[53,737,738],{"class":77},"'slug'",[53,740,287],{"class":77},[53,742,225],{"class":66},[53,744,745],{"class":77}," ''}:${",[53,747,748],{"class":101},"getQuery",[53,750,184],{"class":77},[53,752,721],{"class":70},[53,754,755],{"class":77},").",[53,757,664],{"class":70},[53,759,760],{"class":66}," ??",[53,762,763],{"class":77}," ''}`",[53,765,766],{"class":70}," }\n",[10,768,769],{},"這也是用 Notion 當 CMS 要注意的事：它是編輯工具，不是資料庫，資料的正確性要由程式自己把關。",[14,771,772],{"id":772},"讓內容維持好編輯",[10,774,775,776,779,780,783],{},"交通方式是一段多步驟的說明，如果拆成很多欄位，編輯起來很麻煩。所以我讓它在 Notion 裡維持一段純文字，用 ",[33,777,778],{},"#"," 當標題、",[33,781,782],{},"⏱"," 當時間，再寫一個 parser 轉成結構化資料給前端顯示。",[10,785,786,787,790],{},"票券則是放在一般的 Notion 頁面，由 ",[33,788,789],{},"blocks.children.list"," 讀取標題、圖片和檔案。這部分比較敏感，所以另外加了密碼驗證，通過後才能讀取。",[14,792,793],{"id":793},"內容和使用者狀態分開",[10,795,796,797,800],{},"行李清單的項目來自 Notion，但「打勾」是每個使用者自己的狀態，不該寫回 Notion。所以打勾狀態和自己新增的項目都存在 ",[33,798,799],{},"localStorage","，顯示時再合併：",[25,802,804],{"className":47,"code":803,"language":49,"meta":31,"style":31},"const items = computed(() =>\n  [...(data.value ?? []), ...customItems.value].map(item => ({\n    ...item,\n    checked: checkedMap.value[item.id] ?? false,\n  })),\n)\n",[33,805,806,824,856,864,877,882],{"__ignoreMap":31},[53,807,808,810,813,815,818,821],{"class":55,"line":56},[53,809,91],{"class":66},[53,811,812],{"class":94}," items",[53,814,98],{"class":66},[53,816,817],{"class":101}," computed",[53,819,820],{"class":70},"(() ",[53,822,823],{"class":66},"=>\n",[53,825,826,829,832,835,837,840,842,845,847,849,851,853],{"class":55,"line":63},[53,827,828],{"class":70},"  [",[53,830,831],{"class":66},"...",[53,833,834],{"class":70},"(data.value ",[53,836,225],{"class":66},[53,838,839],{"class":70}," []), ",[53,841,831],{"class":66},[53,843,844],{"class":70},"customItems.value].",[53,846,274],{"class":101},[53,848,184],{"class":70},[53,850,280],{"class":187},[53,852,652],{"class":66},[53,854,855],{"class":70}," ({\n",[53,857,858,861],{"class":55,"line":81},[53,859,860],{"class":66},"    ...",[53,862,863],{"class":70},"item,\n",[53,865,866,869,871,874],{"class":55,"line":88},[53,867,868],{"class":70},"    checked: checkedMap.value[item.id] ",[53,870,225],{"class":66},[53,872,873],{"class":94}," false",[53,875,876],{"class":70},",\n",[53,878,879],{"class":55,"line":108},[53,880,881],{"class":70},"  })),\n",[53,883,884],{"class":55,"line":113},[53,885,677],{"class":70},[14,887,889],{"id":888},"串接-google-maps","串接 Google Maps",[10,891,892],{},"每個行程都有 Notion 裡維護的 Google Maps 連結，在詳情頁點一下就能開啟地圖。",[10,894,895],{},"迪士尼樂園的規劃則用 Google Maps JavaScript API，在地圖上標出每個項目，再用線連起來，看得出當天的動線。點擊標記可以聚焦到對應的項目：",[25,897,899],{"className":47,"code":898,"language":49,"meta":31,"style":31},"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",[33,900,901,929,954,978,985],{"__ignoreMap":31},[53,902,903,906,909,912,914,916,918,920,923,925,927],{"class":55,"line":56},[53,904,905],{"class":70},"markers ",[53,907,908],{"class":66},"=",[53,910,911],{"class":70}," props.items.",[53,913,274],{"class":101},[53,915,277],{"class":70},[53,917,280],{"class":187},[53,919,197],{"class":70},[53,921,922],{"class":187},"index",[53,924,287],{"class":70},[53,926,290],{"class":66},[53,928,338],{"class":70},[53,930,931,933,936,938,940,943,946,949,952],{"class":55,"line":63},[53,932,493],{"class":66},[53,934,935],{"class":94}," marker",[53,937,98],{"class":66},[53,939,127],{"class":66},[53,941,942],{"class":70}," g.maps.",[53,944,945],{"class":101},"Marker",[53,947,948],{"class":70},"({ ",[53,950,951],{"class":59},"\u002F* 位置與標籤 *\u002F",[53,953,609],{"class":70},[53,955,956,959,962,964,967,970,972,975],{"class":55,"line":81},[53,957,958],{"class":70},"  marker.",[53,960,961],{"class":101},"addListener",[53,963,184],{"class":70},[53,965,966],{"class":77},"'click'",[53,968,969],{"class":70},", () ",[53,971,290],{"class":66},[53,973,974],{"class":101}," focusItem",[53,976,977],{"class":70},"(index))\n",[53,979,980,982],{"class":55,"line":88},[53,981,213],{"class":66},[53,983,984],{"class":70}," marker\n",[53,986,987],{"class":55,"line":108},[53,988,145],{"class":70},[10,990,991],{},"地圖腳本不小，也不是每頁都需要，所以只在要用時才載入。我把載入的 Promise 存起來，多個地方同時呼叫也只會載入一次：",[25,993,995],{"className":47,"code":994,"language":49,"meta":31,"style":31},"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",[33,996,997,1029,1033,1064,1085,1097,1101,1127,1150,1165,1175,1194,1227,1239,1244,1249,1256],{"__ignoreMap":31},[53,998,999,1002,1005,1007,1010,1013,1016,1019,1022,1024,1026],{"class":55,"line":56},[53,1000,1001],{"class":66},"let",[53,1003,1004],{"class":70}," loaderPromise",[53,1006,191],{"class":66},[53,1008,1009],{"class":101}," Promise",[53,1011,1012],{"class":70},"\u003C",[53,1014,1015],{"class":94},"void",[53,1017,1018],{"class":70},"> ",[53,1020,1021],{"class":66},"|",[53,1023,674],{"class":94},[53,1025,98],{"class":66},[53,1027,1028],{"class":94}," null\n",[53,1030,1031],{"class":55,"line":63},[53,1032,85],{"emptyLinePlaceholder":84},[53,1034,1035,1037,1039,1042,1044,1047,1049,1051,1053,1055,1057,1059,1061],{"class":55,"line":81},[53,1036,116],{"class":66},[53,1038,178],{"class":66},[53,1040,1041],{"class":101}," loadGoogleMaps",[53,1043,184],{"class":70},[53,1045,1046],{"class":187},"apiKey",[53,1048,191],{"class":66},[53,1050,205],{"class":94},[53,1052,330],{"class":70},[53,1054,191],{"class":66},[53,1056,1009],{"class":101},[53,1058,1012],{"class":70},[53,1060,1015],{"class":94},[53,1062,1063],{"class":70},"> {\n",[53,1065,1066,1069,1072,1075,1077,1080,1083],{"class":55,"line":88},[53,1067,1068],{"class":66},"  if",[53,1070,1071],{"class":70}," (window.google?.maps) ",[53,1073,1074],{"class":66},"return",[53,1076,1009],{"class":94},[53,1078,1079],{"class":70},".",[53,1081,1082],{"class":101},"resolve",[53,1084,105],{"class":70},[53,1086,1087,1089,1092,1094],{"class":55,"line":108},[53,1088,1068],{"class":66},[53,1090,1091],{"class":70}," (loaderPromise) ",[53,1093,1074],{"class":66},[53,1095,1096],{"class":70}," loaderPromise\n",[53,1098,1099],{"class":55,"line":113},[53,1100,85],{"emptyLinePlaceholder":84},[53,1102,1103,1106,1108,1110,1112,1114,1116,1118,1121,1123,1125],{"class":55,"line":136},[53,1104,1105],{"class":70},"  loaderPromise ",[53,1107,908],{"class":66},[53,1109,127],{"class":66},[53,1111,1009],{"class":94},[53,1113,277],{"class":70},[53,1115,1082],{"class":187},[53,1117,197],{"class":70},[53,1119,1120],{"class":187},"reject",[53,1122,287],{"class":70},[53,1124,290],{"class":66},[53,1126,338],{"class":70},[53,1128,1129,1132,1135,1137,1140,1143,1145,1148],{"class":55,"line":142},[53,1130,1131],{"class":66},"    const",[53,1133,1134],{"class":94}," script",[53,1136,98],{"class":66},[53,1138,1139],{"class":70}," document.",[53,1141,1142],{"class":101},"createElement",[53,1144,184],{"class":70},[53,1146,1147],{"class":77},"'script'",[53,1149,677],{"class":70},[53,1151,1152,1155,1157,1160,1162],{"class":55,"line":429},[53,1153,1154],{"class":70},"    script.src ",[53,1156,908],{"class":66},[53,1158,1159],{"class":77}," `https:\u002F\u002Fmaps.googleapis.com\u002Fmaps\u002Fapi\u002Fjs?key=${",[53,1161,1046],{"class":70},[53,1163,1164],{"class":77},"}`\n",[53,1166,1167,1170,1172],{"class":55,"line":435},[53,1168,1169],{"class":70},"    script.async ",[53,1171,908],{"class":66},[53,1173,1174],{"class":94}," true\n",[53,1176,1177,1180,1183,1185,1187,1189,1192],{"class":55,"line":441},[53,1178,1179],{"class":70},"    script.",[53,1181,1182],{"class":101},"onload",[53,1184,98],{"class":66},[53,1186,484],{"class":70},[53,1188,290],{"class":66},[53,1190,1191],{"class":101}," resolve",[53,1193,105],{"class":70},[53,1195,1197,1199,1202,1204,1206,1208,1211,1213,1216,1219,1221,1224],{"class":55,"line":1196},12,[53,1198,1179],{"class":70},[53,1200,1201],{"class":101},"onerror",[53,1203,98],{"class":66},[53,1205,484],{"class":70},[53,1207,290],{"class":66},[53,1209,1210],{"class":101}," reject",[53,1212,184],{"class":70},[53,1214,1215],{"class":66},"new",[53,1217,1218],{"class":101}," Error",[53,1220,184],{"class":70},[53,1222,1223],{"class":77},"'Google Maps 載入失敗'",[53,1225,1226],{"class":70},"))\n",[53,1228,1230,1233,1236],{"class":55,"line":1229},13,[53,1231,1232],{"class":70},"    document.head.",[53,1234,1235],{"class":101},"appendChild",[53,1237,1238],{"class":70},"(script)\n",[53,1240,1242],{"class":55,"line":1241},14,[53,1243,519],{"class":70},[53,1245,1247],{"class":55,"line":1246},15,[53,1248,85],{"emptyLinePlaceholder":84},[53,1250,1252,1254],{"class":55,"line":1251},16,[53,1253,213],{"class":66},[53,1255,1096],{"class":70},[53,1257,1259],{"class":55,"line":1258},17,[53,1260,233],{"class":70},[14,1262,1264],{"id":1263},"不是所有資料都放-notion迪士尼餐廳資料","不是所有資料都放 Notion：迪士尼餐廳資料",[10,1266,1267],{},"規劃東京迪士尼時，我想直接看到各餐廳的菜單，但官網沒有繁體中文，一間一間查也很花時間。這類資料量大、又幾乎不會改，不適合在 Notion 手動維護，所以我用爬蟲把資料整理下來。",[10,1269,1270],{},"我寫了在瀏覽器裡執行的腳本，逐間讀取官網的餐廳頁面，總共整理出 154 間餐廳，其中 122 間有菜單，合計 1,842 個品項，再用 GPT 翻譯成繁體中文。這批資料只供個人旅遊使用，放在密碼驗證之後，沒有公開。",[10,1272,1273],{},"每間餐廳存成一個 JSON 檔，並保留原文和繁體中文，也記下來源網址和取得日期，之後要核對或更新才有依據：",[25,1275,1279],{"className":1276,"code":1277,"language":1278,"meta":31,"style":31},"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","json",[33,1280,1281,1286,1294,1306,1318,1328,1333,1340,1347,1359,1369,1374,1378],{"__ignoreMap":31},[53,1282,1283],{"class":55,"line":56},[53,1284,1285],{"class":70},"{\n",[53,1287,1288,1291],{"class":55,"line":63},[53,1289,1290],{"class":94},"  \"source\"",[53,1292,1293],{"class":70},": {\n",[53,1295,1296,1299,1301,1304],{"class":55,"line":81},[53,1297,1298],{"class":94},"    \"official_url\"",[53,1300,709],{"class":70},[53,1302,1303],{"class":77},"\"...\"",[53,1305,876],{"class":70},[53,1307,1308,1311,1313,1316],{"class":55,"line":88},[53,1309,1310],{"class":94},"    \"retrieved_date\"",[53,1312,709],{"class":70},[53,1314,1315],{"class":77},"\"2026-09-10\"",[53,1317,876],{"class":70},[53,1319,1320,1323,1325],{"class":55,"line":108},[53,1321,1322],{"class":94},"    \"language_original\"",[53,1324,709],{"class":70},[53,1326,1327],{"class":77},"\"en\"\n",[53,1329,1330],{"class":55,"line":113},[53,1331,1332],{"class":70},"  },\n",[53,1334,1335,1338],{"class":55,"line":136},[53,1336,1337],{"class":94},"  \"restaurant\"",[53,1339,1293],{"class":70},[53,1341,1342,1345],{"class":55,"line":142},[53,1343,1344],{"class":94},"    \"name\"",[53,1346,1293],{"class":70},[53,1348,1349,1352,1354,1357],{"class":55,"line":429},[53,1350,1351],{"class":94},"      \"original\"",[53,1353,709],{"class":70},[53,1355,1356],{"class":77},"\"La Taverne de Gaston\"",[53,1358,876],{"class":70},[53,1360,1361,1364,1366],{"class":55,"line":435},[53,1362,1363],{"class":94},"      \"zh_tw\"",[53,1365,709],{"class":70},[53,1367,1368],{"class":77},"\"加斯頓餐館\"\n",[53,1370,1371],{"class":55,"line":441},[53,1372,1373],{"class":70},"    }\n",[53,1375,1376],{"class":55,"line":1196},[53,1377,438],{"class":70},[53,1379,1380],{"class":55,"line":1229},[53,1381,233],{"class":70},[1383,1384,1386],"h3",{"id":1385},"本機正常部署到-vercel-卻找不到檔案","本機正常，部署到 Vercel 卻找不到檔案",[10,1388,1389,1390,1393,1394,1397,1398,1401,1402,1405],{},"一開始 API 用 ",[33,1391,1392],{},"node:fs"," 的 ",[33,1395,1396],{},"readdirSync"," 和 ",[33,1399,1400],{},"readFileSync"," 搭配 ",[33,1403,1404],{},"process.cwd()"," 讀取這些 JSON，本機完全正常，部署到 Vercel 後卻找不到檔案。",[10,1407,1408],{},"原因是這種在執行時才動態決定路徑的讀檔方式，Vercel 的 serverless function 打包器無法靜態追蹤，這批檔案根本沒有被包進部署結果裡。",[10,1410,1411,1412,1415,1416,1419],{},"解法是改用 Nitro 的 ",[33,1413,1414],{},"serverAssets","，把 JSON 內嵌進 server build，再用 ",[33,1417,1418],{},"useStorage()"," 讀取：",[25,1421,1423],{"className":47,"code":1422,"language":49,"meta":31,"style":31},"\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",[33,1424,1425,1430,1437,1457,1461,1465,1470,1489],{"__ignoreMap":31},[53,1426,1427],{"class":55,"line":56},[53,1428,1429],{"class":59},"\u002F\u002F nuxt.config.ts\n",[53,1431,1432,1435],{"class":55,"line":63},[53,1433,1434],{"class":101},"nitro",[53,1436,1293],{"class":70},[53,1438,1439,1442,1445,1448,1451,1454],{"class":55,"line":81},[53,1440,1441],{"class":101},"  serverAssets",[53,1443,1444],{"class":70},": [{ baseName: ",[53,1446,1447],{"class":77},"'tdr-menu'",[53,1449,1450],{"class":70},", dir: ",[53,1452,1453],{"class":77},"'data\u002Ftdr-menu'",[53,1455,1456],{"class":70}," }],\n",[53,1458,1459],{"class":55,"line":88},[53,1460,233],{"class":70},[53,1462,1463],{"class":55,"line":108},[53,1464,85],{"emptyLinePlaceholder":84},[53,1466,1467],{"class":55,"line":113},[53,1468,1469],{"class":59},"\u002F\u002F API 路由\n",[53,1471,1472,1474,1477,1479,1482,1484,1487],{"class":55,"line":136},[53,1473,91],{"class":66},[53,1475,1476],{"class":94}," storage",[53,1478,98],{"class":66},[53,1480,1481],{"class":101}," useStorage",[53,1483,184],{"class":70},[53,1485,1486],{"class":77},"'assets:tdr-menu'",[53,1488,677],{"class":70},[53,1490,1491,1493,1496,1498,1500,1503,1506],{"class":55,"line":142},[53,1492,91],{"class":66},[53,1494,1495],{"class":94}," keys",[53,1497,98],{"class":66},[53,1499,501],{"class":66},[53,1501,1502],{"class":70}," storage.",[53,1504,1505],{"class":101},"getKeys",[53,1507,1508],{"class":70},"(park)\n",[10,1510,1511,1512,1515,1516,1393,1519,1522,1523,1526,1527,1530],{},"這裡還有一個小坑：",[33,1513,1514],{},"dir"," 要寫相對於 ",[33,1517,1518],{},"server\u002F",[33,1520,1521],{},"data\u002Ftdr-menu","。如果寫成 ",[33,1524,1525],{},"server\u002Fdata\u002Ftdr-menu","，路徑會疊成 ",[33,1528,1529],{},"server\u002Fserver\u002Fdata\u002Ftdr-menu","，一樣找不到。",[14,1532,1534],{"id":1533},"另外串接-gpt-與-gemini","另外串接 GPT 與 Gemini",[10,1536,1537],{},"旅途中常會遇到臨時想問的問題，所以網站裡也做了 AI 聊天，同樣由伺服器路由呼叫，金鑰不會出現在前端。可以選擇 GPT 或 Gemini，也可以上傳圖片一起詢問。",[10,1539,1540],{},"Gemini 遇到 429（請求過多）時，會自動改用 OpenAI 回答，使用者不用自己切換：",[25,1542,1544],{"className":47,"code":1543,"language":49,"meta":31,"style":31},"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",[33,1545,1546,1553,1569,1589,1603,1624,1628,1636],{"__ignoreMap":31},[53,1547,1548,1551],{"class":55,"line":56},[53,1549,1550],{"class":66},"try",[53,1552,338],{"class":70},[53,1554,1555,1557,1560,1563,1566],{"class":55,"line":63},[53,1556,213],{"class":66},[53,1558,1559],{"class":70}," { message: ",[53,1561,1562],{"class":66},"await",[53,1564,1565],{"class":101}," askGemini",[53,1567,1568],{"class":70},"({ history, images }) }\n",[53,1570,1571,1574,1577,1580,1583,1585,1587],{"class":55,"line":81},[53,1572,1573],{"class":70},"} ",[53,1575,1576],{"class":66},"catch",[53,1578,1579],{"class":70}," (",[53,1581,1582],{"class":187},"error",[53,1584,191],{"class":66},[53,1586,194],{"class":94},[53,1588,208],{"class":70},[53,1590,1591,1593,1596,1598,1601],{"class":55,"line":88},[53,1592,1068],{"class":66},[53,1594,1595],{"class":70}," (error.status ",[53,1597,658],{"class":66},[53,1599,1600],{"class":94}," 429",[53,1602,208],{"class":70},[53,1604,1605,1608,1611,1614,1617,1619,1622],{"class":55,"line":108},[53,1606,1607],{"class":66},"    return",[53,1609,1610],{"class":70}," { provider: ",[53,1612,1613],{"class":77},"'openai'",[53,1615,1616],{"class":70},", message: ",[53,1618,1562],{"class":66},[53,1620,1621],{"class":101}," askOpenAI",[53,1623,1568],{"class":70},[53,1625,1626],{"class":55,"line":113},[53,1627,438],{"class":70},[53,1629,1630,1633],{"class":55,"line":136},[53,1631,1632],{"class":66},"  throw",[53,1634,1635],{"class":70}," error\n",[53,1637,1638],{"class":55,"line":142},[53,1639,233],{"class":70},[14,1641,1642],{"id":1642},"目前的限制",[1644,1645,1646,1654],"ul",{},[1647,1648,1649,1650,1653],"li",{},"查詢沒有處理分頁。Notion 每次查詢最多回傳 100 筆，資料超過之後要用 ",[33,1651,1652],{},"next_cursor"," 繼續讀取。",[1647,1655,1656],{},"Notion 欄位是用名稱取值，有人在後台改欄位名稱，資料就會變成空值，不會報錯。",[14,1658,1659],{"id":1659},"這次學到的事",[1644,1661,1662,1665,1668],{},[1647,1663,1664],{},"不是每個專案都需要自己做後台，先想清楚內容由誰維護、多常更新。",[1647,1666,1667],{},"第三方資料要在邊界轉換成自己的型別，不要讓外部結構滲進元件。",[1647,1669,1670],{},"快取的 key 要包含所有會影響結果的參數。",[1672,1673,1674],"style",{},"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":31,"searchDepth":63,"depth":63,"links":1676},[1677,1678,1679,1680,1681,1682,1683,1684,1685,1688,1689,1690],{"id":16,"depth":63,"text":17},{"id":37,"depth":63,"text":37},{"id":155,"depth":63,"text":156},{"id":453,"depth":63,"text":454},{"id":615,"depth":63,"text":616},{"id":772,"depth":63,"text":772},{"id":793,"depth":63,"text":793},{"id":888,"depth":63,"text":889},{"id":1263,"depth":63,"text":1264,"children":1686},[1687],{"id":1385,"depth":81,"text":1386},{"id":1533,"depth":63,"text":1534},{"id":1642,"depth":63,"text":1642},{"id":1659,"depth":63,"text":1659},"case","2026-09-11","為了 9 月的東京自由行，用 Nuxt 4 做了一個旅遊行程網站，把 Notion 當成內容後台，並用伺服器路由、transformer 與快取隔開 Notion 的資料結構。",false,"md",{},"\u002Fblog\u002Fnotion-as-cms",{"title":5,"description":1693},"blog\u002Fnotion-as-cms",[1701,1702,1703],"Notion API","CMS","Nuxt","XFUHA8oaWUBKIXrewXcfF_21qt4KZI7agXhRe42-Evs"]