您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
유튜브 타임스탬프 생성, 탐색 및 언아카이브 영상 대체 기능 제공
// ==UserScript== // @name YouTube Timestamp Navigator & Unarchived Video Replacer // @namespace YouTube Timestamp Navigator & Unarchived Video Replacer // @version 1.0 // @description 유튜브 타임스탬프 생성, 탐색 및 언아카이브 영상 대체 기능 제공 // @author Hess // @match https://www.youtube.com/* // @run-at document-start // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @grant GM_getValue // @grant GM_setValue // @license MIT // ==/UserScript== // https://gf.zukizuki.org/ko/scripts/529709-youtube-timestamp-navigator-unarchived-video-replacer (function() { 'use strict'; if (!GM_getValue("unarchived_videos", null)) { GM_setValue("unarchived_videos", {}); } let currentTime; let video = null; const initializeVideoElement = () => { video = document.querySelector("video"); currentTime = video.currentTime; }; window.addEventListener("load", initializeVideoElement); const formatTime = (s) => { const h = Math.floor(s / 3600); return `${h ? `${h}:` : ''}${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`; }; // 키 동작 매핑 객체 생성 const keyHandlers = { keydown: { "p": () => toggleTimestampWindow(), "[": () => { const lowerTime = findRange(parseTimestamps(timestampText), currentTime).lower; if (lowerTime !== null) video.currentTime = lowerTime; }, "]": () => { const upperTime = findRange(parseTimestamps(timestampText), currentTime).upper; if (upperTime !== null) video.currentTime = upperTime; }, "'": () => { addTimestampButton.click(); timestampText = timestampInput.value; }, ";": () => { if (!isTimestampWindowOn) return; event.preventDefault(); event.stopPropagation(); (document.activeElement !== timestampInput ? timestampInput.focus() : timestampInput.blur()); }, }, }; const shouldIgnoreKeyEvent = (event) => { if (event.repeat || !event.isTrusted) return true; const activeElement = document.activeElement; const isTextInput = ( activeElement.tagName.toLowerCase() === "textarea" || (activeElement.tagName.toLowerCase() === "input" && ["text", "password", "email", "search", "tel", "url", "number", "date", "time"].includes(activeElement.type)) || activeElement.isContentEditable ); return isTextInput && ![";", "'"].includes(event.key); // ;와 '는 타임스탬프 창에서 허용 }; const handleKeyEvent = (event) => { initializeVideoElement(); if (shouldIgnoreKeyEvent(event)) return; // 입력창에 포커스가 있을 때도 ;랑 ' 키는 허용 if (isTimestampWindowOn && (document.activeElement === timestampInput || document.activeElement === timestampInput2)) { if (event.key === ";") { event.preventDefault(); if (document.activeElement !== timestampInput) {timestampInput.focus(); return;} else timestampInput.blur(); return; } if (event.key === "'") {event.preventDefault(); addTimestampButton.click(); timestampText = timestampInput.value; return;} } // 이것 이외에 텍스트 입력창에 포커스가 있으면 키 입력 무시 if (document.activeElement === timestampInput || document.activeElement === timestampInput2) return; keyHandlers.keydown?.[event.key]?.(event); }; window.addEventListener("keydown", handleKeyEvent); // 포커싱 확인을 위해 빼둠 let timestampWindow, timestampInput, timestampInput2, addTimestampButton; let timestampText = ""; let isTimestampWindowOn= false; function toggleTimestampWindow() { if (!isTimestampWindowOn) { timestampWindow = document.createElement("div"); timestampWindow.id = "timestampWindow"; Object.assign(timestampWindow.style, { position: "fixed", top: "50%", left: "90%", transform: "translate(-70%, -50%)", width: "340px", height: "360px", backgroundColor: "rgba(50, 50, 50, 0.6)", border: "2px solid rgba(200, 200, 200, 0.6)", borderRadius: "8px", padding: "10px", boxShadow: "0 4px 8px rgba(0, 0, 0, 0.3)", zIndex: "1000", display: "flex", flexDirection: "column", }); // 상단 버튼 바(topBar) 생성 const topBar = document.createElement("div"); Object.assign(topBar.style, { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "8px" }); // 변경: 아이콘들을 담을 iconGroup 컨테이너 const iconGroup = document.createElement("div"); Object.assign(iconGroup.style, { display: "flex", gap: "6px", alignItems: "center" }); // 일반 주소 타임스탬프 버튼 const currentTimestampButton = document.createElement("button"); currentTimestampButton.textContent = "🔗"; currentTimestampButton.style.cssText = "background-color: #ADD8E6; color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer;"; currentTimestampButton.style.fontSize = `12px`; currentTimestampButton.style.display = "flex"; currentTimestampButton.style.justifyContent = "center"; currentTimestampButton.style.alignItems = "center"; currentTimestampButton.onclick = () => copyTimestamp(); // 입력한 시간 타임스탬프 버튼 const customTimestampButton = document.createElement("button"); customTimestampButton.textContent = "🕒"; customTimestampButton.style.cssText = "background-color: #ADD8E6; color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer;"; customTimestampButton.style.fontSize = `12px`; customTimestampButton.style.display = "flex"; customTimestampButton.style.justifyContent = "center"; customTimestampButton.style.alignItems = "center"; customTimestampButton.onclick = () => { let time = parseTimeToSeconds(timestampInput2.value); if (time === null || time < 0 || time > video.duration) { time = Math.floor(video.currentTime); } copyTimestamp(time); }; // 타임스탬프 입력 필드 timestampInput2 = document.createElement("input"); // 한 줄 입력창 timestampInput2.id = "timestampInput2"; timestampInput2.type = "text"; timestampInput2.placeholder = ""; timestampInput2.style.cssText = "width: 50px; text-align: center; background: rgba(255, 255, 255, 0.7); border: none; border-radius: 5px; font-size: 14px;"; timestampInput2.onmousedown = (e) => e.stopPropagation(); // 드래그 방지 timestampInput2.tabIndex = 0; // 문서의 자연스러운 순서에 따라 포커스를 받습니다. Object.assign(timestampInput2.style, { border: "1px solid lightgray", // 테두리 스타일 추가 }); // 포커스 시 스타일 적용 timestampInput2.addEventListener("focus", () => { Object.assign(timestampInput2.style, { outline: "1px auto -webkit-focus-ring-color", // 기본 포커싱 테두리 설정 }); }); // 포커스 해제 시 기본 스타일로 복원 (outline 제거) timestampInput2.addEventListener("blur", () => { timestampInput2.style.outline = ""; }); // 맨 아래에 현재 시간 추가 버튼 addTimestampButton = document.createElement("button"); addTimestampButton.textContent = "📝"; addTimestampButton.style.cssText = "background-color: pink; color: white; border: none; border-radius: 0%; width: 24px; height: 24px; cursor: pointer;"; addTimestampButton.style.fontSize = `12px`; addTimestampButton.style.display = "flex"; addTimestampButton.style.justifyContent = "center"; addTimestampButton.style.alignItems = "center"; addTimestampButton.onclick = () => { const currentTimeText = formatTime(video.currentTime); const fullText = timestampInput.value; // 입력창 전체 텍스트 const cursorPosition = timestampInput.selectionStart; // 커서 위치 가져오기 const textBeforeCursor = fullText.slice(0, cursorPosition); const textAfterCursor = fullText.slice(cursorPosition); const linesBeforeCursor = textBeforeCursor.split("\n"); // 커서 이전의 줄 const allLines = fullText.split("\n"); // 전체 줄 const isCursorAtLastLine = linesBeforeCursor.length === allLines.length; // 커서가 마지막 줄에 있는지 확인 const isLastLineEmptyOrWhitespace = allLines[allLines.length - 1].trim() === ""; // 마지막 줄이 비어있거나 여백만 있는지 확인 const isFullTextEmptyOrWhitespace = fullText.trim() === ""; // 전체 텍스트가 비어있거나 여백만 있는지 확인 if ((isCursorAtLastLine && isLastLineEmptyOrWhitespace) || isFullTextEmptyOrWhitespace) { // 전체 텍스트가 비어있거나 마지막 줄이 여백인 경우, 엔터 없이 타임스탬프 삽입 timestampInput.value = allLines.slice(0, -1).join("\n") + `${isFullTextEmptyOrWhitespace ? '' : '\n'}${currentTimeText}`; } else { // 그렇지 않으면 엔터 포함하여 타임스탬프 추가 timestampInput.value += `\n${currentTimeText}`; } timestampInput.scrollTop = timestampInput.scrollHeight; // 스크롤을 맨 아래로 이동 timestampText = timestampInput.value; // 입력된 텍스트 저장 }; // 홀로덱스로 이동 버튼 const goToHolodexPageButton = document.createElement("button"); goToHolodexPageButton.style.cssText = ` background-color: #A2CC66; color: white; border: none; width: 24px; height: 24px; cursor: pointer; display: flex; justify-content: center; align-items: center; background-image: url("https://i.namu.wiki/i/3yNJVcRTjZqJ7B-FuiE4alfJ3ELyYe3fzQ0oKwUuuPeAQxyyX4e2lKEhV9_lU1PyhZ48FKwQzN_OWSw39rNVMxZ9UtdhKXAP16SZFomjEjVVu5hKvahFD8cSUWQA9KrbjU-QFHIgXQkI6Z_VH5oKhw.svg"); background-size: 70%; background-repeat: no-repeat; background-position: center; `; if (window.location.href.includes('youtube.com') && !window.location.href.includes('/embed/')) { // 유튜브 페이지: 버튼은 기본(holodex) 아이콘 사용 goToHolodexPageButton.style.backgroundImage = 'url("https://i.namu.wiki/i/3yNJVcRTjZqJ7B-FuiE4alfJ3ELyYe3fzQ0oKwUuuPeAQxyyX4e2lKEhV9_lU1PyhZ48FKwQzN_OWSw39rNVMxZ9UtdhKXAP16SZFomjEjVVu5hKvahFD8cSUWQA9KrbjU-QFHIgXQkI6Z_VH5oKhw.svg")'; goToHolodexPageButton.onclick = () => { const videoId = extractYouTubeVideoId(window.location.href); const holodexUrl = videoId ? 'https://holodex.net/watch/' + videoId : 'https://holodex.net'; window.open(holodexUrl, '_blank'); }; } else if (window.location.href.includes('holodex.net')) { // holodex 페이지: 버튼을 보이게 하고, 아이콘은 유튜브 아이콘으로 변경 goToHolodexPageButton.style.backgroundImage = 'url("https://www.google.com/s2/favicons?sz=64&domain=youtube.com")'; goToHolodexPageButton.onclick = () => { const urlInputElement = document.getElementById("urlInput"); const urlInputValue = urlInputElement ? normalizeYouTubeURL(urlInputElement.value) : ""; const videoId = extractYouTubeVideoId(urlInputValue); if (videoId) { const youtubeEmbedUrl = 'https://www.youtube.com/embed/' + videoId; const currentHolodexUrl = 'https://holodex.net/watch/' + extractYouTubeVideoId(location.href); GM_setValue(currentHolodexUrl, youtubeEmbedUrl); location.href = youtubeEmbedUrl; } }; } else { // 삭제: 원래 youtube.com이 아닌 경우 버튼 숨김 처리 goToHolodexPageButton.style.display = 'none'; } // 버튼 컨테이너에 요소 추가 iconGroup.appendChild(currentTimestampButton); iconGroup.appendChild(customTimestampButton); iconGroup.appendChild(timestampInput2); iconGroup.appendChild(addTimestampButton); iconGroup.appendChild(goToHolodexPageButton); // topBar 왼쪽에 iconGroup 추가 topBar.appendChild(iconGroup); // 닫기 버튼 const closeButton = document.createElement("button"); closeButton.textContent = "X"; Object.assign(closeButton.style, { position: "absolute", top: "11px", right: "10px", color: "white", backgroundColor: "red", border: "none", fontSize: "17px", padding: "2px 4px", cursor: "pointer" }); closeButton.onclick = () => timestampWindow.remove(); // topBar 오른쪽에 closeButton 추가 topBar.appendChild(closeButton); // timestampWindow에 topBar 추가 timestampWindow.appendChild(topBar); // 변경: 텍스트 영역을 감싸는 컨테이너 (flex) const textContainer = document.createElement("div"); Object.assign(textContainer.style, { flex: "1", display: "flex", flexDirection: "column", gap: "8px" }); // 추가: textarea 요소 생성 (timestampInput) timestampInput = document.createElement("textarea"); // textarea 생성 (기존 timestampInput) timestampInput.id = "timestampInput"; timestampInput.onmousedown = (e) => e.stopPropagation(); // 드래그 방지 Object.assign(timestampInput.style, { flex: "1", backgroundColor: "rgba(255, 255, 255, 0.7)", color: "#000", fontWeight: "bold", border: "none", resize: "none", fontSize: "14px", padding: "12px", // 좌우 패딩을 넉넉하게 borderRadius: "4px" }); timestampInput.placeholder = "여기에 텍스트를 입력하세요."; timestampInput.value = timestampText; timestampInput.addEventListener("input", () => { timestampText = timestampInput.value; }); // 하단에 URL 입력창 추가 (한 줄짜리 input) const urlInput = document.createElement("input"); urlInput.id = "urlInput"; urlInput.onmousedown = (e) => e.stopPropagation(); // 드래그 방지 urlInput.type = "text"; const savedYoutubeEmbedUrl = GM_getValue('https://holodex.net/watch/' + extractYouTubeVideoId(location.href)); // 변경: 값이 있으면 그 값을, 없으면 비움 urlInput.value = savedYoutubeEmbedUrl ? savedYoutubeEmbedUrl : ""; urlInput.placeholder = "URL을 입력하세요..."; urlInput.style.cssText = "padding: 8px; border: 1px solid lightgray; border-radius: 4px; font-size: 14px;"; // textContainer에 텍스트 영역과 URL 입력창 추가 textContainer.appendChild(timestampInput); textContainer.appendChild(urlInput); // 타임스탬프 창에 textContainer 추가 timestampWindow.appendChild(textContainer); // 드래그 기능 추가 let isDragging = false, startX, startY, startLeft, startTop; timestampWindow.onmousedown = (e) => { isDragging = true; ({ clientX: startX, clientY: startY } = e); ({ left: startLeft, top: startTop } = window.getComputedStyle(timestampWindow)); document.onmousemove = ({ clientX, clientY }) => { if (isDragging) { timestampWindow.style.left = `${parseInt(startLeft) + clientX - startX}px`; timestampWindow.style.top = `${parseInt(startTop) + clientY - startY}px`; } }; document.onmouseup = () => { isDragging = false; document.onmousemove = null; document.onmouseup = null; }; }; document.body.appendChild(timestampWindow); } else { timestampWindow.remove(); } isTimestampWindowOn = !isTimestampWindowOn; } // 타임스탬프 추출 함수, 초 단위로 저장 function parseTimestamps(inputText) { const regex = /\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b/g; const matches = inputText.match(regex) || []; return matches.map(time => { const parts = time.split(':').map(Number).reverse(); let seconds = parts[0] || 0; let minutes = parts[1] || 0; let hours = parts[2] || 0; return seconds + minutes * 60 + hours * 3600; }); } // 현재 시간의 lower, upper 타임스탬프로 이동 function findRange(timestamps, inputValue) { const sortedTimestamps = timestamps.filter((value, index, self) => self.indexOf(value) === index).sort((a, b) => a - b); inputValue = Math.floor(inputValue); const index = sortedTimestamps.indexOf(inputValue); if (index !== -1) sortedTimestamps.splice(index, 1); for (let i = 0; i < sortedTimestamps.length; i++) { if (inputValue < sortedTimestamps[i]) { return {lower: i > 0 ? sortedTimestamps[i - 1] : null, upper: sortedTimestamps[i]}; } if (inputValue === sortedTimestamps[i] && i < sortedTimestamps.length - 1) { return {lower: sortedTimestamps[i], upper: sortedTimestamps[i + 1]}; } } return {lower: sortedTimestamps[sortedTimestamps.length - 1], upper: null}; } // 유튜브 주소 정규화 function normalizeYouTubeURL(url, timestamp = null) { try { const urlObj = new URL(url); let videoId = ""; let timeParam = ""; // 1. 단축 URL (youtu.be) if (urlObj.hostname === "youtu.be") videoId = urlObj.pathname.substring(1); // 2. Shorts, Embed, Live, 기본 watch URL 처리 else if (urlObj.hostname.includes("youtube.com")) { const pathParts = urlObj.pathname.split("/"); if (pathParts.includes("shorts") || pathParts.includes("embed") || pathParts.includes("live")) videoId = pathParts[pathParts.length - 1]; else if (urlObj.pathname === "/watch") videoId = urlObj.searchParams.get("v"); } // 유효한 videoId가 없으면 반환 불가 if (!videoId) return null; // 3. 특정 시간 시작 옵션 유지 if (urlObj.searchParams.has("t")) timeParam = `&t=${urlObj.searchParams.get("t")}`; else if (urlObj.searchParams.has("start")) timeParam = `&t=${urlObj.searchParams.get("start")}`; // 4. 타임스탬프가 있으면 그걸로 시작 시간 설정 if (timestamp !== null) timeParam = `&t=${timestamp}`; // 5. 최종 변환된 URL 반환 (재생목록 정보 제거) return `https://www.youtube.com/watch?v=${videoId}${timeParam}`; } catch (e) { return null; // 잘못된 URL 입력 시 } } function extractYouTubeVideoId(url) { try { const urlObj = new URL(url); let videoId = ""; // 1. 단축 URL (youtu.be) if (urlObj.hostname === "youtu.be") { videoId = urlObj.pathname.substring(1); } // 2. Shorts, Embed, Live, 기본 watch URL 처리 else if (urlObj.hostname.includes("youtube.com")) { const pathParts = urlObj.pathname.split("/"); if (pathParts.includes("shorts") || pathParts.includes("embed") || pathParts.includes("live")) { videoId = pathParts[pathParts.length - 1]; } else if (urlObj.pathname === "/watch") { videoId = urlObj.searchParams.get("v"); } } // 유효한 videoId가 없으면 null 반환 return videoId ? videoId : null; } catch (e) { return null; // 잘못된 URL 입력 시 } } function copyTimestamp(time = null) { const url = normalizeYouTubeURL(location.href, time); if (url) { navigator.clipboard.writeText(url).then(() => { console.log(`Copied: ${url}`); }).catch(err => console.error("Failed to copy", err)); } } // 입력된 다양한 타임스탬프를 초로 변환 function parseTimeToSeconds(input) { if (!input) return null; // 숫자만 입력된 경우 (정수 또는 소수) if (/^\d+(\.\d+)?$/.test(input)) return Math.floor(parseFloat(input)); // h, m, s 형식 (예: "1h2m3s", "2h", "45m30s") const hmsRegex = /^(\d+)h(?:\s*(\d+)m)?(?:\s*(\d+)s)?$|^(\d+)m(?:\s*(\d+)s)?$|^(\d+)s$/; const hmsMatch = input.match(hmsRegex); if (hmsMatch) { return (parseInt(hmsMatch[1] || 0, 10) * 3600) + (parseInt(hmsMatch[2] || hmsMatch[4] || 0, 10) * 60) + (parseInt(hmsMatch[3] || hmsMatch[5] || hmsMatch[6] || 0, 10)); } // hh:mm:ss 또는 mm:ss 형식 (예: "1:02:03", "02:03") const timeRegex = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/; const timeMatch = input.match(timeRegex); if (timeMatch) { const hours = timeMatch[3] ? parseInt(timeMatch[1], 10) : 0; const minutes = timeMatch[3] ? parseInt(timeMatch[2], 10) : parseInt(timeMatch[1], 10); const seconds = parseInt(timeMatch[3] || timeMatch[2], 10); return hours * 3600 + minutes * 60 + seconds; } return null; } })();