Tampermonkey 크롬 익스텐션 톺아보기

Tampermonkey 크롬 익스텐션을 활용하여 웹 페이지 기능을 원하는 대로 수정하고, 유용한 예시 스크립트들을 함께 살펴보세요.

Contents


이 글은 사내에서 공유한 자료를 정리하여 재업로드한 자료입니다



배경


들어가며

  이 글은 100% 개발 업무와 관련된 글이라고 할 수는 없을 것 같습니다. 다만, 제가 업무에서 유용하게 사용하는 사소한 사례들을 소개시켜드리고 싶어 글을 작성하게 되었습니다.

심심하실 때 가벼운 마음으로 읽어주시면 감사하겠습니다. 😀



Greasemonkey 란 ?


  Tampermonkey 라는 단어도 생소한데, “Greasemonkey 라는 단어는 왜 나오지?” 라는 의문이 드실 것 같습니다. Tampermonkey 소개에 앞서 Greasemonkey 에 대한 소개가 나오는 이유는, Tampermonkey 가 Greasemonkey 로부터 유래되었기 때문입니다.

  AllMusic 이라는 음원 사이트에서 사용할 수 있는 Firefox Extension 이 있었습니다. 이 Extension 은 AllMusic 에서 더 쾌적한 UX 를 이용할 수 있도록 클라이언트(브라우저)의 UI 를 변경해 주었다고 합니다. 에런 부드먼(Aaron Boodman) 은 이 Firefox Extension 을 더 범용적으로, 모든 사이트의 클라이언트 기능을 유저가 원하는 대로 커스터마이징하여 사용할 수 있도록 Extension 을 개발하여 출시하였고, 그 Extension 이 Greasemonkey 입니다. Greasemonkey 는 페이지의 HTML 에 유저가 설정한 Javascript 를 삽입하여, 유저가 원하는 대로 웹사이트의 기능을 수정해 줍니다.

  Greasemonkey 는 유저들끼리 자신들의 UserScript 를 공유 하면서, 커뮤니티가 커지게 되었습니다.


관련 문서

Wikipedia   나무위키   Greasemonkey

Tampermonkey 란 ?


  Greasemonkey 는 Firefox 생태계에서 널리 사용되었지만, Firefox 브라우저만 지원하였습니다. 크로스 브라우징 환경에서 사용할 수 있는 Greasemonkey 를 만들기 위한 프로젝트들이 생겨났는데, 그 중 가장 유명한 프로젝트가 Tampermonkey 입니다. 크로미움 기반의 브라우저들의 점유율이 상승하면서, Tampermonkey 의 사용률도 높아지게 되었습니다.


관련 문서

Wikipedia   Tampermonkey

단순한 Tampermonkey 사용법

Tampermonkey Dashboard 화면


  Tampermonkey Extension 의 관리 화면에 접속하면, 다음과 같은 리스트 화면을 만날 수 있습니다. 관리 화면에서 + 버튼을 누르면, 스크립트 작성 화면으로 이동할 수 있습니다.

Tampermonkey Editor 화면


  Tampermonkey 는 원하는 사이트가 로드되었을 때, 유저가 작성한 Javascript 를 삽입해 줍니다. 간단한 예시 스크립트를 살펴보겠습니다.


// ==UserScript==
// @name         Test Script for Tampermonkey
// @namespace    http://tampermonkey.net/
// @version      2024-06-13
// @description  try to take over the world!
// @author       Yakpoong
// @match        https://www.inflearn.com/**
// @grant        none
// ==/UserScript==
(function() {
    'use strict';
    console.log("Hello World from Tampermonkey");
})();


  이 스크립트의 실질적인 코드는 console.log 로 메세지를 출력해 주는 간단한 스크립트입니다. 하지만, Tampermonkey 에서는 상단에 있는 ==UserScript== 부분이 메타데이터를 표시해 주는 역할을 합니다. UserScript 에 표기할 수 있는 메타데이터 정보를 알아보겠습니다.

| 메타데이터 키 | 설명 | 예시 | | --- | --- | --- | | @name | (중요) 스크립트 이름 | // @name Test Script for Tampermonkey | | @napespace | 스크립트의 고유 식별자를 제공하는 네임스페이스 | // @namespace http://tampermonkey.net/ | | @version | 스크립트의 버전 | // @version 1.0 | | @description | 스크립트에 대한 설명 | // @description This script is amazing | | @author | 스크립트 작성자명 | // @author John Doe | | @match | (중요) 스크립트가 실행될 URL 패턴. URL 이 패턴매칭이 되어야 스크립트가 실행된다 | // @match *://*.example.com/* | | @exclude | 스크립트가 실행되지 않을 URL 패턴 | // @exclude http://example.com/private/* | | @grant | (중요) 스크립트에서 사용할 수 있는 권한을 정의합니다. | // @grant GM_xmlhttpRequest | | @run-at | 스크립트가 실행될 시점을 설정합니다.
(e.g., document-start, document-end, document-idle) | // @run-at document-start |

  메타데이터를 표기해주는 부분을 제외하고는, Javascript 를 사용할 줄 안다면, 익스텐션의 사용법을 모두 익히신 것이나 마찬가지입니다. 그렇다면 Tampermonkey 를 어떻게 업무에 유용하게 사용할 수 있을까요 ?



예시 스크립트


Swagger UI 에서 쿠키 인증하기

  많은 서비스들의 인증 시스템은 브라우저의 쿠키를 기반으로 동작합니다. API 개발 후 테스트 과정에서 Swagger UI 의 “Try it out” 를 사용하는데, 인증이 필요한 API 라면 HTTP 요청을 보낼 때 Cookie 헤더에 쿠키를 심어야 합니다. 하지만 안타깝게도, 현재 Swagger UI 에서는 Authorize 기능을 통해 브라우저의 쿠키를 심는 기능을 지원하지 않습니다.


Swagger UI 의 Try it out 기능에서는 보안상의 이유로 쿠키 설정 기능이 제공되지 않습니다.


관련 문서

Swagger Document   Github Issue


이러한 상황에서, Tampermonkey 를 사용하여 해당 기능을 편리하게 사용할 수 있습니다.

// ==UserScript==
// @name         Swagger UI Cookie Setter
// @version      1.0
// @description  Set cookie value in Swagger UI for pages containing "/api-docs" in URL
// @author       Heejae Kim
// @match        *://*/api-docs*
// @grant        none
// ==/UserScript==
(function() {
    'use strict';
    console.log("TamperMonkey Swagger UI Cookie Setter Loaded !");
    monkeyPatchFetch();
    /**
     * Swagger 에서 사용하는 fetch 요청을 하이재킹하여, 요청을 보내기 전 인증 쿠키가 세팅되어 있는지 검사한다.
     *
     * @remarks
     * 만약 쿠키가 없다면, 유저로부터 prompt 로 쿠키값을 입력받는다.
     */
    function monkeyPatchFetch() {
         // 원래의 fetch 함수 보관
        const originalFetch = window.fetch;
        // 새 fetch 함수 정의
        window.fetch = async function(...args) {
            console.log('Fetch request intercepted by TamperMonkey');
            const [_, config] = args;
            const cookieName = 'my_cookie';
            if(!hasCookie(cookieName)) {
                promptForCookie(cookieName);
            }
            // 원래의 fetch 함수 호출
            return await originalFetch(...args);
        };
    }
    /**
      * 쿠키가 존재하는지 검증한다
      */
    function hasCookie(cookieName) {
      const cookie = document.cookie;
      return cookie.includes(`${cookieName}=`);
    }
    /**
      * 사용자로부터 쿠키를 입력받은 후, 쿠키를 저장한다
      */
    function promptForCookie(cookieName) {
        // 사용자로부터 쿠키 값을 입력받습니다.
        const cookieValue = prompt('인증 쿠키(connect.sid) 값을 입력해 주세요:', '');
        // 입력받은 쿠키 값이 있는 경우, document.cookie에 쿠키를 설정합니다.
        if (cookieValue) {
            // 쿠키 만료 날짜를 설정합니다. 여기서는 7일로 설정하였습니다.
            const expires = new Date();
            expires.setDate(expires.getDate() + 7);
            // document.cookie에 쿠키를 설정합니다.
            document.cookie = `${cookieName}=${cookieValue}; expires=${expires.toUTCString()}; path=/`;
        }
    }
})();


Swagger UI 는 Try it out 사용시, 내부적으로 브라우저의 fetch API 를 사용하여 HTTP 요청을 전송합니다. 위의 Tampermonkey 스크립트는 브라우저가 /api-docs 가 포함된 URL 에 접근한다면, fetch API 를 몽키패치합니다. fetch 함수가 실행될 때, 인증 쿠키가 없다면 유저에게 인증 쿠키를 입력받아 브라우저에 쿠키를 세팅한 후 HTTP 요청을 전송합니다.



위의 스크립트를 적용하면, 위의 사진과 같이 API 호출 시 인증 쿠키 값을 세팅 후 HTTP 요청을 전송하도록 Swagger 의 기능을 커스터마이징 할 수 있습니다.



Github 에서 코드리뷰를 조금 더 편하게 해보기

  이번 예시는 제가 편하게 코드리뷰를 하기 위해 작성해 보았습니다. 모두 Github 에서 코드리뷰를 하실 때 다음의 화면을 마주치게 되실 것 같습니다.

  저는 코드 리뷰를 할 때, 검토 완료한 파일의 경우 “Viewed” 버튼을 클릭하여 파일을 닫아놓는 것을 선호합니다. 그런데, 개인적으로는 위의 “Viewed” 체크박스가 너무 작아, 모든 파일에 대해 마우스를 올려 Viewed 체크박스를 누르는 것이 귀찮다고 느꼈습니다.

  이러한 문제를 해결하기 위해, 현재 검토 중인 파일 위에 Mouse 를 올리고, 특정 키를 누르면 Viewed 처리되는 스크립트를 간단히 작성하여 사용하고 있습니다.


// ==UserScript==
// @name         GitHub Code Review Viewed Toggle
// @version      0.1
// @description  Toggle "VIEWED" button in GitHub code review with ESC key
// @author       Heejae Kim
// @match        https://github.com/**
// @grant        none
// ==/UserScript==
'use strict';
console.log("Tampermonkey Custom Script Enabled.");
let currentViewedButton = null;
// 현재 마우스가 어느 파일 위에 hover 되어 있는지를 추적한다.
document.addEventListener('mouseover', function(event) {
    const target = event.target;
    const fileBox = target.closest('.file');
    if (fileBox) {
        currentViewedButton = fileBox.querySelector('.js-reviewed-checkbox');
    }
});
// ESC 키를 눌렀을 때, 현재 파일을 Viewed 처리하기
document.addEventListener('keydown', function(event) {
    // Check if the pressed key is ESC
    if (event.key === 'Escape' && currentViewedButton) {
        // If the "VIEWED" button of the currently hovered file is found, click it
        currentViewedButton.click();
    }
});
// 우클릭 했을 때, 현재 파일을 Viewed 처리하기
document.addEventListener('contextmenu', function(event) {
    // Check if the pressed key is ESC
    // If the "VIEWED" button of the currently hovered file is found, click it
    if (currentViewedButton) {
        currentViewedButton.click();
        event.preventDefault();
    }
});


위의 스크립트는 다음과 같은 특징을 지닙니다.

  물론 HTML 에 부가적인 사용자 Javascript 를 삽입시키는 것이기 때문에, 클라이언트의 성능이 떨어지거나, 의도치 않은 보안 공격이 수행될 수도 있습니다. 하지만 위와 같이 가벼운 스크립트들이면, 편리하게 원하는 기능들을 간단히 추가할 수 있습니다.



브랜치별 저장소 머지 전략 관리하기

  팀별 / 저장소 별 머지 전략이 정해져 있는 경우가 많습니다. 개인적으로는 브랜치별 머지 전략이 헷갈려서, 컨벤션과 다른 머지 전략으로 머지했던 경우가 종종 있습니다. 단순히 다른 전략으로 머지한 것이 문제가 아니라, 경우에 따라서는 추후에 Conflict 를 발생시킬 수 있습니다.   팀별 / 저장소 별 머지 전략이 정해져 있는 경우가 많습니다. 개인적으로는 브랜치별 머지 전략이 헷갈려서, 컨벤션과 다른 머지 전략으로 머지했던 경우가 종종 있습니다. 단순히 다른 전략으로 머지한 것이 문제가 아니라, 경우에 따라서는 추후에 Conflict 를 발생시킬 수 있습니다.

브랜치 머지 전략 선택 UI

이러한 상황에서, 개발자들이 어떻게 실수하는 것을 막을 수 있을까요? Tampermonkey 스크립트를 활용하여 합의된 머지 전략으로만 브랜치를 머지하도록 관리할 수 있습니다.

// ==UserScript==
// @name         Github Merge Strategy
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  강의 백엔드 메인 머지 버튼 활성화
// @author       hong3
// @match        https://github.com/inflearn/course-backend*
// @match        https://github.com/inflearn/inflearn-backend*
// @run-at       document-start
// @grant        none
// ==/UserScript==


(async function () {
  'use strict';

  /** 레포지토리의 브랜치에 맞게 변경하여 사용해 주세요 */
  const BRANCH = {
    MASTER: 'main',
    DEVELOP: 'develop'
  }

  /** 변경 금지 */
  const MERGE = {
    REBASE: 'rebase',
    SQUASH: 'squash',
    MERGE: 'merge',
  }

  /** 저장소별로 머지 전략을 변경하여 사용해 주세요 */
  const MERGE_STRATEGY = {
    [BRANCH.MASTER]: MERGE.REBASE,
    [BRANCH.DEVELOP]: MERGE.SQUASH,
  }

  const INITIAL_WAIT_TIME_MILLISEC = 500;

  console.log("develop 브랜치에는 squash, main 브랜치에는 rebase 전략으로 머지 강제하기");

  overrideATag();

  sleep(INITIAL_WAIT_TIME_MILLISEC).then(() => {
    // 어드민 권한으로 강제 머지 시도할때 체크 추가
    const adminMergeButton = document.querySelector("input.js-admin-merge-override");
    if (adminMergeButton) {
      adminMergeButton.addEventListener("click", check);
    }
  });

  await check().catch((e) => console.error("PR Merge Strategy 체크 과정에서 에러 발생", e));

  async function check() {
    const commitRef = await waitAndSelect(".base-ref .css-truncate-target");

    if (commitRef) {
      [BRANCH.MASTER, BRANCH.DEVELOP].forEach(async branch => {
        if (commitRef.innerText === branch) {
          const mergeBoxButtons = await waitAndSelectAll(".merge-box-button");

          [...mergeBoxButtons].forEach((mergeBoxButton) => {
            if (!mergeBoxButton.innerText.includes('Rebase and merge')) {
              mergeBoxButton.disabled = MERGE_STRATEGY[branch] !== MERGE.REBASE;
            }
            if (!mergeBoxButton.innerText.includes('Create a merge commit')) {
              mergeBoxButton.disabled = MERGE_STRATEGY[branch] !== MERGE.MERGE;
            }
            if (!mergeBoxButton.innerText.includes('Squash and merge')) {
              mergeBoxButton.disabled = MERGE_STRATEGY[branch] !== MERGE.SQUASH;
            }
          })

          /**
           * 각각의 Merge Button 이 여러 개 그려지는 경우가 있음. (Update Branch 뜰 경우)
           * 이 경우, 마지막 element 에만 disabled 처리를 해주기 위해 getLastElement 함수를 사용함.
           */
          const mergeSelectButtons = [...await waitAndSelectAll('button.js-merge-box-button-merge')];
          const mergeSelectButton = getLastElement(mergeSelectButtons);
          mergeSelectButton.disabled = MERGE_STRATEGY[branch] !== MERGE.MERGE;
          if (MERGE_STRATEGY[branch] === MERGE.MERGE) {
            mergeSelectButton.click();
          }

          const squashSelectButtons = [...await waitAndSelectAll("button.js-merge-box-button-squash")];
          const squashSelectButton = getLastElement(squashSelectButtons);
          squashSelectButton.disabled = MERGE_STRATEGY[branch] !== MERGE.SQUASH;
          if (MERGE_STRATEGY[branch] === MERGE.SQUASH) {
            squashSelectButton.click();
          }

          const rebaseSelectButtons = [...await waitAndSelectAll('button.select-menu-item[value="rebase"]')];
          const rebaseSelectButton = getLastElement(rebaseSelectButtons);
          rebaseSelectButton.disabled = MERGE_STRATEGY[branch] !== MERGE.REBASE;
          if (MERGE_STRATEGY[branch] === MERGE.REBASE) {
            rebaseSelectButton.click();
          }
        }
      })

    }
  }

  /**
   * CSS Selector 에 해당하는 HTML Element 가 그려질 때까지 대기하는 함수
   *
   * @param {string} selector - CSS Selector
   */
  async function waitAndSelect(selector) {
    return await waitUntil(() => document.querySelector(selector));
  }

  /**
   * waitAndSelect 와 동일하게 동작하지만,
   * CSS Selector 에 해당하는 *모든* HTML Element 가 그려질 때까지 대기하는 함수
   *
   * @param {string} selector - CSS Selector
   */
  async function waitAndSelectAll(selector) {
    return await waitUntil(() => document.querySelectorAll(selector));
  }

  /**
   * 특정 함수가 Truthy 할 때까지 Polling 한다.
   *
   * @param {Function} fn - 대기할 함수
   * @param {number} max - 최대 Polling 횟수
   * @param {number} pollInterval - Polling 간격
   */
  async function waitUntil(fn, max = 100, pollInterval = 300) {
    let count = 0;

    return new Promise(resolve => {
      const interval = setInterval(() => {
        const result = fn();
        if (count++ > max) {
          clearInterval(interval);
          return result;
        }

        if (result) {
          clearInterval(interval);
          return resolve(result);
        }
      }, pollInterval)
    });
  }

  async function sleep(milliSec) {
    return new Promise(resolve => setTimeout(resolve), milliSec);
  }

  function getLastElement(iterable) {
    return iterable[iterable.length - 1];
  }

  function overrideATag() {
    // Github 에서 <a> 태그가 SPA 형식으로 동작해서 TamperMonkey 스크립트가 정상적으로 동작하지 않는 이슈가 있음.
    // <a> 태그 클릭할 때 페이지 리로드가 수행하도록 수정함
    document.addEventListener('click', function (event) {
      // 이벤트가 발생한 대상이 <a> 태그인지 확인합니다.
      if (event.target.tagName.toLowerCase() === 'a') {
        // 기본 클릭 동작을 방지하여 브라우저가 기본으로 처리하지 않도록 합니다.
        event.preventDefault();

        // href 속성 값을 가져와서 window.location.href로 이동시킵니다.
        const href = event.target.getAttribute('href');
        if (href) {
          window.location.href = href;
        }
      }
    });
  }
})();



UserScript

  이 글에서 제가 작성한 스크립트들 외에도, UserScript.zone 에서 Tampermonkey 유저들이 공유한 수많은 스크립트들을 다운로드 받을 수 있습니다. 단, 검증되지 않은 스크립트도 있을 수 있으니, 내용을 확인하신 후에 다운로드 하는 것을 권장합니다.


UserScript.zone 에서 "youtube" 를 검색한 결과


장난감삼아 간단히 놀아볼 수 있는 Chrome Extension 인 Tampermonkey 를 소개시켜 드렸습니다. 추가적으로, 만약 유용하게 사용하고 계신 UserScript 가 생기신다면 공유해 주셔도 좋을 것 같습니다.

읽어주셔서 감사합니다 🙇🙇🙇



이것도 읽어보세요