[+] Netease stuff

This commit is contained in:
2025-11-15 21:58:34 +08:00
parent 7d3a6ca715
commit c47153c178
10 changed files with 987 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
const DATA_DIR = './data'
+212
View File
@@ -0,0 +1,212 @@
import OpenAI from 'openai'
import type { LyricSegment } from './types'
import { lyric } from '@neteasecloudmusicapienhanced/api'
// Please put OPENAI_API_KEY in your environment variables.
const client = new OpenAI()
const req = {
model: "gpt-5.1-chat-latest",
messages: [
{
role: "system",
content: `あなたは歌詞処理を専門とするAIです。
以下のタスクを実行してください。
タスク
タイムスタンプ付きの日本語の歌詞テキスト(LRC形式)を入力として受け取ります。
このテキストを解析し、指定された形式のJSON配列に変換してください。
処理ルール
メタデータの除外: "作词"、"作曲"、"编曲"など、歌詞以外の情報を含む行はすべて除外してください。
JSON形式: 出力はJSON配列とし、各要素はtimeとlyricの2つのキーを持つオブジェクトとします。
timeキー: 元の行からタイムスタンプ(例:00:15.570)をそのまま文字列として格納します。
lyricキー: 歌詞のテキストを解析し、配列として格納します。
ふりがな: 漢字(または漢字と仮名の混合語)は、["漢字", "ふりがな"]という形式の2要素配列で表現してください。(例: ["蝉", "せみ"]
その他: 漢字を含まないひらがな、カタカナ、句読点、ローマ字などは、そのまま文字列として配列に格納してください。(例: "の", "ママは"
重要
以下の例に示されている形式とまったく同じ出力を生成してください。
`
},
{
role: "user",
content: `[00:00.000] 作词 : 椎名林檎
[00:01.000] 作曲 : 椎名林檎
[00:02.000] 编曲 : 亀田誠治
[00:15.570]蝉の声を聞く度に
[00:19.270]目に浮かぶ九十九里浜
[00:22.600]皺々の祖母の手を離れ
[00:25.880]独りで訪れた歓楽街
[00:29.840]ママは此処の女王様
[00:33.520]生き写しの様なあたし
[00:36.760]誰しもが手を伸べて
[00:39.340]子供ながらに魅せられた歓楽街
[00:43.720]十五になったあたしを
[00:46.890]置いて女王は消えた
[00:50.710]毎週金曜日に来ていた
[00:53.970]男と暮らすのだろう
[01:03.840]「一度栄えし者でも
[01:07.370]必ずや衰えゆく」
[01:10.740]その意味を知る時を迎え
[01:13.990]足を踏み入れたは歓楽街
[01:17.440]消えて行った女を憎めど夏は今
[01:24.480]女王と云う肩書きを誇らしげに掲げる
[01:58.370]女に成ったあたしが
[02:01.950]売るのは自分だけで
[02:05.440]同情を欲したときに
[02:08.790]全てを失うだろう
[02:12.720]JR新宿駅の東口を出たら
[02:19.740]其処はあたしの庭
[02:23.030]大遊戯場歌舞伎町
[02:33.740]今夜からは此の町で
[02:39.300]娘のあたしが女王`
},
{
"role": "assistant",
"content": `\`\`\`json
[
{
"time": "00:15.570",
"lyric": [["蝉", "せみ"], "の", ["声", "こえ"], "を", ["聞", "き"], "く", ["度", "たび"], "に"]
},
{
"time": "00:19.270",
"lyric": [["目", "め"], "に", ["浮", "う"], "かぶ", ["九十九里浜", "くじゅうくりはま"]]
},
{
"time": "00:22.600",
"lyric": [["皺々", "しわしわ"], "の", ["祖母", "そぼ"], "の", ["手", "て"], "を", ["離", "はな"], "れ"]
},
{
"time": "00:25.880",
"lyric": [["独", "ひと"], "り", "で", ["訪", "おとず"], "れた", ["歓楽街", "かんらくがい"]]
},
{
"time": "00:29.840",
"lyric": ["ママは", ["此処", "ここ"], "の", ["女王様", "じょおうさま"]]
},
{
"time": "00:33.520",
"lyric": [["生き写し", "いきうつし"], "の", ["様", "よう"], "なあたし"]
},
{
"time": "00:36.760",
"lyric": [["誰", "だれ"], "しもが", ["手", "て"], "を", ["伸", "の"], "べて"]
},
{
"time": "00:39.340",
"lyric": [["子供", "こども"], "ながらに", ["魅", "み"], "せられた", ["歓楽街", "かんらくがい"]]
},
{
"time": "00:43.720",
"lyric": [["十五", "じゅうご"], "になったあたしを"]
},
{
"time": "00:46.890",
"lyric": [["置", "お"], "いて", ["女王", "じょおう"], "は", ["消", "き"], "えた"]
},
{
"time": "00:50.710",
"lyric": [["毎週", "まいしゅう"], ["金曜日", "きんようび"], "に", ["来", "き"], "ていた"]
},
{
"time": "00:53.970",
"lyric": [["男", "おとこ"], "と", ["暮", "く"], "らすのだろう"]
},
{
"time": "01:03.840",
"lyric": ["「", ["一度", "いちど"], ["栄", "さか"], "えし", ["者", "もの"], "でも"]
},
{
"time": "0E:07.370",
"lyric": [["必", "かなら"], "ずや", ["衰", "おとろ"], "えゆく」"]
},
{
"time": "01:10.740",
"lyric": ["その", ["意味", "いみ"], "を", ["知", "し"], "る", ["時", "とき"], "を", ["迎", "むか"], "え"]
},
{
"time": "01:13.990",
"lyric": [["足", "あし"], "を", ["踏", "ふ"], "み", ["入", "い"], "れたは", ["歓楽街", "かんらくがい"]]
},
{
"time": "01:17.440",
"lyric": [["消", "き"], "えて", ["行", "い"], "った", ["女", "おんな"], "を", ["憎", "にく"], "めど", ["夏", "なつ"], "は", ["今", "いま"]]
},
{
"time": "01:24.480",
"lyric": [["女王", "じょおう"], "と", ["云", "い"], "う", ["肩書き", "かたがき"], "を", ["誇", "ほこ"], "らしげに", ["掲", "かか"], "げる"]
},
{
"time": "01:58.370",
"lyric": [["女", "おんな"], "に", ["成", "な"], "ったあたしが"]
},
{
"time": "02:01.950",
"lyric": [["売", "う"], "るのは", ["自分", "じぶん"], "だけで"]
},
{
"time": "02:05.440",
"lyric": [["同情", "どうじょう"], "を", ["欲", "ほ"], "したときに"]
},
{
"time": "02:08.790",
"lyric": [["全", "すべ"], "てを", ["失", "うしな"], "うだろう"]
},
{
"time": "02:12.720",
"lyric": ["JR", ["新宿駅", "しんじゅくえき"], "の", ["東口", "ひがしぐち"], "を", ["出", "で"], "たら"]
},
{
"time": "02:19.740",
"lyric": [["其処", "そこ"], "はあたしの", ["庭", "にわ"]]
},
{
"time": "02:23.030",
"lyric": [["大遊戯場", "だいゆうぎじょう"], ["歌舞伎町", "かぶきちょう"]]
},
{
"time": "02:33.740",
"lyric": [["今夜", "こんや"], "からは", ["此", "こ"], "の", ["町", "まち"], "で"]
},
{
"time": "02:39.300",
"lyric": [["娘", "むすめ"], "のあたしが", ["女王", "じょおう"]]
}
]
\`\`\``
},
{
role: "user",
content: ""
}
],
response_format: {
type: "text"
},
// temperature: 0.6,
max_completion_tokens: 2048,
// top_p: 0.7,
frequency_penalty: 0,
presence_penalty: 0,
store: false
}
export async function aiParseLyrics(raw: string): Promise<LyricSegment[]> {
const thisReq = JSON.parse(JSON.stringify(req))
thisReq.messages[thisReq.messages.length - 1].content = raw
const response = await client.chat.completions.create(thisReq)
const text = response.choices[0].message?.content || ''
const jsonText = text.replace(/```json/, '').replace(/```/, '').trim()
try {
return JSON.parse(jsonText)
} catch (e) {
console.error('Failed to parse JSON from AI response:', jsonText)
throw e
}
}
// const lrcResp = (await lyric({ id: 25723366 })).body as any
// const lrc = lrcResp.lrc.lyric
// console.log(await aiParseLyrics(lrc))
+190
View File
@@ -0,0 +1,190 @@
import OpenAI from 'openai'
import type { LyricLine, LyricSegment } from './types'
// Please put OPENAI_API_KEY in your environment variables.
const client = new OpenAI()
const req = {
model: "gpt-5.1-chat-latest",
messages: [
{
role: "system",
content: `あなたは歌詞処理を専門とするAIです。
以下のタスクを実行してください。
タスク
タイムスタンプ付きの日本語の歌詞テキスト(LRC形式)を入力として受け取ります。
このテキストを解析し、指定された形式の「ふりがな付きテキスト」に変換してください。
処理ルール
1. **メタデータの除外**: "作词"、"作曲"、"编曲"など、歌詞以外の情報を含む行はすべて除外してください。
2. **出力形式**: 1行につき \`[タイムスタンプ] ふりがな付き歌詞\` の形式で出力してください。
3. **ふりがな**: 歌詞本文では、**全ての漢字**に \`漢字(ふりがな)\` の形式でふりがな(ルビ)を付けてください。
4. **その他**: ひらがな、カタカナ、句読点、ローマ字などはそのまま出力してください。
重要
* **全ての漢字にふりがなを付けてください。** 読み方が不明な場合でも、最も一般的だと思われる読み方を付けてください。漢字をそのまま残してはいけません。
* 以下の例に示されている形式とまったく同じ出力を生成してください。
[00:15.570] 蝉(せみ)の 声(こえ)を 聞(き)く 度(たび)に
[00:19.270] 目(め)に 浮(う)かぶ 九十九里浜(くじゅうくりはま)
[00:22.600] 皺々(しわしわ)の 祖母(そぼ)の 手(て)を 離(はな)れ
[00:25.880] 独(ひと)り で 訪(おとず)れた 歓楽街(かんらくがい)
[00:29.840] ママは 此処(ここ)の 女王様(じょおうさま)`
},
{
role: "user",
content: `[00:00.000] 作词 : 椎名林檎
[00:01.000] 作曲 : 椎名林檎
[00:02.000] 编曲 : 亀田誠治
[00:15.570]蝉の声を聞く度に
[00:19.270]目に浮かぶ九十九里浜
[00:22.600]皺々の祖母の手を離れ
[00:25.880]独りで訪れた歓楽街
[00:29.840]ママは此処の女王様
[00:33.520]生き写しの様なあたし
[00:36.760]誰しもが手を伸べて
[00:39.340]子供ながらに魅せられた歓楽街
[00:43.720]十五になったあたしを
[00:46.890]置いて女王は消えた
[00:50.710]毎週金曜日に来ていた
[00:53.970]男と暮らすのだろう
[01:03.840]「一度栄えし者でも
[01:07.370]必ずや衰えゆく」
[01:10.740]その意味を知る時を迎え
[01:13.990]足を踏み入れたは歓楽街
[01:17.440]消えて行った女を憎めど夏は今
[01:24.480]女王と云う肩書きを誇らしげに掲げる
[01:58.370]女に成ったあたしが
[02:01.950]売るのは自分だけで
[02:05.440]同情を欲したときに
[02:08.790]全てを失うだろう
[02:12.720]JR新宿駅の東口を出たら
[02:19.740]其処はあたしの庭
[02:23.030]大遊戯場歌舞伎町
[02:33.740]今夜からは此の町で
[02:39.300]娘のあたしが女王`
},
{
"role": "assistant",
"content": `[00:15.570] 蝉(せみ)の 声(こえ)を 聞(き)く 度(たび)に
[00:19.270] 目(め)に 浮(う)かぶ 九十九里浜(くじゅうくりはま)
[00:22.600] 皺々(しわしわ)の 祖母(そぼ)の 手(て)を 離(はな)れ
[00:25.880] 独(ひと)りで 訪(おとず)れた 歓楽街(かんらくがい)
[00:29.840] ママは 此処(ここ)の 女王様(じょおうさま)
[00:33.520] 生(い)き 写(うつ)しの 様(よう)なあたし
[00:36.760] 誰(だれ)しもが 手(て)を 伸(の)べて
[00:39.340] 子供(こども)ながらに 魅(み)せられた 歓楽街(かんらくがい)
[00:43.720] 十五(じゅうご)になったあたしを
[00:46.890] 置(お)いて 女王(じょおう)は 消(き)えた
[00:50.710] 毎週(まいしゅう) 金曜日(きんようび)に 来(き)ていた
[00:53.970] 男(おとこ)と 暮(く)らすのだろう
[01:03.840] 「 一度(いちど) 栄(さか)えし 者(もの)でも
[01:07.370] 必(かなら)ずや 衰(おとろ)えゆく」
[01:10.740] その 意味(いみ)を 知(し)る 時(とき)を 迎(むか)え
[01:13.990] 足(あし)を 踏(ふ)み 入(い)れたは 歓楽街(かんらくがい)
[01:17.440] 消(き)えて 行(い)った 女(おんな)を 憎(にく)めど 夏(なつ)は 今(いま)
[01:24.480] 女王(じょおう)と 云(い)う 肩書(かたが)きを 誇(ほこ)らしげに 掲(かか)げる
[01:58.370] 女(おんな)に 成(な)ったあたしが
[02:01.950] 売(う)るのは 自分(じぶん)だけで
[02:05.440] 同情(どうじょう)を 欲(ほ)したときに
[02:08.790] 全(すべ)てを 失(うしな)うだろう
[02:12.720] JR 新宿駅(しんじゅくえき)の 東口(ひがしぐち)を 出(で)たら
[02:19.740] 其処(そこ)はあたしの 庭(にわ)
[02:23.030] 大遊戯場(だいゆうぎじょう) 歌舞伎町(かぶきちょう)
[02:33.740] 今夜(こんや)からは 此(こ)の 町(まち)で
[02:39.300] 娘(むすめ)のあたしが 女王(じょおう)`
},
{
role: "user",
content: "" // This will be filled by the function
}
],
response_format: {
type: "text" // We explicitly want text output
},
max_completion_tokens: 2048,
frequency_penalty: 0,
presence_penalty: 0,
store: false
}
/**
* NEW: This function parses the AI's formatted text output
* and converts it into the LyricSegment[] structure.
*/
function parseFuriganaText(text: string): LyricLine[] {
const segments: LyricLine[] = [];
const lines = text.split('\n').filter(line => line.trim() !== '');
// Regex to capture [timestamp] and (the rest of the line)
const lineRegex = /\[(\d+:\d+\.\d+)\](.*)/;
// Regex to parse the lyric part:
// It captures either:
// 1. `Kanji(furigana)` -> match[1] = Kanji, match[2] = furigana
// 2. `PlainText` -> match[3] = PlainText
const tokenRegex = /([^]+)([^]+)|([^]+)/g;
for (const line of lines) {
const lineMatch = line.trim().match(lineRegex);
if (!lineMatch) {
console.warn(`Skipping unparseable line: ${line}`);
continue;
}
const time = lineMatch[1] as string;
const lyricText = lineMatch[2].trim();
const lyric: LyricSegment[] = [];
const matches = lyricText.matchAll(tokenRegex);
for (const match of matches) {
const kanji = match[1];
const furigana = match[2];
const plain = match[3];
if (kanji !== undefined && furigana !== undefined) {
// This is a Kanji(furigana) group
lyric.push([kanji.trim(), furigana.trim()]);
} else if (plain) {
// This is a plain text group
const trimmedPlain = plain.trim();
if (trimmedPlain) {
lyric.push(trimmedPlain);
}
}
}
if (lyric.length > 0) {
segments.push({ time, lyric });
}
}
return segments;
}
/**
* UPDATED: This function now gets text from the AI
* and uses the new local parser.
*/
export async function aiParseLyrics(raw: string): Promise<LyricLine[]> {
const thisReq = JSON.parse(JSON.stringify(req))
thisReq.messages[thisReq.messages.length - 1].content = raw
const response = await client.chat.completions.create(thisReq)
const text = response.choices[0].message?.content || ''
// Clean up potential markdown blocks
const responseText = text.replace(/```/g, '').trim()
try {
// Use the new, robust text parser
return parseFuriganaText(responseText)
} catch (e) {
console.error('Failed to parse Furigana text from AI response:', responseText)
console.error(e)
throw new Error('Failed to parse AI response text.');
}
}
+101
View File
@@ -0,0 +1,101 @@
import * as ne from '@neteasecloudmusicapienhanced/api';
import * as fs from 'fs/promises';
import * as path from 'path';
import { aiParseLyrics } from './lyricsParse';
// Filesystem structure:
// $data/playlists/{playlistId}/detail.json
// $data/songs/{songId}/meta.json
// $data/songs/{songId}/cover.jpg
// $data/songs/{songId}/lyrics.json
// $data/songs/{songId}/lyricsProcessed.json
/**
* Functional wrapper to cache API results to filesystem.
* @param filePath Function to get cache file path from argument.
* @param fn Function to get data if not cached.
* @returns Function that gets data from cache or calls fn and caches result.
*/
const cached = <T, R>(filePath: (arg: T) => Promise<string>, fn: (arg: T) => Promise<R>): (arg: T, noCache?: boolean) => Promise<R> =>
async (arg: T, noCache = false): Promise<R> => {
const file = await filePath(arg)
if (!noCache && await fs.stat(file).catch(() => false)) {
const data = await fs.readFile(file, 'utf-8')
return JSON.parse(data) as R
}
const result = await fn(arg)
await fs.mkdir(path.dirname(file), { recursive: true })
await fs.writeFile(file, JSON.stringify(result, null, 2), 'utf-8')
return result
}
/**
* Convert a playlist reference (URL or ID) to an ID number.
*/
function parsePlaylistRef(ref: string): number {
const urlMatch = ref.match(/playlist\?id=(\d+)/)
if (urlMatch) return +urlMatch[1]
if (/^\d+$/.test(ref)) return +ref
throw new Error('Invalid playlist reference')
}
/**
* Get raw playlist data from cache or netease API.
*/
const getPlaylistRaw = cached(
async ({ id }: { id: number }) => path.join('data', 'playlists', `${id}`, 'detail.json'),
async ({ id }: { id: number }) => {
const pl = ((await ne.playlist_detail({ id })).body as any)
// Save each song
for (const track of pl.playlist.tracks) {
const p = path.join('data', 'songs', `${track.id}`, 'meta.json')
await fs.mkdir(path.dirname(p), { recursive: true })
await fs.writeFile(p, JSON.stringify(track, null, 2), 'utf-8')
}
return pl
}
)
export interface NeteaseSongBrief {
id: number
name: string
album: string
albumId: number
albumPic: string
artists: { id: number, name: string }[]
}
/**
* Get a list of songs from a playlist reference.
*/
export async function getSongsFromPlaylist(ref: string): Promise<any[]> {
const playlistId = parsePlaylistRef(ref)
const plData = await getPlaylistRaw({ id: playlistId })
return plData.playlist.tracks.map((track: any) => ({
id: track.id,
name: track.name,
album: track.al.name,
albumId: track.al.id,
albumPic: track.al.picUrl,
artists: track.ar.map((ar: any) => ({ id: ar.id, name: ar.name }))
}))
}
interface NeteaseLyricsResponse { lrc: { lyric: string } }
export const getLyricsRaw = cached(
async (songId: number) => path.join('data', 'songs', `${songId}`, 'lyrics.json'),
async (songId: number) => (await ne.lyric({ id: songId })).body as any as NeteaseLyricsResponse
)
export const getLyricsProcessed = cached(
async (songId: number) => path.join('data', 'songs', `${songId}`, 'lyricsProcessed.json'),
async (songId: number) => {
const raw = await getLyricsRaw(songId)
return aiParseLyrics(raw.lrc.lyric)
}
)
// console.log((await getSongsFromPlaylist('580208139')).length)
console.log(await getLyricsProcessed(25723366, true))
+36
View File
@@ -0,0 +1,36 @@
export type LyricSegment = string | string[]
export interface LyricLine {
time: string
lyric: LyricSegment[]
}
export interface Song {
title: string
lyrics: LyricLine[]
}
export interface ProcessedLyricLine {
jp: LyricSegment[]
kanji: string
hiragana: string
cleanHiragana: string
romaji: string
cleanRomaji: string
}
export interface DisplayCharacter {
char: string
state: 'correct' | 'incorrect' | 'untyped' | 'ignored'
originalSegment?: LyricSegment
segmentHiragana?: string
}
export interface GameStats {
wpm: number
accuracy: number
totalTyped: number
totalCorrect: number
startTime: number
totalTime: number
}