0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Manifest V3はeval()禁止。Chrome拡張の検索バーに、依存ゼロで安全な数式・変換パーサーを自作した話

0
Posted at

ブラウザの検索バーに (12 + 3) * 4 と打ち込んだら即座に 60 と出る。10 km to mi と打てば 6.213711922 mi と出る。GoogleやAlfred、Raycastのようなインライン回答を、自作の新しいタブ拡張機能「ZenithTab」の検索バーにも組み込みたいと考えた(Issue #29)。

image.png

Webアプリケーションであれば、eval() を呼ぶか、あるいは巨大な外部パーサーライブラリを npm install すれば数分で実装できる。しかし、Chrome拡張機能の Manifest V3(MV3)環境ではそうはいかない。

MV3 では拡張機能ページの CSP(Content Security Policy)が script-src 'self' に固定され、緩めることができない。eval() や new Function() は呼んだ瞬間に CSP 違反として実行を拒否される(ZenithTab の manifest.json も "extension_pages": "script-src 'self'; object-src 'self';" と明記している)。サンドボックス化した別ページに eval を閉じ込める抜け道は用意されているが、検索バーの1機能のためにそこまでするのは本末転倒だ。

外部サーバーやLLMのAPIに問い合わせる構成にすれば計算自体は可能だが、ネットワーク通信が発生するため数百ミリ秒の遅延が生じる。何より、ユーザーが検索バーに入力した文字列を外部サーバーへ一切送信しないという「完全ローカル・プライバシー最優先」の設計思想を捨てるわけにはいかなかった。

選んだ道は、外部依存ライブラリを一切使わず、ブラウザのメインスレッドで安全かつ数ミリ秒で完結する専用のトークナイザーと再帰下降パーサー(Recursive-Descent Parser)を自作することだった。


検索バーに入ってくる「計算」のリアルな汚さ

教科書通りの数式パーサーを作るだけであれば、数式の構文木を組み立てれば終わる。だが、一般ユーザーが検索バーに入力する文字列は、数学の教科書のように整ってはいない。

  • 1,000 * 2 のように、3桁区切りのカンマが混ざる
  • スマホやOSの変換候補から ×(掛ける)や ÷(割る)、全角マイナス − が入力される
  • 20% of 150 のようなパーセンテージ計算をしたい
  • sqrt(144) や sin(pi / 2) のような関数呼び出し
  • プログラマー特有の 2 ** 8 や 255 to hex、0b1010

これらを柔軟に解釈しつつ、かつ react 19 release date や iphone 15 * 2 のような「普通の検索キーワード」が入力されたときには、絶対に誤爆して不自然なエラーを出してはならない。

「数式やコマンドと確実に判定できるものだけを高速に拾い上げ、それ以外は即座に null を返して通常のWeb検索に譲る」という境界制御が求められる。


ステップ1:ノイズを吸収する字句解析(Tokenizer)

処理の第1段階は、生の入力文字列を意味のある最小単位(トークン)の列へと分解する字句解析だ。

type Token =
  | { type: 'num'; value: number }
  | { type: 'op'; value: string }
  | { type: 'id'; value: string }
  | { type: 'lparen' }
  | { type: 'rparen' };

字句解析器(tokenize 関数)の冒頭で、ユーザーの表記揺れを一気に正規化する。

function tokenize(src: string): Token[] | null {
  const tokens: Token[] = [];

  // 3桁区切りのカンマを除去し、記号をASCII標準へ置換する
  const s = src
    .replace(/(\d),(?=\d{3}(\D|$))/g, '$1')
    .replace(/×/g, '*')
    .replace(/÷/g, '/')
    .replace(/\*\*/g, '^')
    .replace(/−/g, '-');

  let i = 0;
  while (i < s.length) {
    const ch = s[i];

    // 空白はスキップ
    if (/\s/.test(ch)) {
      i++;
      continue;
    }

    // 数値リテラル(小数・指数表記 1e5 を含む)
    if (/[\d.]/.test(ch)) {
      const m = /^(\d+\.?\d*|\.\d+)(e[+-]?\d+)?/i.exec(s.slice(i));
      if (!m) return null;
      tokens.push({ type: 'num', value: parseFloat(m[0]) });
      i += m[0].length;
      continue;
    }

    // 関数名や定数名(sqrt, sin, pi, π など)
    if (/[a-zπ]/i.test(ch)) {
      const m = /^[a-zπ][a-z0-9]*/i.exec(s.slice(i))!;
      tokens.push({ type: 'id', value: m[0].toLowerCase() });
      i += m[0].length;
      continue;
    }

    // 演算子
    if ('+-*/^%'.includes(ch)) {
      tokens.push({ type: 'op', value: ch });
      i++;
      continue;
    }

    // 括弧
    if (ch === '(') {
      tokens.push({ type: 'lparen' });
      i++;
      continue;
    }
    if (ch === ')') {
      tokens.push({ type: 'rparen' });
      i++;
      continue;
    }

    // 未知の記号が含まれていれば数式ではないと判断して即座に中断
    return null;
  }

  return tokens;
}

ここで重要なのが、最後の「未知の記号が含まれていれば即座に null を返す」ガードだ。日本語の検索語句やURLが紛れ込んだ瞬間に字句解析を打ち切るため、無駄な構文解析コストを一切払わずに済む。


ステップ2:演算子の優先順位を表現する再帰下降パーサー

トークンの配列が得られたら、数学の規則(乗除は加減より優先、括弧内は最優先、べき乗はさらに優先)に従って評価する。これを eval なしで行うための定石が再帰下降パーサーだ。

文法規則をそのままメソッドの呼び出し階層に落とし込む。

  • expr(式): +, - で結ばれた項の並び
  • term(項): *, /, % で結ばれた単項の並び
  • unary(単項): +, - の符号がついた要素
  • power(べき乗): atom ('^' unary)? ——右結合で、2^3^2 は 2^(3^2) になる
  • atom(基本要素): 数値、定数、括弧で囲まれた式、または関数呼び出し
class Parser {
  private pos = 0;
  constructor(private tokens: Token[]) {}

  parse(): number | null {
    const value = this.expr();
    // すべてのトークンを綺麗に消費し切っていれば成功
    return this.pos === this.tokens.length ? value : null;
  }

  private peek(): Token | undefined {
    return this.tokens[this.pos];
  }

  private isOp(value: string): boolean {
    const t = this.peek();
    return !!t && t.type === 'op' && t.value === value;
  }

  // expr := term (('+'|'-') term)*
  private expr(): number {
    let left = this.term();
    while (this.isOp('+') || this.isOp('-')) {
      const op = (this.tokens[this.pos++] as { value: string }).value;
      const right = this.term();
      left = op === '+' ? left + right : left - right;
    }
    return left;
  }

  // term := unary (('*'|'/'|'%') unary)*
  private term(): number {
    let left = this.unary();
    while (this.isOp('*') || this.isOp('/') || this.isOp('%')) {
      const op = (this.tokens[this.pos++] as { value: string }).value;
      const right = this.unary();
      left = op === '*' ? left * right : op === '/' ? left / right : left % right;
    }
    return left;
  }

  // unary := ('-'|'+') unary | power
  private unary(): number {
    if (this.isOp('-')) {
      this.pos++;
      return -this.unary();
    }
    if (this.isOp('+')) {
      this.pos++;
      return this.unary();
    }
    return this.power();
  }

  // power := atom ('^' unary)?   (right-associative)
  private power(): number {
    const base = this.atom();
    if (this.isOp('^')) {
      this.pos++;
      return Math.pow(base, this.unary());
    }
    return base;
  }

  // atom := number | const | func '(' expr ')' | '(' expr ')'
  private atom(): number {
    const t = this.tokens[this.pos++];
    if (!t) throw new Error('unexpected end');
    if (t.type === 'num') return t.value;
    if (t.type === 'lparen') {
      const v = this.expr();
      const close = this.tokens[this.pos++];
      if (!close || close.type !== 'rparen') throw new Error('expected )');
      return v;
    }
    if (t.type === 'id') {
      if (t.value in CONSTANTS) return CONSTANTS[t.value];
      const fn = FUNCTIONS[t.value];
      if (!fn) throw new Error('unknown identifier');
      const open = this.tokens[this.pos++];
      if (!open || open.type !== 'lparen') throw new Error('expected (');
      const arg = this.expr();
      const close = this.tokens[this.pos++];
      if (!close || close.type !== 'rparen') throw new Error('expected )');
      return fn(arg);
    }
    throw new Error('unexpected token');
  }
}

文法エラーは例外として投げ、呼び出し側の evaluateExpression が try/catch で受けて null に変換する。-2^2 は unary → power の順なので -(2^2) = -4 と数学の慣例どおりに評価され、2^-1 のように指数側の符号も unary が吸収する。

このパーサーは、悪意あるコード片が紛れ込んでもスクリプトとして評価される余地が一切ない。未知のトークンや壊れた文法が入ってきた瞬間、単に null を返して終了する。トークン列を前へ進めるだけなので無限ループも起こり得ず、XSSの入り込む余地も構造的に存在しない。

誤爆防止の門番:evaluateExpression

パーサーの前段には、「これは本当に計算か?」を判定する門番がいる。

export function evaluateExpression(src: string): number | null {
  const tokens = tokenize(src.trim());
  if (!tokens || tokens.length === 0) return null;
  // 数値や識別子が1つだけなら「計算」ではない
  const hasOperator = tokens.some((t) => t.type === 'op' || (t.type === 'id' && t.value in FUNCTIONS));
  if (!hasOperator) return null;
  // 知らない識別子が1つでもあれば検索クエリ: "iphone 15 * 2" は計算しない
  if (tokens.some((t) => t.type === 'id' && !(t.value in FUNCTIONS) && !(t.value in CONSTANTS))) return null;
  try {
    const value = new Parser(tokens).parse();
    return value === null || Number.isNaN(value) ? null : value;
  } catch {
    return null;
  }
}

ステップ3:単位変換、進数変換、サイコロ記法の拡張

数式パーサーの土台ができあがれば、検索バーが処理できる「インライン回答」の幅は一気に広がる。実装は正規表現によるパターンマッチで、入り口の evaluateSmartInput が 曖昧さの少ないものから順に 試していく(コイン → サイコロ → 乱数 → 選択 → 日数 → パーセント → 進数 → 単位 → 最後に計算機)。計算機を最後に回すのは、普通の検索クエリを最も誤認しやすいのが計算機だからだ。

1. 単位変換(Unit Conversion)

10 km to mi や 72 f in c、3 kg → lb のような入力は、専用の正規表現でパースする。区切り語は英語の to / in / as に加えて に / を / 으로 / en / zu など各言語の助詞も受け付ける。

const NUM = '(-?\\d+(?:[.,]\\d+)?)';
const UNIT = '([a-zµ°℃℉][a-z0-9µ°℃℉/²"\']*|坪)';
const UNIT_SEP = '(?:to|in|into|as|→|=|に|を|으로|로|en|a|zu|in)';
// 例: /^(-?\d+(?:[.,]\d+)?)\s*(unit)\s+(?:to|in|...)\s+(unit)$/i

単位テーブルは「カテゴリ」と「基準単位への係数」を持つフラットな配列だ。長さ(基準: m)、質量(g)、体積(L)、データ量(byte, 1024 進)、時間(s)、速度(m/s)、面積(m²、坪 も含む)を持ち、同じカテゴリ同士なら value * a.factor / b.factor で相互変換できる。温度だけは加算を伴うので、摂氏を経由する専用関数で扱う。

export function convertUnits(value: number, fromRaw: string, toRaw: string) {
  const from = fromRaw.toLowerCase();
  const to = toRaw.toLowerCase();
  if (from in TEMPERATURE_ALIASES && to in TEMPERATURE_ALIASES) {
    /* 摂氏を経由して変換 */
  }
  const a = UNIT_INDEX.get(from);
  const b = UNIT_INDEX.get(to);
  if (!a || !b || a.category !== b.category) return null; // km → kg は拒否
  return { value: (value * a.factor) / b.factor, unit: toRaw };
}

浮動小数点の端数は表示側の formatNumber で「有効桁 10 桁・末尾ゼロなし・-0 を 0 に」と整形して返す。

2. 進数変換(Base Conversion)

0xff、0b1010、255 to hex、42 to bin をサポートした。

const BASE_LITERAL = /^(0x[0-9a-f]+|0b[01]+|0o[0-7]+)$/i;
const BASE_CONVERT = /^(0x[0-9a-f]+|0b[01]+|0o[0-7]+|\d+)\s+(?:to|in|as|→|に)\s+(hex|hexadecimal|bin|binary|dec|decimal|oct|octal|16進|2進|10進|8進)$/i;

JavaScript標準の parseInt(str, radix) と Number.toString(radix) を組み合わせるだけで、瞬時に基数を切り替えられる。結果カードは指定された基数だけでなく 10進・16進・2進・8進の4つを同時に 表示する。「どの基数で見たいか」を言い当てるより、全部出すほうが速い。

3. TRPG風のダイスロール(Dice Notation)

3d6(6面ダイスを3回振る)や d20、dice、あるいは coin(コイントス)、random 1-100(乱数)、choose tea, coffee, water(選択)のような乱数生成。日本語の サイコロ / コイン / 乱数 / 選んで も同じ正規表現で拾う。

image.png

この機能には、もう一度振り直せる「再ロールボタン(Reroll)」をUI側に設けた。テスト時には乱数生成器(random: () => number)を options から注入できるように設計しておくことで、テスト結果の決定性を担保している。

export interface SmartInputOptions {
  now?: Date;
  /** [0, 1) の乱数源。テストと「もう一度振る」のために注入可能 */
  random?: () => number;
}

4. 日数計算

days until 2026-12-31 や 2026年12月31日まで と打てば、今日からの残り日数を返す。同じプロジェクトのカウントダウンウィジェットが持つ日付ユーティリティ(parseDayKey / daysBetween)をそのまま再利用しており、2026-02-30 のような「日付に見えて日付ではない」入力には何も返さない。


誤爆を防ぐ「確信度」の境界設計

この機能で最も気を使ったのは、「ユーザーが普通の検索をしたいときに邪魔をしない」ことだ。

例えば、ユーザーが「3-2」と入力したとする。これは計算の 1 を求めているのか、それとも「第3章第2節」や「スコア 3-2」についてググりたいのか。

ZenithTabでは、以下の基準で回答カードの表示を制御している。

  1. ただの数値だけの場合は回答を出さない: 単に 42 と打ったときは、計算結果カードは出さない(0xff のような進数リテラルは例外)。
  2. 演算子か関数がある場合のみ計算カードを出す: +, *, /, ^, sqrt など、明確に演算を意図している場合のみインライン表示する。しかも数字を1つも含まない入力は計算機に到達しない。
  3. 知らない単語が混ざったら計算しない: iphone 15 * 2 は演算子を含むが iphone が関数でも定数でもないので検索に譲る。
  4. 入力長の上限: 120 文字を超える入力はそもそも評価しない。
  5. Enterキーの挙動は変えない: 回答カードが出ている状態でも、Enterキーを押せばデフォルトの検索エンジン(GoogleやDuckDuckGoなど)で通常検索が実行される。結果を使いたいときは カードをクリックするとクリップボードにコピー される。検索という主機能の動線を、おまけ機能が奪ってはならない。

image.png

さらに、検索バーに ? とだけ打つとチートシートが開き、各種類の入力例(SMART_INPUT_EXAMPLES)が並ぶ。この例は単体テストで「すべて必ず結果を返すこと」を検証しているので、ヘルプに死んだ例が載ることはない。

image.png


外部ライブラリを入れずに自前で作る価値

このスマート入力エンジン全体の実装コードは、src/utils/smartInput.ts の1ファイル、470行弱で完結している。うち半分近くは単位テーブルと多言語の正規表現で、パーサー本体は150行に満たない。

外部の重厚な数式ライブラリ(mathjs など)を導入した場合、バンドルサイズは数十KBから数百KB単位で膨らむ。ブラウザの新しいタブを開くたびに読み込まれる拡張機能において、数百KBのバンドル肥大化は初回描画の遅延に直結する。

何より、Manifest V3の厳格なCSPの下では、外部ライブラリが内部でこっそり使っている動的コード生成が、本番で初めて CSP 違反として牙を剥くリスクが常につきまとう。

自分の手で書いた純粋関数群であれば、依存関係の脆弱性に怯えることもなく、バンドルサイズも数キロバイトに収まり、完全オフラインで爆速で動く。

制約の多い環境だからこそ、枯れたアルゴリズム(再帰下降構文解析)を自らの手で実装するアプローチが、長期的な保守性とパフォーマンスにおいて最も堅牢な選択肢となる。


さいごに:ZenithTabについて

本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。

ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。

  • 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
  • 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
  • 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備

Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!

ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?