aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2024-02-07 01:25:54 -0800
committerFuwn <[email protected]>2024-02-07 01:25:54 -0800
commitc838fe433de484acbc96d7cae6da48e27fcd751d (patch)
treefa11c70484f4462b61dd7726aa39a1986b0117da
parentrevert: "revert: "feat(hololive): re-implement get schedule html"" (diff)
downloaddue.moe-c838fe433de484acbc96d7cae6da48e27fcd751d.tar.xz
due.moe-c838fe433de484acbc96d7cae6da48e27fcd751d.zip
refactor(hololive): locally implement hololive-schedule
-rwxr-xr-xbun.lockbbin180933 -> 169570 bytes
-rw-r--r--package.json1
-rw-r--r--src/routes/api/hololive/+server.ts164
3 files changed, 163 insertions, 2 deletions
diff --git a/bun.lockb b/bun.lockb
index f9154f2d..c6e0a4d6 100755
--- a/bun.lockb
+++ b/bun.lockb
Binary files differ
diff --git a/package.json b/package.json
index 3f0173be..a7287a13 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,6 @@
"@vercel/postgres": "^0.5.1",
"@vercel/speed-insights": "^1.0.9",
"dexie": "^4.0.1-alpha.25",
- "holo-schedule": "^0.5.4",
"jsdom": "^23.0.1",
"lz-string": "^1.5.0",
"modern-screenshot": "^4.4.33",
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 };
+}