weiduhuo / Bangumi-Episode Air Status Corrector

// ==UserScript==
// @name         Bangumi-Episode Air Status Corrector
// @name:zh-CN   班固米-单集放送状态修正
// @version      0.2.2
// @description  Correct the air status of individual episodes, using timing data and your custom settings from the /dev/app/2527 gadget
// @author       banzhe, age, weiduhuo
// @namespace    https://github.com/weiduhuo/scripts
// @match        *://bgm.tv/
// @match        *://bgm.tv/subject/*
// @match        *://bgm.tv/ep/*
// @match        *://bangumi.tv/
// @match        *://bangumi.tv/subject/*
// @match        *://bangumi.tv/ep/*
// @match        *://chii.in/
// @match        *://chii.in/subject/*
// @match        *://chii.in/ep/*
// @grant        none
// @license      MIT
// @description:zh-CN  基于/dev/app/2527组件的时间数据与用户设置,对单集放送状态进行修正,若数据不存在默认将当天放送标为绿色
// ==/UserScript==

(function () {
  'use strict';

  const TIME_SET_STORAGE_KEY = 'BGM_HOME_ANIME_TIME_SET_AGE';
  const TIME_SET_SETTINGS_KEY = 'BGM_HOME_ANIME_TIME_SETTINGS_AGE';
  const EPS_AIR_DATE_KEY_PREF = 'epsAirDate-';

  const Now = new Date();
  const Today = Now.getDay();
  const CurrentHours = Now.getHours();
  const CurrentMinutes = Now.getMinutes();
  // 待修正的单集候选日期
  const CandDays = [
    new Date(Date.now() - 86400e3),
    Now,
    new Date(Date.now() + 86400e3)
  ].map(
    (date) => `首播:${date.toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })}`
  );

  const TimeStatus2AirStatus = {
    'past':    {en: 'Air',   zh: '已放送'},
    'future':  {en: 'Today', zh: '即将放送'}, // 原为 '放送中'
    'horizon': {en: 'NA',    zh: '未放送'},
  };
  const AirStatusClasses = Object.values(TimeStatus2AirStatus).map(c => c.en);
  const EpBtnClasses = AirStatusClasses.map(c => `epBtn${c}`);

  let animeTimeData;
  let animeTimeSettings;
  let subjectId;

  function main() {
    const pathname = window.location.pathname;
    const urlPatterns = [
      { type: 'home', regex: /^\/$/ },
      { type: 'subject', regex: /^\/subject\/(\d+)$/ },
      { type: 'eps', regex: /^\/subject\/(\d+)\/ep$/ },
      { type: 'ep', regex: /^\/ep\/(\d+)$/ },
    ]
    for (const pattern of urlPatterns) {
      const matches = pathname.match(pattern.regex);
      if (!matches) continue;
      switch(pattern.type) {
        case 'home': subjectId = null; handlerEpBlocks(); break;
        case 'subject': subjectId = matches[1]; handlerEpBlocks(); break;
        case 'eps': subjectId = matches[1]; handlerEpLines(); break;
        case 'ep': handlerSideEpList(); break;
      }
      break;
    }
  }

  function getStoragedata(key) {
    const data = localStorage.getItem(key);
    if (!data) return {};
    try {
      return JSON.parse(data);
    } catch {
      return {};
    }
  }

  function getTimeStatus(subId) {
    animeTimeData ??= getStoragedata(TIME_SET_STORAGE_KEY);
    const timeData = animeTimeData[subId];
    if (!timeData) return null;
    animeTimeSettings ??= getStoragedata(TIME_SET_SETTINGS_KEY);

    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 (totalDiffHours < 0) return 'past';
    // 即将放送
    switch (animeTimeSettings.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;
      // 预留功能:今天内30小时制即将放送
      case 6: if ((dayDiff === 0 && totalDiffHours >= 0 && (CurrentHours >= 6 === hours >=6)) || (dayDiff === 1 && hours < 6 && CurrentHours >= 6)) return 'future'; break;
      case 5:
      default: return null;
    }
    // 未放送
    return 'horizon';
  }

  function correctEpBtn(btn, status, day) {
    if (btn.classList.contains('epBtnWatched')) return;
    if (status === null) {
      // 默认仅将当天放送标为绿色
      if (day === 1) status = 'future';
      else if (day === 2) status = 'horizon';
      else return;
    }
    status = TimeStatus2AirStatus[status].en;
    const targetClass = `epBtn${status}`;
    if (btn.classList.contains(targetClass)) return;
    let preStatus;
    EpBtnClasses.forEach(c => {
      if (btn.classList.contains(c)) preStatus = c.slice(5);
      btn.classList.toggle(c, c === targetClass);
    });
    console.log(`修正状态 ${preStatus} > ${status}`, btn);
  }

  function correctAirStatus(ele, status, day) {
    if (status === null) {
      if (day === 1) status = 'future';
      else if (day === 2) status = 'horizon';
      else return;
    } else {
      // 修改文体提示:'放送中' > '即将放送'
      ele.title = TimeStatus2AirStatus[status].zh;
    }
    status = TimeStatus2AirStatus[status].en;
    const inner = ele.children[0];
    if (!inner.classList.contains(status)) return;
    let preStatus;
    AirStatusClasses.forEach(c => {
      if (inner.classList.contains(c)) preStatus = c
      inner.classList.toggle(c, c === status)
    });
    console.log(`修正状态 ${preStatus} > ${status}`, ele);
  }

  function handlerEpBlocks() {
    const episodeList = document.getElementsByClassName('prg_popup');
    for (const episode of episodeList) {
      const tipElement = episode.getElementsByClassName('tip')[0]
      if (!tipElement) continue;
      let airDate;
      for (const node of tipElement.childNodes) {
        if (node.nodeType !== Node.TEXT_NODE) continue;
        const text = node.textContent.trim();
        if (text.startsWith('首播')) {
          airDate = text;
          break;
        }
      }
      if (!airDate) continue;
      const day = CandDays.indexOf(airDate);
      if (day === -1) continue;
      const candEpBtn = document.getElementById(episode.id.replace('info', ''));
      const subId = subjectId || candEpBtn.getAttribute('subject_id');
      const timeStatus = getTimeStatus(subId);
      correctEpBtn(candEpBtn, timeStatus, day);
    }
  }

  /** @param {Document} doc */
  function handlerEpLines(doc = document) {
    const episodeList = doc.querySelectorAll('.line_detail > ul > li');
    const virtual = doc !== document;
    const epsAirDate = {};
    for (const episode of episodeList) {
      const airStatus = episode.getElementsByClassName('epAirStatus')[0];
      if (!airStatus) continue;
      const textList = episode.querySelector('h6 ~ small')?.textContent.split('/');
      const airDate = textList?.map(t => t.trim()).find(t => t.startsWith('首播'));
      if (!airDate) continue;
      const day = CandDays.indexOf(airDate);
      if (day === -1) continue;
      const epPath = episode.querySelector('h6 a[href^="/ep"]').pathname;
      epsAirDate[epPath] = airDate;
      if (virtual) continue;
      
      const timeStatus = getTimeStatus(subjectId);
      correctAirStatus(airStatus, timeStatus, day);
    }
    const dataKey = EPS_AIR_DATE_KEY_PREF + subjectId;
    sessionStorage.setItem(dataKey, JSON.stringify(epsAirDate));
    return epsAirDate;
  }

  async function fetchEpLinesPage() {
    const host = location.host;
    const protocol = location.protocol;
    const res = await fetch(`${protocol}//${host}/subject/${subjectId}/ep`);
    if (!res.ok) return null;
    const html = await res.text();
    const doc = new DOMParser().parseFromString(html, 'text/html');
    return doc;
  }

  async function handlerSideEpList() {
    subjectId = document.querySelector('#subject_inner_info a').href.split('/').pop();
    const dataKey = EPS_AIR_DATE_KEY_PREF + subjectId;
    let epsAirDate = sessionStorage.getItem(dataKey);
    if (epsAirDate) {
      epsAirDate = JSON.parse(epsAirDate);
    } else {
      const doc = await fetchEpLinesPage();
      if (!doc) return;
      epsAirDate = handlerEpLines(doc);
    }
    const sideEpList = document.getElementsByClassName('sideEpList')[0];
    for (const [epPath, airDate] of Object.entries(epsAirDate)) {
      const epLink = sideEpList.querySelector(`a[href="${epPath}"]`);
      if (!epLink) continue;
      const airStatus = epLink.previousElementSibling;
      const day = CandDays.indexOf(airDate);
      const timeStatus = getTimeStatus(subjectId);
      correctAirStatus(airStatus, timeStatus, day);
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', main);
  } else {
    setTimeout(main, 0);
  }
})();