こんにちは、ぼくです。Excelの巨大な一覧表を前に、「あのデータどこだっけ…」「この条件と、あの条件に合うものだけ見たい…」と、フィルターやCtrl+Fと格闘する時間は、もう終わりにしませんか?なぜなら、Ctrl+Fって1キーワードしか検索できないですよね。Excel on the webで使える「Office Scripts」は、そんな日常のイライラを解消する魔法のツールです。今回は、ぼくが練り上げた「複数キーワード絞り込みビュー」スクリプトを、開発の裏話も交えながら全文公開します。これは、アクティブなシートを安全に複製し、複数キーワードのAND検索(A かつ B)や除外検索(C を含まない)で、一致した行だけを瞬時に表示するスクリプトです。つまるところGoogle検索みたいに複数キーワードにヒットした行だけを別シートにピックアップするんです。元しかもハイフン”-”をキーワードの先頭につけるとそのキーワードを除外できます。
データは絶対に壊さないので、誰でも安全に、何度でも試せますよ!

巨大なExcelシート、検索で消耗していませんか?
日々の業務で向き合うExcelシートは、時に数万行、数十列にも及びます。問い合わせ管理、案件台帳、製品マスタ…。これらの中から特定の情報を見つけ出すのは、まさに宝探しです。標準機能も優秀ですが、かゆいところに手が届かない、そんなもどかしさを感じたことはありませんか?
フィルター(オートフィルター)の限界と「行横断検索」の夢
Excelの「オートフィルター」は非常に強力な機能です。ぼくも毎日使っています。「A列が『東京』のデータ」や、「A列が『東京』で、かつB列が『完了』のデータ」を絞り込むのは得意ですよね。
でも、このフィルターの思考法は「列ごと(Column-based)」なんです。
- シナリオ1: 「ステータス列が『至急』のものを探す」 → 簡単。
- シナリオ2: 「担当者列が『田中』で、かつステータス列が『至急』のものを探す」 → 簡単。
- シナリオ3(限界): 「『至急』という単語が、A列からZ列の どこか に含まれている行を探す」 → ほぼ不可能。
- シナリオ4(完全な限界): 「『AI』と『予算』という単語が 両方 含まれていて、 かつ 『Phase1』という単語が 含まれていない 行を探す」 → 絶対に不可能。
なぜなら、「AI」は備考欄にあるかもしれないし、「予算」はチャットのコピペ列にあるかもしれないからです。オートフィルターでは、こんな「行を横断した(Row-transcendent)」検索はできません。
ぼくたちが本当に欲しかったのは、「この行(A列~CV列まで全部(100列です))を1つの塊とみなして、そこにAとBがあり、Cがないか?」を判定する仕組みです。今回のスクリプトは、まさにその「行横断検索」の夢を実現するために作られました。
“シート内検索” (Ctrl+F) のもどかしさと「抽出」の必要性
「いやいや、Ctrl+F (シート内検索) があるじゃないか」という声も聞こえてきそうです。もちろん、Ctrl+Fは高速で便利です。でも、その目的は「検索(Find)」、つまりナビゲーション(Navigation)です。
Ctrl+Fの弱点は、それが「絞り込み(Filter)」や「抽出(Extract)」ではない点にあります。
例えば、1万行のシートで「AI」と検索して50件ヒットしたとしましょう。ぼくたちは「次へ」ボタンを50回クリックして、その都度コンテキストを把握し、頭の中でリストを作らなければいけません。ぼくたちが本当に欲しいのは、「その50件の行 だけ が表示された、クリーンな一覧表」ではありませんか?
さらに、Ctrl+Fでは「A かつ B」や「A かつ B を除く C」といった複雑な条件は扱えません。
今回のスクリプトは、Ctrl+Fの「見つける」機能と、オートフィルターの「絞り込む」機能の、“いいとこ取り”を目指しました。キーワードに一致しない行を setRowHidden(true) で非表示にすることで、「検索結果だけが抽出されたビュー」を擬似的に作り出します。これは「検索」ではなく、まさに「絞り込みビューの自動生成」なんです。
Office Scriptsで解決する「非破壊的な検索ビュー」
そこで登場するのが「Office Scripts」です。これは、Excel on the web (ブラウザ版のExcel) で動作するTypeScriptベースの自動化機能です。VBA(マクロ)と似ていますが、クラウドネイティブで、共同編集中のファイルでも安全に使えるのが大きな強みです。
参考情報:Office Scripts のドキュメント
Office Scripts の詳細については、Microsoftの公式ドキュメント(https://docs.microsoft.com/ja-jp/office/dev/scripts/) を参照すると、その可能性の広がりがよくわかります。
このスクリプトを設計する上で、ぼくが最も重視したのが「非破壊的(Non-destructive)」であることです。元データを1ビットたりとも変更してはいけません。
このスクリプトは、2つの安全機構によってこれを実現しています。
- 安全機構1:シートの完全複製スクリプトが最初に実行するのは let newSheet = sheet.copy(); です。つまり、作業はすべて「コピーされたシート(OUT_1、OUT_2…)」上で行われます。あなたの「MasterData」や「問い合わせ一覧」シートは、一切触れられません。
- 安全機構2:行の「削除」ではなく「非表示」キーワードに一致しなかった行を delete() することは絶対にしません。 newSheet.getRange(…).setRowHidden(true); を使って、「非表示」にしているだけです。なぜこれが良いのでしょうか? もし結果を見て「あれ、この前後の行も確認したいな」と思ったら、手動で「再表示」すればコンテキストを確認できるからです。何より、不要になったら OUT_1 シートごと削除して、また新しい条件で検索をやり直せばいいだけ。
この「コピーして非表示にする(Copy -> Hide)」というワークフローこそ、巨大なデータを安全かつ繰り返し分析するための、最も優れたアプローチだとぼくは考えています。
ぼくが作った「複数キーワード絞り込みビュー」スクリプト

このスクリプトは、ぼくが「AI(ChatGPT5)と壁打ち」しながら、いくつかの「これじゃ実用的じゃないな」というポイントを一つずつ潰して磨き上げたものです。単に動くだけでなく、「速く」「賢く」「安全に」動くことを目指しました。
強力な絞り込み:AND検索と除外検索(半角・全角対応)
このスクリプトの心臓部は、G2セルに入力されたキーワードを解析するロジックです。
まず、let searchWords: string[] = … .split(/\s+/).map(…) という部分。
ここで使っている split(/\s+/) がミソです。\s+ というのは「1つ以上の連続する空白文字」という意味の正規表現です。
この \s には、なんと「半角スペース」と「全角スペース(U+3000)」の両方が含まれます。
これはユーザー体験(UX)において非常に重要です。ぼくたちは、キーワードを区切るとき、意識せずに全角スペースを使ったり、半角と全角が混在したりしがちですよね。このスクリプトは、そのどちらでも(東京 会議 でも 東京 会議 でも)正しく「”東京”と”会議”」の2語として認識してくれます。
次に、その単語リストを「含むべき単語(includeWords)」と「除外すべき単語(excludeWords)」に分類します。
let excludeWords = searchWords.filter((word) => word.startsWith("-"))...let includeWords = searchWords.filter((word) => !word.startsWith("-"))...
ルールは単純。「先頭にハイフン (-) が付いていたら除外ワード」です。
最終的な判定は、every というJavaScriptのメソッドで行います。
TypeScript
(includeWords.length === 0 || includeWords.every((word) => rowText.includes(word))) &&
(excludeWords.length === 0 || excludeWords.every((word) => !rowText.includes(word)))
これを日本語に翻訳すると、こうなります。
「(includeWordsが0個、または、includeWordsの すべて が行テキストに含まれている) かつ (excludeWordsが0個、または、excludeWordsの すべて が行テキストに含まれて いない)」
具体的な入力例で見てみましょう。
| G2セルの入力 | includeWords | excludeWords | 判定 |
納期 遅延 -クレーム | ["納期", "遅延"] | ["クレーム"] | 「納期」と「遅延」を両方含み、かつ「クレーム」を含まない行 |
見積 提出 -失注 | ["見積", "提出"] | ["失注"] | 「見積」と「提出」を両方含み、かつ「失注」を含まない行 |
-完了 | [] (0個) | ["完了"] | 「完了」という文字を含まない行(includeが0個なので第1条件は自動でTRUE) |
(空欄) | [] (0個) | [] (0個) | すべての行(両方の条件が自動でTRUE) |
これにより、オートフィルターでは絶対に不可能だった、柔軟なAND検索と除外検索が実現するわけです。
最大のキモ:「行末最適化」による高速処理(100列対応)
このスクリプトが「v1」から進化した最大のポイントが、この「行末最適化」です。ぼくがAIとの壁打ちで最もこだわった部分です。
最初の単純な実装(v1)は、こうでした。
let dataRange = newSheet.getRange(“A4:CV${lastRow}”); で100列分のデータを取得し、行ごとに let rowText: string = values[i].join(” “); で100列すべてを連結していました。
これは動きます。でも、とてつもなく無駄が多い。
考えてみてください。10,000行のデータのうち、ほとんどの行はA列からE列(5列)くらいまでしか入力されておらず、F列からCV列(95列)は空欄だったとします。
v1の実装では、この95個の空欄セルを、10,000行すべてで律儀に join(連結)しようとします。10,000行 × 95列 = 950,000回もの無駄な連結処理が発生するわけです。これでは実用的な速度は出ません。
そこで生まれたのが、「各行ごとに、実際にデータが入っている最後の列(右端)を見つけ、そこまでしか連結しない」というアイデアです。
それを実現するのが、findLastNonEmptyIndexFromRight(row, MAX_COLS) というヘルパー関数です。
TypeScript
// 行配列の後方から最大N列の範囲で右端の非空セルindexを返す(なければ-1)
function findLastNonEmptyIndexFromRight(row: (string | number | boolean)[], maxColsFromLeft: number): number {
const rightMost = Math.min(row.length, maxColsFromLeft) - 1; // 0-based
for (let idx = rightMost; idx >= 0; idx--) { // ★後ろからスキャン
const cell = row[idx];
if (cell === null || cell === undefined) continue;
if (typeof cell === "string") {
if (cell.trim() === "") continue; // 空白のみのセルは無視
return idx; // ★見つけたら即座にその位置(index)を返す
}
// number / boolean は値があれば非空とみなす
return idx;
}
return -1; // 該当なし
}
この関数は、100列ぶんの行データ(配列)を受け取ると、idx = 99 (CV列) から idx = 0 (A列) に向かって後ろからスキャンします。
そして、cell.trim() === “” で「空白だけのセル」も無視し、最初に見つけた「意味のあるデータ(数値、文字列、真偽値)」の列番号(index)を返します。
例えば、A列(0)~C列(2)までデータがあり、D列(3)~CV列(99)が空だった場合。
スキャンは99, 98, 97…と進み、idx = 2 (C列) でデータを見つけ、2 を返します。
そして、メインのループ側では…
for (let c = 0; c <= lastIdx; c++) { … }
…となっており、c = 0 (A列) から c = 2 (C列) まで だけ を連結します。
これにより、あの950,000回もの無駄な処理が、ほぼゼロになります。これが、このスクリプトが「A~CV (100列) 対応」と謳いながらも、実用的な速度で動作する秘密(行末最適化)です。
堅牢性の塊:正規化と「見えないゴミ」の掃除
最後に、プロが使うツールとして「堅牢性(Robustness)」、つまり「ちょっとしたことでエラーになったり、期待通りに動かなかったりしない」ことを追求しました。
1. 正規化(Normalization)
const normalizeText = (text: string): string => { return text.normalize(“NFKC”).toLowerCase(); };
これは、検索における「表記ゆれ」を吸収するための、最強のおまじないです。
text.normalize(“NFKC”) は、JavaScriptの標準機能で、「互換形式」で文字を正規化します。
これが何をしてくれるかというと…
"Apple"(全角英数) →"Apple"(半角英数)"アップル"(半角カナ) →"アップル"(全角カナ)"㈱"(環境依存文字) →"(株)"
…といった変換を一括で行います。
参考情報:String.prototype.normalize() – MDN
normalize メソッドの技術的な詳細は、MDNのドキュメント(https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/normalize) が最も詳しいです。NFKCが何をしているか、その強力さがわかります。
その上で、.toLowerCase() で大文字を小文字に統一します(例: "Apple" → "apple")。
この結果、何が起きるか。
G2セルに入力する検索語も、検索対象となる行データも、すべて この normalizeText 関数を通されます。
これにより、ユーザーがG2に apple と入力すれば、データが Apple でも APPLE でも Apple (全角) でも、すべて apple に変換されて比較されるため、正しくヒットします。
同様に、ユーザーが イベント (半角カナ) と入力しても、データ側の イベント (全角カナ) に正しくヒットします。
この「表記ゆれの吸収」こそが、検索ツールの使いやすさを決定づける、とぼくは思っています。
2. 「見えないゴミ」の掃除
let cleanedInput = rawInput.replace(/[\u200B-\u200D\uFEFF]/g, “”);
「Webページからコピペしたキーワードで検索したら、なぜかヒットしない」という経験はありませんか?
それは、目に見えない「ゴミ」の文字が紛れ込んでいるせいかもしれません。
代表的なのが \u200B (ゼロ幅スペース) や \uFEFF (バイトオーダーマーク/BOM) です。これらは目には見えませんが、split や includes のロジックを破壊します。
このスクリプトは、G2セルから読み取ったキーワードを split する 前 に、この replace 処理で「見えないゴミ」を強制的に大掃除します。
この1行が、原因不明の「検索がヒットしない」イライラから、あなたを救ってくれます。
いますぐ使える!導入手順と設定カスタマイズ
お待たせしました。では、このスクリプトをあなたのExcelに導入する手順です。プログラミングの知識は不要です。コピー&ペーストだけで終わります。
Office Scriptsへのコード貼り付け方
- まず、Excel on the web (ブラウザ版のExcel) で、対象のファイルを開きます。
- 上部リボンメニューから「自動化」タブをクリックします。
- 「新しいスクリプト」をクリックします。画面右側に「スクリプト エディター」という領域が開きます。
- 最初から
function main(workbook: ExcelScript.Workbook) { ... }というようなサンプルコードが書かれていますが、これを全部選択して削除(Delete)してください。 - この記事の最後にある「完成版コード」を、
function mainから};まで、すべてコピーします。 - 空になったスクリプト エディターに、コピーしたコードを貼り付けます。
- エディターの上部にある「スクリプト名」をクリックし、
複数キーワード絞り込みビューのように、わかりやすい名前に変更します。 - 「保存」ボタンをクリックします。
これで準備完了です! このスクリプトはあなたのMicrosoftアカウントに保存されるため、これ以降、どのExcelファイル(ブラウザ版)を開いても「自動化」タブから呼び出せるようになります。
スクリプトの使い方:G2セルがあなたの検索窓
使い方は、G2セルを「検索窓」として使うだけです。
- 検索したいデータが入力されているシート(例: “問い合わせ一覧”)を開きます。
- そのシートの G2セル をクリックします。
- 検索したいキーワードを、スペース区切りで入力します。(半角・全角スペースどちらでもOK)
- 例1:
納期 遅延 -クレーム(納期 と 遅延 を含み、クレーム を含まない) - 例2:
設計 仕様 -未確定
- 例1:
- 「自動化」タブをクリックし、先ほど保存した「複数キーワード絞り込みビュー」スクリプトの「▷ 実行」ボタンを押します。
- G1セルに「🔍 処理中です…少々お待ちください」と一瞬表示されます。
- 処理が終わると、
OUT_1という名前の新しいシートが自動で作成され、そのシートがアクティブになります。 OUT_1シートには、検索条件に一致しなかった行が 非表示 になった状態の結果が表示されています。- 新しくできたシートの G1セル には
ヒット: 25 件のように、一致した件数が表示されます。 - J1セル には
検索日: 2025/10/23 10:12:20のように、検索を実行した日時が記録されます。
もし、元のシートに戻ってG2セルの内容を変え、もう一度スクリプトを実行すると、今度は OUT_2 というシートが作られます。OUT_1 が上書きされることはないので、安心して色々な条件で「検索ビュー」を量産できます。
環境に合わせたカスタマイズ(定数ブロックの解説)
このスクリプトは、あなたのExcel環境に合わせて、コードの先頭部分を書き換えるだけで簡単にカスタマイズできるように設計されています。(これが、AIとの壁打ちで「汎用的にして!」とお願いした結果です)
コードの先頭にある // 設定 ブロックを見てください。
| 定数名 | 既定値 | 説明 |
OUTPUT_PREFIX | "OUT" | 生成される結果シート名の接頭辞です。「OUT_1」「OUT_2」となります。これを "Result" に変えれば「Result_1」になります。 |
META_SHEET_NAME | "Meta" | 検索後にG1とG2セルをクリアする「親」シートの名前です。もしG2をダッシュボードシート(例: “Dashboard”)に置いているなら、ここを "Dashboard" に設定すると、検索後にG2のキーワードが自動で消去され便利です。 |
START_ROW | 4 | 検索を開始する行番号です。4 は、1〜3行目が見出し行であることを想定しています。もし見出しが1行だけでデータが2行目から始まるなら、2 に変更してください。 |
MAX_COLS | 100 | 検索対象の最大列数です(100列目 = CV列)。 |
END_COL_LETTER | "CV" | 検索対象の最終列のアルファベットです。MAX_COLSと必ず連動させてください。 |
STATUS_CELL | "G1" | 処理中のステータスを表示するセルです。 |
QUERY_CELL | "G2" | <b>最重要</b>:検索キーワードを入力するセルです。G2が不便なら "F1" など自由に変更できます。 |
STAMP_CELL | "J1" | 検索日時(タイムスタンプ)を結果シートに出力するセルです。 |
一番重要なのは QUERY_CELL と START_ROW ですね。
G2セルが他の用途で埋まっている場合は、QUERY_CELL = “Z1” のように、絶対に誰も使わないセルを検索窓に指定できます。
あなたの表の見出しが1行だけなら、START_ROW = 2 に変更してください。
もし「100列(CV列)より右側も検索したい!」という場合は、MAX_COLS と END_COL_LETTER の 両方 を変更する必要があります。
例えば200列(GX列)まで検索したいなら、
const MAX_COLS = 200;
const END_COL_LETTER = “GX”;
と書き換えて保存すればOKです。(GXが200列目だということを調べるには、Excelで =ADDRESS(1, 200, 4) という数式を打つと GX1 と教えてくれますよ)
【全コード】複数キーワード絞り込みビュー(コピー&ペーストOK)

これが、今回共有するスクリプトの完成版です。Office Scriptsの可能性と、ちょっとした工夫でExcel業務がどれだけ快適になるかを感じていただけたら嬉しいです。
このスクリプトでできること(機能おさらい)
このスクリプトが提供する「価値」を、もう一度おさらいします。
- 1. 元データを絶対に壊さない(非破壊・シート複製)何よりも安全性を優先。sheet.copy() で複製し、setRowHidden(true) で非表示にするだけ。あなたのマスターデータは100%安全です。
- 2. 複雑な条件で「行ごと」検索できる(AND / 除外)オートフィルターやCtrl+Fでは不可能な、「A かつ B、ただし C を除く」といった「行横断検索」を、G2セルへのキーワード入力だけで実現します。
- 3. 入力が雑でも賢く動く(半角/全角/正規化/ゴミ掃除)split(/\s+/)(全角/半角スペース対応)、normalize(“NFKC”).toLowerCase()(表記ゆれ吸収)、replace(…)(見えないゴミ掃除)。利用者が何も気にしなくても「期待通りに」動くよう、堅牢性にこだわりました。
- 4. 大量の列があっても「賢く」処理(行末最適化)100列(A~CV)対応ですが、データがC列までしか入っていない行は、C列までしか検索しません。findLastNonEmptyIndexFromRight による行末最適化で、無駄な処理を徹底的に排除しました。
- 5. 検索結果が「証跡」として残る(タイムスタンプ / 件数)結果シート(OUT_x)には、ヒット: 25 件 と 検索日: … が自動で記録されます。これは単なるフィルターではなく、いつ、どんな条件で、何件見つかったかを示す「簡易レポート」として機能します。
完成版コード (TypeScript)
お待たせしました。これが「複数キーワード絞り込みビュー」スクリプトの全コードです。以下のfunction mainから最後の};まで、すべてコピーしてOffice Scriptsエディタに貼り付けてください。
TypeScript
function main(workbook: ExcelScript.Workbook) {
// 設定
const OUTPUT_PREFIX = "OUT";
const META_SHEET_NAME = "Meta";
const START_ROW = 4;
const MAX_COLS = 100; // A(1)~CV(100)
const END_COL_LETTER = "CV";
const STATUS_CELL = "G1";
const QUERY_CELL = "G2";
const STAMP_CELL = "J1";
let sheet = workbook.getActiveWorksheet();
// 検索中のお知らせ(文言を変更)
sheet.getRange(STATUS_CELL).setValue("🔍 処理中です…少々お待ちください");
// 既存の「OUT_x」のシートがあるかチェックし、次の番号を決定
let existingSheets = workbook.getWorkSHeets();
let sheetNumber = 1;
let newSheetName = `${OUTPUT_PREFIX}_${sheetNumber}`;
while (existingSheets.some((ws) => ws.getName() === newSheetName)) {
sheetNumber++;
newSheetName = `${OUTPUT_PREFIX}_${sheetNumber}`;
}
// シートを複製
let newSheet = sheet.copy();
newSheet.setName(newSheetName);
// 検索ワードを取得(スペース区切り)
let rawInput = String(sheet.getRange(QUERY_CELL).getValue() ?? "");
// ゼロ幅空白などを除去してから分割
let cleanedInput = rawInput.replace(/[\u200B-\u200D\uFEFF]/g, "");
let searchWords: string[] = cleanedInput
? cleanedInput.trim().split(/\s+/).map((word) => normalizeText(word))
: [];
// 除外ワードと通常ワードを分類
let excludeWords = searchWords.filter((word) => word.startsWith("-")).map((word) => word.substring(1));
let includeWords = searchWords.filter((word) => !word.startsWith("-"));
// C列の最終行を取得
let lastRow = getLastRowInColumn(newSheet, "C");
// 4行目から最終行までのデータを一括取得(A~CV列)
let dataRange = newSheet.getRange(`A${START_ROW}:${END_COL_LETTER}${lastRow}`);
let values = dataRange.getValues() as (string | number | boolean)[][];
// 検索に一致した行数のカウント用
let visibleRowCount = 0;
// 行ごとに右端の非空セルを後方から特定し、その列までを連結して判定
for (let i = 0; i < values.length; i++) {
let rowIndex = i + START_ROW; // 実際のExcel行番号
let row = values[i];
// 右端の非空セルを特定(最大100列の範囲で後方走査)
let lastIdx = findLastNonEmptyIndexFromRight(row, MAX_COLS);
// すべて空なら空文字列
let rowText: string = "";
if (lastIdx >= 0) {
const acc: string[] = [];
for (let c = 0; c <= lastIdx; c++) {
const cell = row[c];
if (cell === null || cell === undefined) continue;
if (typeof cell === "string" && cell.trim() === "") continue;
acc.push(normalizeText(String(cell)));
}
rowText = acc.join(" ");
}
// ヒット判定(AND条件+除外)
let shouldBeVisible =
(includeWords.length === 0 || includeWords.every((word) => rowText.includes(word))) &&
(excludeWords.length === 0 || excludeWords.every((word) => !rowText.includes(word)));
newSheet.getRange(`A${rowIndex}:${END_COL_LETTER}${rowIndex}`).setRowHidden(!shouldBeVisible);
if (shouldBeVisible) {
visibleRowCount++;
}
}
// 件数をG1に出力
newSheet.getRange(STATUS_CELL).setValue(`ヒット: ${visibleRowCount} 件`);
// 検索中のお知らせの削除(存在する場合のみ)
const meta = workbook.getWorksheets().find(ws => ws.getName() === META_SHEET_NAME);
if (meta) {
meta.getRange(STATUS_CELL).setValue("");
meta.getRange(QUERY_CELL).setValue("");
}
// 最後にフィルタ済みのシートをアクティブにする
newSheet.activate();
// タイムスタンプをJ1に出力
const timestamp = new Date().toLocaleString();
newSheet.getRange(STAMP_CELL).setValue(`検索日: ${timestamp}`);
}
function getLastRowInColumn(sheet: ExcelScript.Worksheet, columnLetter: string): number {
let columnRange = sheet.getRange(`${columnLetter}1:${columnLetter}10000`);
let values = columnRange.getValues() as (string | number | boolean)[][];
if (!values || values.length === 0) {
return 4;
}
for (let row = values.length - 1; row >= 3; row--) {
if (values[row][0] !== "" && values[row][0] !== null) {
return row + 1;
}
}
return 4;
}
// 行配列の後方から最大N列の範囲で右端の非空セルindexを返す(なければ-1)
function findLastNonEmptyIndexFromRight(row: (string | number | boolean)[], maxColsFromLeft: number): number {
const rightMost = Math.min(row.length, maxColsFromLeft) - 1; // 0-based
for (let idx = rightMost; idx >= 0; idx--) {
const cell = row[idx];
if (cell === null || cell === undefined) continue;
if (typeof cell === "string") {
if (cell.trim() === "") continue;
return idx;
}
// number / boolean は値があれば非空とみなす
return idx;
}
return -1;
}
const normalizeText = (text: string): string => {
return text.normalize("NFKC").toLowerCase();
};
なぜこの形になったのか(開発のちょっとした裏話)
最後に、このスクリプトが完成するまでの、ちょっとした裏話を。
実はこのスクリプト、最初からこの形だったわけじゃないんです。開発中(…というか、AIと壁打ち問答してる時ですが)に、いくつかの「あ、これじゃダメだ」という瞬間に直面しました。
第1の壁:全部の列を結合する「v1」の致命的な遅さ
最初は単純に、getRange(“A4:CV${lastRow}”)で100列分ぜんぶ取得して、行ごとにjoin(” “)で連結していました。これは動きます。でも、ものすごく遅かった。
10,000行のデータで、実際に入力があるのはA~E列だけなのに、F~CV列の95個の空セルを毎回律儀に連結するのは、あまりにも無駄だと気づいたんです。(これがAIへの最初のダメ出しでした)
そこから生まれたのが「後ろからスキャンして、最後のデータ位置を見つける」というfindLastNonEmptyIndexFromRightのアイデアでした。これが一番のブレークスルーでしたね。
第2の壁:検索がヒットしない「見えない敵」
次に、「あれ、G2にWebからコピペした単語で検索してもヒットしないぞ?」という壁にぶつかりました。
これが、いわゆる「ゼロ幅スペース(\u200B)」の罠でした。Webからコピペすると、目に見えないゴミが紛れ込むんです。AIが「こういうのも掃除した方がいいですよ」と提案してくれた replace(/[\u200B-\u200D\uFEFF]/g, “”) の1行は、そのイライラを解消するためのお守りみたいなものです。
第3の壁:共有しやすさ(汎用性)の確保
そして最後の壁が「これをどうやってブログで共有するか?」でした。
ぼくの最初のコードは、当然ながら自分のシート名(”共有”とか”検索結果”とか)がハードコードされていました。でも、これをそのまま公開しても、他の人の環境では動きません。
そこで、「AIに『秘密保護の観点で、全部の固有名詞を汎用的なものに書き換えて』」とお願いしました。…が、これが大変でした。「// 旧:’共有’」みたいなコメントをデカデカと残してきたり、逆に「コメント全部消せってことか!」と極端に走ってコードが読みづらくなったり…。
最終的に「コメントの体裁は元に戻しつつ、名前だけ汎用的にして!」と数回やり取りして、ようやくこの「設定ブロック」の形に落ち着きました。OUTPUT_PREFIXやMETA_SHEET_NAMEのように、スクリプトの冒頭で簡単に設定できる「定数」として切り出すことで、誰でも自分の環境に合わせて使える、汎用的なスクリプトになりました。
この「ちょっとした改善」の積み重ねが、この完成版コードです。
Excelの定型作業は、Office Scriptsでどんどん自動化できます。このスクリプトが、あなたの「検索のイライラ」を解消する、最初の一歩になれば嬉しいです。

コメント