← すべてのニュース

エンジニアリング 16 分で読めます

300 のテンプレートページを誘導ページにしないために:100 業種 × 3 言語の作り方と計測

サイトには 100 業種の見積書テンプレートが英語・中国語・日本語であり、300 ページと 600 の Excel・Word ファイルをひとつの型付きデータから生成しています。中身の薄い、似たようなページにしないために何をしたか、どう計測したか、ページの JavaScript が gzip 221 KB から 123 KB に減った理由、iPhone のプレビューで金額が空欄になっていた問題をまとめます。

300 のテンプレートページを誘導ページにしないために:100 業種 × 3 言語の作り方と計測

私たちのサイトには、エアコン取付から弁当・仕出しまで 100 業種の見積書テンプレートがあり、英語・繁体字中国語・日本語でそれぞれ 1 ページずつ用意しています。合計 300 ページで、どのページでも同じ見積書を Excel ファイルと Word ファイルで無料ダウンロードできます。ダウンロードは 600 ファイル。すべてひとつの型付きデータから生成しています。

「100 のキーワード向けにページを自動生成する」は、そのまま誘導ページ(doorway pages)の説明にもなります。Google のスパムに関するポリシーは誘導ページをはっきり挙げています。似た検索語句で上位を取るために作られ、互いによく似ていて、見た目ほど役に立たないページです。そこでコードを書く前にひとつ規則を決めました。ページを置くのは、その業種・その市場について、ほかのページが言っていないことを言えるときだけ。この記事は、300 ページの規模でこの規則をどう守り、どう確かめたかの記録です。数字はすべて実測値で、コードは現在の main のもの(抜粋)です。

全体の形

図 1 · ひとつのデータから 3 つの出力
ページとダウンロードは src/lib/quote-templates の同じファイルを読み、ツールのプリセットはアプリ側にあって slug の一覧を共有している(どちらも 100)。件数:sitemap 300 件、見積ファイル 600 個(ほかに収支表 2 個)。

同じ TypeScript ファイル群から 3 つのものを出力します。ページ、ダウンロード用ファイル、そしてアプリ側にある無料見積ツールのプリセット(/tools/quote?preset=<slug>)で、プリセットは同じ slug と同じ項目を使います。ページの枠(見出し、ダウンロード欄、行動ボタン)とタイトルの型を除けば、テンプレートページの文はすべてその業種自身のデータから来ています。

1 業種 1 レコード、3 回書く

業種はそれぞれ QuoteTemplate です。肝心なのは、en・zh・ja が別々に書いた 3 つの QtCopy であって、原文 1 つと翻訳 2 つではないことです。

// 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 があります。項目の行は 3 言語でそろえてあるので、同じ行がそのまま見積ツールのプリセットになります。

今回は 51 業種から 100 業種へ一度に増やしました。何を作らないかも、何を作るかと同じくらい考えて決めています。外したのは次の業種です。

  • 既存のページと重なる業種
  • 資格が必要、または規制のある業務で、見積書が専門的な助言のように読めてしまうもの
  • 3 つの市場のうち少なくとも 2 つで検索需要が少ない業種
  • 日本にしかなく、英語や中国語で自然なページにならない業種

「似すぎ」をどう測るか

「どのページも違う」と言うのは簡単ですが、300 ページを読んで確かめるのは大変です。変更のたびに再実行できる数字がほしかったのです。

方法は単純です。ローカルの本番ビルドから各ページの描画済み <main> のテキストを取り出し、空白をすべて除き、8 文字ずつ重なり合う断片(shingle)に切って、2 ページの断片集合の Jaccard 類似度を計算します。共通の断片の数を、重複を除いたすべての断片の数で割った値です。文字単位なので形態素解析は要らず、英語・中国語・日本語に同じコードが使えます。各ページについて最近傍、つまり同じ言語で最も似ている別のテンプレートページを取ります。

図 2 · 各ページは一番似ているページとどれだけ似ているか
点は 1 ページ。同じ言語のテンプレートページのうち最も似ているページとの、8 文字 shingle の Jaccard 類似度(0 は共通部分なし、1 は同一)。「ページ全体」はローカルの本番ビルドで描画した <main> のテキストから空白を除いたもの、「業種の本文のみ」は導入文・項目・書くべきこと・記載漏れ・支払条件・FAQ。各組の 1 行目で左に離れた点は、長い解説記事を持つ工事のページ。線は平均。

目立つ点は 3 つあります。

新しいページは既存のページより似ていません。 最近傍との類似度の平均は、英語で新規 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)で測ると、2 ページの平均は言語によって 0.001〜0.009 で、どの 2 業種の組み合わせでも最大 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 の数式セルがあり、計算済みの値がないセルが 1 つでもあればテストが失敗します。

日本語のファイルにはもう一手間あります。消費税は 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 円になり、注意深いお客様なら差し戻す見積書になっていました。

ほかに 2 つ、各業種の支払条件を読んで決めたことがあります。各ファイルの前金の割合は、ページの支払条件で前金の言葉の近くに書かれた割合から始まります。ただし 19 業種は、前金を取るのが一部の案件(部品の発注、初回の取引、大きな案件)だけなので、downloadDepositPercent で 0% から始め、どのファイルにも割合は変更できると書いています。Word ファイルの列幅はパーセントではなく twip の固定値です。Google ドキュメントに取り込むとパーセント指定の列幅がつぶれるためです。

一度生成し、バイト単位で同じに

602 ファイル(見積書 600 と収支表 2、計 7.15 MB)は、pnpm build ではなくコミット時にスクリプトで生成し、リポジトリに入れています。これで表計算と Word のライブラリが本番のイメージに入らず、生成に失敗したファイルへのリンクもあり得ません。

生成したバイナリをコミットするなら、再生成しても何も変わらないことが前提です。zip はファイルの日時を記録し、Word のライブラリはハイパーリンクにランダムな relationship id を付けます。そこで全ファイルを固定の日時と連番の id で zip し直しています。

/** 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 業種を足したとき、意図しないものまで足すところでした。テンプレートページはいくつかの UI 文字列とリンク用の関数 2 つを、モジュールの index から import していました。

// 変更前
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 は全業種の配列も export しているため、バンドラは全業種・3 言語分の文章をページのクライアント JavaScript に入れていました。ページ自身の業種の本文はサーバーから受け取っているにもかかわらずです。51 業種の時点ですでに gzip で 98 KB、100 業種なら 249 KB になるところでした。

図 3 · テンプレートページ 1 枚の JavaScript
英語ページ /templates/quote/auto-repair、ローカルの本番ビルド。ハイドレーション後にネットワークが落ち着くまでに Chromium が読み込んだすべての .js を、ファイルごとに gzip -9 で計測。中国語・日本語のページはそれぞれの辞書も読み込み、非圧縮で 695.7 → 427.3 KB、737.6 → 469.0 KB。

3 つの状態をすべてローカルの本番ビルドで計測しました。拡張前のページ、100 業種で旧 import のままの同じページ(計測用にだけビルド)、そして実際にリリースした版です。英語ページは gzip 221.3 KB から 123.5 KB になり、業種の本文は 1 文字も含みません。同じ理由で、一覧ページに表示する業種数(100)は QUOTE_TEMPLATES.length ではなく定数にして、テストでデータ件数と照合しています。そうしないと、その数を表示するサイトの辞書ファイルまで全業種を import してしまいます。

タイトル、hreflang、サイトマップ

タイトルは市場ごとの検索のされ方に合わせ、文字数の上限を決めています。日本語の検索結果はタイトルを 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 と、3 言語版への hreflang(en、zh-Hant、ja、英語を x-default)があり、サイトマップにも同じ alternates と業種ごとの最終更新日が入っています。全部で 300 件です。本番のルーティングには制約があります。私たちのエッジは特定のパスのプレフィックスだけをこのサイトに振り分け、それ以外はアプリに送ります。英語ページには言語のプレフィックスがないので、英語の URL はすべてそのプレフィックスの下、ここでは /templates に置く必要があります。そうでない英語 URL を足すとテストが失敗します。

100 ページあると内部リンクも大事です。各ページは関連する 4 業種にリンクします。まず自分の related リスト、残りは同じカテゴリでその業種の次に並ぶ業種から、末尾まで行ったら先頭に戻って埋めます。以前は同じカテゴリのどのページも、先頭の 4 業種にリンクしていました。どの業種もほかの業種ページから少なくとも 1 回リンクされていることをテストで確認しています。

今のところ分かっていること

まだ多くはありません。正直にそう書いておきます。今回の拡張の前に日本の検索結果を抜き取りで確認したところ、いくつかのニッチな業種ページがすぐに上位に入っていました。OEM 見積書 テンプレートで 1 位、看板で 4 位、自動車整備で 4 位です。「見積書テンプレート」という大きな語句そのものは大手の会計・オフィスソフトの領域で、だからこそ個別の業種を広くカバーする方針にしました。ただしこれは、もとの 51 ページのうち数ページについての初期の観察です。新しい 49 業種はこの記事を書いた当日に公開したばかりで、検索のデータはまだありません。

限界

  • 価格は例示です。 どのページにもどのファイルにも参考価格と明記しています。私たちが書いた幅であり、市場調査ではありません。
  • デスクトップ版の Excel や Word ではファイルを開いていません。 LibreOffice と macOS の Quick Look で確認し、すべてのブックをテストで読み込んでいます。
  • 類似度は私たちの代理指標で、Google の指標ではありません。 同じ型から作ったページは捕まえられますが、ページが役に立つかどうかは分かりません。
  • ダウンロードに構造化データは付けていません。 ダウンロード可能な文書向けのリッチリザルトは Google にないので、無視されるマークアップは足していません。

データからページを生成するなら

  1. 生成スクリプトを書く前に、ページを置いてよい条件を書く。 条件は、そのページにしかない内容で決める。
  2. データは文字列ごとではなく市場ごとに持つ。 2 つの言語が同じ一文を共有できるなら、同じページも共有すべきでないことが多い。
  3. 類似度は、人が見るテキスト全体と、本文だけの両方で測る。 その差が枠の大きさ。
  4. 生成したファイルは、使う人が開く場所で開く。 スマートフォンのプレビューは数式を計算しない。
  5. ページが何を import しているか確かめる。 index からの import 1 行で、どのページにも gzip 249 KB 分のほかのページの文章が載っていた。

NEXUPIRA は小さな会社のための AI 同僚をつくっています。LINE で価格を聞かれたら返信と見積書の下書きを用意し、経費の整理や予定の調整も手伝います。この記事で紹介したテンプレートは見積書テンプレートのページから無料でダウンロードできます。