1
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?

家族のレシピアプリ - 「しょうゆ…大さじ1」を正規表現で名前と分量に分ける(基本機能編 3)

1
Last updated at Posted at 2026-10-21

わが家の脱・料理属人化プロジェクト、基本機能編の 3 回目です。

前回は、コツやアレンジを残す 3 つの置き場所(メモ・作った記録・変更履歴)と、その Firestore の設計を紹介しました。
ただ、置き場所を用意しても、入力が面倒では記録が続きません。
今回は、レシピの手入力を楽にする工夫を紹介します。

困っていたこと

残っているレシピがあっても、スマホのメモやメッセージにテキストで書いたものがほとんどでした。
それを材料 1 つずつ、名前の欄と分量の欄に分けて入れ直すのはかなりの手間です。
登録が面倒なままだと、そのうち誰もレシピを登録しなくなってしまいます。

そこで、レシピの登録では手間を減らすことを何より優先し、次の 2 つを決めました。

  • 必須の項目は料理名だけにし、書きかけの状態でも保存できる
  • 材料と手順は、複数行をまとめて貼り付けられる

材料の 1 行を、名前と分量に分ける

1000002279.gif

貼り付けた材料は、1 行ずつ「名前」と「分量」に分けます。
分け方は次の 3 段階です。

  1. 区切りの記号(「……」「・・・」「:」)があれば、そこで分ける
  2. 行末に分量らしき文字があれば、空白がなくても分ける(「塩少々」「豚肉200g」)
  3. どちらもなければ、最後の空白で分ける。空白もなければ全体を名前にする

テストのケースからいくつか抜き出すと、次のように分かれます。

入力 名前 分量
砂糖……大さじ1 砂糖 大さじ1
サラダ油 大さじ 1 サラダ油 大さじ 1
塩少々 塩 少々
豚肉200g 豚肉 200g
牛乳200ml 牛乳 200ml
砂糖大さじ1と1/2 砂糖 大さじ1と1/2
なす中2本 なす 中2本
鶏もも肉1枚(300g) 鶏もも肉 1枚(300g)
ねぎお好みで ねぎ お好みで
7種のスパイス 7種のスパイス (なし)
3色そぼろ 3色そぼろ (なし)

「分量らしき文字」を正規表現で組み立てる

行末の分量は、部品を組み合わせて作った正規表現で探します。

// 分量らしき文字の部品。長いものを先に並べる(「お好みで」を「お好み」より先に、など)
const NUMBER = '(?:[0-90-9]+(?:[..//][0-90-9]+)?|半)';
// 1〜2、1と1/2
const NUMBER_RANGE = `${NUMBER}(?:\\s*(?:[〜~~\\--]|と)\\s*${NUMBER})?`;
const MEASURES = ['計量カップ', '大さじ', '小さじ', '大匙', '小匙', 'カップ'].join('|');
// 長い単位を先に並べる(「切れ」を「切」より先に、など)
const UNITS = [
  'キログラム グラム リットル センチ パック カップ 切れ かけ',
  'kg mg ml mL cc cm kg ml cc cm g g L ℓ',
  '個 本 枚 片 株 束 袋 缶 尾 匹 玉 房 丁 合 杯 粒 膳 箱 節 把 切',
]
  .join(' ')
  .replace(/ /g, '|');
const WORDS = ['お好みで', 'お好み', 'ひとつまみ', 'ひとつかみ', 'たっぷり', '少々', '少量', '適量', '適宜', '少し'].join('|');
// 「約」「各」、大きさの「大」「中」「小」
const PREFIX = '(?:約|各)?\\s*(?:[大中小](?!さじ|匙))?';
// 「強」「弱」「程度」などと、後ろの括弧(「200g(1枚)」)
const SUFFIX = '(?:分|強|弱|程度|くらい|ぐらい|ほど)?(?:\\s*[((][^))]*[))])?';
/** 行末にある分量。空白をまたいでもよい(「大さじ 1」) */
const AMOUNT_AT_END = new RegExp(`(${PREFIX}(?:(?:${MEASURES})\\s*${NUMBER_RANGE}|${NUMBER_RANGE}\\s*(?:${UNITS})?|${WORDS})${SUFFIX})$`);

工夫したのは次の 5 点です。

  • 数字は全角も半角も拾う([0-90-9])
    → 手書きのメモを打ち直すと、全角の数字が混ざりやすいため
  • 「半」も数字として扱う
    → 「玉ねぎ半分」の「半」を分量として拾うため
  • 大きさの「大」「中」「小」の後ろに、否定先読み (?!さじ|匙) を付ける
    → 「なす中2本」の「中」は拾いつつ、「大さじ」の「大」を大きさと読まないため
  • 選択肢は長いものを先に並べる(「お好みで」と「お好み」、「切れ」と「切」)
    → 順番をそろえておくと、後で読む人(未来の自分と AI)が迷わないため
  • 後ろの括弧まで分量に含める
    → 「鶏もも肉1枚(300g)」の「(300g)」が名前に残らないようにするため

この正規表現を使って、3 段階の分け方を書いています。

/** 名前と分量の間の区切りの記号(「砂糖……大さじ1」「砂糖:大さじ1」) */
const SEPARATOR = /^(.*?\S)\s*(?:…+|‥+|・{2,}|\.{3,}|[::])\s*(\S.*)$/;

/**
 * 材料の 1 行を名前と分量に分ける。
 * 1. 区切りの記号があれば、そこで分ける
 * 2. 行末に分量らしき文字があれば、空白がなくても分ける(「塩少々」「豚肉200g」)
 * 3. どちらもなければ、最後の空白(全角の空白・タブを含む)で分ける。空白もなければ全体を名前にする
 */
export function splitIngredientLine(line: string): PastedIngredient {
  const trimmed = line.trim();
  const separated = SEPARATOR.exec(trimmed);
  if (separated?.[1] && separated[2]) {
    return { name: separated[1], amount: separated[2] };
  }
  const amount = AMOUNT_AT_END.exec(trimmed);
  if (amount?.[1]) {
    const name = trimmed.slice(0, amount.index).trim();
    if (name !== '') {
      return { name, amount: amount[1].trim() };
    }
  }
  const match = /^(.*\S)\s+(\S+)$/.exec(trimmed);
  if (!match) {
    return { name: trimmed, amount: null };
  }
  return { name: match[1] ?? trimmed, amount: match[2] ?? null };
}

区切りの記号の「・」は、2 つ以上続くときだけ区切りとして扱います。
「塩・こしょう」の「・」で分けてしまわないようにするためです。

手順の番号は捨てる

手順は、行の先頭にある番号を取り除いてから 1 行ずつ入れます。
番号はアプリが振り直すため、貼り付けた文章の番号は不要です。

const DIGITS = '[0-90-9]+';
const STEP_NUMBER_PATTERNS = [
  // STEP1、Step 2.、ステップ3:、手順4
  new RegExp(`^(?:step|ステップ|手順)\\s*${DIGITS}\\s*[..))、,,::]?\\s*`, 'i'),
  // (1)、(2)、【3】、[4]
  new RegExp(`^[((【[\\[]\\s*${DIGITS}\\s*[))】]\\]]\\s*`),
  // 1.、2)、3.、4、(小数の「1.5」は除く)
  new RegExp(`^${DIGITS}\\s*[..))、,,::](?![0-90-9])\\s*`),
  // ①〜⑳、㉑〜㊿
  /^[①-⑳㉑-㉟㊱-㊿]\s*/,
  // (一部を省略)
];

「1.5倍の水を入れる」のような手順の「1.」を番号と読まないよう、後ろに数字が続くときは番号として扱いません。
丸数字は ㉑〜㊿ も対象にしています。50 を超える手順のレシピはまずないため、ここまであれば十分だと考えています。

画面から切り離して、テストする

こうした処理は、画面に依存しない共有のパッケージ(packages/core)に置き、bun test で単体テストしています。
最初に載せた表も、テストの test.each から抜き出したものです。

test.each([
  ['塩少々', '塩', '少々'],
  ['豚肉200g', '豚肉', '200g'],
  ['砂糖大さじ1と1/2', '砂糖', '大さじ1と1/2'],
  ['鶏もも肉1枚(300g)', '鶏もも肉', '1枚(300g)'],
  // …
])('空白がなくても、行末の分量で分ける「%s」→ 名前「%s」・分量「%s」', (line, name, amount) => {
  expect(splitIngredientLine(line)).toEqual({ name, amount });
});

正規表現は、1 か所直すと別のケースに影響することがよくあります。
そのため、家族から「この書き方だと分かれない」と言われたら、まずテストに 1 行足してから直すようにしています。

運用してから足したもの

分量の入力を楽にする「キーボードの真上の入力補助バー」を足しました。
こちらは運用編で紹介する予定です。

まとめ

必須の項目を料理名だけにし、材料と手順は貼り付けでまとめて入れられるようにしました。
材料は「区切りの記号 → 行末の分量 → 最後の空白」の 3 段階で名前と分量に分けています。
正規表現は部品に分けて画面から切り離し、家族の書き方を test.each に 1 つずつ足しながら育てています。

次回は、登録したレシピを探す検索の話を予定しています。

1
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
1
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?