用程式產生 300 個範本頁,又不做成入口網頁:我們的做法與量測
官網上有 100 個行業的報價單範本,英文、中文、日文各一頁:300 頁、600 個 Excel 和 Word 檔,全部從同一份有型別的資料產生。這篇講我們怎麼避免它們變成內容單薄、彼此雷同的頁面、怎麼量,為什麼頁面的 JavaScript 從 gzip 221 KB 降到 123 KB,以及 iPhone 預覽時試算表金額一片空白的問題。
我們的官網替 100 個行業各做了一份報價單範本,從冷氣安裝到團體便當都有,英文、繁體中文、日文三個語系各一頁。一共 300 頁,每一頁都能免費下載同一份報價單的 Excel 檔和 Word 檔,也就是 600 個下載檔。這些全部從同一份有型別的資料產生。
「用程式替 100 個關鍵字產生頁面」,換個說法也可以拿來形容入口網頁(doorway pages)。Google 的垃圾內容政策把入口網頁寫得很明白:為了搶一大堆相似搜尋而做、彼此大同小異、實際上沒那麼有用的頁面。所以寫程式之前,我們先訂了一條規則:一頁要存在,就必須講出只屬於那個行業、那個市場、別頁沒有的東西。這篇記錄我們怎麼在 300 頁的規模守住這條規則,又怎麼檢查。數字都是實際量到的,程式碼是現在 main 上的版本(有刪減)。
整體長這樣
src/lib/quote-templates/*.ts- /templates/quote/<slug>、/zh/…、/ja/…
- 標題與描述的固定句型和字數上限
- canonical、hreflang、sitemap
- 即時公式+快取結果
- 日文版逐列 10%/8% 稅率
- 每次產生的檔案位元組都相同
- /tools/quote?preset=<slug>
- 同一組 slug、同樣的品項
同一批 TypeScript 檔案產出三樣東西:頁面、可下載的檔案,以及產品裡免費報價工具的預設(/tools/quote?preset=<slug>),後者用同一組 slug、同樣的品項。除了頁面的框架(段落標題、下載區塊、行動按鈕)和標題句型,範本頁上的每一句話都來自那個行業自己的資料。
一個行業一筆資料,寫三次
每個行業是一個 QuoteTemplate。重點是 en、zh、ja 是三個分別撰寫的 QtCopy,不是一份原文加兩份翻譯:
// src/lib/quote-templates/types.ts(刪減)
export interface QtCopy {
name: string; // 大家搜尋時怎麼稱呼這個行業
desc: string; // meta description
intro: string; // 誰會寄這份報價單、它跟別的有什麼不同
items: QtItem[]; // [品項, 單位, 數量, 參考價]
include: string[]; // 這個行業的報價單該寫清楚的事
mistakes: string[]; // 會賠錢或丟案子的漏寫
terms: string; // 訂金、尾款、有效期、變更
faq: { q: string; a: string }[];
}
export interface QuoteTemplate {
slug: string;
category: QtCategory;
downloadDepositPercent?: number; // 只有部分案子收訂金時
jaReducedRateRows?: number[]; // 日文檔裡適用 8% 的品項
en: QtCopy;
zh: QtCopy;
ja: QtCopy;
}
團體便當最能說明為什麼不能用翻譯。美國版報 40 個便當,飲料另列一行,銷售稅開發票時才加。台灣版報 50 個,問客人要不要打統編,並提醒報價單沒寫清楚含稅還是未稅,結帳時就會差 5%。日本版則要依消費稅率拆開:便當和茶適用 8% 的軽減税率,外送費是 10%,還要寫インボイス登録番号:
// src/lib/quote-templates/occasions.ts(刪減)
slug: 'bento',
downloadDepositPercent: 0,
jaReducedRateRows: [1, 2, 3, 6],
// …
ja: {
name: '弁当・仕出し',
items: [
['会議用弁当(幕の内)', '個', '30', '1千〜2千5百円/個'],
['ベジタリアン・アレルギー対応弁当', '個', '4', '1千2百〜3千円/個'],
['お茶(ペットボトル 500ml)', '本', '34', '120〜200円/本'],
['配達料(最低注文数未満・エリア外)', '回', '1', '500〜3,000円'],
// …
每個新行業都有 6 個品項(用當地幣別寫參考價)、該寫清楚的事、常見漏寫、付款條件和 3 到 4 則 FAQ。三個語系的品項一列對一列,所以同一列可以直接變成報價工具的預設。
這次一口氣從 51 個行業加到 100 個。決定「不做哪些」和決定「做哪些」一樣花心思,以下這些我們刻意不做:
- 和既有頁面重疊的行業;
- 需要執照或受法規管制的工作,報價單讀起來會像專業意見;
- 三個市場裡至少有兩個搜尋需求很少的行業;
- 只存在於日本、做不出自然的英文或中文頁的行業。
怎麼量「太像」
「每一頁都不一樣」很容易說,但要靠人讀 300 頁來確認很難。我們要的是每次改動都能重跑的數字。
方法很單純:從本機 production build 取出每頁渲染後的 <main> 文字,去掉所有空白,切成互相重疊、每段 8 個字元的片段(shingle),再算兩頁片段集合的 Jaccard 相似度:共同的片段數除以全部不重複的片段數。用字元切不需要斷詞,同一段程式碼英文、中文、日文都能用。每一頁取它的最近鄰,也就是同語系裡和它最像的另一個範本頁。
有三件事很明顯。
新頁比舊頁更不相像。 英文 49 個新頁的最近鄰相似度平均 0.254,51 個原有頁是 0.319;中文 0.258 對 0.304;日文 0.266 對 0.327。新頁裡最高的一個是 0.290(英文),比原有頁的最高值 0.367 低。新頁也比較長:主要內容平均英文 2,822 字元對 2,109,中文 1,190 對 964,日文 1,464 對 1,164。
頁面之間共用的,大部分是框架。 只看各行業自己的內容(簡介、品項、該寫清楚的事、常見漏寫、付款條件、FAQ),兩兩平均只有 0.001 到 0.009(依語系不同),任兩個行業最接近的一對也只有 0.061。整頁層級的 0.2 到 0.3,來自段落標題、下載區塊和行動按鈕。有個現象很能說明:中文版裡,壁紙施工、傳單派發、廢棄物清運這幾頁的最近鄰都是美甲頁。它們共用的是版面,不是主題。
這是回歸檢查,不是判決。 Google 沒有公布任何相似度門檻,Jaccard 低也不代表頁面有用。這個數字抓的是我們最擔心的失敗:同一個模子刻出一批頁面,只換了行業名稱。如果新頁量出來比舊頁還像,我們會先改寫再上線。
不開 Excel 也看得到金額的試算表
Excel 檔不是報價單的截圖。每一列的數量 × 單價都是即時公式,小計、稅、總計、訂金和尾款跟著算。產生器的第一版只寫公式,Excel 打開時會自己算,所以在 Excel 裡看起來沒問題。
但很多人根本不會用 Excel 打開。他們在 iPhone 的郵件、檔案或 LINE 裡點一下,或在 Mac 上用 Quick Look 預覽,而這些檢視器不會計算,只顯示檔案裡存的值。結果每一列金額、小計、總計全是空白,範本就壞在大家第一眼會看的地方。這個問題在上線前的審查就被抓到了。
解法是每個公式都連同計算結果一起寫進檔案,同時要求 Excel 打開時照樣重算:
// scripts/build-template-downloads.ts(刪減)
// Every formula also carries its result, so previews that do not calculate
// (iOS Files, Mail, LINE, Quick Look) still show the amounts; Excel recalculates on open.
wb.calcProperties.fullCalcOnLoad = true;
// …
values.amount = { formula: `IF(OR(${Q}${r}="",${P}${r}=""),"",${Q}${r}*${P}${r})`, result: amount };
也就是說,產生器要用和公式一樣的方式自己把每個數字算一遍:逐列、再依稅率,各市場用各自的進位方式。302 個活頁簿裡共有 7,790 個公式儲存格,只要有一個沒存計算結果,測試就會失敗。
日文檔還多一步。日本消費稅是 10%,外帶或外送的飲食適用 8%,インボイス上要寫出各稅率的合計和稅額。所以日文檔每一列都有稅率欄,再依稅率分別加總、無條件捨去:
const b10 = addSum(j.base10, { formula: `SUMIF(${rates},0.1,${range})`, result: base['10'] }, copy.currency);
const t10 = addSum(j.tax10, { formula: round(`${b10}*0.1`), result: tax10 }, copy.currency);
const b8 = addSum(j.base8, { formula: `SUMIF(${rates},0.08,${range})`, result: base['8'] }, copy.currency);
const t8 = addSum(j.tax8, { formula: round(`${b8}*0.08`), result: tax8 }, copy.currency);
以便當這份為例,8% 的便當和茶 ¥40,880、10% 的外送費 ¥500,總計 ¥44,700。如果每一列都算 10%,會變成 ¥45,518,細心的客人會退回來的報價單。
另外兩個決定來自讀各行業自己的付款條件。每個檔案的訂金比例,預設取頁面付款條件裡寫在「訂金」旁邊的百分比。但有 19 個行業只有部分案子才收訂金(要訂零件、第一次合作、金額較大),這些檔案透過 downloadDepositPercent 從 0% 開始,而且每個檔案都註明比例可以改。Word 檔的欄寬用 twip 固定值而不是百分比,因為 Google 文件匯入時會把百分比欄寬壓扁。
產生一次,每個位元組都一樣
這 602 個檔案(600 份報價單加兩份收支表,共 7.15 MB)是在 commit 時用腳本產生並提交進版本庫,不在 pnpm build 時產生。這樣試算表和 Word 的函式庫不會進到正式環境的映像檔,頁面也不可能連到一個產生失敗的檔案。
把產生出來的二進位檔提交進去,前提是重新產生時什麼都不會變。zip 會記錄檔案日期,Word 函式庫又會替超連結取隨機的 relationship id,所以每個檔案都用固定日期、依序編號的 id 重新壓縮:
/** Re-zip with fixed entry dates and document dates so the output is reproducible. */
async function normalize(buf: Buffer | ArrayBuffer | Uint8Array): Promise<Buffer> {
// …排序檔案、把隨機 rId 換成 rIdLink1、rIdLink2…、固定 docProps 的日期…
out.file(n, data, { date: FIXED_DATE, createFolders: false });
// …
}
在我們的機器上重跑一次約 8 秒,跑完 git 是乾淨的,所以 static/templates/files/ 只要出現差異,就代表資料改了。檔案和行業資料對不上時,測試會失敗。
沒人要的 JavaScript
加 49 個行業時,差點順便加了一個沒打算加的東西。範本頁需要幾個介面字串和兩個連結函式,原本是從模組的 index 匯入:
// 改之前
import { UI, signupHref, toolHref } from '$lib/quote-templates';
// 改之後
// From ui.ts, not the index: the index pulls every trade's copy into the page's JavaScript.
import { UI, signupHref, toolHref } from '$lib/quote-templates/ui';
index 同時匯出所有行業的陣列,打包工具就把每個行業、三種語言的文字全放進了頁面的前端 JavaScript,雖然頁面自己那個行業的內容其實是從伺服器拿的。51 個行業時,這部分就已經是 gzip 98 KB;到 100 個會變成 249 KB。
三種狀態我們都在本機 production build 上量過:擴充前的頁面、100 個行業但沿用舊 import 的同一頁(只為了量而建置),以及實際上線的版本。英文頁從 gzip 221.3 KB 降到 123.5 KB,而且裡面沒有任何行業內容。同樣的理由,總覽頁上顯示的行業數(100)是一個常數,由測試去比對資料筆數,而不是直接用 QUOTE_TEMPLATES.length:不然顯示這個數字的網站字典檔也會把所有行業一起匯入。
標題、hreflang 和 sitemap
標題依各市場的搜尋習慣寫,而且有字數預算。日文搜尋結果大約在 32 個字元處截斷標題,所以標題會先拿掉「Word」、再拿掉「Excel」,最後才輪到行業名稱:
// src/lib/quote-templates/ui.ts
const JA_TITLE_SUFFIXES = ['(無料・Excel・Word)', '(無料・Excel)', '(無料)'];
// …
const h1 = `${t.ja.name}の見積書テンプレート`;
const suffix = JA_TITLE_SUFFIXES.find((s) => h1.length + s.length <= TITLE_MAX) ?? '(無料)';
return { h1, title: `${h1}${suffix}|NEXUPIRA` };
100 個日文標題裡有 15 個因此拿掉了「Word」。中文和日文的描述先放各行業自己的摘要,最後接同一句承諾:免費下載 Excel/Word、不用註冊。最長的中文描述 93 字、日文 107 字,預算是 110。每個標題和描述都有測試檢查這些上限。
每頁都有指向自己的 canonical,以及指向三個語系版本的 hreflang(en、zh-Hant、ja,英文同時是 x-default),sitemap 也帶同樣的 alternates 和各行業的最後更新日期:一共 300 筆。正式環境的路由有一個限制:我們的邊緣只把特定路徑前綴送到官網,其餘都送到產品。英文頁沒有語系前綴,所以每個英文網址都必須放在這些前綴之下,這裡是 /templates。新的英文網址如果不在裡面,測試會失敗。
100 頁也要顧內部連結。每頁連到 4 個相關行業:先用自己的 related 清單,再依序補上同分類裡排在它後面的行業,到底再從頭繞回來。以前同一分類的每一頁都連到同樣的前 4 個。測試會檢查每個行業至少被另一個行業頁連到。
目前知道的結果
還不多,我們寧可直說。在這次擴充之前,抽查日本的搜尋結果時,有幾個小眾行業頁很快就排到前面:OEM 見積書 テンプレート 第 1 名、看板(招牌)第 4 名、自動車整備(汽車保養)第 4 名。「見積書テンプレート」這個大字本身是大型會計與辦公軟體廠商的天下,這也是我們改走大量特定行業的原因。不過那些只是原本 51 頁裡少數幾頁的早期觀察。49 個新行業在這篇寫成的當天才上線,還沒有任何搜尋數據。
限制
- 價格只是示意。 每一頁、每個檔案都標明是參考價;那是我們寫的範圍,不是市場調查。
- 檔案沒有在桌面版 Excel 或 Word 打開過。 我們用 LibreOffice 和 macOS Quick Look 檢查,並用測試讀過每一個活頁簿。
- 相似度是我們的代理指標,不是 Google 的。 它抓得到同一個模子刻出來的頁面,但不能說明一頁有沒有幫助。
- 下載檔沒有加結構化資料。 Google 沒有針對可下載文件的複合式搜尋結果,所以我們不加會被忽略的標記。
如果你也要用資料產生頁面
- 寫產生器之前,先寫好「一頁什麼時候可以存在」的規則,而且要以只有那一頁才有的內容為準。
- 資料要依市場分,不要依字串分。 兩個語言如果能共用一句話,它們大概也不該共用一頁。
- 相似度要量使用者看到的文字,也要另外只量內容。 兩者的差距就是你的框架。
- 在使用者打開檔案的地方打開你產生的檔案。 手機預覽不會跑公式。
- 檢查頁面匯入了什麼。 一行從 index 匯入,就讓每一頁多背了 gzip 249 KB 別頁的文字。
NEXUPIRA 做的是小公司用的 AI 同事:客人在 LINE 問價時先擬好回覆和報價單,也幫忙整理收支、排行程。這篇提到的範本都可以在報價單範本頁免費下載。