From a7f6ea5d6f9c6f482848dc1e2711d1b00e584875 Mon Sep 17 00:00:00 2001 From: wukko Date: Mon, 7 Jul 2025 20:11:24 +0600 Subject: [PATCH] api: add initial draft support for nicovideo --- api/src/processing/match.js | 9 ++ api/src/processing/service-config.js | 5 + api/src/processing/service-patterns.js | 2 + api/src/processing/services/nicovideo.js | 120 +++++++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 api/src/processing/services/nicovideo.js diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 360ed532..5ee699e3 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -29,6 +29,7 @@ import loom from "./services/loom.js"; import facebook from "./services/facebook.js"; import bluesky from "./services/bluesky.js"; import xiaohongshu from "./services/xiaohongshu.js"; +import nicovideo from "./services/nicovideo.js"; let freebind; @@ -268,6 +269,14 @@ export default async function({ host, patternMatch, params, authType }) { }); break; + case "nicovideo": + r = await nicovideo({ + ...patternMatch, + dispatcher, + quality: params.videoQuality, + }); + break; + default: return createResponse("error", { code: "error.api.service.unsupported" diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index 3ffcf10a..6d29b6b5 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -60,6 +60,11 @@ export const services = { loom: { patterns: ["share/:id", "embed/:id"], }, + nicovideo: { + patterns: ["watch/:id"], + tld: "jp", + subdomains: ["sp"], + }, ok: { patterns: [ "video/:id", diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js index fd6daef9..5180ee94 100644 --- a/api/src/processing/service-patterns.js +++ b/api/src/processing/service-patterns.js @@ -79,4 +79,6 @@ export const testers = { "xiaohongshu": pattern => pattern.id?.length <= 24 && pattern.token?.length <= 64 || pattern.shareId?.length <= 24, + + "nicovideo": pattern => pattern.id?.length <= 12, } diff --git a/api/src/processing/services/nicovideo.js b/api/src/processing/services/nicovideo.js new file mode 100644 index 00000000..68daa0cd --- /dev/null +++ b/api/src/processing/services/nicovideo.js @@ -0,0 +1,120 @@ +import { genericUserAgent } from "../../config.js"; + +const genericHeaders = { + "user-agent": genericUserAgent, + "accept-language": "en-US,en;q=0.9", +} + +const getVideoInfo = async (id, dispatcher, quality) => { + const html = await fetch(`https://www.nicovideo.jp/watch/${id}`, { + dispatcher, + headers: genericHeaders + }).then(r => r.text()).catch(() => {}); + + if (!(html.includes("accessRightKey") + || !(html.includes('')[0] + .replaceAll(""", '"'); + + const data = JSON.parse(rawContent)?.data?.response; + + if (!data) { + return { error: "fetch.fail" }; + } + + const audio = data.media?.domand?.audios.find(audio => audio.isAvailable); + const bestVideo = data.media?.domand?.videos.find(video => video.isAvailable); + + const preferredVideo = data.media?.domand?.videos.find(video => + video.isAvailable && video.label.split('p')[0] === quality + ); + + const video = preferredVideo || bestVideo; + + return { + watchTrackId: data.client?.watchTrackId, + accessRightKey: data.media?.domand?.accessRightKey, + + video, + + outputs: [[video.id, audio.id]], + + author: data.owner?.nickname, + title: data.video?.title, + } +} + +const getHls = async (dispatcher, id, trackId, accessRightKey, outputs) => { + const response = await fetch( + `https://nvapi.nicovideo.jp/v1/watch/${id}/access-rights/hls?actionTrackId=${trackId}`, + { + method: "POST", + dispatcher, + headers: { + ...genericHeaders, + "content-type": "application/json", + "x-access-right-key": accessRightKey, + "x-frontend-id": "6", + "x-frontend-version": "0", + "x-request-with": "nicovideo", + }, + body: JSON.stringify({ + outputs, + }) + } + ).then(r => r.json()).catch(() => {}); + + if (!response?.data?.contentUrl) return; + return response.data.contentUrl; +} + +export default async function ({ id, dispatcher, quality }) { + const { + watchTrackId, + accessRightKey, + video, + outputs, + author, + title, + error + } = await getVideoInfo(id, dispatcher, quality); + + if (error) { + return { error }; + } + + if (!watchTrackId || !accessRightKey || !outputs) { + return { error: "fetch.empty" }; + } + + const hlsUrl = await getHls( + dispatcher, + id, + watchTrackId, + accessRightKey, + outputs + ); + + if (!hlsUrl) { + return { error: "fetch.empty" }; + } + + return { + urls: hlsUrl, + isHLS: true, + filenameAttributes: { + service: "nicovideo", + id, + title, + author, + resolution: `${video.width}x${video.height}`, + qualityLabel: `${video.label}`, + extension: "mp4" + } + } +}