Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

489 lines
19 KiB
JavaScript

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',
});
});
});