// ==UserScript==
// @name BGM Anime Time Set
// @namespace http://tampermonkey.net/
// @version 1.2
// @description 在BGM.tv的追番页面添加设置按钮,可以设置番剧的播放时间并排序
// @author age
// @match https://bgm.tv/
// @match https://bangumi.tv/
// @match https://chii.in/
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// 常量定义
const STORAGE_KEY = 'BGM_HOME_ANIME_TIME_SET_AGE';
const SETTINGS_KEY = 'BGM_HOME_ANIME_TIME_SETTINGS_AGE';
const EXPIRATION_DAYS = 200;
const WEEK_DAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
// 缓存DOM元素
let cachedContainer = null;
let cachedAnimeTimeData = null;
let cachedSettings = null;
// 添加CSS样式
const style = document.createElement('style');
style.textContent = `
#Age-js01-button {
margin-left: 5px;
padding: 1px 5px;
border-radius: 3px;
border: none;
font-size: 12px;
cursor: pointer;
background-color: #2e2e2e;
color: #eee;
}
#Age-js01-button.past {
background-color: #118FDD;
}
#Age-js01-button.future {
background-color: #10C745;
}
#Age-js01-button.soon {
background-color: #FF3333;
}
#Age-js01-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 15px;
border-radius: 4px;
box-shadow: 0 0 10px rgba(0,0,0,0.5);
z-index: 9999;
font-size: 14px;
background-color: #333;
color: #fff;
min-width: 300px;
}
#Age-js01-select, #Age-js01-time {
padding: 3px;
border-radius: 3px;
background-color: #444;
color: #fff;
}
#Age-js01-time {
margin-right: 8px;
}
#Age-js01-button-container {
margin-top: 10px;
text-align: right;
}
#Age-js01-save, #Age-js01-cancel, #Age-js01-clear {
padding: 3px 8px;
border-radius: 3px;
border: none;
font-size: 12px;
cursor: pointer;
background-color: #444;
color: #fff;
}
#Age-js01-clear {
margin-right: 8px;
}
#Age-js01-manager-button {
margin-left: 1px;
border-radius: 100px;
border: none;
font-size: 22px;
cursor: pointer;
background-color: #3f3e3f;
color: #eee;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2px 6px;
width: 25px;
height: 40px;
}
#Age-js01-manager-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 15px;
border-radius: 4px;
box-shadow: 0 0 10px rgba(0,0,0,0.5);
z-index: 9999;
background-color: #333;
color: #fff;
min-width: 300px;
max-height: 80vh;
overflow-y: auto;
}
#Age-js01-manager-modal h3 {
margin-top: 0;
border-bottom: 1px solid #555;
padding-bottom: 5px;
}
#Age-js01-manager-modal button {
padding: 3px 8px;
border-radius: 3px;
border: none;
font-size: 12px;
cursor: pointer;
background-color: #444;
color: #fff;
margin: 5px;
}
#Age-js01-manager-modal-content {
margin: 10px 0;
}
#Age-js01-storage-content {
background-color: #444;
padding: 10px;
border-radius: 3px;
max-height: 200px;
overflow-y: auto;
font-family: monospace;
white-space: pre-wrap;
word-wrap: break-word;
}
#Age-js01-timezone-select {
padding: 3px;
border-radius: 3px;
background-color: #444;
color: #fff;
margin-left: 8px;
}
#Age-js01-show-style-select {
padding: 3px;
border-radius: 3px;
background-color: #444;
color: #fff;
margin-left: 8px;
}
html[data-theme='light'] #Age-js01-button {
background-color: #f5f5f5;
color: #333;
}
html[data-theme='light'] #Age-js01-button.past {
background-color: #AEEFFF;
}
html[data-theme='light'] #Age-js01-button.future {
background-color: #AEFFB8;
}
html[data-theme='light'] #Age-js01-button.soon {
background-color: #FFAAAA;
}
html[data-theme='light'] #Age-js01-dialog {
background-color: white;
color: #333;
}
html[data-theme='light'] #Age-js01-select,
html[data-theme='light'] #Age-js01-time,
html[data-theme='light'] #Age-js01-timezone-select,
html[data-theme='light'] #Age-js01-show-style-select {
background-color: #fff;
color: #333;
}
html[data-theme='light'] #Age-js01-save,
html[data-theme='light'] #Age-js01-cancel,
html[data-theme='light'] #Age-js01-clear {
background-color: #f5f5f5;
color: #333;
}
html[data-theme='light'] #Age-js01-manager-button {
background-color: #fff;
color: #333;
}
html[data-theme='light'] #Age-js01-manager-modal {
background-color: white;
color: #333;
}
html[data-theme='light'] #Age-js01-manager-modal h3 {
border-bottom-color: #ddd;
}
html[data-theme='light'] #Age-js01-manager-modal button {
background-color: #f5f5f5;
color: #333;
}
html[data-theme='light'] #Age-js01-storage-content {
background-color: #f5f5f5;
}
`;
document.head.appendChild(style);
// 数据存储操作
function getAnimeTimeData() {
if (cachedAnimeTimeData) return cachedAnimeTimeData;
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return {};
try {
const parsed = JSON.parse(data);
// 检查并清理过期数据
const now = new Date();
const cleanedData = {};
for (const [id, entry] of Object.entries(parsed)) {
if (entry.expiresAt && new Date(entry.expiresAt) > now) {
cleanedData[id] = {
weekDay: entry.weekDay,
time: entry.time
};
}
}
cachedAnimeTimeData = cleanedData;
return cachedAnimeTimeData;
} catch (e) {
console.error('Failed to parse anime time data:', e);
return {};
}
}
function setAnimeTimeData(data) {
const now = new Date();
const storageData = {};
// 保留现有的过期时间,只更新修改的条目
const existingData = getAnimeTimeData();
for (const [id, entry] of Object.entries(data)) {
storageData[id] = {
...entry,
// 如果条目已存在且未被修改,保留原过期时间
expiresAt: existingData[id] && existingData[id].weekDay === entry.weekDay && existingData[id].time === entry.time
? existingData[id].expiresAt
: new Date(now.getTime() + EXPIRATION_DAYS * 24 * 60 * 60 * 1000).toISOString()
};
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(storageData));
cachedAnimeTimeData = data;
}
function getSettings() {
if (cachedSettings) return cachedSettings;
const settings = localStorage.getItem(SETTINGS_KEY);
if (!settings) return getDefaultSettings();
try {
const parsed = JSON.parse(settings);
cachedSettings = validateSettings(parsed);
return cachedSettings;
} catch (e) {
console.error('Failed to parse settings:', e);
return getDefaultSettings();
}
}
function getDefaultSettings() {
return {
setShow: true,
showStyleRed: 0,
showStyleGreen: 0,
showStyleBlue: 0
};
}
function validateSettings(settings) {
// 确保设置存在且是有效范围内的数字
if (typeof settings.showStyleRed !== 'number' || settings.showStyleRed < 0 || settings.showStyleRed > 3) {
settings.showStyleRed = 0;
}
if (typeof settings.showStyleGreen !== 'number' || settings.showStyleGreen < 0 || settings.showStyleGreen > 5) {
settings.showStyleGreen = 0;
}
if (typeof settings.showStyleBlue !== 'number' || settings.showStyleBlue < 0 || settings.showStyleBlue > 3) {
settings.showStyleBlue = 0;
}
return settings;
}
function setSettings(settings) {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
cachedSettings = settings;
}
// 主初始化函数
function init() {
cachedContainer = document.getElementById('cloumnSubjectInfo');
if (!cachedContainer) return;
// 添加管理按钮
addManagerButton();
// 初始化数据
cachedAnimeTimeData = getAnimeTimeData();
cachedSettings = getSettings();
// 为每个番剧添加设置按钮
addSetButtons();
// 根据设置显示/隐藏SET按钮
toggleSetButtons(cachedSettings.setShow);
// 重新排序番剧列表
sortAnimeList();
}
// 添加管理按钮
function addManagerButton() {
const prgManagerMode = document.getElementById('prgManagerMode');
if (!prgManagerMode || document.getElementById('Age-js01-manager-button')) return;
const managerButton = document.createElement('button');
managerButton.id = 'Age-js01-manager-button';
managerButton.innerHTML = '<div style="text-align:center;line-height:2;">⏰︎</div><div style="text-align:center;line-height:0;"></div>';
managerButton.addEventListener('click', showManagerModal);
prgManagerMode.appendChild(managerButton);
}
// 显示管理框
function showManagerModal() {
const modal = document.createElement('div');
modal.id = 'Age-js01-manager-modal';
modal.innerHTML = `
<h3>时间管理</h3>
<div id="Age-js01-manager-modal-content">
<div>
<button id="Age-js01-auto-fetch">自动获取时间</button>
<select id="Age-js01-timezone-select">
<option value="-12">UTC-12</option>
<option value="-11">UTC-11</option>
<option value="-10">UTC-10</option>
<option value="-9">UTC-9</option>
<option value="-8">UTC-8</option>
<option value="-7">UTC-7</option>
<option value="-6">UTC-6</option>
<option value="-5">UTC-5</option>
<option value="-4">UTC-4</option>
<option value="-3">UTC-3</option>
<option value="-2">UTC-2</option>
<option value="-1">UTC-1</option>
<option value="0">UTC±0</option>
<option value="1">UTC+1</option>
<option value="2">UTC+2</option>
<option value="3">UTC+3</option>
<option value="4">UTC+4</option>
<option value="5">UTC+5</option>
<option value="6">UTC+6</option>
<option value="7">UTC+7</option>
<option value="8" selected>UTC+8</option>
<option value="9">UTC+9</option>
<option value="10">UTC+10</option>
<option value="11">UTC+11</option>
<option value="12">UTC+12</option>
</select>
</div>
<div style="margin-top: 15px;">
<h4>显示样式设置</h4>
<div>
<label for="Age-js01-show-style-red">红:</label>
<select id="Age-js01-show-style-red">
<option value="0">1小时内即将放送</option>
<option value="1">2小时内即将放送</option>
<option value="2">4小时内即将放送</option>
<option value="3">禁用</option>
</select>
</div>
<div style="margin-top: 5px;">
<label for="Age-js01-show-style-green">绿:</label>
<select id="Age-js01-show-style-green">
<option value="0">18小时内即将放送</option>
<option value="1">24小时内即将放送</option>
<option value="2">今天内即将放送</option>
<option value="3">明天6点前即将放送</option>
<option value="4">明天8点前即将放送</option>
<option value="5">禁用</option>
</select>
</div>
<div style="margin-top: 5px;">
<label for="Age-js01-show-style-blue">蓝:</label>
<select id="Age-js01-show-style-blue">
<option value="0">18小时内已经放送</option>
<option value="1">24小时内已经放送</option>
<option value="2">今天内已经放送</option>
<option value="3">禁用</option>
</select>
</div>
</div>
<button id="Age-js01-toggle-set">${cachedSettings.setShow ? '隐藏所有SET按钮' : '显示所有SET按钮'}</button>
<div style="margin-top: 15px;">
<h4>存储内容 (${STORAGE_KEY})</h4>
<div id="Age-js01-storage-content" contenteditable="true">${JSON.stringify(getFullStorageData(), null, 2)}</div>
</div>
</div>
<div style="text-align: right;">
<button id="Age-js01-save-storage">保存修改</button>
<button id="Age-js01-close-manager">关闭</button>
</div>
`;
// 设置当前显示样式
modal.querySelector('#Age-js01-show-style-red').value = cachedSettings.showStyleRed;
modal.querySelector('#Age-js01-show-style-green').value = cachedSettings.showStyleGreen;
modal.querySelector('#Age-js01-show-style-blue').value = cachedSettings.showStyleBlue;
document.body.appendChild(modal);
// 使用事件委托处理模态框内的事件
modal.addEventListener('click', (e) => {
const target = e.target;
if (target.id === 'Age-js01-auto-fetch') {
autoFetchSchedule();
} else if (target.id === 'Age-js01-toggle-set') {
const newSettings = { ...cachedSettings, setShow: !cachedSettings.setShow };
setSettings(newSettings);
toggleSetButtons(newSettings.setShow);
target.textContent = newSettings.setShow ? '隐藏所有SET按钮' : '显示所有SET按钮';
} else if (target.id === 'Age-js01-save-storage') {
try {
const newData = JSON.parse(document.getElementById('Age-js01-storage-content').textContent);
// 保留未修改条目的过期时间
const existingData = getFullStorageData();
const now = new Date();
const mergedData = {};
for (const [id, entry] of Object.entries(newData)) {
mergedData[id] = {
...entry,
expiresAt: existingData[id] && existingData[id].weekDay === entry.weekDay && existingData[id].time === entry.time
? existingData[id].expiresAt
: new Date(now.getTime() + EXPIRATION_DAYS * 24 * 60 * 60 * 1000).toISOString()
};
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(mergedData));
cachedAnimeTimeData = getAnimeTimeData(); // 重新加载数据
addSetButtons();
sortAnimeList();
alert('保存成功');
} catch (e) {
alert('保存失败: JSON格式错误');
console.error(e);
}
} else if (target.id === 'Age-js01-close-manager') {
const newSettings = {
...cachedSettings,
showStyleRed: parseInt(modal.querySelector('#Age-js01-show-style-red').value),
showStyleGreen: parseInt(modal.querySelector('#Age-js01-show-style-green').value),
showStyleBlue: parseInt(modal.querySelector('#Age-js01-show-style-blue').value)
};
setSettings(newSettings);
addSetButtons();
sortAnimeList();
document.body.removeChild(modal);
}
});
}
// 获取完整的存储数据(包括过期时间)
function getFullStorageData() {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : {};
}
// 自动获取时间表
async function autoFetchSchedule() {
if (!confirm('此操作将清空目前的时间表,是否继续?')) return;
try {
const subjectLinks = document.querySelectorAll('#cloumnSubjectInfo .infoWrapper_tv.hidden.clearit a[href^="/subject/"]');
const subjectIds = new Set();
subjectLinks.forEach(link => {
const href = link.getAttribute('href');
const match = href.match(/^\/subject\/(\d+)/);
if (match && match[1]) subjectIds.add(match[1]);
});
if (subjectIds.size === 0) {
alert('未找到任何动画条目ID');
return;
}
const response = await fetch('https://raw.githubusercontent.com/zhollgit/bgm-onair/main/onair.json');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
const newAnimeTimeData = {};
const timezoneOffset = parseInt(document.getElementById('Age-js01-timezone-select')?.value || 8);
const now = new Date();
data.items.forEach(item => {
const bangumiSite = item.sites.find(site => site.site === 'bangumi');
if (bangumiSite?.id && subjectIds.has(bangumiSite.id) && item.begin) {
const beginDate = new Date(item.begin);
let adjustedHours = beginDate.getUTCHours() + timezoneOffset;
let adjustedDay = beginDate.getUTCDay();
if (adjustedHours >= 24) {
adjustedHours -= 24;
adjustedDay = (adjustedDay + 1) % 7;
} else if (adjustedHours < 0) {
adjustedHours += 24;
adjustedDay = (adjustedDay - 1 + 7) % 7;
}
const time = `${adjustedHours.toString().padStart(2, '0')}:${beginDate.getUTCMinutes().toString().padStart(2, '0')}`;
newAnimeTimeData[bangumiSite.id] = {
weekDay: adjustedDay,
time,
expiresAt: new Date(now.getTime() + EXPIRATION_DAYS * 24 * 60 * 60 * 1000).toISOString()
};
}
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(newAnimeTimeData));
cachedAnimeTimeData = getAnimeTimeData();
addSetButtons();
sortAnimeList();
const storageContent = document.getElementById('Age-js01-storage-content');
if (storageContent) storageContent.textContent = JSON.stringify(newAnimeTimeData, null, 2);
alert(`成功获取 ${Object.keys(newAnimeTimeData).length} 个番剧的时间数据`);
} catch (error) {
console.error('自动获取时间表失败:', error);
alert('自动获取时间表失败: ' + error.message);
}
}
// 切换SET按钮的显示/隐藏
function toggleSetButtons(show) {
document.querySelectorAll('#Age-js01-button').forEach(button => {
const subjectId = button.getAttribute('data-subject-id');
if (!cachedAnimeTimeData[subjectId]) {
button.style.display = show ? '' : 'none';
}
});
}
// 添加设置按钮
function addSetButtons() {
const editLinks = cachedContainer.querySelectorAll('a.thickbox.l[id^="sbj_prg_"]:not([data-processed])');
editLinks.forEach(editLink => {
if (editLink.textContent.trim() === '[edit]') return;
const subjectId = editLink.id.split('_')[2];
editLink.setAttribute('data-processed', 'true');
const setButton = document.createElement('button');
setButton.id = 'Age-js01-button';
setButton.setAttribute('data-subject-id', subjectId);
if (cachedAnimeTimeData[subjectId]) {
setButton.textContent = formatTimeData(cachedAnimeTimeData[subjectId]);
const timeStatus = getTimeStatus(cachedAnimeTimeData[subjectId]);
if (timeStatus) setButton.classList.add(timeStatus);
} else {
setButton.textContent = 'SET';
if (!cachedSettings.setShow) setButton.style.display = 'none';
}
setButton.addEventListener('click', () => showTimeSettingDialog(subjectId, setButton));
editLink.parentNode.insertBefore(setButton, editLink.nextSibling);
});
}
// 获取时间状态
function getTimeStatus(timeData) {
const now = new Date();
const today = now.getDay();
const currentHours = now.getHours();
const currentMinutes = now.getMinutes();
const [hours, minutes] = timeData.time.split(':').map(Number);
const targetDay = timeData.weekDay;
let dayDiff = targetDay - today;
if (dayDiff < -3) dayDiff += 7;
else if (dayDiff > 3) dayDiff -= 7;
const totalDiffHours = dayDiff * 24 + (hours - currentHours) + (minutes - currentMinutes) / 60;
// 红色优先级最高
if (cachedSettings.showStyleRed !== 3) {
switch (cachedSettings.showStyleRed) {
case 0: if (totalDiffHours >= 0 && totalDiffHours < 1) return 'soon'; break;
case 1: if (totalDiffHours >= 0 && totalDiffHours < 2) return 'soon'; break;
case 2: if (totalDiffHours >= 0 && totalDiffHours < 4) return 'soon'; break;
}
}
// 然后检查绿色条件
if (cachedSettings.showStyleGreen !== 5) {
switch (cachedSettings.showStyleGreen) {
case 0: if (totalDiffHours >= 0 && totalDiffHours < 18) return 'future'; break;
case 1: if (totalDiffHours >= 0 && totalDiffHours < 24) return 'future'; break;
case 2: if (dayDiff === 0 && totalDiffHours >= 0) return 'future'; break;
case 3: if ((dayDiff === 0 && totalDiffHours >= 0) || (dayDiff === 1 && hours < 6)) return 'future'; break;
case 4: if ((dayDiff === 0 && totalDiffHours >= 0) || (dayDiff === 1 && hours < 8)) return 'future'; break;
}
}
// 最后检查蓝色条件
if (cachedSettings.showStyleBlue !== 3) {
switch (cachedSettings.showStyleBlue) {
case 0: if (totalDiffHours >= -18 && totalDiffHours < 0) return 'past'; break;
case 1: if (totalDiffHours >= -24 && totalDiffHours < 0) return 'past'; break;
case 2: if (dayDiff === 0 && totalDiffHours < 0) return 'past'; break;
}
}
return '';
}
// 显示时间设置对话框
function showTimeSettingDialog(subjectId, button) {
const dialog = document.createElement('div');
dialog.id = 'Age-js01-dialog';
const weekDaySelect = document.createElement('select');
weekDaySelect.id = 'Age-js01-select';
WEEK_DAYS.forEach((day, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = day;
weekDaySelect.appendChild(option);
});
const timeInput = document.createElement('input');
timeInput.id = 'Age-js01-time';
timeInput.type = 'time';
if (cachedAnimeTimeData[subjectId]) {
weekDaySelect.value = cachedAnimeTimeData[subjectId].weekDay;
timeInput.value = cachedAnimeTimeData[subjectId].time;
}
dialog.innerHTML = `
<label>星期: </label>
<select id="Age-js01-select">${WEEK_DAYS.map((day, i) =>
`<option value="${i}">${day}</option>`).join('')}</select>
<label style="margin-left:8px;">时间: </label>
<input id="Age-js01-time" type="time" ${cachedAnimeTimeData[subjectId] ?
`value="${cachedAnimeTimeData[subjectId].time}"` : ''}>
<div id="Age-js01-button-container">
<button id="Age-js01-save">保存</button>
<button id="Age-js01-clear">清除</button>
<button id="Age-js01-cancel">取消</button>
</div>
`;
document.body.appendChild(dialog);
// 事件处理
dialog.addEventListener('click', (e) => {
if (e.target.id === 'Age-js01-save') {
const weekDay = parseInt(dialog.querySelector('#Age-js01-select').value);
const time = dialog.querySelector('#Age-js01-time').value;
if (!time) {
alert('请选择时间');
return;
}
// 获取当前存储的完整数据
const fullData = getFullStorageData();
const now = new Date();
// 更新数据
fullData[subjectId] = {
weekDay,
time,
expiresAt: new Date(now.getTime() + EXPIRATION_DAYS * 24 * 60 * 60 * 1000).toISOString()
};
// 保存更新
localStorage.setItem(STORAGE_KEY, JSON.stringify(fullData));
cachedAnimeTimeData = getAnimeTimeData();
button.textContent = formatTimeData(cachedAnimeTimeData[subjectId]);
button.className = 'Age-js01-button';
const timeStatus = getTimeStatus(cachedAnimeTimeData[subjectId]);
if (timeStatus) button.classList.add(timeStatus);
sortAnimeList();
document.body.removeChild(dialog);
} else if (e.target.id === 'Age-js01-clear') {
// 获取当前存储的完整数据
const fullData = getFullStorageData();
if (fullData[subjectId]) {
delete fullData[subjectId];
localStorage.setItem(STORAGE_KEY, JSON.stringify(fullData));
cachedAnimeTimeData = getAnimeTimeData();
button.textContent = 'SET';
button.className = 'Age-js01-button';
sortAnimeList();
}
document.body.removeChild(dialog);
} else if (e.target.id === 'Age-js01-cancel') {
document.body.removeChild(dialog);
}
});
}
// 格式化时间数据显示
function formatTimeData(timeData) {
return `${WEEK_DAYS[timeData.weekDay]} ${timeData.time}`;
}
// 排序
function sortAnimeList() {
const wrapper = cachedContainer.querySelector('.infoWrapperContainer.infoWrapper_tv.hidden.clearit');
if (!wrapper) return;
const animeItems = Array.from(wrapper.querySelectorAll('.clearit.infoWrapper'));
animeItems.sort((a, b) => {
const aId = a.id.split('_')[1];
const bId = b.id.split('_')[1];
const aData = cachedAnimeTimeData[aId];
const bData = cachedAnimeTimeData[bId];
if (!aData && !bData) return 0;
if (!aData) return 1;
if (!bData) return -1;
if (aData.weekDay !== bData.weekDay) return aData.weekDay - bData.weekDay;
return aData.time.localeCompare(bData.time);
});
// 使用文档片段减少重绘
const fragment = document.createDocumentFragment();
animeItems.forEach(item => fragment.appendChild(item));
wrapper.appendChild(fragment);
}
// 使用DOMContentLoaded而不是load事件,加快响应速度
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
setTimeout(init, 0);
}
})();