
プレイドインターン体験記:管理画面から配信基盤までを横断した機能開発
Posted on
こんにちは。8月1日から10月31日までの3ヶ月間、Blocksチームでソフトウェアエンジニアとしてインターンをしていた中村杏莉です。
本ブログでは、インターンで取り組んだタスクや、そこから得た学びについて紹介します。
プレイドのインターンに興味がある方や、KARTE Blocksの開発がどのようなものか知りたい方にとって、少しでも参考になれば幸いです。
KARTE Blocksとは?
私が所属していたBlocksチームでは、KARTE Blocksというプロダクトの開発を行っています。
KARTE Blocksとは、既存のサイトにbuilder.jsと呼ばれるタグを設置するだけで、「サイト改修・更新の効率化」「テストによる仮説検証やパーソナライズによるパフォーマンス向上」「データによる課題発見」までを、誰でも簡単に一気通貫で実施可能にするプロダクトです。
より詳しく知りたい方は、ぜひ以下のリンクをご覧ください。
- KARTE Blocksとは?: https://support.karte.io/post/qRCb56jHua3CKpWpFyXPc
- KARTE Blocksの技術構成: https://tech.plaid.co.jp/karte-blocks-architecture
インターンで取り組んだこと
インターン期間中、フロントエンドからバックエンドまで、幅広い粒度のさまざまなタスクに取り組ませていただきました。具体的には以下のような内容です。
- TypeScriptのバージョンアップ
- リデザインに伴う欠陥・課題修正
- graceful shutdownの実装
- 配信ページのURL条件に「#」をサポート
- 配信ページのURL条件に複数条件をサポート
- Blocksセグメントに複数条件をサポート
- スケジュール配信の改善
次の章からは、これらの中から印象的だったタスクを紹介します。
配信ページのURL条件に複数条件を追加
背景・課題
KARTE Blocksでは、施策を公開する際、どのページにブロックを配信するかを配信ページとして登録します。この配信ページは、パスやハッシュといったURLの構成要素を組み合わせたURL条件によって指定されます。URL条件をカスタマイズすることで、1つの施策を複数ページにまたがって一括で配信することも可能になります。
しかし、従来は同じ構造を持つ複数のURLをまとめて登録する方法が限られていました。
例えば、「https://example.com/page-A」と「https://example.com/page-B」の両方を対象にしたい場合、OR条件で一つずつ繋げるか、正規表現を使うしかありませんでした。

この手間を解消し、「次のどれかと一致する」といった形で、複数のURLをまとめて指定できるようにするのが今回の課題でした。
実装
仕様として、以下の4つの複数条件を実装しました。
- 次のどれかと一致する(完全一致)
- 次のどれとも一致しない(完全一致の否定)
- 次のどれかを含む(部分一致)
- 次のどれも含まない(部分一致の否定)
具体的な実装として、バックエンドでは、まずURL条件の型定義を拡張しました。 従来は value: string のような単一の値しか持てませんでしたが、matchプロパティ(一致条件の種類)に応じて、単一の文字列を持つ SingleNode と、文字列の配列を持つ MultipleNode を判別できるように、MatchNode をdiscriminated unionとして再設計しました。
// URL条件の型定義
export type TargetType = (typeof TARGET_TYPES)[number];
export type SingleMatches = (typeof SINGLE_MATCHES)[number];
export type MultipleMatches = (typeof MULTIPLE_MATCHES)[number];
export type MatchType = (typeof MATCH_TYPES)[number];
type MatchBase = {
  target: TargetType;
};
export type SingleNode = MatchBase & { match: SingleMatches; value: string };
export type MultipleNode = MatchBase & { match: MultipleMatches; value: string[] };
// MatchNode は match プロパティによって SingleNode か MultipleNode か推論可能
export type MatchNode = SingleNode | MultipleNode;
次に、この新しい型定義に合わせて、任意のURLが条件に一致するかを判定するmatcherを修正しました。
// URLが条件に一致しているかどうか判定する matcher
export function isUrlMatch(url: string, matchNode: MatchNode): boolean {
  const { match, value } = matchNode;
  const target = getTarget(url, matchNode.target);
  if (target === null) return false;
  switch (match) {
    // any-of-exact は MultipleMatches 型なので
    // この case ブロック内では value は string[] と推論される
    case 'any-of-exact':
      return value.includes(target);
    case 'none-of-exact':
      return !value.includes(target);
    case 'any-of-partial':
      return value.some(v => target.includes(v));
    case 'none-of-partial':
      return value.every(v => !target.includes(v));
    // exact は SingleMatches 型なので
    // この case ブロック内では value は string と推論される
    case 'exact':
      return target === value;
    case 'forward':
      return target.indexOf(value) === 0;
    case 'backward': {
      const lengthDiff = target.length - value.length;
      return lengthDiff >= 0 && target.lastIndexOf(value) === lengthDiff;
    }
    case 'partial':
      return target.indexOf(value) !== -1;
    case 'regex':
      try {
        return new RegExp(value).test(target);
      } catch {
        return false;
      }
    default:
      return false;
  }
}
フロントエンドでは、既存の入力フォームを拡張し、ユーザーが「次のどれかと一致する」などの複数条件を選択した場合は、従来のテキスト入力欄から、複数の値を入力できるマルチセレクトコンポーネントに切り替わるように表示を修正しました。


学び・感想
このタスクでは、初めてTypeScriptの型定義の実装を行いました。
コードレビューの過程で、メンターの方から型についての知識についてたくさん教えていただきました。
- discriminated union(判別可能なユニオン型)
- オーバーロード関数
- ユーザー定義の型ガード関数
など、型の設計を工夫することで、データを処理するロジックがいかにシンプルになるかを実感できました。
この機能はリリースノートも公開しました!
Blocksセグメントに複数条件を追加
URL条件のタスクに続いて、Blocksセグメントに複数条件を追加するタスクにも取り組みました。
背景・課題
セグメントとは、サイトに来訪するユーザーをその行動や属性に応じて分類できる機能です。KARTE Blocksでは、このセグメントをブロックの配信対象を絞り込む条件などに利用できます。
KARTE Blocksには「Insightセグメント」と「Blocksセグメント」の2種類があり、これまではBlocksセグメントではInsightセグメントで可能な複数条件の設定ができませんでした。
これにより、似たようなセグメントを個々に設定する必要があり手間がかかる上、セグメント設定の上限数に達しやすいという課題がありました。
実装
基本的な実装方針は、先ほどの配信ページのURL条件とほぼ同じです。
違いがあった点としては、Blocksセグメントでは値を正規表現で登録することができるため、matcherのロジックがURL条件と異なることでした。URL条件のmatcherが単純な文字列比較だったのに対し、Blocksセグメントのmatcherは、既存の正規表現での一致と、今回新しく追加した複数条件を両方考慮する必要がありました。
そこで、比較対象の値が文字列配列の場合、配列内の各値を既存の正規表現を扱える比較関数 isMatchStringValue に渡して判定し、比較タイプ(any-of-exactやnone-of-exactなど)に応じて最終的な判定結果を返すロジックを実装しました。
export class Matcher {
  matchCondition(condition: t.MatchCondition, dimension: DimensionValue | undefined): boolean {
  // ... (省略) ...
    // 複数条件
    if (Array.isArray(condition.value) && condition.value.every(isStringValue)) {
      // 複数条件用の比較タイプを、単一条件用の比較タイプに変換
      const compare = this.toSingleCompareType(condition.compare);
      
      // 配列内の各値を、既存の正規表現も扱える isMatchStringValue で判定
      const results = condition.value.map(value => {
        return this.isMatchStringValue(dimensionValue, value, compare, condition.isRegex);
      });
      // any-of か none-of かに応じて結果を集約
      switch (condition.compare) {
        case t.CompareType.AOE: // Any Of Exact
        case t.CompareType.AOP: // Any Of Partial
          return results.includes(true);
        case t.CompareType.NOE: // None Of Exact
        case t.CompareType.NOP: // None Of Partial
          return results.every(Boolean);
        default:
          return false;
      }
    }
    // 単一条件
    if (isStringValue(condition.value)) {
      return this.isMatchStringValue(
        dimensionValue,
        condition.value,
        condition.compare,
        condition.isRegex,
      );
    }
    // ... (省略) ...
    return false;
  }
}

スケジュール配信の改善
インターン最後の1ヶ月では、スケジュール配信の改善タスクに取り組みました。
背景・課題
KARTE Blocksでは、施策を公開する際に配信期間(開始時間と終了時間のペア)を設定できます。
一方で、KARTEの別機能であるAction機能では、画像のようなより柔軟なスケジュール設定が可能です。

今回のタスクのゴールは、KARTE BlocksでもAction機能相当の柔軟なスケジュール配信を可能にすることでした。
仕様決め
影響範囲の大きな変更になるため、実装に取り掛かる前に、メンター、チームのPdM、CS、デザイナーの方を巻き込んで仕様を固めました。
- Action機能のスケジュール配信の仕様をできるだけ踏襲する
- 以下の機能を追加する
- 開始日・終了日の個別指定
- 毎週・毎月の繰り返し
- 配信時間帯の複数設定
 
- これらの機能はリニューアル後のKARTE Blocksでのみ提供する
実装
このタスクはKARTE Blocksの根幹である「ブロックの配信」そのものに関わるため、まずは既存の配信まわりの実装を把握することから始めました。
具体的には、以下の一連の流れがどのように実装されているか、実際にコードを読んで処理を追う作業を行いました。
- 施策が作成・設定される
- builder.js(サイト上でブロックの書き換えを行うサードパーティスクリプト)のデプロイリクエストが送られる
- builder.jsが生成・デプロイされる
- ユーザーサイトでbuilder.jsが読み込まれ、ブロックが書き換わる

また、今回はAction機能のスケジュール設定の仕様を踏襲するため、Action側の実装(主にDBのスキーマ定義や、スケジュール設定をどのようなデータ型で保持しているか)についても、参考として確認しました。
実装では、上記のフロー全体を新しいスケジュール配信の仕様に合わせて拡張していきました。
1. DBスキーマとAPIの更新
まずは、バックエンド側で新しいスケジュール設定(毎週・毎月など)を保存できるようにする必要がありましたが、そのスキーマ設計についてはいくつか案を考えました。
- iCalendarのRRULE(Recurrence Rule, 繰り返しイベントの標準フォーマット)の仕様に合わせる
- Action機能の既存実装に合わせる
- 開始日、終了日、繰り返し、配信時間帯などを個別のプロパティとして新たにもつ
これらの案を検討した結果、社内の既存リソースを有効活用できる点や、他のチームのエンジニアが開発に参加する際の認知負荷を減らせるという理由から、Action機能の実装に合わせることにしました。
この方針に基づき、MongoDBの施策スキーマモデルに、Action機能の仕様を踏襲した新しいプロパティを追加しました。例えば、曜日と時間帯の組み合わせを保持する distDaysAndHours や、毎月の繰り返し日を指定する recurringDateSetting などです。
  // スケジュール配信関連のスキーマ定義
  
  /**
   * 配信期間(既存のスケジュール配信でも使用)
   */
  scheduleTimeRange: [
    {
      type: Date, // [0]: 開始日, [1]: 終了日, null: 未設定
    },
  ],
  /**
   * 毎日: distDaysAndHours のみ(全曜日)
   * 毎週: distDaysAndHours のみ
   * 毎月: distDaysAndHours(全曜日)+ recurringDateSetting.dates(指定日)
   */
  distDaysAndHours: {
    type: [daysAndHoursSchema], // 曜日×秒、日曜日0時からのオフセットで保持
  },
  /**
   * 月別繰り返しの指定日
   */
  recurringDateSetting: {
    dates: {
      type: [Number], // 指定日 (1-31, -1: 月末日)
      required: false,
    },
  },
あわせて、施策の作成・更新・複製を行う既存のAPIを修正し、これらの新しいプロパティを正しくDBに保存・反映できるようにしました。
2. builder configの生成ロジックを更新
次に取り組んだのは、builder.jsを生成するための設定ファイルであるbuilder configを扱うロジックの更新です。
このconfigはRPCサーバーが生成し、Builderサーバーに渡すという流れになっています。しかし、DBに保存された distDaysAndHours などのプロパティは、builder.jsが配信判定にそのまま使うには抽象度が高く、扱いづらい形式でした。
そこで、builder.js側で扱いやすいスケジュール配信設定用の型 RecurrenceSettings を別途定義し、RPCサーバー側でDBから取得したデータをこの RecurrenceSettings に変換してconfigに含めるよう実装しました。
3. builder.js内の配信判定ロジックを更新
configに新しい設定を含めても、builder.js本体がそれを理解できなければ配信は行われません。
builder.jsの内部には、ユーザーのアクセス時の現在時刻と施策のスケジュール設定を参照し、そのブロックを今書き換えるべきか否かを判定する関数 isOn() が存在します。
この判定ロジックを拡張し、新しい RecurrenceSettings(毎週・毎月の繰り返し設定など)を正しくパースして、配信すべきかどうかを判断できるように修正しました。
/**
 * 現在時刻が配信スケジュールの期間内かどうか判定する
 * 配信期間 > 繰り返し設定 > 時間帯設定 の順に確認する
 *
 * 配信期間 (scheduleTimeRange) の想定されるパターン:
 * - 開始日も終了日も指定: [string, string]
 * - 開始日のみ指定: [string, '']
 * - 終了日のみ指定: ['', string]
 * - 開始日も終了日も指定しない: []
 */
function isOn(
  scheduleTimeRange: readonly string[] | undefined,
  recurrenceSettings: Raw.RecurrenceSettings | undefined,
): boolean {
  // 旧スケジュール配信画面で設定された場合
  if (!recurrenceSettings) {
    return _isOn(scheduleTimeRange);
  }
  const current = getDateTimeSyncedWithServer(Date.now());
  // 配信期間が設定されていないパターン
  if (!scheduleTimeRange || scheduleTimeRange.length < 2) {
    return isOnRecurring(recurrenceSettings, current);
  }
  const [startDate, endDate] = scheduleTimeRange;
  const start = startDate === '' ? null : new Date(startDate).getTime();
  const end = endDate === '' ? null : new Date(endDate).getTime();
  // 開始日・終了日どちらかが設定されているパターン
  if (start === null && end != null && end > current) {
    return isOnRecurring(recurrenceSettings, current);
  }
  if (end === null && start != null && start < current) {
    return isOnRecurring(recurrenceSettings, current);
  }
  // 開始日・終了日どちらも指定されているパターン
  if (start != null && end != null && start < current && current < end) {
    return isOnRecurring(recurrenceSettings, current);
  }
  return false;
}
/**
 * 繰り返し設定を参照して、現在の日付が配信期間内かどうか判定する
 */
function isOnRecurring(recurrenceSettings: Raw.RecurrenceSettings, current: number): boolean {
  const currentDate = new Date(current);
  const currentDayOfWeek = currentDate.getUTCDay(); // Sun: 0 - Sat: 6
  switch (recurrenceSettings.type) {
    case 'daily':
      return isOnTime(recurrenceSettings.distDaysAndHours, currentDate);
    case 'weekly':
      if (!recurrenceSettings.daysOfWeek.includes(currentDayOfWeek as scheduleTypes.DayOfWeek)) {
        return false;
      }
      return isOnTime(recurrenceSettings.distDaysAndHours, currentDate);
    case 'monthly': {
      const currentDayOfMonth = getDayOrNegativeOneIfLastDay(currentDate);
      if (!recurrenceSettings.daysOfMonth.includes(currentDayOfMonth as scheduleTypes.DayOfMonth)) {
        return false;
      }
      return isOnTime(recurrenceSettings.distDaysAndHours, currentDate);
    }
    default:
      return false;
  }
}
/**
 * 現在時刻が配信時間帯に含まれているかどうか判定する
 */
function isOnTime(
  distDaysAndHours: readonly scheduleTypes.IDaysAndHours[] | undefined,
  currentDate: Date,
): boolean {
  if (!distDaysAndHours || distDaysAndHours.length === 0) {
    return false;
  }
  // 日曜日0時からの経過秒数を計算
  const currentOffset =
    currentDate.getDay() * 24 * 60 * 60 +
    currentDate.getHours() * 60 * 60 +
    currentDate.getMinutes() * 60 +
    currentDate.getSeconds();
  return distDaysAndHours.some(range => {
    return range.start <= currentOffset && currentOffset < range.end;
  });
}
4. 配信条件のUIを更新
続いて、ユーザーが新しいスケジュール設定を使えるように、フロントエンドのUIも大幅に更新しました。
UIはActionの設定画面をベースに、デザイナーの方にFigmaでデザインを作成していただきました。新しい設定画面は「毎日」「毎週」「毎月」の3パターンがあり、旧画面と比べてもUIコンポーネントの数が多くなっています。

新しいスケジュール設定画面のUI(現在開発中)
私はCSSやTailwindについての知識があまり深くなかったため、既存のUIのスタイルを保ちつつ新しいコンポーネントを追加したり、どのスタイル設定がどう影響するかを把握したりするのが難しいと感じていました。Figma MCPを利用することで、デザインからコードの雛形を生成でき、これらの負担がかなり軽減されました。
また、プレイドではデザインシステムである「Sour」を利用しています。MCPがFigmaのデザインからどのデザイントークン・コンポーネントが使われているか読み取り、SourのReactライブラリであるsour-reactを使ったコードを出力してくれたので、Sourに慣れていない私にとって、これは非常に助かりました。
さらに、KARTE Blocksのフロントエンドではフォームの状態管理やバリデーションにReact Hook FormとZodを使用しているため、それらの仕組みに合わせて実装を行いました。
5. フロントエンドとバックエンドの繋ぎ込み
最後に、これまで個別に実装してきたフロントエンドとバックエンドの繋ぎ込みを行いました。
新しいUIのフォームが扱うデータ形式と、APIが要求するデータ形式が異なっていたため、それらを相互に変換するためのconverterを実装しました。これにより、施策の作成時・編集時どちらのフローでも、データを正しくマッピングしてやり取りできるようにしました。
感想
このタスクは、曜日繰り返し配信などユーザーからの要望が多かった機能開発に携われるということで、非常にやりがいがありました。
同時に、これはKARTE Blocksの「ブロックの配信」というプロダクトの根幹に関わるタスクでした。そのため、影響範囲が非常に広く、変更すべきコードの量も多かったため、技術的にとてもチャレンジングな内容でした。
特に難しかったのは、各レイヤー(フロントエンド、RPCサーバー、DB、builder.js)でデータ構造や型が異なる点です。それぞれの責務に最適化されたデータ形式が存在するため、それらの整合性を保ちながら相互に変換する処理を実装するのが複雑でした。
インターン期間中に、機能の一通りの実装から、検証環境での動作確認、そして社内展開までを完了させることができました。顧客向けの正式リリースは近日行われる予定です。
おわりに
今回のインターン選考の際に、「新しい技術に挑戦してみたい」「フロントエンドからバックエンドまで幅広いレイヤの開発に関わりたい」というリクエストを伝えていました。
結果として、まさにそのリクエスト通りのタスクを割り当てていただき、非常に充実した経験になりました。
最初は小さなバグ修正から始まり、徐々に機能改善、そして最後はプロダクトの根幹に関わる大きな機能開発へと、段階的にステップアップできるようなタスクアサインでした。「細かいタスクを短期間でこなす経験」と「粒度の荒い大きなタスクを長期間かけて解く経験」の両方を積むことができ、自分の成長を実感することができました。
また、どのタスクもゼロからの実装というよりは、既存の仕様やコードベースに乗っかって機能を追加するものが多かったです。
そのため、
- コードを読んだりプロダクトを手元で動かしたりして、既存の実装と仕様を正確に理解する
- 既存の機能を壊さないように、慎重に新機能を追加する
この2点が特に難しく、また、実務ならではの良い学びになったと感じています。
業務以外では、インターン生でもウェルカムランチ制度が利用できたり、プロダクト組織内のLT回であるDemo Dayに参加できたりと、普段業務では関わることがない社員の方々とも交流できる機会が充実していました。
さらに、タイミング良くインターン期間中にKARTE Blocks Meetupに参加することができました。Meetupでは、リニューアルしたKARTE Blocksの体験会やその後の懇親会を通して、ユーザーの皆様から直接フィードバックを伺うことができました。インターン生がこのようなイベントに参加できるのは本当に貴重な機会だったと思います。
最後に、Blocksチームをはじめとした社員のみなさん、3ヶ月間お世話になりました。
みなさんのおかげで、毎日楽しく充実した3ヶ月を過ごすことができました。本当にありがとうございました!