TwitterとYoutubeのURLから投稿idを取得する関数とそのテスト

ちょっと必要だったので便利関数を作った。
要件的に簡単な処理であったし、丁度良かったので初めてJestを使ってみた。

chatGPTにある程度やらせてcopilot使いながら手直し、テスト書かせて手直しという感じ。
最近はコードを0から書くことが全く無くなったがまだまだ使いこなせていない気もする。

## 目次
- [Twitter関連の関数](#twitter関連の関数)
- [Twitter関連の関数のテスト](#twitter関連の関数のテスト)
- [Youtube関連の関数](#youtube関連の関数)
- [Youtube関連の関数のテスト](#youtube関連の関数のテスト)

#### Twitter関連の関数
```ts:utils/twitter.ts
export const isTwitterUrl = (url: string) => {
  const twitterHostnames = [
    'twitter.com',
    'www.twitter.com',
    'mobile.twitter.com',
    // 'pbs.twimg.com',
    // 't.co',
  ];

  try {
    const parser = new URL(url);
    return twitterHostnames.includes(parser.hostname);
  } catch (error) {
    return false;
  }
};

export const isTweetUrl = (url: string) => {
  if (!isTwitterUrl(url)) return false;
  const regex =
    /^https?:\/\/(?:www\.|mobile\.)?twitter\.com\/(?:#!\/)?([a-zA-Z0-9_]{1,15})\/status(?:es)?\/(\d+)(?:\?.*)?$/;
  return regex.test(url);
};

export const getTweetId = (url: string) => {
  if (!isTweetUrl(url)) return null;

  const regexes = [
    /https?:\/\/twitter\.com\/(\w+)\/status(es)?\/(\d+)/,
    /https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(es)?\/(\d+)/,
    /https?:\/\/mobile\.twitter\.com\/(\w+)\/status(es)?\/(\d+)/,
  ];

  for (const regex of regexes) {
    const match = url.match(regex);
    if (match) {
      return match[match.length - 1];
    }
  }

  return null;
};
```

#### Twitter関連の関数のテスト
```ts:utils/__tests__/twitter.test.ts
import { isTwitterUrl, isTweetUrl, getTweetId } from '../twitter';

describe('isTwitterUrl', () => {
  test('returns true for valid twitter URLs', () => {
    expect(isTwitterUrl('https://twitter.com/')).toBe(true);
    expect(isTwitterUrl('https://twitter.com/hashtag/tbt')).toBe(true);
    expect(isTwitterUrl('https://twitter.com/search?q=hello')).toBe(true);
    expect(isTwitterUrl('https://twitter.com/Twitter/status/1234567890')).toBe(true);
    expect(isTwitterUrl('https://www.twitter.com/Twitter/status/1234567890')).toBe(true);
    expect(isTwitterUrl('https://mobile.twitter.com/Twitter/status/1234567890')).toBe(true);
  });

  test('returns false for invalid twitter URLs', () => {
    expect(isTwitterUrl('https://pbs.twimg.com/profile_images/1234567890/photo.jpg')).toBe(false);
    expect(isTwitterUrl('https://t.co/abcdefg')).toBe(false);
    expect(isTwitterUrl('https://www.google.com/')).toBe(false);
  });
});

describe('isTweetUrl', () => {
  test('valid tweet url', () => {
    const urls = [
      'https://twitter.com/elonmusk/status/1367528057626767874',
      'https://www.twitter.com/elonmusk/status/1367528057626767874',
      'https://mobile.twitter.com/elonmusk/status/1367528057626767874',
      'https://twitter.com/elonmusk/statuses/1367528057626767874',
      'https://www.twitter.com/elonmusk/statuses/1367528057626767874',
      'https://mobile.twitter.com/elonmusk/statuses/1367528057626767874',
    ];
    urls.forEach((url) => {
      expect(isTweetUrl(url)).toBe(true);
    });
  });

  test('invalid tweet url', () => {
    const urls = [
      'https://twitter.com/',
      'https://twitter.com/elonmusk',
      'https://twitter.com/elonmusk/status/',
      'https://twitter.com/elonmusk/status/abc',
      'https://www.google.com/search?q=elon+musk+twitter&oq=elon+musk+twitter',
    ];
    urls.forEach((url) => {
      expect(isTweetUrl(url)).toBe(false);
    });
  });
});

describe('getTweetId', () => {
  it('returns null for invalid URL', () => {
    expect(getTweetId('not a URL')).toBeNull();
    expect(getTweetId('https://example.com')).toBeNull();
    expect(getTweetId('https://twitter.com/')).toBeNull();
    expect(getTweetId('https://twitter.com/user')).toBeNull();
    expect(getTweetId('https://twitter.com/user/status')).toBeNull();
    expect(getTweetId('https://twitter.com/user/status/invalidid')).toBeNull();
  });

  it('returns the tweet ID for valid URLs', () => {
    expect(getTweetId('https://twitter.com/user/status/1234567890')).toBe('1234567890');
    expect(getTweetId('https://twitter.com/user/statuses/1234567890')).toBe('1234567890');
    expect(getTweetId('https://twitter.com/user/status/1234567890?ref_src=twsrc%5Etfw')).toBe(
      '1234567890'
    );
    expect(getTweetId('https://mobile.twitter.com/user/status/1234567890')).toBe('1234567890');
  });
});
```

#### Youtube関連の関数

```utils/youtube.ts
export const isYouTubeUrl = (url: string) => {
  try {
    const parser = new URL(url);
    return parser.hostname === 'www.youtube.com' || parser.hostname === 'youtu.be';
  } catch (error) {
    return false;
  }
};

export const isYouTubeVideoUrl = (url: string) => {
  if (!isYouTubeUrl(url)) return false;
  const parser = new URL(url);
  return (
    parser.pathname.startsWith('/watch') ||
    parser.pathname.startsWith('/embed') ||
    (parser.hostname === 'youtu.be' && !!parser.pathname.match(/^\/[0-9A-Za-z_-]{10,}$/))
  );
};

export const getVideoId = (url: string) => {
  if (!isYouTubeVideoUrl(url)) return null;
  const regex = /(?:\/|v=)([0-9A-Za-z_-]{10,})+(?:[%#?&]|$)/;
  const match = url.match(regex);
  return match ? match[1] : null;
};

export const getThumbnailUrl = (youtubeVideoId: string) => {
  return `https://img.youtube.com/vi/${youtubeVideoId}/maxresdefault.jpg`;
};
```

#### Youtube関連の関数のテスト
```utils/__tests__/youtube.test.ts
import { isYouTubeUrl, isYouTubeVideoUrl, getVideoId } from '../youtube';

// 参考
// https://takashiski.hatenablog.com/entry/2021/09/19/124500

describe('isYouTubeUrl', () => {
  test('should return true for www.youtube.com', () => {
    expect(isYouTubeUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true);
    expect(isYouTubeUrl('https://www.youtube.com/embed/dQw4w9WgXcQ')).toBe(true);
    expect(isYouTubeUrl('https://www.youtube.com/channel/UC-lHJZR3Gqxm24_Vd_AJ5Yw')).toBe(true);
  });
  test('should return true for youtu.be', () => {
    expect(isYouTubeUrl('https://youtu.be/dQw4w9WgXcQ')).toBe(true);
  });
  test('should return false for other URLs', () => {
    expect(isYouTubeUrl('https://www.google.com/')).toBe(false);
  });
});

describe('isYouTubeVideoUrl', () => {
  test('returns true for valid YouTube video URLs', () => {
    const validUrls = [
      'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
      'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=10s',
      'https://www.youtube.com/embed/dQw4w9WgXcQ',
      'https://www.youtube.com/embed/dQw4w9WgXcQ?start=10',
      'https://youtu.be/dQw4w9WgXcQ',
      'https://www.youtube.com/watch?v=ngNhpdaT5V0&list=PLxai42gkPeMN1nTZFD4PEc96sMI8mdT03&index=3',
    ];
    validUrls.forEach((url) => {
      expect(isYouTubeVideoUrl(url)).toBe(true);
    });
  });

  test('returns false for invalid YouTube video URLs', () => {
    const invalidUrls = [
      'https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw',
      'https://www.youtube.com/results?search_query=cats',
      'https://www.youtube.com/',
      'https://youtu.be/',
    ];
    invalidUrls.forEach((url) => {
      expect(isYouTubeVideoUrl(url)).toBe(false);
    });
  });

  test('returns false for non-YouTube URLs', () => {
    const nonYouTubeUrls = ['https://example.com', 'https://www.google.com/search?q=youtube'];
    nonYouTubeUrls.forEach((url) => {
      expect(isYouTubeVideoUrl(url)).toBe(false);
    });
  });
});

describe('getVideoId', () => {
  test('returns video ID for valid YouTube video URLs', () => {
    const urlsAndIds = [
      ['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
      ['https://youtu.be/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
      ['https://youtu.be/dQw4w9WgXcQ?t=10s', 'dQw4w9WgXcQ'],
      ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=10s', 'dQw4w9WgXcQ'],
      ['https://www.youtube.com/embed/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
      ['https://www.youtube.com/embed/dQw4w9WgXcQ?start=10', 'dQw4w9WgXcQ'],
      [
        'https://www.youtube.com/watch?v=ngNhpdaT5V0&list=PLxai42gkPeMN1nTZFD4PEc96sMI8mdT03&index=3',
        'ngNhpdaT5V0',
      ],
    ];
    urlsAndIds.forEach(([url, expectedId]) => {
      expect(getVideoId(url)).toBe(expectedId);
    });
  });

  test('returns null for invalid or non-YouTube URLs', () => {
    const invalidUrls = [
      'https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw',
      'https://www.youtube.com/results?search_query=cats',
      'https://www.youtube.com/',
      'https://youtu.be/',
      'https://example.com',
      'https://www.google.com/search?q=youtube',
    ];
    invalidUrls.forEach((url) => {
      expect(getVideoId(url)).toBe(null);
    });
  });
});
```