From 2dd175c9ffc313b36cb2971751423b1b58cb1472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?100=E3=81=AE=E4=BA=BA=20=28=E3=82=A8=E3=82=B9=E3=83=91?= =?UTF-8?q?=E3=83=BC=E3=83=BB=E3=82=A4=E3=83=BC=E3=82=B7=E3=82=A2=29?= <100@pokemori.jp> Date: Sun, 31 Aug 2025 23:57:10 +0900 Subject: [PATCH] feat(route): add Niconico Mylist (#19938) --- lib/routes/nicovideo/mylist.ts | 39 ++++++++++++++++++++++++++++++++++ lib/routes/nicovideo/types.ts | 27 +++++++++++++++++++++++ lib/routes/nicovideo/utils.ts | 28 +++++++++++++++++++++++- 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 lib/routes/nicovideo/mylist.ts diff --git a/lib/routes/nicovideo/mylist.ts b/lib/routes/nicovideo/mylist.ts new file mode 100644 index 0000000000..2b47b8d372 --- /dev/null +++ b/lib/routes/nicovideo/mylist.ts @@ -0,0 +1,39 @@ +import { DataItem, Route } from '@/types'; + +import { getMylist, renderVideo } from './utils'; +import { parseDate } from '@/utils/parse-date'; + +export const route: Route = { + name: 'Mylist', + path: '/mylist/:id', + parameters: { id: 'Mylist ID' }, + example: '/nicovideo/mylist/2973737', + maintainers: ['esperecyan'], + radar: [ + { + source: ['www.nicovideo.jp/user/:user/mylist/:id'], + target: '/mylist/:id', + }, + ], + handler: async (ctx) => { + const { id } = ctx.req.param(); + + const mylist = await getMylist(id); + + return { + title: `マイリスト ${mylist.name}‐ニコニコ動画`, + link: `https://www.nicovideo.jp/user/${mylist.owner.id}/mylist/${mylist.id}`, + language: 'ja', + item: mylist.items.map( + (item): DataItem => ({ + title: item.video.title, + link: `https://www.nicovideo.jp/watch/${item.video.id}`, + pubDate: parseDate(item.addedAt), + author: [{ name: item.video.owner.name, avatar: item.video.owner.iconUrl, url: `https://www.nicovideo.jp/user/${item.video.owner.id}` }], + description: renderVideo(item.video, false), + image: item.video.thumbnail.nHdUrl ?? item.video.thumbnail.largeUrl ?? item.video.thumbnail.middleUrl ?? undefined, + }) + ), + }; + }, +}; diff --git a/lib/routes/nicovideo/types.ts b/lib/routes/nicovideo/types.ts index f8690c625e..1c42651e5b 100644 --- a/lib/routes/nicovideo/types.ts +++ b/lib/routes/nicovideo/types.ts @@ -52,3 +52,30 @@ export interface VideoItem { series: null; essential: Essential; } + +export type Mylist = { + id: number; + name: string; + description: string; + decoratedDescriptionHtml: string; + defaultSortKey: string; + defaultSortOrder: string; + items: MylistItem[]; + totalItemCount: number; + hasNext: boolean; + isPublic: boolean; + owner: Owner; + hasInvisibleItems: boolean; + followerCount: number; + isFollowing: boolean; +}; + +type MylistItem = { + itemId: number; + watchId: string; + description: string; + decoratedDescriptionHtml: string; + addedAt: string; + status: string; + video: Essential; +}; diff --git a/lib/routes/nicovideo/utils.ts b/lib/routes/nicovideo/utils.ts index 3e6c3f293d..2761ebc9e6 100644 --- a/lib/routes/nicovideo/utils.ts +++ b/lib/routes/nicovideo/utils.ts @@ -1,4 +1,4 @@ -import { Essential, UserInfo, VideoItem } from './types'; +import { Essential, Mylist, UserInfo, VideoItem } from './types'; import ofetch from '@/utils/ofetch'; import cache from '@/utils/cache'; @@ -31,4 +31,30 @@ export const getUserVideosById = (id: string) => false ) as Promise; +export const getMylist = (id: string): Promise => + cache.tryGet( + `nicovideo:mylist:${id}`, + async () => { + const { data } = await ofetch(`https://nvapi.nicovideo.jp/v2/mylists/${id}`, { + headers: { + 'X-Frontend-Id': '6', + }, + query: { + sortKey: 'addedAt', + sortOrder: 'desc', + /** + * @remarks + * Up to 500 items can be registered in a single mylist. + * @see https://qa.nicovideo.jp/faq/show/648?site_domain=default + */ + pageSize: 500, + page: 1, + }, + }); + return data.mylist; + }, + config.cache.routeExpire, + false + ); + export const renderVideo = (video: Essential, embed: boolean) => art(path.join(__dirname, 'templates/video.art'), { video, embed });