// ==UserScript==
// @name DoubanRatingForMovie
// @name:zh-CN 在线电影添加豆瓣评分
// @namespace https://github.com/ciphersaw/DoubanRatingForMovie
// @version 1.2.1
// @description Display Douban rating for online movies.
// @description:zh-CN 在主流电影网站上显示豆瓣评分。
// @author CipherSaw
// @match *://*.olehdtv.com/index.php*
// @match *://*.olevod.com/details*
// @match *://*.olevod.com/player/vod/*
// @match *://*.olevod.tv/details*
// @match *://*.olevod.tv/player/vod/*
// @match *://v.qq.com/x/cover/*
// @match *://www.iqiyi.com/v_*
// @match *://v.youku.com/v_show/*
// @require https://code.jquery.com/jquery-3.6.0.min.js
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @connect douban.com
// @license GPL-3.0
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @supportURL https://github.com/ciphersaw/DoubanRatingForMovie/issues
// ==/UserScript==
'use strict';
const LOG_LEVELS = {
NONE: 0,
ERROR: 1,
INFO: 2,
DEBUG: 3
};
class Logger {
constructor(initialLevel = 'INFO') {
this.currentLogLevel = LOG_LEVELS[initialLevel] || LOG_LEVELS.INFO;
}
error(...args) {
if (this.currentLogLevel >= LOG_LEVELS.ERROR) {
console.error(...args);
}
}
info(...args) {
if (this.currentLogLevel >= LOG_LEVELS.INFO) {
console.info(...args);
}
}
debug(...args) {
if (this.currentLogLevel >= LOG_LEVELS.DEBUG) {
console.debug(...args);
}
}
}
const logger = new Logger('INFO');
const TERM_OF_VALID_CACHE = 1;
const PERIOD_OF_CLEARING_CACHE = 1;
const DOUBAN_RATING_API = 'https://www.douban.com/search?cat=1002&q=';
(function () {
clearExpiredCache();
const host = location.hostname;
if (host === 'www.olehdtv.com') {
OLEHDTV_setRating();
} else if (host === 'www.olevod.com' || host === 'www.olevod.tv') {
// Both main site and test site.
OLEVOD_setRating();
} else if (host === 'v.qq.com') {
VQQ_setRating();
} else if (host === 'www.iqiyi.com') {
IQIYI_setRating();
} else if (host === 'v.youku.com') {
YOUKU_setRating();
}
})();
// ==OLEHDTV==
function OLEHDTV_setRating() {
const id = OLEHDTV_getID();
const title = OLEHDTV_getTitle();
const director = OLEHDTV_getDirector();
const year = OLEHDTV_getYear();
getDoubanRating(`olehdtv_${id}`, title, director, year)
.then(data => {
OLEHDTV_setMainRating(data.ratingNums, data.url);
})
.catch(err => {
OLEHDTV_setMainRating("N/A", DOUBAN_RATING_API + encodeSpaces(title));
});
}
function OLEHDTV_getID() {
const id = /id\/(\d+)/.exec(location.href);
return id ? id[1] : 0;
}
function OLEHDTV_getTitle() {
// Remove the annotated suffix of title.
const suffixRegex = /【.*】$/;
let clone = $('h2.title').clone();
clone.children().remove();
return clone.text().trim().replace(suffixRegex, '');
}
function OLEHDTV_getDirector() {
let selector = '';
if (OLEHDTV_isDetailPage()) {
selector = '.content_min li.data:last';
} else if (OLEHDTV_isPlayPage()) {
selector = '.play_content p:first-child';
}
const directorText = $(selector).text().trim();
const directors = /^导演:(.+)$/.exec(directorText);
if (directors) {
const array = directors[1].split(/\s+/);
return array[0];
} else {
return '';
}
}
function OLEHDTV_getYear() {
let selector = '';
if (OLEHDTV_isDetailPage()) {
selector = 'ul li.data:first-child';
} else if (OLEHDTV_isPlayPage()) {
selector = '.play_text a';
}
const yearText = $(selector).text().trim();
const year = /\d{4}/.exec(yearText);
return year ? year[0] : '';
}
function OLEHDTV_setMainRating(ratingNums, url) {
const doubanLink = `<a href="${url}" target="_blank">豆瓣评分:${ratingNums}</a>`;
if (OLEHDTV_isDetailPage()) {
let ratingObj = $('.content_detail .data>.text_muted:first-child');
ratingObj.empty();
ratingObj.append(doubanLink);
} else if (OLEHDTV_isPlayPage()) {
let ratingObj = $('.play_text .nstem');
const replacedHTML = ratingObj.html().replace('豆瓣评分:', '');
ratingObj.html(replacedHTML);
ratingObj.append(doubanLink);
}
}
function OLEHDTV_isDetailPage() {
return /.+\/vod\/detail\/id\/\d+.*/.test(location.href);
}
function OLEHDTV_isPlayPage() {
return /.+\/vod\/play\/id\/\d+.*/.test(location.href);
}
// ==OLEVOD==
async function OLEVOD_setRating() {
const id = OLEVOD_getID();
let title = '';
try {
title = await OLEVOD_waitForTitle(1000, 10);
} catch (error) {
logger.error(`OLEVOD_waitForTitle: id=${id} error=${error}`);
return;
}
// Note that director and year must be selected after OLEVOD_waitForTitle,
// which means elements have been already loaded and do not need to wait again.
const director = OLEVOD_getDirector();
const year = OLEVOD_getYear();
getDoubanRating(`olevod_${id}`, title, director, year)
.then(data => {
OLEVOD_setMainRating(data.ratingNums, data.url);
})
.catch(err => {
OLEVOD_setMainRating("N/A", DOUBAN_RATING_API + encodeSpaces(title));
});
}
function OLEVOD_getID() {
const id = /\d{1}-\d{4,5}/.exec(location.href);
return id ? id[0] : 0;
}
function OLEVOD_waitForTitle(delay, iterations) {
let selector = '';
if (OLEVOD_isDetailPage()) {
selector = ".pc-container .info .title";
} else if (OLEVOD_isPlayPage()) {
selector = ".el-tabs__content .tab-label";
}
return new Promise((resolve, reject) => {
let count = 0;
const intervalID = setInterval(() => {
count++;
if (count === iterations) {
const error = new Error(`ResolveError: title is not found and iterations have reached the maximum`);
clearInterval(intervalID);
reject(error);
}
const obj = $(selector);
if (obj.length > 0) {
const title = OLEVOD_resolveTitle(obj);
if (title !== "") {
clearInterval(intervalID);
resolve(title);
}
}
}, delay);
});
}
function OLEVOD_resolveTitle(obj) {
// Remove the annotated suffix of title.
const suffixRegex = /【.*】$/;
if (OLEVOD_isDetailPage()) {
return obj.text().trim().replace(suffixRegex, '');
} else if (OLEVOD_isPlayPage()) {
const clone = obj.clone();
clone.children().remove();
return clone.text().trim().replace(suffixRegex, '');
}
}
function OLEVOD_getDirector() {
if (OLEVOD_isDetailPage()) {
const directorText = $('.pc-container .info p:nth-of-type(2)').text().trim();
const directors = /^导演:(.+)$/.exec(directorText);
if (directors) {
const array = directors[1].split(/\s*\/\s*/);
return array[0];
} else {
return '';
}
} else if (OLEVOD_isPlayPage()) {
// Director is not found in the movie play page.
return '';
}
}
function OLEVOD_getYear() {
let selector = '';
if (OLEVOD_isDetailPage()) {
selector = '.pc-container .info .label';
} else if (OLEVOD_isPlayPage()) {
selector = '.el-tabs__content .tab-label p.wes';
}
const yearText = $(selector).text().trim();
const year = /\d{4}/.exec(yearText);
return year ? year[0] : '';
}
function OLEVOD_setMainRating(ratingNums, url) {
if (OLEVOD_isDetailPage()) {
let ratingObj = $('.pc-container .info .label:first-child');
ratingObj.before(`<span class="label"><a href="${url}" target="_blank" style="color:white">豆瓣评分:${ratingNums}</a></span>`);
// Set MutationObserver for the title element of current page.
const titleObj = $('.pc-container .info .title');
const originalText = titleObj.text().trim();
if (titleObj.length > 0) {
const observer = new MutationObserver(observerCallback);
observer.observe(titleObj[0], { subtree: true, characterData: true });
// Stop watching for mutations before page is unloaded.
window.onbeforeunload = () => {
if (observer) {
observer.disconnect();
}
};
function observerCallback(mutations, observer) {
mutations.forEach(function (mutation) {
// Check if the character data is changed.
if (mutation.type === 'characterData') {
const changedText = mutation.target.data.trim();
// If the movie page is reloaded by AJAX,
// remove the Douban rating of current page and reset for the new page.
if (originalText !== changedText) {
let ratingObj = $('.pc-container .info .label:first-child');
if (/豆瓣/.test(ratingObj.text().trim())) {
ratingObj.remove();
}
observer.disconnect();
OLEVOD_setRating();
}
}
});
}
}
} else if (OLEVOD_isPlayPage()) {
let ratingObj = $('#pane-first .tab-label .wes');
const clone = ratingObj.clone();
clone.children().remove();
const originalText = clone.text().trim();
const array = originalText.split(/ +/);
if (array.length === 2) {
const revisedHTML = `${array[0]} <a href="${url}" target="_blank" style="color:#798499">豆瓣${ratingNums}</a>/${array[1]}`;
ratingObj.html(revisedHTML);
}
}
}
function OLEVOD_isDetailPage() {
return /.+\/details-\d{1}-\d{4,5}\.html/.test(location.href);
}
function OLEVOD_isPlayPage() {
return /.+\/player\/vod\/\d{1}-\d{4,5}-\d{1}\.html/.test(location.href);
}
// ==VQQ==
function VQQ_setRating() {
const id = VQQ_getID();
const title = VQQ_getTitle();
// It is hard to get director in VQQ, so set them to null temporarily.
const director = '';
const year = VQQ_getYear();
getDoubanRating(`vqq_${id}`, title, director, year)
.then(data => {
VQQ_setMainRating(data.ratingNums, data.url);
})
.catch(err => {
VQQ_setMainRating("N/A", DOUBAN_RATING_API + encodeSpaces(title));
});
}
function VQQ_getID() {
const id = /x\/cover\/(\S+)\//.exec(location.href);
return id ? id[1] : 0;
}
function VQQ_getTitle() {
// Remove the annotated suffix of title.
const suffixRegex = /\[.*\]$/;
const title = $('span.playlist-intro__title');
return title.text().trim().replace(suffixRegex, '');
}
function VQQ_getYear() {
const yearText = $('span.playlist-intro-info__item').text();
const year = /\S*· (\d{4}) ·\S*/.exec(yearText);
return year ? year[1] : '';
}
function VQQ_setMainRating(ratingNums, url) {
let ratingObj = $('span.playlist-intro__title');
ratingObj.after(`<a href="${url}" target="_blank" style="vertical-align:middle; margin-right:6px; color:rgba(255,255,255,0.600)">豆瓣${ratingNums}</a>`);
}
// ==IQIYI==
async function IQIYI_setRating() {
const id = IQIYI_getID();
let title = '';
try {
title = await IQIYI_waitForTitle(1000, 10);
} catch (error) {
logger.error(`IQIYI_waitForTitle: id=${id} error=${error}`);
return;
}
// It is hard to get director and year in IQIYI, so set them to null temporarily.
const director = '';
const year = '';
getDoubanRating(`iqiyi_${id}`, title, director, year)
.then(data => {
IQIYI_setMainRating(data.ratingNums, data.url);
})
.catch(err => {
IQIYI_setMainRating("N/A", DOUBAN_RATING_API + encodeSpaces(title));
});
}
function IQIYI_getID() {
const id = /v_(\S+).html/.exec(location.href);
return id ? id[1] : 0;
}
function IQIYI_waitForTitle(delay, iterations) {
const selector = '.meta_titleNotCloud__O2Ffr';
return new Promise((resolve, reject) => {
let count = 0;
const intervalID = setInterval(() => {
count++;
if (count === iterations) {
const error = new Error(`ResolveError: title is not found and iterations have reached the maximum`);
clearInterval(intervalID);
reject(error);
}
const obj = $(selector);
if (obj.length > 0) {
const title = obj.text().trim();
if (title !== "") {
clearInterval(intervalID);
resolve(title);
}
}
}, delay);
});
}
function IQIYI_setMainRating(ratingNums, url) {
let count = 0;
const intervalID = setInterval(() => {
const obj = $('#doubanRating');
if (obj.length === 0) {
count = 0;
// Set the align-items to center, for the parent div element with flex layout.
let flexObj = $('.meta_titleContent__cUi2t');
flexObj.css("align-items", "center");
// Insert rating div element after title div element.
let ratingObj = $('.meta_titleNotCloud__O2Ffr');
ratingObj.after(`<div id="doubanRating" style="margin-left:6px"><a href="${url}" target="_blank" style="color:#f939; font-family:IQYHT-Medium">豆瓣${ratingNums}</a></div>`);
} else {
count++;
}
// If rating div element is not overwritten and removed in 10s, then clear interval.
if (count === 10) {
clearInterval(intervalID);
}
}, 1000);
}
// ==YOUKU==
async function YOUKU_setRating() {
const id = YOUKU_getID();
const title = YOUKU_getTitle();
let director = '';
try {
director = await YOUKU_waitForDirector(1000, 10);
} catch (error) {
logger.error(`YOUKU_waitForDirector: id=${id} error=${error}`);
return;
}
const year = YOUKU_getYear();
getDoubanRating(`youku_${id}`, title, director, year)
.then(data => {
YOUKU_setMainRating(data.ratingNums, data.url);
})
.catch(err => {
YOUKU_setMainRating("N/A", DOUBAN_RATING_API + encodeSpaces(title));
});
}
function YOUKU_getID() {
const id = /id_(\S+).html/.exec(location.href);
return id ? id[1] : 0;
}
function YOUKU_getTitle() {
const title = $('h3.new-title-name');
return title.text().trim();
}
function YOUKU_waitForDirector(delay, iterations) {
const selector = '.starBox .star .starName:first';
return new Promise((resolve, reject) => {
let count = 0;
const intervalID = setInterval(() => {
count++;
if (count === iterations) {
const error = new Error(`ResolveError: title is not found and iterations have reached the maximum`);
clearInterval(intervalID);
reject(error);
}
const obj = $(selector);
if (obj.length > 0) {
const director = obj.text().trim();
if (director !== "") {
clearInterval(intervalID);
resolve(director);
}
}
}, delay);
});
}
function YOUKU_getYear() {
const yearText = $('.new-title-name-left span:last-child').text();
const year = /\S*·(\d{4})·\S*/.exec(yearText);
return year ? year[1] : '';
}
function YOUKU_setMainRating(ratingNums, url) {
let ratingObj = $('.new-title-name-left span:last-child');
const originalText = ratingObj.text().trim();
const revisedHTML = `<a href="${url}" target="_blank" style="color:white">豆瓣${ratingNums}</a>·${originalText}`;
const revisedAttr = `豆瓣${ratingNums}·${originalText}`;
ratingObj.html(revisedHTML);
ratingObj.attr('title', revisedAttr);
}
// ==COMMON==
function clearExpiredCache() {
const t = GM_getValue('clear_time');
if (!t || !isValidTime(new Date(t), PERIOD_OF_CLEARING_CACHE)) {
logger.info(`clearExpiredCache: clear_time=${t}`);
const idList = GM_listValues();
idList.forEach(function (id) {
// Delete the expired IDs periodically.
const data = GM_getValue(id);
if (data.uptime && !isValidTime(new Date(data.uptime), TERM_OF_VALID_CACHE)) {
GM_deleteValue(id);
}
});
GM_setValue('clear_time', new Date().toISOString());
}
}
async function getDoubanRating(key, title, director, year) {
const data = GM_getValue(key);
if (data && isValidTime(new Date(data.uptime), TERM_OF_VALID_CACHE)) {
logger.info(`getDoubanRating: title=${title} rating=${data.ratingData.ratingNums} uptime=${data.uptime}`);
return data.ratingData;
}
const url = DOUBAN_RATING_API + encodeSpaces(title);
logger.info(`getDoubanRating: title=${title} searchURL=${url}`);
const ratingData = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
"method": "GET",
"url": url,
"onload": (r) => {
const response = $($.parseHTML(r.response));
if (r.status !== 200) {
const error = new Error(`StatusError: response status is ${r.status} and message is ${r.statusText}`);
reject(error);
} else {
try {
let data = resolveDoubanRatingResult(url, director, year, response);
logger.info(`getDoubanRating: title=${title} rating=${data.ratingNums}`);
resolve(data);
} catch (error) {
logger.error(`getDoubanRating: title=${title} error=${error}`);
reject(error);
}
}
}
});
});
cacheDoubanRatingData(key, ratingData);
return ratingData;
}
function isValidTime(uptime, term) {
const oneDayMillis = 24 * 60 * 60 * 1000;
const nowDate = new Date();
const diffMillis = nowDate.getTime() - uptime.getTime();
return diffMillis < oneDayMillis * term;
}
function cacheDoubanRatingData(key, ratingData) {
const uptime = new Date().toISOString();
const data = {
ratingData,
uptime
};
GM_setValue(key, data);
}
function resolveDoubanRatingResult(searchURL, director, year, data) {
const s = getDoubanRatingItem(director, year, data);
if (s === null) {
throw Error("ResolveError: search result is not found");
}
const ratingNums = s.find('.rating_nums').text() || '暂无评分';
const doubanLink = s.find('.content .title a').attr('href') || '';
let url = resolveDoubanURL(doubanLink);
if (url === "") {
url = searchURL;
}
const ratingData = {
ratingNums,
url
}
return ratingData;
}
function getDoubanRatingItem(director, year, data) {
let item = null;
if (director === '' && year === '') {
item = data.find('.result-list .result:first-child');
} else {
const list = data.find('.result-list').children();
list.each(function () {
const info = $(this).find('.subject-cast').text();
const array = info.split(/\s*\/\s*/); // e.g. ['原名:毕业那年', '姚宇', '顾莉雅', '2012']
if (array.length > 0 && array[0].includes('原名')) {
array.shift();
}
let releaseYear = null;
if (/^\d{4}$/.test(array[array.length - 1])) {
releaseYear = array.pop();
}
if (director !== '' && array.indexOf(director) === -1) {
return true;
}
if (year !== '' && releaseYear !== year) {
return true;
}
item = $(this);
return false;
});
if (item === null) {
item = data.find('.result-list .result:first-child');
}
}
return item;
}
function resolveDoubanURL(doubanLink) {
try {
return (new URL(doubanLink)).searchParams.get('url');
} catch (error) {
logger.error(`resolveDoubanURL: error=${error.message}`);
return "";
}
}
function encodeSpaces(text) {
return text.replace(/ /g, '%20');
}