// ==UserScript== // @name SOOP 다시보기 라이브 당시 시간 표시 // @namespace http://tampermonkey.net/ // @version 5.1.1 // @description SOOP 다시보기에서 생방송 당시 시간을 표시/이동 (최근 기록, 셀렉터 폴백, 접근성, 최적화) // @author WakViewer // @match https://vod.sooplive.co.kr/player/* // @icon https://www.google.com/s2/favicons?sz=64&domain=www.sooplive.co.kr // @grant unsafeWindow // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @run-at document-end // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; // ---------------- Config & Selectors ---------------- const SELECTORS = { startTimeTip: "span.broad_time[tip*='방송시간']", infoUL: ".broadcast_information .cnt_info ul", }; const CURRENT_TIME_CANDIDATES = [ "span.time-current", ".time-current", ".player .time-current", ".time_display .time-current", '[aria-label="Current time"]', '[data-role="current-time"]' ]; const DURATION_CANDIDATES = [ "span.time-duration", ".time-duration", ".player .time-duration", ".time_display .time-duration", '[aria-label="Duration"]', '[data-role="duration"]' ]; const EDIT_THRESHOLD_SEC = 180; // 편집 감지 여유 const UPDATE_INTERVAL_MS = 500; // 표시 갱신 주기 const HISTORY_KEY = 'wv_soop_dt_history'; const HISTORY_MAX = 5; // ---------------- State ---------------- let startTime = null, endTime = null; let currentLiveTimeStr = ''; let updateTimer = null, routeObserver = null, initDoneForHref = null; let timeObserver = null; // MutationObserver let lastActiveEl = null; // a11y 포커스 복귀용 // ---------------- Tiny utils ---------------- const $ = (sel, root=document) => root.querySelector(sel); const p2 = (n)=> String(n).padStart(2,'0'); const fmtDate = (d) => `${d.getFullYear()}-${p2(d.getMonth()+1)}-${p2(d.getDate())}, ${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`; const userTZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; const waitFor = (selector, {timeout=10000, root=document}={}) => new Promise((resolve, reject) => { const found = $(selector, root); if (found) return resolve(found); const obs = new MutationObserver(() => { const el2 = $(selector, root); if (el2) { obs.disconnect(); resolve(el2); } }); obs.observe(root.body || root, { childList:true, subtree:true }); if (timeout > 0) setTimeout(() => { obs.disconnect(); reject(new Error('waitFor timeout: '+selector)); }, timeout); }); const pickFirst = (qList, root=document) => { for (const q of qList) { const el = root.querySelector(q); if (el) return el; } return null; }; function getCurrentTimeEl() { let el = pickFirst(CURRENT_TIME_CANDIDATES); if (el) return el; // 패턴 폴백: 짧은 HH:MM:SS / MM:SS 텍스트 const nodes = Array.from(document.querySelectorAll('span,div,time')) .filter(n => /:\d{2}/.test((n.textContent||'').trim())) .filter(n => (n.textContent||'').trim().length <= 8); return nodes[0] || null; } function getDurationEl() { let el = pickFirst(DURATION_CANDIDATES); if (el) return el; const cands = Array.from(document.querySelectorAll('span,div,time')) .filter(n => /:\d{2}/.test((n.textContent||'').trim())); cands.sort((a,b)=> (a.textContent||'').length - (b.textContent||'').length); return cands[cands.length-1] || null; } // ---------------- Parse helpers ---------------- const parseTipTimes = (tip) => { const m = tip && tip.match(/방송시간\s*:\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s*~\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/); if (!m) return null; const s = new Date(m[1].replace(' ', 'T')); const e = new Date(m[2].replace(' ', 'T')); if (isNaN(s) || isNaN(e)) return null; return { start:s, end:e }; }; const parseHMSFlexible = (text) => { if (!text) return 0; const parts = text.trim().split(':').map(Number); if (parts.some(isNaN)) return 0; if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2]; if (parts.length === 2) return parts[0]*60 + parts[1]; return 0; }; // --------- Timezone transforms ---------- function zonedComponentsToUTCms(comp, timeZone) { const utcGuess = Date.UTC(comp.y, comp.M-1, comp.d, comp.h, comp.m, comp.s); const fmt = new Intl.DateTimeFormat('en-US', { timeZone, year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false }); const parts = fmt.formatToParts(new Date(utcGuess)); const get = t => Number(parts.find(p => p.type === t).value); const tzY=get('year'), tzM=get('month'), tzD=get('day'), tzH=get('hour'), tzMin=get('minute'), tzS=get('second'); const tzEpoch = Date.UTC(tzY, tzM-1, tzD, tzH, tzMin, tzS); const offset = tzEpoch - utcGuess; return Date.UTC(comp.y, comp.M-1, comp.d, comp.h, comp.m, comp.s) - offset; } function startOfDayZoned(date, timeZone) { const f = new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit'}); const p = f.formatToParts(date); const y = +p.find(v=>v.type==='year').value; const M = +p.find(v=>v.type==='month').value; const d = +p.find(v=>v.type==='day').value; return zonedComponentsToUTCms({y,M,d,h:0,m:0,s:0}, timeZone); } function listDaysInRange(start, end) { const res = []; if (!start || !end) return res; const endDayMs = startOfDayZoned(end, userTZ); let curMs = startOfDayZoned(start, userTZ); let guard = 0; while (curMs <= endDayMs && guard < 370) { const d = new Date(curMs); const f = new Intl.DateTimeFormat('en-CA',{timeZone:userTZ,year:'numeric',month:'2-digit',day:'2-digit'}); const p = f.formatToParts(d); res.push({ y:+p.find(v=>v.type==='year').value, M:+p.find(v=>v.type==='month').value, d:+p.find(v=>v.type==='day').value }); curMs += 24*3600*1000; guard++; } return res; } // --------------- Natural input parse --------------- function normalizeSpaces(s){ return s.replace(/\u00A0/g,' ').replace(/\s+/g,' ').trim(); } function inferYearFromYY(yy) { const yys = [startTime.getFullYear()%100, endTime.getFullYear()%100]; if (yy === yys[0]) return startTime.getFullYear(); if (yy === yys[1]) return endTime.getFullYear(); return 2000 + yy; } function parseInputToTarget(text) { if (!text) return null; let s = normalizeSpaces(text).replace(/,/g,' '); // 한국어 날짜/시간 const korDate = s.match(/(?:(\d{2,4})\s*년\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일/); const korTime = s.match(/(\d{1,2})\s*시(?:\s*(\d{1,2})\s*분)?(?:\s*(\d{1,2})\s*초)?/); if (korDate || korTime) { let y, M, d, h=0, m=0, sec=0; if (korDate) { const yyRaw = korDate[1]; M = +korDate[2]; d = +korDate[3]; if (yyRaw) y = (yyRaw.length===2) ? inferYearFromYY(+yyRaw) : +yyRaw; else y = startTime.getFullYear(); } else if (korTime) { h = +korTime[1]; m = korTime[2]?+korTime[2]:0; sec = korTime[3]?+korTime[3]:0; if (h>23||m>59||sec>59) return null; const days = listDaysInRange(startTime, endTime); for (const dc of days) { const ms = zonedComponentsToUTCms({y:dc.y,M:dc.M,d:dc.d,h,m,s:sec}, userTZ); const cand = new Date(ms); if (cand >= startTime && cand <= endTime) return { comp:{y:dc.y,M:dc.M,d:dc.d,h,m,s:sec} }; } return null; } if (korTime) { h=+korTime[1]; m=korTime[2]?+korTime[2]:0; sec=korTime[3]?+korTime[3]:0; } if (h>23||m>59||sec>59) return null; if (!y||!M||!d) return null; return { comp:{y,M,d,h,m,s:sec} }; } let m; m = s.match(/^(\d{4})[-.](\d{1,2})[-.](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (m) { const y=+m[1], M=+m[2], d=+m[3], h=+m[4], mm=+m[5], ss=m[6]?+m[6]:0; if (h>23||mm>59||ss>59) return null; return { comp:{y,M,d,h,m:mm,s:ss} }; } m = s.match(/^(\d{2})[-.](\d{1,2})[-.](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (m) { const y=inferYearFromYY(+m[1]), M=+m[2], d=+m[3], h=+m[4], mm=+m[5], ss=m[6]?+m[6]:0; if (h>23||mm>59||ss>59) return null; return { comp:{y,M,d,h,m:mm,s:ss} }; } m = s.match(/^(\d{1,2})[-.](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (m) { const M=+m[1], d=+m[2], h=+m[3], mm=+m[4], ss=m[5]?+m[5]:0; if (h>23||mm>59||ss>59) return null; const candidates=[startTime.getFullYear(), endTime.getFullYear()]; for (const y of [...new Set(candidates)]) { const ms=zonedComponentsToUTCms({y,M,d,h,m:mm,s:ss}, userTZ); const cand=new Date(ms); if (cand>=startTime && cand<=endTime) return { comp:{y,M,d,h,m:mm,s:ss} }; } return { comp:{ y:startTime.getFullYear(), M, d, h, m:mm, s:ss } }; } m = s.match(/^(\d{4}-\d{1,2}-\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (m) { const [y,M,d]=m[1].split('-').map(Number); const h=+m[2], mm=+m[3], ss=m[4]?+m[4]:0; if (h>23||mm>59||ss>59) return null; return { comp:{ y,M,d,h,m:mm,s:ss } }; } const t = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (t && startTime && endTime) { const hh=+t[1], mm=+t[2], ss=t[3]?+t[3]:0; if (hh>23||mm>59||ss>59) return null; const days=listDaysInRange(startTime, endTime); for (const d of days) { const candMs=zonedComponentsToUTCms({ y:d.y,M:d.M,d:d.d,h:hh,m:mm,s:ss }, userTZ); const cand=new Date(candMs); if (cand>=startTime && cand<=endTime) return { comp:{ y:d.y,M:d.M,d:d.d,h:hh,m:mm,s:ss } }; } return null; } const onlyKorTime = s.match(/^(\d{1,2})\s*시(?:\s*(\d{1,2})\s*분)?(?:\s*(\d{1,2})\s*초)?$/); if (onlyKorTime && startTime && endTime) { const hh=+onlyKorTime[1], mm=onlyKorTime[2]?+onlyKorTime[2]:0, ss=onlyKorTime[3]?+onlyKorTime[3]:0; if (hh>23||mm>59||ss>59) return null; const days=listDaysInRange(startTime, endTime); for (const d of days) { const candMs=zonedComponentsToUTCms({ y:d.y,M:d.M,d:d.d,h:hh,m:mm,s:ss }, userTZ); const cand=new Date(candMs); if (cand>=startTime && cand<=endTime) return { comp:{y:d.y,M:d.M,d:d.d,h:hh,m:mm,s:ss} }; } return null; } const korDateAndHm = s.match(/(?:(\d{2,4})\s*년\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일\s+(\d{1,2}):(\d{2})$/); if (korDateAndHm) { let y = korDateAndHm[1] ? (korDateAndHm[1].length===2 ? inferYearFromYY(+korDateAndHm[1]) : +korDateAndHm[1]) : startTime.getFullYear(); const M = +korDateAndHm[2], d = +korDateAndHm[3], h = +korDateAndHm[4], m = +korDateAndHm[5]; if (h>23||m>59) return null; return { comp:{ y,M,d,h,m,s:0 } }; } m = s.match(/^(\d{1,2})[.-](\d{1,2})\s+(\d{1,2}):(\d{2})$/); if (m) { const M=+m[1], d=+m[2], h=+m[3], mm=+m[4]; if (h>23||mm>59) return null; const candidates=[startTime.getFullYear(), endTime.getFullYear()]; for (const y of [...new Set(candidates)]) { const ms=zonedComponentsToUTCms({y,M,d,h,m:mm,s:0}, userTZ); const cand=new Date(ms); if (cand>=startTime && cand<=endTime) return { comp:{y,M,d,h,m:mm,s:0} }; } return { comp:{ y:startTime.getFullYear(), M, d, h, m:mm, s:0 } }; } return null; } // ---------------- Toast ---------------- function showToastMessage(message, isError=false) { const container = document.querySelector('#toastMessage') || document.querySelector('#toast-message') || document.querySelector('.toastMessage') || document.querySelector('.toast-message') || document.querySelector('.toast_container, .toast-container, .toast-wrap, .toast_wrap'); if (container) { const wrap = document.createElement('div'); const text = document.createElement('p'); text.textContent = String(message ?? ''); wrap.appendChild(text); container.appendChild(wrap); setTimeout(() => { if (wrap.parentNode === container) container.removeChild(wrap); }, 2000); return; } try { window.dispatchEvent(new CustomEvent('toast-message', { detail:{ message:String(message ?? ''), type:isError?'error':'info' } })); } catch {} alert(String(message ?? '')); } // ---------------- History store ---------------- const loadHistory = () => { try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; } }; const saveHistory = (arr) => localStorage.setItem(HISTORY_KEY, JSON.stringify(arr.slice(0, HISTORY_MAX))); const addHistory = (item) => { const list = loadHistory().filter(v => v !== item); list.unshift(item); saveHistory(list); }; const clearHistory = () => saveHistory([]); // ---------------- Modal ---------------- let jumpModalHost = null; function openJumpModal(triggerBtn) { lastActiveEl = triggerBtn || document.activeElement; const startStr = fmtDate(startTime); const endStr = fmtDate(endTime); // 시작 + 2분 힌트 const hintBase = new Date(startTime.getTime() + 2*60*1000); const y = hintBase.getFullYear(), M = p2(hintBase.getMonth()+1), D = p2(hintBase.getDate()); const H = p2(hintBase.getHours()), m = p2(hintBase.getMinutes()), s = p2(hintBase.getSeconds()); const yy = String(y).slice(-2), kH = String(hintBase.getHours()); const placeholderHint = `예: ${y}-${M}-${D}, ${H}:${m}:${s} / ${yy}.${M}.${D} ${H}:${m} / ${M}월 ${D}일 ${kH}시 ${m}분`; if (!jumpModalHost) { jumpModalHost = document.createElement('div'); jumpModalHost.style.position = 'fixed'; jumpModalHost.style.inset = '0'; jumpModalHost.style.zIndex = '2147483647'; jumpModalHost.attachShadow({ mode:'open' }); document.documentElement.appendChild(jumpModalHost); } const root = jumpModalHost.shadowRoot; root.innerHTML = ''; const style = document.createElement('style'); style.textContent = ` :host { all: initial; } .backdrop { all: initial; position: fixed; inset: 0; background: rgba(0,0,0,.38); display: grid; place-items: center; } .card { all: initial; width: min(720px, 94vw); background: #1f2329; color: #e9edf3; border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,.45); font-family: "Pretendard", -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Malgun Gothic", "맑은 고딕", helvetica, sans-serif; text-rendering: optimizeSpeed; font-size: 14px; line-height: 1.5; padding: 22px 24px 18px; } .titlebar { display:flex; align-items:center; justify-content:space-between; margin-bottom: 14px; } .title { font-weight: 800; font-size: 18px; letter-spacing: .1px; } .desc { opacity: .85; margin-bottom: 12px; white-space: pre-line; } .section { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,.08); } .section:first-of-type { margin-top: 0; padding-top: 0; border-top: none; } .section-title { display:flex; align-items:center; gap:8px; font-weight: 700; color:#dbe5f5; margin: 6px 0 8px; } .section-title::before { content:""; display:inline-block; width:14px; height:14px; border-radius:3px; background: linear-gradient(135deg, #3aa0ff, #8f77ff); } .row { display: grid; grid-template-columns: 160px 1fr; gap: 12px; align-items: center; margin: 8px 0; } .row > div:last-child { min-width: 0; } .label { opacity: .85; } .inputwrap { position: relative; display: flex; align-items: center; gap: 8px; } input[type="text"]{ all: initial; background:#2a2f36; color:#e9edf3; padding:10px 12px; border-radius:10px; border:1px solid transparent; outline:none; font:13px/1.2 inherit; width:100%; box-sizing:border-box; display:block; } input[type="text"]:focus{ border-color:#048BFF; } /* 히스토리 드롭다운 */ .hist-panel { position: absolute; left: 0; right: 36px; top: calc(100% + 6px); background: #1f2329; border: 1px solid #2f3540; border-radius: 12px; box-shadow: 0 16px 40px rgba(0,0,0,.45); padding: 8px; z-index: 5; display: none; } .hist-panel.show { display: block; } .hist-item { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:8px 10px; border-radius:10px; cursor:pointer; } .hist-item:hover { background:#2a2f36; } .hist-text { pointer-events:none; } .hist-del { all:initial; color:#9aa3ad; cursor:pointer; padding:2px 4px; border-radius:6px; } .hist-del:hover { background:#2a2f36; color:#e9edf3; } .hist-footer { display:flex; justify-content:flex-end; padding-top:6px; border-top:1px solid #2a2f36; margin-top:6px; } .hist-clear { all:initial; cursor:pointer; padding:6px 10px; border-radius:999px; background:#2a2f36; color:#e9edf3; font-size:12px; } .hist-clear:hover { background:#343a43; } .iconbtn{ all: initial; cursor:pointer; width:36px; height:36px; display:grid; place-items:center; border-radius:10px; background:#2a2f36; color:#e9edf3; user-select:none; } .iconbtn:hover{ background:#343a43; } .picker{ all: initial; position:absolute; right:0; top:calc(100% + 8px); background:#22262c; color:#e9edf3; border:1px solid #2f3540; border-radius:12px; box-shadow:0 16px 50px rgba(0,0,0,.45); padding:12px; z-index:4; min-width: 440px; font-family: inherit; text-rendering: inherit; } .picker[hidden]{ display:none !important; } .pick-row{ display:flex; align-items:center; gap:10px; margin-top:8px; flex-wrap:wrap; } .seg{ background:#2a2f36; border-radius:10px; padding:6px 10px; font-size:12px; } select{ all: initial; background:#2a2f36; color:#e9edf3; padding:8px 10px; border-radius:10px; border:1px solid transparent; outline:none; font:13px/1.2 inherit; } select:focus{ border-color:#048BFF; } input[type=number]::-webkit-outer-spin-button, input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } input[type=number] { -moz-appearance: textfield; } .numbox { display:flex; align-items:center; background:transparent; } .num{ all: initial; background:#2a2f36; color:#e9edf3; padding:8px 8px; border-radius:10px; border:1px solid transparent; outline:none; width:54px; text-align:center; font:13px/1.2 inherit; } .num:focus{ border-color:#048BFF; } .steppers { display:flex; flex-direction:column; gap:2px; margin-left:4px; } .step { all: initial; cursor:pointer; width:18px; height:16px; display:grid; place-items:center; border-radius:6px; background:#2a2f36; color:#e9edf3; font-size:10px; line-height:1; } .step:hover { background:#343a43; } .colon { opacity:.8; margin: 0 2px; } .pillbar, .tz, .hint { margin-left: 172px; } .pillbar { display:flex; gap:6px; margin-top: 12px; margin-bottom: 10px; flex-wrap:wrap; } .pill { all: initial; cursor:pointer; padding:6px 10px; border-radius:999px; background:#2a2f36; color:#e9edf3; font-size:12px; } .pill:hover { background:#343a43; } .pill.primary { background:#048BFF; color:#fff; } .pill.primary:hover { background:#048BFF; color:#fff; } .tz { font-size:12px; opacity:.8; margin-top: 14px; } .hint { font-size:12px; opacity:.75; margin-top:6px; } .actions { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; } .btn { all: initial; cursor: pointer; padding: 8px 12px; border-radius: 10px; background: #2a2f36; color: #e9edf3; } .btn.primary { background:#048BFF; color:#fff; } `; const container = document.createElement('div'); container.className = 'backdrop'; const card = document.createElement('div'); card.className = 'card'; card.setAttribute('role', 'dialog'); card.setAttribute('aria-modal', 'true'); card.setAttribute('aria-label', '특정 시간으로 이동하기'); card.innerHTML = `