diff --git a/cli-manifest.json b/cli-manifest.json index 944958737..f50ae3cfd 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -8560,10 +8560,11 @@ "type": "str", "default": "movie", "required": false, - "help": "条目类型(movie=电影, book=图书)", + "help": "条目类型(movie=电影, book=图书, music=音乐)", "choices": [ "movie", - "book" + "book", + "music" ] } ], @@ -8584,6 +8585,11 @@ "series", "isbn10", "isbn13", + "artists", + "albumType", + "media", + "barcode", + "discCount", "year", "rating", "ratingCount", @@ -8593,6 +8599,8 @@ "country", "duration", "summary", + "tracks", + "cover", "url" ], "type": "js", diff --git a/clis/douban/subject.js b/clis/douban/subject.js index 4cfa46136..9cac6212d 100644 --- a/clis/douban/subject.js +++ b/clis/douban/subject.js @@ -12,7 +12,7 @@ cli({ navigateBefore: false, args: [ { name: 'id', required: true, positional: true, help: '豆瓣条目 ID' }, - { name: 'type', default: 'movie', choices: ['movie', 'book'], help: '条目类型(movie=电影, book=图书)' }, + { name: 'type', default: 'movie', choices: ['movie', 'book', 'music'], help: '条目类型(movie=电影, book=图书, music=音乐)' }, ], columns: [ 'id', @@ -31,6 +31,11 @@ cli({ 'series', 'isbn10', 'isbn13', + 'artists', + 'albumType', + 'media', + 'barcode', + 'discCount', 'year', 'rating', 'ratingCount', @@ -40,6 +45,8 @@ cli({ 'country', 'duration', 'summary', + 'tracks', + 'cover', 'url', ], func: async (page, args) => [await loadDoubanSubjectDetail(page, args.id, args.type)], diff --git a/clis/douban/subject.test.js b/clis/douban/subject.test.js index 4f30ac122..63322c44b 100644 --- a/clis/douban/subject.test.js +++ b/clis/douban/subject.test.js @@ -8,4 +8,10 @@ describe('douban subject command', () => { expect(command).toBeDefined(); expect(command?.navigateBefore).toBe(false); }); + + it('accepts music subject type', () => { + const command = getRegistry().get('douban/subject'); + const typeArg = command?.args.find((arg) => arg.name === 'type'); + expect(typeArg?.choices).toEqual(['movie', 'book', 'music']); + }); }); diff --git a/clis/douban/utils.js b/clis/douban/utils.js index 058fcbbe6..b625e7572 100644 --- a/clis/douban/utils.js +++ b/clis/douban/utils.js @@ -210,6 +210,32 @@ export function normalizeDoubanBookSubject(raw) { url: firstNonEmpty([raw?.url]), }; } +export function normalizeDoubanMusicSubject(raw) { + const info = parseDoubanBookInfoText(raw?.infoText); + return { + id: normalizeDoubanSubjectId(raw?.id), + type: 'music', + title: firstNonEmpty([raw?.title]), + subtitle: firstNonEmpty([raw?.subtitle, info['又名']]), + artists: splitDoubanPeople(firstNonEmpty([info['表演者'], info['作者']])), + genres: firstNonEmpty([info['流派']]), + albumType: firstNonEmpty([info['专辑类型']]), + media: firstNonEmpty([info['介质']]), + publishDate: firstNonEmpty([info['发行时间'], info['出版年']]), + publishYear: extractDoubanPublishYear(firstNonEmpty([info['发行时间'], info['出版年']])), + publisher: firstNonEmpty([info['出版者'], info['出版社']]), + discCount: parseDoubanPageCount(info['唱片数']), + barcode: firstNonEmpty([info['条形码'], info['ISBN']]), + rating: parseDoubanRating(raw?.rating), + ratingCount: parseDoubanCount(raw?.ratingCount), + summary: normalizeText(raw?.summary).replace(/\s*投诉$/, ''), + tracks: Array.isArray(raw?.tracks) + ? raw.tracks.map((track) => normalizeText(track)).filter(Boolean) + : [], + cover: firstNonEmpty([raw?.cover]), + url: firstNonEmpty([raw?.url]), + }; +} async function loadDoubanMovieSubject(page, subjectId) { const normalizedId = normalizeDoubanSubjectId(subjectId); const data = await withDetachedRetry(async () => { @@ -300,11 +326,57 @@ async function loadDoubanBookSubject(page, subjectId) { }); return normalizeDoubanBookSubject(data); } +async function loadDoubanMusicSubject(page, subjectId) { + const normalizedId = normalizeDoubanSubjectId(subjectId); + const data = await withDetachedRetry(async () => { + await page.goto(`https://music.douban.com/subject/${normalizedId}/`, { waitUntil: 'load', settleMs: 1500 }); + await ensureDoubanReady(page); + await page.wait({ selector: 'h1, #info', timeout: 8 }).catch(() => { }); + return page.evaluate(` + (() => { + const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const sectionText = (headingPattern) => { + const headings = Array.from(document.querySelectorAll('h2')); + const heading = headings.find((node) => headingPattern.test(normalize(node.textContent))); + let next = heading?.nextElementSibling || null; + while (next) { + if (next.matches?.('.indent, .track-items, #link-report')) { + const text = normalize(next.innerText || next.textContent || ''); + if (text) return text; + } + next = next.nextElementSibling; + } + return ''; + }; + const tracks = Array.from(document.querySelectorAll('.track-items li')) + .map((node) => normalize(node.textContent)) + .filter(Boolean); + return { + id: ${JSON.stringify(normalizedId)}, + title: normalize(document.querySelector('h1 span')?.textContent || document.querySelector('h1')?.textContent || ''), + subtitle: '', + infoText: document.querySelector('#info')?.innerText || document.querySelector('#info')?.textContent || '', + rating: normalize(document.querySelector('strong.rating_num, strong[property="v:average"]')?.textContent || ''), + ratingCount: normalize(document.querySelector('a.rating_people > span, span[property="v:votes"]')?.textContent || ''), + summary: sectionText(/简介/), + tracks, + cover: document.querySelector('#mainpic img')?.getAttribute('src') || '', + url: location.href, + }; + })() + `); + }); + return normalizeDoubanMusicSubject(data); +} export async function loadDoubanSubjectDetail(page, subjectId, subjectType = 'movie') { - const type = String(subjectType || 'movie').trim() === 'book' ? 'book' : 'movie'; + const rawType = String(subjectType || 'movie').trim(); + const type = rawType === 'book' || rawType === 'music' ? rawType : 'movie'; if (type === 'book') { return loadDoubanBookSubject(page, subjectId); } + if (type === 'music') { + return loadDoubanMusicSubject(page, subjectId); + } return loadDoubanMovieSubject(page, subjectId); } export async function loadDoubanSubjectPhotos(page, subjectId, options = {}) { diff --git a/clis/douban/utils.test.js b/clis/douban/utils.test.js index 48722157f..4ef3d03eb 100644 --- a/clis/douban/utils.test.js +++ b/clis/douban/utils.test.js @@ -7,6 +7,7 @@ import { loadDoubanSubjectDetail, loadDoubanSubjectPhotos, normalizeDoubanBookSubject, + normalizeDoubanMusicSubject, normalizeDoubanSubjectId, promoteDoubanPhotoUrl, resolveDoubanPhotoAssetUrl, @@ -276,6 +277,51 @@ ISBN: 9787544270871 }); }); + it('normalizes douban music subject raw data into structured fields', () => { + const normalized = normalizeDoubanMusicSubject({ + id: '26812952', + title: '周杰伦的床边故事', + infoText: ` +又名: Jay Chou's Bedtime Stories +表演者: 周杰伦 +流派: 流行 +专辑类型: 专辑 +介质: CD +发行时间: 2016-06-24 +出版者: 杰威尔音乐 +唱片数: 1 +条形码: 9787799452302 + `, + rating: '8.1', + ratingCount: '30853', + summary: '周杰伦2016年最新专辑 投诉', + tracks: ['床边故事', '告白气球'], + cover: 'https://img9.doubanio.com/view/subject/m/public/s28836865.jpg', + url: 'https://music.douban.com/subject/26812952/', + }); + expect(normalized).toMatchObject({ + id: '26812952', + type: 'music', + title: '周杰伦的床边故事', + subtitle: "Jay Chou's Bedtime Stories", + artists: ['周杰伦'], + genres: '流行', + albumType: '专辑', + media: 'CD', + publishDate: '2016-06-24', + publishYear: '2016', + publisher: '杰威尔音乐', + discCount: 1, + barcode: '9787799452302', + rating: 8.1, + ratingCount: 30853, + summary: '周杰伦2016年最新专辑', + tracks: ['床边故事', '告白气球'], + cover: 'https://img9.doubanio.com/view/subject/m/public/s28836865.jpg', + url: 'https://music.douban.com/subject/26812952/', + }); + }); + it('parses movie-hot rows with real chart fields only', async () => { const items = [ createFakeMovieHotItem({ @@ -364,6 +410,60 @@ ISBN: 9787544270871 }); }); + it('loads music subject details from music.douban.com when type=music', async () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn() + .mockResolvedValueOnce({ blocked: false, title: '周杰伦的床边故事 (豆瓣)', href: 'https://music.douban.com/subject/26812952/' }) + .mockResolvedValueOnce({ + id: '26812952', + title: '周杰伦的床边故事', + subtitle: '', + infoText: ` +表演者: 周杰伦 +流派: 流行 +专辑类型: 专辑 +介质: CD +发行时间: 2016-06-24 +出版者: 杰威尔音乐 +唱片数: 1 +条形码: 9787799452302 + `, + rating: '8.1', + ratingCount: '30853', + summary: '周杰伦2016年最新专辑', + tracks: ['床边故事', '告白气球'], + cover: 'https://img9.doubanio.com/view/subject/m/public/s28836865.jpg', + url: 'https://music.douban.com/subject/26812952/', + }), + }; + const detail = await loadDoubanSubjectDetail(page, '26812952', 'music'); + expect(page.goto).toHaveBeenCalledWith('https://music.douban.com/subject/26812952/', { + waitUntil: 'load', + settleMs: 1500, + }); + expect(page.wait).toHaveBeenCalledWith({ selector: 'h1, #info', timeout: 8 }); + expect(detail).toMatchObject({ + id: '26812952', + type: 'music', + title: '周杰伦的床边故事', + artists: ['周杰伦'], + genres: '流行', + albumType: '专辑', + media: 'CD', + publishDate: '2016-06-24', + publishYear: '2016', + publisher: '杰威尔音乐', + discCount: 1, + barcode: '9787799452302', + rating: 8.1, + ratingCount: 30853, + tracks: ['床边故事', '告白气球'], + url: 'https://music.douban.com/subject/26812952/', + }); + }); + it('retries transient detached navigation errors when loading douban search results', async () => { const page = { goto: vi.fn()