// ==UserScript==
// @name 豆瓣影视筛选大师
// @namespace doveboy_js
// @version 2.6
// @description 恢复豆瓣“选电影”和“选剧集”的经典分类导航、排序、分页,包含类别筛选和国家及地区筛选
// @author 白鸽男孩
// @license Zlib/Libpng License
// @icon data:image/png;base64,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
// @match https://movie.douban.com/explore*
// @match https://movie.douban.com/tv*
// @icon https://img1.doubanio.com/favicon.ico
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @connect movie.douban.com
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
/*
* ------------------------------------------------------------------
* 基础配置
* ------------------------------------------------------------------
*/
const API_NEW_URL = 'https://movie.douban.com/j/new_search_subjects';
const API_TAG_URL = 'https://movie.douban.com/j/search_subjects';
const API_CHART_URL = 'https://movie.douban.com/j/chart/top_list';
const SETTINGS_KEY = 'douban-classic-settings-v1';
const DB_NAME = 'douban-classic-nav-v2';
const DB_VERSION = 1;
const PAGE_SIZE = 20;
// 列表接口缓存时间:5 分钟。
const LIST_CACHE_MAX_AGE = 5 * 60 * 1000;
const NEW_SORT_MAP = {
recommend: 'T',
time: 'R',
rank: 'S'
};
// 电影 /j/chart/top_list 的类型 ID 映射。
const CHART_GENRE_IDS = {
剧情: 11,
喜剧: 24,
动作: 5,
爱情: 13,
科幻: 17,
动画: 25,
悬疑: 10,
惊悚: 19,
恐怖: 20,
犯罪: 3,
同性: 26,
音乐: 14,
歌舞: 7,
传记: 2,
历史: 4,
战争: 22,
西部: 27,
奇幻: 16,
冒险: 15,
灾难: 12,
武侠: 29,
情色: 6,
纪录片: 1,
短片: 23
};
const MOVIE_GENRES = [
'剧情', '喜剧', '动作', '爱情', '科幻', '动画',
'悬疑', '惊悚', '恐怖', '犯罪', '同性', '音乐',
'歌舞', '传记', '历史', '战争', '西部', '奇幻',
'冒险', '灾难', '武侠', '情色', '纪录片', '短片'
];
const TV_GENRES = [
'剧情', '喜剧', '动作', '爱情', '科幻', '动画',
'悬疑', '惊悚', '恐怖', '犯罪', '同性', '音乐',
'歌舞', '传记', '历史', '战争', '奇幻', '冒险',
'古装', '武侠', '家庭', '儿童', '纪录片', '真人秀'
];
const REGIONS = [
'中国大陆', '中国香港', '中国台湾', '美国', '日本',
'韩国', '英国', '法国', '德国', '意大利', '西班牙',
'印度', '泰国', '俄罗斯', '加拿大', '澳大利亚',
'爱尔兰', '瑞典', '巴西', '丹麦'
];
const SORTS = [
{ value: 'recommend', label: '按推荐排序' },
{ value: 'time', label: '按时间排序' },
{ value: 'rank', label: '按评分排序' }
];
const state = {
mediaType: isTvPage() ? 'tv' : 'movie',
genre: '',
region: '',
sort: 'time',
minRate: 0,
playableOnly: false,
view: 'grid',
page: 1,
loading: false,
abortToken: 0,
candidates: [],
filteredItems: [],
reachedEnd: false,
fetchedCandidateCount: 0,
fastModePage: false,
dataSource: 'new',
fallbackMessage: ''
};
let root = null;
let statusElement = null;
let resultElement = null;
let paginationElement = null;
let clickHandler = null;
let changeHandler = null;
let popstateHandler = null;
const storage = createStorage();
/*
* ------------------------------------------------------------------
* 存储层:IndexedDB 为主,GM API 为降级
* ------------------------------------------------------------------
*/
function createStorage() {
const idb = window.indexedDB ||
(typeof unsafeWindow !== 'undefined' && unsafeWindow.indexedDB);
if (idb) {
try {
return createIndexedDBStorage(idb);
} catch (error) {
console.warn('[豆瓣经典导航] IndexedDB 初始化失败,回退到 GM 存储', error);
}
}
return createGMStorage();
}
function createIndexedDBStorage(idb) {
let dbPromise = null;
function open() {
if (dbPromise) {
return dbPromise;
}
dbPromise = new Promise((resolve, reject) => {
const request = idb.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('lists')) {
db.createObjectStore('lists', { keyPath: 'key' });
}
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'key' });
}
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
request.onerror = (event) => {
dbPromise = null;
reject(event.target.error);
};
request.onblocked = () => {
dbPromise = null;
reject(new Error('IndexedDB blocked'));
};
});
return dbPromise;
}
function get(store, key) {
return open().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction([store], 'readonly');
const obj = tx.objectStore(store);
const req = obj.get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
}));
}
function put(store, value) {
return open().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction([store], 'readwrite');
const obj = tx.objectStore(store);
const req = obj.put(value);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
req.onerror = () => reject(req.error);
}));
}
function clearStore(store) {
return open().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction([store], 'readwrite');
const obj = tx.objectStore(store);
const req = obj.clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
req.onerror = () => reject(req.error);
}));
}
return {
getList: (key) => get('lists', key).then((result) => {
return result && result.data
? { data: result.data, savedAt: result.savedAt }
: null;
}),
setList: (key, data) => put('lists', { key, data, savedAt: Date.now() }),
getMeta: (key) => get('meta', key),
setMeta: (key, value) => put('meta', { key, value }),
clearLists: async () => {
await clearStore('lists');
}
};
}
function createGMStorage() {
const listsKey = 'douban-classic-lists-v1';
let listsCache = null;
function getLists() {
if (listsCache === null) {
listsCache = gmGetValue(listsKey, {});
}
return listsCache;
}
function saveLists() {
gmSetValue(listsKey, listsCache);
}
return {
getList: async (key) => {
const lists = getLists();
const value = lists[key];
if (!value || !value.data) {
return null;
}
if (Date.now() - value.savedAt > LIST_CACHE_MAX_AGE) {
delete lists[key];
saveLists();
return null;
}
return { data: value.data, savedAt: value.savedAt };
},
setList: async (key, data) => {
getLists();
listsCache[key] = { savedAt: Date.now(), data };
saveLists();
},
getMeta: async (key) => gmGetValue(`douban-classic-meta:${key}`, null),
setMeta: async (key, value) => gmSetValue(`douban-classic-meta:${key}`, value),
clearLists: async () => {
listsCache = {};
gmDeleteValue(listsKey);
}
};
}
async function getListCache(key) {
const result = await storage.getList(key);
if (!result) {
return null;
}
if (Date.now() - result.savedAt > LIST_CACHE_MAX_AGE) {
return null;
}
return result.data;
}
async function setListCache(key, data) {
await storage.setList(key, data);
}
/*
* ------------------------------------------------------------------
* GM API 兼容封装
* ------------------------------------------------------------------
*/
function gmGetValue(key, fallback) {
try {
if (typeof GM_getValue === 'function') {
return GM_getValue(key, fallback);
}
} catch (error) {
console.warn('[豆瓣经典导航] 读取存储失败:', error);
}
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch {
return fallback;
}
}
function gmSetValue(key, value) {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(key, value);
return;
}
} catch (error) {
console.warn('[豆瓣经典导航] 写入存储失败:', error);
}
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// 忽略存储错误。
}
}
function gmDeleteValue(key) {
try {
if (typeof GM_deleteValue === 'function') {
GM_deleteValue(key);
return;
}
} catch (error) {
console.warn('[豆瓣经典导航] 删除存储失败:', error);
}
try {
localStorage.removeItem(key);
} catch {
// 忽略存储错误。
}
}
function gmRequest(options) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== 'function') {
fetch(options.url, {
method: options.method || 'GET',
credentials: 'include',
headers: options.headers || {}
})
.then(async (response) => {
const text = await response.text();
resolve({
status: response.status,
responseText: text,
finalUrl: response.url
});
})
.catch(reject);
return;
}
GM_xmlhttpRequest({
method: options.method || 'GET',
url: options.url,
headers: options.headers || {},
timeout: options.timeout || 20000,
responseType: options.responseType || 'text',
onload(response) {
resolve(response);
},
onerror() {
reject(new Error('网络请求失败'));
},
ontimeout() {
reject(new Error('网络请求超时'));
}
});
});
}
/*
* ------------------------------------------------------------------
* 页面识别与初始化
* ------------------------------------------------------------------
*/
function isTvPage() {
return /^\/tv(?:\/|$)/.test(location.pathname);
}
function waitForApp() {
let scheduled = false;
const restoreApplication = () => {
scheduled = false;
const app = document.querySelector('#app');
if (!app) {
return;
}
const hasClassicNavigation = app.querySelector('.dcn-app');
if (hasClassicNavigation) {
root = app;
return;
}
if (app.dataset.doubanClassicInitialized === '1') {
app.dataset.doubanClassicInitialized = '';
}
initialize(app);
};
const scheduleRestore = () => {
if (scheduled) {
return;
}
scheduled = true;
requestAnimationFrame(() => {
requestAnimationFrame(restoreApplication);
});
};
const observer = new MutationObserver(() => {
const app = document.querySelector('#app');
if (!app || !app.querySelector('.dcn-app')) {
scheduleRestore();
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
scheduleRestore();
}
function initialize(app) {
if (app.dataset.doubanClassicInitialized === '1') {
return;
}
app.dataset.doubanClassicInitialized = '1';
root = app;
injectStyles();
loadInitialState();
renderApplication();
bindEvents();
updateDocumentTitle();
runSearch();
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('清除豆瓣经典导航缓存', clearAllCache);
GM_registerMenuCommand('恢复默认筛选设置', resetAllSettings);
}
if (popstateHandler) {
window.removeEventListener('popstate', popstateHandler);
}
popstateHandler = () => {
loadStateFromUrl();
renderControls();
runSearch();
};
window.addEventListener('popstate', popstateHandler);
}
function loadInitialState() {
const settings = gmGetValue(SETTINGS_KEY, {});
if (settings.view === 'list' || settings.view === 'grid') {
state.view = settings.view;
}
if (Number.isFinite(Number(settings.minRate))) {
state.minRate = Number(settings.minRate);
}
loadStateFromUrl();
}
function loadStateFromUrl() {
const params = new URLSearchParams(location.search);
state.genre = params.get('genre') || '';
state.region = params.get('region') || '';
state.sort = validSort(params.get('sort')) ? params.get('sort') : 'time';
state.minRate = normalizeRate(params.get('min_rate'), state.minRate);
state.playableOnly = params.get('playable') === '1';
state.page = Math.max(1, Number.parseInt(params.get('page'), 10) || 1);
const urlView = params.get('view');
if (urlView === 'grid' || urlView === 'list') {
state.view = urlView;
}
}
function normalizeRate(value, fallback = 0) {
if (value === null || value === undefined || value === '') {
return fallback;
}
const number = Number(value);
if (!Number.isFinite(number)) {
return fallback;
}
return Math.min(10, Math.max(0, number));
}
function validSort(sort) {
return SORTS.some((item) => item.value === sort);
}
/*
* ------------------------------------------------------------------
* 页面结构
* ------------------------------------------------------------------
*/
function renderApplication() {
root.innerHTML = `
`;
statusElement = root.querySelector('#dcn-status');
resultElement = root.querySelector('#dcn-results');
paginationElement = root.querySelector('#dcn-pagination');
renderControls();
}
function renderControls() {
const container = root.querySelector('#dcn-controls');
if (!container) {
return;
}
const genres = state.mediaType === 'tv' ? TV_GENRES : MOVIE_GENRES;
container.innerHTML = `
${renderFilterRow(
'类型',
'genre',
genres.map((value) => ({ value, label: value })),
state.genre
)}
${renderFilterRow(
'地区',
'region',
REGIONS.map((value) => ({ value, label: value })),
state.region
)}
`;
updateToolbar();
updateSelectedSummary();
}
function renderFilterRow(label, key, options, selected) {
return `
${escapeHtml(label)}:
${options.map((option) => `
`).join('')}
`;
}
function updateToolbar() {
const sort = root.querySelector('#dcn-sort');
const rate = root.querySelector('#dcn-min-rate');
const playable = root.querySelector('#dcn-playable');
const gridButton = root.querySelector('#dcn-grid-view');
const listButton = root.querySelector('#dcn-list-view');
if (sort) sort.value = state.sort;
if (rate) rate.value = String(state.minRate);
if (playable) playable.checked = state.playableOnly;
gridButton?.classList.toggle('active', state.view === 'grid');
listButton?.classList.toggle('active', state.view === 'list');
}
function updateSelectedSummary() {
const container = root.querySelector('#dcn-selected');
if (!container) {
return;
}
const selected = [];
if (state.genre) selected.push(['genre', '类型', state.genre]);
if (state.region) selected.push(['region', '地区', state.region]);
if (state.minRate > 0) {
selected.push(['minRate', '评分', `${state.minRate} 分以上`]);
}
if (state.playableOnly) {
selected.push(['playableOnly', '播放', '可播放']);
}
if (!selected.length) {
container.innerHTML = `
当前条件:
全部${state.mediaType === 'tv' ? '剧集' : '电影'}
`;
return;
}
container.innerHTML = `
当前条件:
${selected.map(([key, name, value]) => `
`).join('')}
`;
}
/*
* ------------------------------------------------------------------
* 事件
* ------------------------------------------------------------------
*/
function bindEvents() {
if (clickHandler) {
root.removeEventListener('click', clickHandler);
}
if (changeHandler) {
root.removeEventListener('change', changeHandler);
}
clickHandler = (event) => {
const filter = event.target.closest('[data-filter]');
if (filter) {
const key = filter.dataset.filter;
const value = filter.dataset.value || '';
state[key] = value;
state.page = 1;
renderControls();
saveUrl();
runSearch();
return;
}
const clear = event.target.closest('[data-clear]');
if (clear) {
clearStateValue(clear.dataset.clear);
state.page = 1;
renderControls();
saveUrl();
runSearch();
return;
}
if (event.target.closest('#dcn-clear-all')) {
clearFilters();
renderControls();
saveUrl();
runSearch();
return;
}
if (event.target.closest('.dcn-relax-button')) {
state.region = '';
state.minRate = 0;
state.playableOnly = false;
state.page = 1;
saveSettings();
saveUrl();
renderControls();
applyLocalFiltersAndRender();
return;
}
if (event.target.closest('#dcn-grid-view')) {
setView('grid');
return;
}
if (event.target.closest('#dcn-list-view')) {
setView('list');
return;
}
if (event.target.closest('#dcn-refresh')) {
runSearch(true);
return;
}
const pageButton = event.target.closest('[data-page]');
if (pageButton && !pageButton.disabled && !state.loading) {
const page = Number(pageButton.dataset.page);
if (Number.isFinite(page) && page >= 1) {
state.page = page;
saveUrl();
if (state.fastModePage) {
runSearch();
} else {
renderResults();
}
scrollToResults();
}
}
};
changeHandler = (event) => {
if (event.target.id === 'dcn-sort') {
state.sort = validSort(event.target.value)
? event.target.value
: 'recommend';
state.page = 1;
saveUrl();
runSearch();
return;
}
if (event.target.id === 'dcn-min-rate') {
state.minRate = normalizeRate(event.target.value, 0);
state.page = 1;
saveSettings();
saveUrl();
runSearch();
return;
}
if (event.target.id === 'dcn-playable') {
state.playableOnly = event.target.checked;
state.page = 1;
saveUrl();
applyLocalFiltersAndRender();
}
};
root.addEventListener('click', clickHandler);
root.addEventListener('change', changeHandler);
}
function clearStateValue(key) {
if (key === 'minRate') {
state.minRate = 0;
saveSettings();
return;
}
if (key === 'playableOnly') {
state.playableOnly = false;
return;
}
if (Object.prototype.hasOwnProperty.call(state, key)) {
state[key] = '';
}
}
function clearFilters() {
state.genre = '';
state.region = '';
state.minRate = 0;
state.playableOnly = false;
state.page = 1;
saveSettings();
}
function setView(view) {
if (view !== 'grid' && view !== 'list') {
return;
}
state.view = view;
saveSettings();
saveUrl();
updateToolbar();
renderResults();
}
function saveSettings() {
gmSetValue(SETTINGS_KEY, {
view: state.view,
minRate: state.minRate
});
}
function saveUrl() {
const params = new URLSearchParams();
if (state.genre) params.set('genre', state.genre);
if (state.region) params.set('region', state.region);
if (state.sort !== 'time') params.set('sort', state.sort);
if (state.minRate > 0) params.set('min_rate', String(state.minRate));
if (state.playableOnly) params.set('playable', '1');
if (state.view !== 'grid') params.set('view', state.view);
if (state.page > 1) params.set('page', String(state.page));
const query = params.toString();
const newUrl = `${location.pathname}${query ? `?${query}` : ''}`;
history.replaceState(null, '', newUrl);
updateDocumentTitle();
}
function updateDocumentTitle() {
const parts = [];
if (state.genre) parts.push(state.genre);
if (state.region) parts.push(state.region);
const mediaName = state.mediaType === 'tv' ? '选剧集' : '选电影';
document.title = parts.length
? `${parts.join(' · ')} - ${mediaName}`
: mediaName;
}
/*
* ------------------------------------------------------------------
* 搜索
* ------------------------------------------------------------------
*/
async function runSearch(forceRefresh = false) {
if (state.loading) {
state.abortToken++;
}
const token = ++state.abortToken;
state.loading = true;
state.page = Math.max(1, state.page);
state.candidates = [];
state.filteredItems = [];
state.reachedEnd = false;
state.fetchedCandidateCount = 0;
state.fastModePage = false;
state.dataSource = 'new';
state.fallbackMessage = '';
showLoadingSkeleton();
try {
const result = await fetchCandidates(token, forceRefresh);
if (token !== state.abortToken) {
return;
}
state.candidates = result.items;
state.filteredItems = result.items;
state.fetchedCandidateCount = result.items.length;
state.reachedEnd = result.reachedEnd;
state.fastModePage = true;
state.dataSource = result.source;
state.fallbackMessage = result.fallbackMessage;
applyLocalFiltersAndRender();
if (!state.filteredItems.length) {
showStatus(
'没有找到符合当前条件的条目。可以减少筛选条件后重试。',
'empty'
);
}
} catch (error) {
if (token !== state.abortToken) {
return;
}
console.error('[豆瓣经典导航] 搜索失败:', error);
showError(
error?.message ||
'请求失败,可能遇到豆瓣访问限制,请稍后重试。'
);
} finally {
if (token === state.abortToken) {
state.loading = false;
}
}
}
function shouldUseChart() {
return (
state.mediaType === 'movie' &&
state.genre &&
!state.region &&
state.sort === 'rank' &&
CHART_GENRE_IDS[state.genre]
);
}
async function fetchCandidates(token, forceRefresh = false) {
const start = Math.max(0, (state.page - 1) * PAGE_SIZE);
// 1) 排行榜优先
if (shouldUseChart()) {
showStatus(`正在读取“${state.genre}”排行榜……`, 'loading');
const data = await fetchChartPage(start, PAGE_SIZE, forceRefresh, token);
checkToken(token);
const items = normalizeChartSubjects(Array.isArray(data) ? data : []);
if (items.length) {
return {
items,
source: 'chart',
reachedEnd: items.length < PAGE_SIZE,
fallbackMessage: ''
};
}
}
const label = [state.genre, state.region]
.filter(Boolean)
.join('·') || (state.mediaType === 'tv' ? '电视剧' : '电影');
// 2) 新接口全条件
showStatus(`正在读取“${label}”列表……`, 'loading');
const newData = await fetchNewSearchPage(start, { forceRefresh, token });
checkToken(token);
const newItems = normalizeNewSearchSubjects(newData);
if (newItems.length) {
return {
items: newItems,
source: 'new',
reachedEnd: (newData?.data?.length || 0) < PAGE_SIZE,
fallbackMessage: ''
};
}
// 3) 去掉地区重试
if (state.region) {
showStatus(
`“${state.genre || (state.mediaType === 'tv' ? '剧集' : '电影')}”在“${state.region}”暂无数据,尝试扩大地区范围……`,
'loading'
);
const noRegionData = await fetchNewSearchPage(start, {
includeRegion: false,
forceRefresh,
token
});
checkToken(token);
const noRegionItems = normalizeNewSearchSubjects(noRegionData);
if (noRegionItems.length) {
return {
items: noRegionItems,
source: 'new',
reachedEnd: (noRegionData?.data?.length || 0) < PAGE_SIZE,
fallbackMessage: `未在“${state.region}”找到结果,已扩大至全部地区搜索。`
};
}
}
// 4) 标签接口兜底
const tag = state.genre || (state.mediaType === 'tv' ? '电视剧' : '电影');
showStatus(`尝试通过标签“${tag}”查找……`, 'loading');
const tagData = await fetchTagSearchPage(start, tag, forceRefresh, token);
checkToken(token);
const tagItems = normalizeTagSearchSubjects(tagData);
if (tagItems.length) {
return {
items: tagItems,
source: 'tag',
reachedEnd: (tagData?.subjects?.length || 0) < PAGE_SIZE,
fallbackMessage: `精确筛选无结果,已使用“${tag}”标签数据。`
};
}
// 5) 排行榜兜底
if (state.mediaType === 'movie' && state.genre && CHART_GENRE_IDS[state.genre]) {
showStatus(`尝试读取“${state.genre}”排行榜……`, 'loading');
const chartData = await fetchChartPage(start, PAGE_SIZE, forceRefresh, token);
checkToken(token);
const chartItems = normalizeChartSubjects(Array.isArray(chartData) ? chartData : []);
if (chartItems.length) {
return {
items: chartItems,
source: 'chart',
reachedEnd: chartItems.length < PAGE_SIZE,
fallbackMessage: `已使用“${state.genre}”排行榜数据。`
};
}
}
return { items: [], source: 'new', reachedEnd: true, fallbackMessage: '' };
}
async function fetchJson(url, options = {}) {
const { useCache = true, cacheKey = url, token, cacheWhen } = options;
if (useCache) {
const cached = await getListCache(cacheKey);
if (cached) {
return cached;
}
}
const response = await gmRequest({
url,
headers: {
Accept: 'application/json, text/plain, */*',
Referer: location.href
},
timeout: 20000
});
if (response.status !== 200) {
throw new Error(`接口返回 HTTP ${response.status}`);
}
let data;
try {
data = JSON.parse(response.responseText);
} catch {
if (looksLikeVerificationPage(response.responseText)) {
throw new Error(
'豆瓣返回了验证页面。请先正常浏览豆瓣,稍后再刷新。'
);
}
throw new Error('接口返回了无法识别的数据。');
}
// 不缓存空结果,避免“刷新仍然空白”。
if (useCache && (!token || token === state.abortToken) && (!cacheWhen || cacheWhen(data))) {
await setListCache(cacheKey, data);
}
return data;
}
async function fetchNewSearchPage(start, { includeRegion = true, forceRefresh = false, token } = {}) {
const params = {
tags: state.mediaType === 'tv' ? '电视剧' : '电影',
sort: NEW_SORT_MAP[state.sort] || 'T',
range: `${state.minRate},10`,
start: String(start)
};
if (state.genre) {
params.genres = state.genre;
}
if (includeRegion && state.region) {
params.countries = state.region;
}
const url = `${API_NEW_URL}?${new URLSearchParams(params).toString()}`;
return fetchJson(url, {
useCache: !forceRefresh,
token,
cacheWhen: (d) => d && Array.isArray(d.data) && d.data.length > 0
});
}
async function fetchTagSearchPage(start, tag, forceRefresh = false, token) {
const params = {
type: state.mediaType,
tag,
sort: state.sort,
page_limit: String(PAGE_SIZE),
page_start: String(start)
};
const url = `${API_TAG_URL}?${new URLSearchParams(params).toString()}`;
return fetchJson(url, {
useCache: !forceRefresh,
token,
cacheWhen: (d) => d && Array.isArray(d.subjects) && d.subjects.length > 0
});
}
async function fetchChartPage(start, limit, forceRefresh = false, token) {
const typeId = CHART_GENRE_IDS[state.genre];
const params = {
type: String(typeId),
interval_id: '100:90',
action: '',
start: String(start),
limit: String(limit)
};
const url = `${API_CHART_URL}?${new URLSearchParams(params).toString()}`;
return fetchJson(url, {
useCache: !forceRefresh,
token,
cacheWhen: (d) => Array.isArray(d) && d.length > 0
});
}
function normalizeNewSearchSubjects(data) {
const items = (data && data.data) || [];
return items.map((item) => ({
id: String(item.id || ''),
title: item.title || '未知标题',
url: item.url || (
item.id
? `https://movie.douban.com/subject/${item.id}/`
: '#'
),
cover: item.cover || '',
rate: normalizeSubjectRate(item.rate),
playable: item.playable === undefined && item.is_playable === undefined
? undefined
: Boolean(item.playable || item.is_playable),
isNew: Boolean(item.is_new),
episodesInfo: item.episodes_info || '',
year: item.year || '',
genres: item.genres || [],
regions: item.regions || [],
directors: item.directors || [],
actors: item.casts || [],
releaseDates: [],
summary: '',
detailLoaded: false,
source: 'new'
}));
}
function normalizeTagSearchSubjects(data) {
const items = (data && data.subjects) || [];
return items.map((item) => ({
id: String(item.id || ''),
title: item.title || '未知标题',
url: item.url || (
item.id
? `https://movie.douban.com/subject/${item.id}/`
: '#'
),
cover: item.cover || '',
rate: normalizeSubjectRate(item.rate),
playable: item.playable === undefined
? undefined
: Boolean(item.playable),
isNew: Boolean(item.is_new),
episodesInfo: item.episodes_info || '',
year: '',
genres: [],
regions: [],
directors: [],
actors: [],
releaseDates: [],
summary: '',
detailLoaded: false,
source: 'tag'
}));
}
function normalizeChartSubjects(list) {
return list.map((item) => ({
id: String(item.id || ''),
title: item.title || '未知标题',
url: item.url || (
item.id
? `https://movie.douban.com/subject/${item.id}/`
: '#'
),
cover: item.cover_url || '',
rate: normalizeSubjectRate(item.score),
playable: Boolean(item.is_playable),
isNew: false,
episodesInfo: '',
year: extractYear(item.release_date),
genres: item.types || [],
regions: item.regions || [],
directors: [],
actors: item.actors || [],
releaseDates: item.release_date ? [item.release_date] : [],
summary: '',
detailLoaded: true,
source: 'chart'
}));
}
function extractYear(dateString) {
const match = String(dateString || '').match(/(\d{4})/);
return match ? match[1] : '';
}
function normalizeSubjectRate(rate) {
const number = Number(rate);
return Number.isFinite(number) && number > 0 ? number : 0;
}
/*
* ------------------------------------------------------------------
* 缓存清理
* ------------------------------------------------------------------
*/
async function clearAllCache() {
try {
await storage.clearLists();
alert('豆瓣经典导航的列表缓存已清除。');
} catch (error) {
console.error('[豆瓣经典导航] 清除缓存失败', error);
alert('清除缓存失败:' + error.message);
}
}
function resetAllSettings() {
gmDeleteValue(SETTINGS_KEY);
const cleanPath = state.mediaType === 'tv' ? '/tv/' : '/explore';
location.href = cleanPath;
}
/*
* ------------------------------------------------------------------
* 本地筛选与排序
* ------------------------------------------------------------------
*/
function applyLocalFiltersAndRender(showSummary = true) {
let items = state.candidates.filter(matchesFilters);
// new 与 tag 接口本身已按用户选择的排序返回,不再客户端重排。
const serverSorted = state.dataSource === 'new' || state.dataSource === 'tag';
if (!serverSorted) {
if (state.sort === 'rank') {
items = stableSort(items, (left, right) => {
return right.rate - left.rate;
});
}
if (state.sort === 'time') {
items = stableSort(items, (left, right) => {
return Number(right.year || 0) - Number(left.year || 0);
});
}
}
state.filteredItems = items;
renderResults();
if (showSummary) {
updateResultStatus();
}
updateSelectedSummary();
}
function matchesFilters(item) {
if (state.minRate > 0 && item.rate < state.minRate) {
return false;
}
// 仅当接口明确返回不可播放时才过滤;未返回 playable 时不做假设。
if (state.playableOnly && item.playable === false) {
return false;
}
if (state.genre && item.genres && item.genres.length) {
if (!item.genres.some((genre) => tagMatches(genre, state.genre))) {
return false;
}
}
if (state.region && item.regions && item.regions.length) {
if (!item.regions.some((region) => regionMatches(region, state.region))) {
return false;
}
}
return true;
}
function tagMatches(actual, selected) {
const left = normalizeRegionOrGenre(actual);
const right = normalizeRegionOrGenre(selected);
return left === right || left.includes(right) || right.includes(left);
}
function regionMatches(actual, selected) {
const aliases = {
'中国香港': ['中国香港', '香港'],
'中国台湾': ['中国台湾', '台湾'],
'中国大陆': ['中国大陆', '大陆']
};
const actualNormalized = normalizeRegionOrGenre(actual);
const acceptable = aliases[selected] || [selected];
return acceptable.some((value) => {
const normalized = normalizeRegionOrGenre(value);
return (
actualNormalized === normalized ||
actualNormalized.includes(normalized)
);
});
}
function normalizeRegionOrGenre(value) {
return String(value)
.replace(/\s+/g, '')
.replace(/^中国/, '中国')
.toLowerCase();
}
function stableSort(items, compare) {
return items
.map((item, index) => ({ item, index }))
.sort((left, right) => {
return compare(left.item, right.item) ||
left.index - right.index;
})
.map((entry) => entry.item);
}
/*
* ------------------------------------------------------------------
* 结果渲染
* ------------------------------------------------------------------
*/
function renderResults() {
if (state.fastModePage) {
renderFastModeResults(state.filteredItems);
return;
}
const total = state.filteredItems.length;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (state.page > totalPages) {
state.page = totalPages;
saveUrl();
}
const start = (state.page - 1) * PAGE_SIZE;
const items = state.filteredItems.slice(start, start + PAGE_SIZE);
renderItemCollection(items);
renderPagination(totalPages);
updateResultStatus();
}
function renderFastModeResults(items) {
renderItemCollection(items);
const currentPage = state.page;
paginationElement.innerHTML = `
第 ${currentPage} 页
`;
}
function renderItemCollection(items) {
if (!items.length) {
const candidateCount = state.candidates.length;
let extra = '';
if (candidateCount > 0) {
extra = `
实际搜索到 ${candidateCount} 条候选,均被当前评分/地区/可播放条件过滤。
`;
}
resultElement.innerHTML = `
🎬
当前页没有符合条件的条目
尝试清除地区或评分条件。
${extra}
`;
return;
}
resultElement.className =
state.view === 'grid' ? 'dcn-grid' : 'dcn-list';
resultElement.innerHTML = items
.map(state.view === 'grid' ? renderGridCard : renderListCard)
.join('');
installImageFallbacks();
}
function renderGridCard(item) {
const meta = buildMeta(item);
return `
${item.isNew ? '新' : ''}
${renderStars(item.rate)}
${item.rate ? item.rate.toFixed(1) : '暂无评分'}
${meta ? `
${escapeHtml(meta)}
` : ''}
${item.playable
? '可播放'
: ''}
${item.episodesInfo
? `${escapeHtml(item.episodesInfo)}`
: ''}
`;
}
function renderListCard(item) {
const meta = buildMeta(item);
const creators = [
item.directors.length
? `导演:${item.directors.slice(0, 2).join(' / ')}`
: '',
item.actors.length
? `主演:${item.actors.slice(0, 4).join(' / ')}`
: ''
].filter(Boolean);
return `
${renderStars(item.rate)}
${item.rate ? item.rate.toFixed(1) : '暂无评分'}
${meta ? `
${escapeHtml(meta)}
` : ''}
${creators.map((line) => `
${escapeHtml(line)}
`).join('')}
${item.summary ? `
${escapeHtml(truncate(item.summary, 150))}
` : ''}
${item.playable
? '可播放'
: ''}
${item.episodesInfo
? `${escapeHtml(item.episodesInfo)}`
: ''}
`;
}
function buildMeta(item) {
const parts = [];
if (item.year) parts.push(item.year);
if (item.regions.length) {
parts.push(item.regions.slice(0, 3).join(' / '));
}
if (item.genres.length) {
parts.push(item.genres.slice(0, 4).join(' / '));
}
return parts.join(' · ');
}
function renderStars(rate) {
if (!rate) {
return '☆☆☆☆☆';
}
const full = Math.round(rate / 2);
const empty = 5 - full;
return `
${'★'.repeat(full)}${'☆'.repeat(empty)}
`;
}
function renderPagination(totalPages) {
if (totalPages <= 1) {
paginationElement.innerHTML = '';
return;
}
const pages = buildPageNumbers(state.page, totalPages);
paginationElement.innerHTML = `
${pages.map((page) => {
if (page === '…') {
return '…';
}
return `
`;
}).join('')}
`;
}
function buildPageNumbers(current, total) {
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
const pages = new Set([
1,
total,
current - 2,
current - 1,
current,
current + 1,
current + 2
]);
const valid = Array.from(pages)
.filter((page) => page >= 1 && page <= total)
.sort((a, b) => a - b);
const output = [];
for (let index = 0; index < valid.length; index++) {
if (index > 0 && valid[index] - valid[index - 1] > 1) {
output.push('…');
}
output.push(valid[index]);
}
return output;
}
function updateResultStatus() {
const count = state.filteredItems.length;
if (state.fallbackMessage && count > 0) {
showStatus(`${state.fallbackMessage}(本页 ${count} 条)`, 'warning');
return;
}
showStatus(
`第 ${state.page} 页,共显示 ${count} 个条目。`,
'success'
);
}
function showLoadingSkeleton() {
resultElement.className = 'dcn-grid';
paginationElement.innerHTML = '';
resultElement.innerHTML = Array.from({ length: 8 }, () => `
`).join('');
}
function showError(message) {
showStatus(message, 'error');
resultElement.className = '';
resultElement.innerHTML = `
加载失败
${escapeHtml(message)}
`;
paginationElement.innerHTML = '';
}
function showStatus(message, type = '') {
if (!statusElement) {
return;
}
statusElement.className = `dcn-status ${type}`;
statusElement.textContent = message;
}
function installImageFallbacks() {
root.querySelectorAll('img.dcn-poster, img.dcn-list-poster')
.forEach((image) => {
image.addEventListener('error', () => {
image.removeAttribute('src');
image.classList.add('dcn-image-missing');
}, { once: true });
});
}
function scrollToResults() {
const top = resultElement.getBoundingClientRect().top +
window.scrollY - 20;
window.scrollTo({
top,
behavior: 'smooth'
});
}
/*
* ------------------------------------------------------------------
* 工具函数
* ------------------------------------------------------------------
*/
function checkToken(token) {
if (token !== state.abortToken) {
throw new Error('搜索已取消');
}
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function cleanText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function truncate(text, maxLength) {
const normalized = cleanText(text);
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength)}…`;
}
function looksLikeVerificationPage(html) {
const text = String(html || '').toLowerCase();
return (
text.includes('sec.douban.com') ||
text.includes('captcha') ||
text.includes('访问验证') ||
text.includes('检测到有异常请求') ||
text.includes('机器人程序')
);
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function escapeAttribute(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/"/g, '"');
}
function injectStyles() {
const css = `
.dcn-app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
color: #111;
line-height: 1.6;
}
.dcn-filter-box {
background: #f7f7f7;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.dcn-filter-row {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
}
.dcn-filter-row:last-child {
margin-bottom: 0;
}
.dcn-filter-name {
flex-shrink: 0;
width: 48px;
font-weight: 600;
color: #666;
padding-top: 4px;
}
.dcn-filter-values {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.dcn-filter-link {
background: transparent;
border: none;
color: #37a;
cursor: pointer;
padding: 4px 10px;
border-radius: 4px;
font-size: 14px;
line-height: 1.4;
}
.dcn-filter-link:hover {
background: #e8f1f8;
}
.dcn-filter-link.active {
background: #37a;
color: #fff;
}
.dcn-separator {
color: #ccc;
margin: 0 6px;
}
.dcn-year-input {
width: 90px;
padding: 4px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.dcn-mini-button {
background: #37a;
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
font-size: 14px;
}
.dcn-mini-button:hover {
background: #256aa7;
}
.dcn-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.dcn-toolbar-left,
.dcn-toolbar-right {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.dcn-toolbar label {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
.dcn-label-text {
color: #666;
}
.dcn-toolbar select {
padding: 4px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.dcn-check {
cursor: pointer;
}
.dcn-icon-button {
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.dcn-icon-button.active {
background: #37a;
color: #fff;
border-color: #37a;
}
.dcn-button {
background: #37a;
color: #fff;
border: none;
border-radius: 4px;
padding: 6px 16px;
cursor: pointer;
font-size: 14px;
}
.dcn-button:hover {
background: #256aa7;
}
.dcn-button.secondary {
background: #fff;
color: #37a;
border: 1px solid #37a;
}
.dcn-selected {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
min-height: 28px;
}
.dcn-selected-title {
color: #666;
font-size: 14px;
}
.dcn-selected-tag {
display: inline-flex;
align-items: center;
gap: 4px;
background: #eef7ff;
color: #37a;
border: 1px solid #cce3f5;
border-radius: 12px;
padding: 2px 10px;
cursor: pointer;
font-size: 13px;
}
.dcn-selected-tag:hover {
background: #dcefff;
}
.dcn-clear-all {
background: transparent;
border: none;
color: #999;
cursor: pointer;
font-size: 13px;
}
.dcn-clear-all:hover {
color: #37a;
}
.dcn-muted {
color: #999;
}
.dcn-status {
margin-bottom: 12px;
font-size: 14px;
min-height: 22px;
}
.dcn-status.loading {
color: #37a;
}
.dcn-status.success {
color: #666;
}
.dcn-status.empty {
color: #999;
}
.dcn-status.error {
color: #c33;
}
.dcn-status.warning {
color: #c90;
}
.dcn-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.dcn-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.dcn-card {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.dcn-poster-link {
display: block;
position: relative;
aspect-ratio: 2 / 3;
background: #f0f0f0;
}
.dcn-poster {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.dcn-poster.dcn-image-missing {
display: flex;
align-items: center;
justify-content: center;
background: #e0e0e0;
}
.dcn-poster.dcn-image-missing::after {
content: "暂无海报";
color: #999;
font-size: 13px;
}
.dcn-new {
position: absolute;
top: 6px;
left: 6px;
background: #ff6a00;
color: #fff;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
}
.dcn-card-body {
padding: 12px;
}
.dcn-title {
margin: 0 0 6px;
font-size: 15px;
line-height: 1.4;
}
.dcn-title a {
color: #37a;
text-decoration: none;
}
.dcn-title a:hover {
color: #256aa7;
text-decoration: underline;
}
.dcn-rating {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.dcn-stars {
color: #ffac2d;
letter-spacing: -1px;
}
.dcn-stars.empty {
color: #ddd;
}
.dcn-rate-number {
color: #ffac2d;
font-weight: 600;
font-size: 14px;
}
.dcn-no-rate {
color: #999;
font-size: 13px;
}
.dcn-meta {
margin: 0 0 8px;
font-size: 13px;
color: #666;
line-height: 1.5;
}
.dcn-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dcn-badge {
display: inline-block;
background: #f0f0f0;
color: #666;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
}
.dcn-badge.playable {
background: #e6f7e6;
color: #2a8a2a;
}
.dcn-list-item {
display: flex;
gap: 16px;
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.dcn-list-poster-link {
flex-shrink: 0;
width: 100px;
height: 150px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.dcn-list-poster {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.dcn-list-content {
flex: 1;
min-width: 0;
}
.dcn-list-title {
margin: 0 0 8px;
font-size: 17px;
}
.dcn-list-title a {
color: #37a;
text-decoration: none;
}
.dcn-list-title a:hover {
color: #256aa7;
text-decoration: underline;
}
.dcn-title-year {
color: #999;
font-weight: normal;
margin-left: 6px;
}
.dcn-list-meta {
margin: 0 0 8px;
font-size: 13px;
color: #666;
}
.dcn-creator {
margin: 0 0 6px;
font-size: 13px;
color: #555;
}
.dcn-summary {
margin: 8px 0 0;
font-size: 13px;
color: #777;
line-height: 1.6;
}
.dcn-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 24px;
flex-wrap: wrap;
}
.dcn-page-button,
.dcn-page-number {
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
color: #37a;
}
.dcn-page-button:hover:not(:disabled),
.dcn-page-number:hover:not(.active) {
background: #f7f7f7;
}
.dcn-page-button:disabled {
color: #bbb;
cursor: not-allowed;
}
.dcn-page-number.active {
background: #37a;
color: #fff;
border-color: #37a;
}
.dcn-current-page {
color: #666;
font-size: 14px;
}
.dcn-page-ellipsis {
color: #999;
padding: 0 4px;
}
.dcn-empty,
.dcn-error-box {
text-align: center;
padding: 48px 20px;
color: #666;
}
.dcn-empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.dcn-skeleton-card {
background: #fff;
border-radius: 8px;
padding: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.dcn-skeleton {
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%;
animation: dcn-skeleton-loading 1.2s infinite;
border-radius: 4px;
}
.dcn-skeleton-poster {
aspect-ratio: 2 / 3;
margin-bottom: 12px;
}
.dcn-skeleton-line {
height: 14px;
margin-bottom: 8px;
width: 60%;
}
.dcn-skeleton-line.wide {
width: 90%;
}
@keyframes dcn-skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`;
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
waitForApp();
})();