// https://github.com/wabilin/holo-schedule function mapNodeList(list: NodeListOf, mapper: (ele: E) => T): T[] { const ary: T[] = []; list.forEach((node) => { ary.push(mapper(node)); }); return ary; } function selectTrimTextContent(ele: Element, selector: string): string { return ele.querySelector(selector)?.textContent?.trim() || ''; } const livePreviewImageHosts: readonly string[] = [ 'img.youtube.com', 'schedule-static.hololive.tv' ] as const; function hostOf(src: string) { return new URL(src).host; } function dataFromAThumbnail(thumb: Element) { const time = selectTrimTextContent(thumb, '.datetime'); const name = selectTrimTextContent(thumb, '.name'); const images = mapNodeList(thumb.querySelectorAll('img'), (img) => img.src); const avatarImages = images.filter((src) => hostOf(src) === 'yt3.ggpht.com'); const livePreviewImage = images.find((src) => livePreviewImageHosts.includes(hostOf(src))) || ''; return { time, name, avatarImages, livePreviewImage }; } interface LiveBlock { time: Date; streamer: string; avatarImages: string[]; livePreviewImage: string; link: string; streaming: boolean; } function parseToLiveBlocks(html: string): LiveBlock[] { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const year = new Date().getFullYear().toString(); const rows = doc.querySelectorAll('#all > .container > .row'); let date = ''; const lives: LiveBlock[] = []; rows.forEach((row) => { const dateDiv = row.querySelector('.holodule'); if (dateDiv) { date = dateDiv.textContent?.replace(/\s+/g, '') || ''; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion date = date.match(/\d+\/\d+/)![0].replace('/', '-'); // const dateMatch = date.match(/\d+\/\d+/); // // date = dateMatch ? dateMatch[0].replace('/', '-') : ''; } const allThumbnail = row.querySelectorAll('a.thumbnail'); allThumbnail.forEach((t) => { const thumbnail = t as HTMLAnchorElement; const link = thumbnail.href; const streaming = thumbnail.style.borderColor === 'red'; // The dataFromAThumbnail function needs to be adjusted to work with the parsed document. const { time, name, avatarImages, livePreviewImage } = dataFromAThumbnail(thumbnail); lives.push({ link, avatarImages, livePreviewImage, time: new Date(`${year}-${date}T${time}:00+09:00`), streamer: name, streaming }); }); }); return lives; } export type StreamerImageDict = Record; type ImageStreamerDict = Record; function nextStreamerImageDict(liveBlocks: LiveBlock[], oldDict: StreamerImageDict) { const dict = { ...oldDict }; liveBlocks.forEach(({ avatarImages: images, streamer }) => { dict[streamer] = images[0]; }); return dict; } function reverseDict(dict: StreamerImageDict): ImageStreamerDict { const reversed: ImageStreamerDict = {}; Object.entries(dict).forEach(([streamer, img]) => { reversed[img] = streamer; }); return reversed; } export interface LiveInfo { time: Date; link: string; videoId: string; streamer: string; livePreviewImage: string; guests: string[]; streaming: boolean; } interface ParseResult { lives: LiveInfo[]; dict: StreamerImageDict; } function getVideoId(link: string): string { return link.replace('https://www.youtube.com/watch?v=', ''); } /** * @param html - Html of https://schedule.hololive.tv. Get with Japan timezone (GTM+9) * @param storedDict - An object stored { vtuberName: iconImageSrc } * @returns - Lives schedule and updated dict */ export function parseScheduleHtml(html: string, storedDict: StreamerImageDict = {}): ParseResult { const liveBlocks = parseToLiveBlocks(html); const streamerImageDict = nextStreamerImageDict(liveBlocks, storedDict); const dict = reverseDict(streamerImageDict); const lives = liveBlocks.map((liveBlocks) => { const { streamer, avatarImages, time, link, livePreviewImage, streaming } = liveBlocks; const guests = avatarImages .splice(1) .map((x) => dict[x]) .filter(Boolean); const videoId = getVideoId(link); return { time, streamer, guests, link, videoId, livePreviewImage, streaming }; }); return { lives, dict: streamerImageDict }; }