
시각장애인을 위한 YouTube 동영상 정보 자동 음성 안내 기능
깃허브링크
- 언어 & 런타임: Node.js, TypeScript
- 번들러: Vite (MV3 Chrome Extension 템플릿)
- 브라우저 & API: Chrome Extension MV3, Web Speech API (speechSynthesis)
2025-04-24 ~ 현재 진행 중
(기능 개발이 어느 정도 끝나면 확장 프로그램 등록 예정)
이 확장 프로그램은 크게 Content Script, Background Service Worker, 그리고 SpeechSynthesis 엔진 세 부분으로 나뉘어 동작합니다.
사용자가 지정한 단축키(예: Shift + 마우스Hover)를 누르면, 브라우저가 현재 탭에 주입된
Content Script로 해당 이벤트를 전달합니다.
Content Script는 이 이벤트를 감지해 바로 동작을 시작할 준비를 합니다.
Content Script는 YouTube 플레이어 페이지의 DOM을 스캔해제목,채널명,조회수,업로드 날짜요소를 찾아 텍스트로 파싱합니다.
파싱이 완료되면, 이 정보를 담은 메시지를Background Service Worker로 전송합니다.
Background Service Worker는Content Script로부터 받은 메시지를 받자마자speechSynthesis.cancel()을 호출해 이전에 남아 있던 모든 TTS를 정리하고, 새로 받은 텍스트를 음성 합성 요청으로 넘깁니다.
Web Speech API의speechSynthesis.speak()가 호출되면, 브라우저 내장 음성 엔진이 문장을 합성해 스피커로 출력합니다.
사용자는 화면을 보지 않고도 동영상의 핵심 메타정보를 청각으로 확인할 수 있습니다.
이렇게 각 컴포넌트가 메시지를 주고받으며 “이벤트 감지 → 정보 추출 → TTS 요청 → 음성 출력” 의 사이클을 명확하게 분리하여, 유지보수성과 확장성을 높였습니다.
- 유튜브 영상 페이지에서 제목·채널명·업로드 날짜를 사용자가 지정한 단축키(예: Shift+마우스Hover)로 TTS 읽어주기
- 시각적 피로 없이 청취로만 콘텐츠 메타 정보를 빠르게 확인
영상 썸네일 → 재생 페이지로 이동해도 이전 TTS가 계속 실행됨
Ctrl+Hover 키 조합이 클릭 동작과 충돌하여 의도치 않게 TTS가 발동
speechSynthesis.cancel() 호출로 기존 읽기 중단 chrome.webNavigation.onHistoryStateUpdated 이벤트 리스너 등록 Shift + 마우스Hover 등 다른 키로 변경 keydown/keyup 이벤트로 토글 상태 관리 Debounce 로직 추가로 빠른 연속 이벤트 방지 조회수나 업로드 날짜 등의 숫자를 아라비아 숫자(하나, 둘, 셋...)로 읽어 부자연스러움
"1억"을 "억", "1조"를 "조"로 1이란 숫자를 읽지 않고 어색하게 읽음
숫자를 한자식(일, 이, 삼...)으로 변환하지 않고 그대로 읽음
억/조 단위에서도 1(일)을 생략하는 기존 숫자 변환 로직
"1.2만", "4.1천" 같은 단위 표현은 parseKoreanUnitNumber()로 숫자로 환산 후 변환 ➡ ex) "1.2만"은 "만이천", "4.1천"은 "사천백"numberToKoreanSino() 함수를 만들어 숫자를 한자식으로 변환export function numberToKoreanSino(num: number): string {
if (num === 0) return '영';
let res = '';
for (const { v, str } of units) {
const cnt = Math.floor(num / v);
if (cnt > 0) {
if (v >= 1e8) { // 억(1e8), 조(1e12) 단위
if (cnt === 1) {
res += '일' + str;
} else {
res += numberToKoreanSino(cnt) + str;
}
} else if (v >= 10) { // 십, 백, 천
if (cnt > 1) {
res += numberToKoreanSino(cnt);
}
res += str;
} else {
res += digits[cnt];
res += str;
}
num %= v;
}
}
return res;
}
일부 영상에서 제목 대신 재생시간(1, 24, 39 등)을 읽음
innerText 기준으로 제목보다 먼저 노출됨innerText.split('\n')로 줄 단위 분리 시, 가장 첫 줄을 제목으로 오인시간 문자열인지 판별하는 정규식 함수 추가
제목이 아닌 재생시간만 포함된 텍스트를 필터링하기 위해 사용
function hasLiveBadge(card: HTMLElement): boolean {
return !!card.querySelector('ytd-badge-supported-renderer p')?.textContent?.includes('실시간');
}
innerText의 비정형 구조에 의존하지 않고, 의미 있는 구조화된 요소(DOM) 위주로 접근
기존에는 특정 요소를 하드코딩해서 고정된 위치에서만 제목, 채널명, 조회수 등을 읽음
ex) #info-strings, #metadata-line, yt-formatted-string#video-title 등 선택자를 하드코딩으로 직접 지정
유튜브는 SPA 구조 + 지속적인 레이아웃 변경이 있어서 정된 셀렉터만 의존하면 구조가 바뀔 때마다 코드가 깨짐
카드(div#dismissible 등)의 전체 innerText를 줄 단위로 동적으로 읽어오기
(card.innerText || '').split(/\r?\n/).map(line => line.trim())
카드 전체 innerText를 가져온 뒤 줄(\n) 단위로 분리
줄들 중에서
필터링된 줄 중에서
조회수/업로드 날짜는 별도로 검색해서 파싱
추가로 MutationObserver를 이용해 동적으로 추가되는 카드에도 실시간 바인딩
SPA 이동 대응을 위해 yt-navigate-finish 이벤트로 초기화
실시간이라는 뱃지가 영상 하단에 붙음라이브라는 뱃지가 붙음기존 코드에서는 innerText.includes('LIVE') 로만 판단하여 두 경우를 모두 실시간 방송 중으로 처리하는 문제가 발생함
ytd-badge-supported-renderer 내부에 포함됨function hasLiveBadge(card: HTMLElement): boolean {
return !!card.querySelector('ytd-badge-supported-renderer p')?.textContent?.includes('실시간');
}