kone base64 자동복호화

base64코드 자동복호화

// ==UserScript==
// @name         kone base64 자동복호화
// @namespace    http://tampermonkey.net/
// @version      1.4.9
// @description   base64코드 자동복호화
// @author       SYJ
// @match        https://arca.live/*
// @match        https://kone.gg/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=arca.live
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addValueChangeListener
// @require            https://openuserjs.org/src/libs/sizzle/GM_config.js
// @license MIT
// ==/UserScript==

// 자주 바뀜. 취약한 셀렉터
const SHADOW_ROOT_SELECTOR = "body  main div.prose-container";
const ARTICLE_LIST_SELECTOR = 'main .grow.flex .grow.grid'; // 글목록 셀렉터
const MAX_DECODE_COUNT = 10+1;

window.addEventListener('load', ()=>setTimeout(main, 1000));

async function main(){
    observeUrlChange(renderUI);
    const isAutoMode = await GM_getValue('toggleVal', true);
    if (isAutoMode) {
        observeUrlChange(applyAuto);
    }
    else {
        setTimeout(applyManually, 1000);
    }
}

function applyManually() {
    document.body.addEventListener('dblclick', function(e) {
        console.log('더블클릭 감지! 🎉',e.target,event.composedPath()[0]);
        const el = e.composedPath()[0];
        const nodes = Array.from(el.childNodes).filter(node=>node.nodeType ===Node.TEXT_NODE)
        console.log(nodes)
        for (const node of nodes){
            const original = node.textContent;
            const decodedLink = doDecode(original);
            // console.log(node, original, decodedLink);
            if (original === decodedLink) continue;
            linkifyTextNode(node, decodedLink);
        }
    })
}

function applyAuto() {
    if(document.body.querySelector('#post_editor')) {return;} // 글수정중엔 변환하지 않음
    const contents = Array.from(document.querySelectorAll(`${ARTICLE_LIST_SELECTOR} :is(${textTagNames})`)); // 글목록

    const mainContents = Array.from(document.querySelector(SHADOW_ROOT_SELECTOR)?.shadowRoot?.querySelectorAll(textTagNames) ?? []); // 글본문
    contents.push(...mainContents);

    for (const tag of contents) {
        const nodes = Array.from(tag.childNodes).filter(node=>node.nodeType ===Node.TEXT_NODE)

        for (const node of nodes){
            const original = node.textContent;
            const decodedLink = doDecode(original);

            if (original === decodedLink) continue;
            linkifyTextNode(node, decodedLink);
        }
    }

    //console.log('더이상 디코드할 수 없는 목록 :', Array.from(nonBase64Collection));
}

const textTagNames = 'p, span, div, a, li,' +      // 일반 컨테이너
      'h1, h2, h3, h4, h5, h6,' +    // 제목 요소
      'em, strong, u, b, i, small, mark, ' +   // 인라인 포맷팅 요소
      'label, button, option, textarea' // 폼/인터페이스 요소

// 텍스트노드에 존재하는 url을 a태그로 바꿈. (url포함 텍스트노드 -> 텍스트노드1 + a태그 + 텍스트노드2)
function linkifyTextNode(Node, text) {
    const urlRegex = /(https?:\/\/[^\s]+)/; // URL 매칭 (https:// 로 시작해서 공백 전까지)
    Node.textContent = text;

    if (!urlRegex.test(text)) { // URL 없으면 텍스트 덮어씌우고 종료
        return;
    }

    let node = Node;
    while(urlRegex.test(node?.textContent ?? '')){
        const match = urlRegex.exec(node.textContent);

        const url = match[0];
        const start = match.index;
        const urlLen = url.length;

        // "텍스트1 URL 텍스트2" 꼴의 텍스트노드를 세 개로 분리
        // 1) URL 앞부분과 뒤를 분리
        const textNode = document.createTextNode(node.textContent);
        const afterUrlStart = textNode.splitText(start);
        const afterUrlEnd = afterUrlStart.splitText(urlLen);
        const beforeUrlStart = textNode;

        // 2) <a> 요소 생성 후 URL 텍스트 노드 대신 교체. parent
        const a = makeATag(url)
        node.parentNode.replaceChild(a, node);
        node = afterUrlEnd;
        a.before(beforeUrlStart);
        a.after(afterUrlEnd);

    }

    function makeATag(link){
        const aTag = document.createElement('a');
        aTag.href = link;
        aTag.textContent = link;
        aTag.target = '_blank';
        aTag.rel = 'noreferrer';
        return aTag;
    }
}



// 노드 하나에 존재하는 모든 base64구문을 복원함.
function doDecode(text) {
    ///'use strict';
    let result = text;
    result = dec(/[0-9A-Za-z+/]{6,}[=]{0,2}/g, result); //문자열 6회 + '=' 0~2회
    return result;

    function dec(reg, text) {
        let result = text;
        const maps = Array.from(result.match(reg) ?? []) // base64 청크
        .map(o=>({before:o, after:decodeNtime(o)})) // base64 to 원본 매핑
        maps.forEach(({before, after})=>{result = result.replace(before, after)}); // 적용

        return result;
    }
}

// 원문으로 가능한 패턴 (한영숫자 + 자주쓰는 특문 + 한자)
// 허용 범위
// 한글                 : \uAC00-\uD7A3
// 히라가나             : \u3040-\u309F
// 카타카나             : \u30A0-\u30FF
// CJK 한자             : \u3400-\u4DBF, \u4E00-\u9FFF, \uF900-\uFAFF
// CJK 구두점·전각 특수문자: \u3000-\u303F
// 전각 괄호             : \uFF08-\uFF09
// 영숫자               : A-Za-z0-9
// 반각 특수문자         : !@#\$%\^&\*\(\)_\-\+=\[\]\{\}\\|;:'",.<>\/\?
const WORD_TEST = /^[ㄱ-ㅎ가-힣A-Za-z0-9!@#\$%\^&\*\(\)_\-\+=\[\]\{\}\\|;:/'",.<>\/\?!@#$%^&*()_+-=`~|\s\uAC00-\uD7A3\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF08-\uFF09]+$/;

const nonBase64Collection = new Set();

function decodeNtime(str) {
    let decoded = str;

    for (let i=0; i<MAX_DECODE_COUNT; i++){
        const old = decoded;
        decoded = decodeOneTime(decoded);
        if (decoded === old) return decoded;
    }

    function decodeOneTime(str) {
        try {
            const decoded = base64DecodeUnicode(str)
            if (!WORD_TEST.test(decoded)) {
                nonBase64Collection.add(str);
                throw new Error('[정상 유니코드 범위가 아님]'+JSON.stringify(str)+JSON.stringify(decoded));}
            return decoded;
        }
        catch(e) {
            //console.log('[FAIL]',str, e);
            return str; }
    }

    function base64DecodeUnicode(str) {
        const binary = atob(str); // 1) atob으로 디코딩 → 1바이트 문자열
        const bytes = new Uint8Array(
            Array.from(binary, ch => ch.charCodeAt(0)) // 2) 각 문자(=바이트)를 숫자로 뽑아 Uint8Array 생성
        );
        return new TextDecoder('utf-8').decode(bytes); // 3) TextDecoder로 'utf-8' 디코딩
    }

}

// UI

async function renderUI() {
    // 1) 값 로드
    let val = await GM_getValue('toggleVal', false);
    let menuId;

    // 렌더
    render();

    function render() {
        // 메뉴 해제 후 다시 등록
        if (menuId) GM_unregisterMenuCommand(menuId);
        menuId = GM_registerMenuCommand(
            `자동모드 토글 (현재: ${val?'ON':'OFF'})`,
            toggleValue
        );
    }

    async function toggleValue() {
        const newVal = !val;
        await GM_setValue('toggleVal', newVal);
        val = newVal;    // 변수 갱신
        render();        // 메뉴·배지 즉시 갱신
    }
}

const observeUrlChange = (func) => {
    const body = document.querySelector('body');
    const throttled = throttle(func);
    throttled(0);
    const observer = new MutationObserver(mutations => {
        throttled(100)
        throttled(1000);
        throttled(1500);
    });
    observer.observe(body, { childList: true, subtree: true, characterData:true });

};

function throttle(func) {
let timeoutId = null;
  return function(delay) {
    if (!timeoutId) {
      timeoutId = setTimeout(() => {
        func();
        timeoutId = null;
      }, delay);
    }
  };
}