aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/routes/api/hololive/+server.ts164
1 files changed, 163 insertions, 1 deletions
diff --git a/src/routes/api/hololive/+server.ts b/src/routes/api/hololive/+server.ts
index 8cf3fbca..fae1482b 100644
--- a/src/routes/api/hololive/+server.ts
+++ b/src/routes/api/hololive/+server.ts
@@ -1,4 +1,4 @@
-import parseScheduleHtml from 'holo-schedule';
+import { JSDOM } from 'jsdom';
export const GET = async ({ url }) =>
Response.json(
@@ -18,3 +18,165 @@ export const GET = async ({ url }) =>
}
}
);
+
+// https://github.com/wabilin/holo-schedule
+
+function mapNodeList<E extends Element, T>(list: NodeListOf<E>, 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 | Buffer): LiveBlock[] {
+ const { window } = new JSDOM(html);
+ const { document } = window;
+ const year = new Date().getFullYear().toString();
+
+ const rows = document.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, '') || '';
+ date = date.match(/\d+\/\d+/)![0].replace('/', '-');
+ }
+
+ const allThumbnail: NodeListOf<HTMLAnchorElement> = row.querySelectorAll('a.thumbnail');
+ allThumbnail.forEach((thumbnail) => {
+ const link = thumbnail.href;
+ const streaming = thumbnail.style.borderColor === 'red';
+ 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<string, string>;
+type ImageStreamerDict = Record<string, string>;
+
+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
+ */
+function parseScheduleHtml(html: string | Buffer, 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 };
+}