import vm from 'node:vm'; import { describe, expect, it, vi } from 'vitest'; import { getDoubanPhotoExtension, inferDoubanSearchResultType, loadDoubanMovieHot, loadDoubanSubjectDetail, loadDoubanSubjectPhotos, normalizeDoubanBookSubject, normalizeDoubanSubjectId, promoteDoubanPhotoUrl, resolveDoubanPhotoAssetUrl, searchDouban, splitDoubanTitle, } from './utils.js'; function createFakeNode(text = '', attrs = {}) { return { textContent: text, getAttribute(name) { return attrs[name] || ''; }, }; } function createFakeSearchItem({ title, url, rating, abstract, cover }) { return { querySelector(selector) { if (selector === '.title-text, .title a, .title h3 a, h3 a, a[title]') { return createFakeNode(title, { href: url, title }); } if (selector === '.rating_nums') { return createFakeNode(rating); } if (selector === '.meta.abstract, .meta, .abstract, .subject-abstract, p') { return createFakeNode(abstract); } if (selector === 'a[href*="/subject/"]') { return createFakeNode('', { href: url }); } if (selector === 'img') { return createFakeNode('', { src: cover }); } return null; }, }; } function createFakeMovieHotItem({ title, url, info, rating, votes, cover }) { return { querySelector(selector) { if (selector === '.pl2 a') { return createFakeNode(title, { href: url }); } if (selector === '.pl2 p') { return createFakeNode(info); } if (selector === '.star .pl') { return createFakeNode(votes); } if (selector === '.rating_nums') { return createFakeNode(rating); } if (selector === 'img') { return createFakeNode('', { src: cover }); } return null; }, }; } function runMovieHotEvaluate(script, items) { const document = { querySelectorAll(selector) { return selector === '.item' ? items : []; }, }; return vm.runInNewContext(script, { document, URL }); } async function runSearchEvaluate(script, rawItems, domItems) { const document = { querySelector(selector) { if (selector === '.item-root .title-text, .item-root .title a') { return domItems[0]?.querySelector('.title-text, .title a, .title h3 a, h3 a, a[title]') || null; } if (selector === '.item-root .title-text, .item-root .title a, .result-list .result-item h3 a') { return domItems[0]?.querySelector('.title-text, .title a, .title h3 a, h3 a, a[title]') || null; } return null; }, querySelectorAll(selector) { if (selector === '.item-root') { return domItems; } if (selector === '.item-root, .result-list .result-item') { return domItems; } return []; }, }; return vm.runInNewContext(script, { Map, Promise, document, window: { __DATA__: { items: rawItems } }, location: { href: 'https://search.douban.com/movie/subject_search?search_text=%E5%B0%84%E9%9B%95%E8%8B%B1%E9%9B%84%E4%BC%A0', origin: 'https://search.douban.com', }, setTimeout(fn) { fn(); return 0; }, }); } describe('douban utils', () => { it('normalizes valid subject ids', () => { expect(normalizeDoubanSubjectId(' 30382501 ')).toBe('30382501'); }); it('rejects invalid subject ids', () => { expect(() => normalizeDoubanSubjectId('tt30382501')).toThrow('Invalid Douban subject ID'); }); it('promotes thumbnail urls to large photo urls', () => { expect(promoteDoubanPhotoUrl('https://img1.doubanio.com/view/photo/m/public/p2913450214.webp')).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp'); expect(promoteDoubanPhotoUrl('https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2578474613.jpg')).toBe('https://img9.doubanio.com/view/photo/l/public/p2578474613.jpg'); }); it('rejects non-http photo urls during promotion', () => { expect(promoteDoubanPhotoUrl('data:image/gif;base64,abc')).toBe(''); }); it('prefers lazy-loaded photo urls over data placeholders', () => { expect(resolveDoubanPhotoAssetUrl([ '', 'https://img1.doubanio.com/view/photo/m/public/p2913450214.webp', 'data:image/gif;base64,abc', ], 'https://movie.douban.com/subject/30382501/photos?type=Rb')).toBe('https://img1.doubanio.com/view/photo/m/public/p2913450214.webp'); }); it('drops unsupported non-http photo urls when no real image url exists', () => { expect(resolveDoubanPhotoAssetUrl(['data:image/gif;base64,abc', 'blob:https://movie.douban.com/example'], 'https://movie.douban.com/subject/30382501/photos?type=Rb')).toBe(''); }); it('removes the default photo cap when scanning for an exact photo id', async () => { const evaluate = vi.fn() .mockResolvedValueOnce({ blocked: false, title: 'Some Movie', href: 'https://movie.douban.com/subject/30382501/photos?type=Rb' }) .mockResolvedValueOnce({ subjectId: '30382501', subjectTitle: 'The Wandering Earth 2', type: 'Rb', photos: [ { index: 731, photoId: '2913450215', title: 'Character poster', imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg', thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450215.jpg', detailUrl: 'https://movie.douban.com/photos/photo/2913450215/', page: 25, }, ], }); const page = { goto: vi.fn().mockResolvedValue(undefined), wait: vi.fn().mockResolvedValue(undefined), evaluate, }; await loadDoubanSubjectPhotos(page, '30382501', { type: 'Rb', targetPhotoId: '2913450215', }); const scanScript = evaluate.mock.calls[1]?.[0]; expect(scanScript).toContain('const targetPhotoId = "2913450215";'); expect(scanScript).toContain(`const limit = ${Number.MAX_SAFE_INTEGER};`); expect(scanScript).toContain('for (let pageIndex = 0; photos.length < limit; pageIndex += 1)'); }); it('keeps image extensions when download urls contain query params', () => { expect(getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp?foo=1')).toBe('.webp'); expect(getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.jpeg')).toBe('.jpeg'); }); it('maps tv series results to tvshow in searchDouban output', async () => { const domItems = [ createFakeSearchItem({ title: '射雕英雄传‎ (2017)', url: 'https://movie.douban.com/subject/26663086/', rating: '7.9', abstract: '中国大陆 / 剧情 / 武侠 / 古装 / 45分钟', cover: 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2411844029.webp', }), createFakeSearchItem({ title: '射雕英雄传:侠之大者‎ (2025)', url: 'https://movie.douban.com/subject/36289423/', rating: '5.2', abstract: '中国大陆 / 武侠 / 146分钟', cover: 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2917502509.webp', }), ]; const rawItems = [ { id: 26663086, labels: [{ text: '剧集' }, { text: '可播放' }], more_url: "onclick=\"moreurl(this,{from:'mv_subject_search',subject_id:'26663086',is_tv:'1'})\"", }, { id: 36289423, labels: [{ text: '可播放' }], more_url: "onclick=\"moreurl(this,{from:'mv_subject_search',subject_id:'36289423',is_tv:'0'})\"", }, ]; const page = { goto: vi.fn().mockResolvedValue(undefined), wait: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn() .mockResolvedValueOnce({ blocked: false, title: '射雕英雄传 - 电影 - 豆瓣搜索', href: 'https://search.douban.com/movie/subject_search?search_text=%E5%B0%84%E9%9B%95%E8%8B%B1%E9%9B%84%E4%BC%A0' }) .mockImplementationOnce((script) => runSearchEvaluate(script, rawItems, domItems)), }; await expect(searchDouban(page, 'movie', '射雕英雄传', 20)).resolves.toMatchObject([ { id: '26663086', type: 'tvshow', title: '射雕英雄传‎ (2017)' }, { id: '36289423', type: 'movie', title: '射雕英雄传:侠之大者‎ (2025)' }, ]); }); it('normalizes douban book subject raw data into structured fields', () => { const normalized = normalizeDoubanBookSubject({ id: '2567698', title: '小狗钱钱', subtitle: '让孩子和家长共同成长的财商童话', originalTitle: 'Ein Hund namens Money', infoText: ` 作者: [德] 博多·舍费尔 出版社: 南海出版公司 副标题: 让孩子和家长共同成长的财商童话 原作名: Ein Hund namens Money 译者: 王钟欣 / 余茜 出版年: 2014-1-1 页数: 208 定价: 26.00元 装帧: 平装 丛书: 新经典文库·爱心树童书 ISBN: 9787544270871 `, rating: '8.9', ratingCount: '12345', summary: '理财启蒙故事', cover: 'https://img9.doubanio.com/view/subject/l/public/s29618581.jpg', url: 'https://book.douban.com/subject/2567698/', }); expect(normalized).toMatchObject({ id: '2567698', type: 'book', title: '小狗钱钱', subtitle: '让孩子和家长共同成长的财商童话', originalTitle: 'Ein Hund namens Money', authors: ['[德] 博多·舍费尔'], translators: ['王钟欣', '余茜'], publisher: '南海出版公司', publishDate: '2014-1-1', publishYear: '2014', pageCount: 208, binding: '平装', price: '26.00元', series: '新经典文库·爱心树童书', isbn13: '9787544270871', rating: 8.9, ratingCount: 12345, summary: '理财启蒙故事', cover: 'https://img9.doubanio.com/view/subject/l/public/s29618581.jpg', url: 'https://book.douban.com/subject/2567698/', }); }); it('parses movie-hot rows with real chart fields only', async () => { const items = [ createFakeMovieHotItem({ title: ' 少年与犬 ', url: '/subject/36840171/', info: '2025-03-20 / 日本 / 剧情', rating: '6.8', votes: '(12345人评价)', cover: 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p1.jpg', }), ]; const page = { goto: vi.fn().mockResolvedValue(undefined), wait: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn() .mockResolvedValueOnce({ blocked: false, title: '豆瓣电影排行榜', href: 'https://movie.douban.com/chart' }) .mockImplementationOnce((script) => runMovieHotEvaluate(script, items)), }; await expect(loadDoubanMovieHot(page, 20)).resolves.toEqual([ { rank: 1, id: '36840171', title: '少年与犬', rating: 6.8, votes: 12345, year: '2025', url: 'https://movie.douban.com/subject/36840171/', cover: 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p1.jpg', }, ]); }); it('throws EmptyResultError when movie-hot parses no chart rows', async () => { const page = { goto: vi.fn().mockResolvedValue(undefined), wait: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn() .mockResolvedValueOnce({ blocked: false, title: '豆瓣电影排行榜', href: 'https://movie.douban.com/chart' }) .mockResolvedValueOnce([]), }; await expect(loadDoubanMovieHot(page, 20)).rejects.toThrow('douban movie-hot returned no data'); }); it('loads book subject details from book.douban.com when type=book', async () => { const page = { goto: vi.fn().mockResolvedValue(undefined), wait: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn() .mockResolvedValueOnce({ blocked: false, title: '小狗钱钱 (豆瓣)', href: 'https://book.douban.com/subject/2567698/' }) .mockResolvedValueOnce({ id: '2567698', title: '小狗钱钱', subtitle: '', originalTitle: '', infoText: ` 作者: [德] 博多·舍费尔 出版社: 南海出版公司 出版年: 2014-1-1 ISBN: 9787544270871 `, rating: '8.9', ratingCount: '12345', summary: '理财启蒙故事', cover: 'https://img9.doubanio.com/view/subject/l/public/s29618581.jpg', url: 'https://book.douban.com/subject/2567698/', }), }; const detail = await loadDoubanSubjectDetail(page, '2567698', 'book'); expect(page.goto).toHaveBeenCalledWith('https://book.douban.com/subject/2567698/', { waitUntil: 'load', settleMs: 1500, }); expect(page.wait).toHaveBeenCalledWith({ selector: 'h1 span, #info', timeout: 8 }); expect(detail).toMatchObject({ id: '2567698', type: 'book', title: '小狗钱钱', authors: ['[德] 博多·舍费尔'], publisher: '南海出版公司', isbn13: '9787544270871', rating: 8.9, ratingCount: 12345, url: 'https://book.douban.com/subject/2567698/', }); }); it('retries transient detached navigation errors when loading douban search results', async () => { const page = { goto: vi.fn() .mockRejectedValueOnce(new Error('Detached while handling command')) .mockResolvedValueOnce(undefined), wait: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn() .mockResolvedValueOnce({ blocked: false, title: '经济学思维 - 豆瓣搜索', href: 'https://search.douban.com/book/subject_search?search_text=%E7%BB%8F%E6%B5%8E%E5%AD%A6%E6%80%9D%E7%BB%B4&cat=1001', }) .mockResolvedValueOnce([ { rank: 1, id: '26895402', type: 'book', title: '经济学思维', rating: 7.9, abstract: '李子畅 / 中信出版社 / 2016-7', url: 'https://book.douban.com/subject/26895402/', cover: 'https://img1.doubanio.com/view/subject/m/public/s29000000.jpg', }, ]), }; const results = await searchDouban(page, 'book', '经济学思维', 3); expect(page.goto).toHaveBeenNthCalledWith(1, 'https://search.douban.com/book/subject_search?search_text=%E7%BB%8F%E6%B5%8E%E5%AD%A6%E6%80%9D%E7%BB%B4&cat=1001', { waitUntil: 'load', settleMs: 1500, }); expect(page.goto).toHaveBeenCalledTimes(2); expect(page.wait).toHaveBeenCalledWith({ selector: '.item-root .title-text, .item-root .title a, .result-list .result-item h3 a', timeout: 8, }); expect(results).toEqual([ { rank: 1, id: '26895402', type: 'book', title: '经济学思维', rating: 7.9, abstract: '李子畅 / 中信出版社 / 2016-7', url: 'https://book.douban.com/subject/26895402/', cover: 'https://img1.doubanio.com/view/subject/m/public/s29000000.jpg', }, ]); }); }); describe('inferDoubanSearchResultType', () => { it('returns tvshow for movie search results marked as TV', () => { expect(inferDoubanSearchResultType('movie', { moreUrl: "onclick=\"moreurl(this,{is_tv:'1'})\"", labels: [{ text: '剧集' }], })).toBe('tvshow'); }); it('returns movie when a movie search result has no TV signal', () => { expect(inferDoubanSearchResultType('movie', { moreUrl: "onclick=\"moreurl(this,{is_tv:'0'})\"", labels: [{ text: '可播放' }], })).toBe('movie'); }); it('preserves non-movie search types', () => { expect(inferDoubanSearchResultType('book', { moreUrl: '', labels: [{ text: '图书' }], })).toBe('book'); }); }); // Regression for #1851: splitDoubanTitle must be self-contained so its // .toString()-injected form runs inside page.evaluate, where the module scope // (and therefore the module-level normalizeText) is unavailable. Pre-fix the // injected copy threw `ReferenceError: normalizeText is not defined`. describe('splitDoubanTitle — page.evaluate injection (#1851)', () => { // Rebuild the function from its source in a fresh scope that, like the // browser page context, has no access to the module-level normalizeText. function injectViaToString(fn) { // eslint-disable-next-line no-new-func return new Function(`return (${fn.toString()})`)(); } it('does not throw when injected into page.evaluate (normalizeText is not defined there)', () => { const injected = injectViaToString(splitDoubanTitle); expect(() => injected('盗梦空间 Inception')).not.toThrow(); }); it('splits a CJK + Latin title correctly when injected', () => { const injected = injectViaToString(splitDoubanTitle); expect(injected('盗梦空间 Inception')).toEqual({ title: '盗梦空间', originalTitle: 'Inception', }); }); it('returns the same result whether called directly or via injection', () => { const injected = injectViaToString(splitDoubanTitle); const samples = [ '盗梦空间 Inception', '千与千寻 千と千尋の神隠し', "哈尔的移动城堡 Howl's Moving Castle", '无名之辈', '', ' ', ]; for (const input of samples) { expect(injected(input)).toEqual(splitDoubanTitle(input)); } }); it('collapses internal whitespace in the injected copy', () => { const injected = injectViaToString(splitDoubanTitle); expect(injected(' 流浪地球 The Wandering Earth ')).toEqual({ title: '流浪地球', originalTitle: 'The Wandering Earth', }); }); });