🎉 欢迎访问GreasyFork.Org 镜像站!本镜像站由公众号【爱吃馍】搭建,用于分享脚本。联系邮箱📮

Greasy fork 爱吃馍镜像

SOOP 닉네임(아이디) 복사 버튼

SOOP 라이브 채팅창 닉네임 클릭 시 기능 메뉴에 '닉네임(아이디) 복사' 기능 추가

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

// ==UserScript==
// @name         SOOP 닉네임(아이디) 복사 버튼
// @icon         https://www.google.com/s2/favicons?sz=64&domain=www.sooplive.co.kr
// @author       oioi
// @namespace    sooplive-nick-id-copier
// @version      2.0
// @description  SOOP 라이브 채팅창 닉네임 클릭 시 기능 메뉴에 '닉네임(아이디) 복사' 기능 추가
// @match        https://play.sooplive.co.kr/*
// @run-at       document-idle
// @grant        GM_addStyle
// ==/UserScript==

(function () {
  'use strict';

  /* =============== 스타일 =============== */
  // 토스트만 최소 스타일. 버튼은 사이트 CSS(.chatIct-card .menu-list > li button)를 그대로 상속.
  GM_addStyle(`
    .__nickid_toast__{
      position: fixed; left: 50%; top: 12%;
      transform: translateX(-50%);
      background: rgba(30,30,30,.9); color: #fff;
      padding: 10px 14px; border-radius: 8px;
      font-size: 12px; z-index: 999999; pointer-events: none;
      opacity: 0; transition: opacity .15s ease;
    }
    .__nickid_toast__.on{ opacity: 1; }

    /* 아이콘만 살짝 정렬(사이트 버튼 높이/패딩은 상속) */
    .__nickid_btn_icon__{ display:inline-block; width:16px; height:16px; margin-right:8px; }
  `);

  /* =============== 상수/유틸 =============== */
  const MENU_TEXT_HINTS = ["선물하기","쪽지 보내기","귓속말 보내기","채팅 안보기","채팅 신고하기"];
  // 영/숫자 시작 허용 + . _ - 포함, 끝의 (숫자) 허용 (멀티창 suffix)
  const idRegexAllowSuffix = /^[a-zA-Z0-9][a-zA-Z0-9._-]{1,63}(?:\(\d+\))?$/;

  const toast = (msg) => {
    const el = document.createElement('div');
    el.className = '__nickid_toast__';
    el.textContent = msg;
    document.body.appendChild(el);
    void el.offsetHeight; el.classList.add('on');
    setTimeout(() => { el.classList.remove('on'); setTimeout(() => el.remove(), 180); }, 900);
  };

  const copyText = async (text) => {
    try { await navigator.clipboard.writeText(text); }
    catch {
      const ta = document.createElement('textarea');
      ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
      document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
    }
    toast('복사됨: ' + text);
  };

  const countHints = (t) => MENU_TEXT_HINTS.reduce((c,h)=>c+(t.includes(h)?1:0),0);
  const isBefore = (a,b) => !!(a && b && (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING));

  function getDirectChildOf(container, el) {
    let cur = el;
    while (cur && cur.parentElement !== container) cur = cur.parentElement;
    return cur && cur.parentElement === container ? cur : null;
  }

  /* =============== 닉네임/아이디 추출 =============== */
  function extractNameAndId(popupRoot) {
    const full = popupRoot.innerText || '';

    // 메뉴 시작 전까지만 사용
    let firstMenuIndex = Infinity;
    for (const hint of MENU_TEXT_HINTS) {
      const idx = full.indexOf(hint);
      if (idx !== -1 && idx < firstMenuIndex) firstMenuIndex = idx;
    }
    const header = firstMenuIndex === Infinity ? full : full.slice(0, firstMenuIndex);

    const lines = header.split('\n').map(s => s.trim()).filter(Boolean);
    if (!lines.length) return null;

    // 아이디 후보 수집
    const idIdxList = [];
    lines.forEach((l, i) => { if (idRegexAllowSuffix.test(l)) idIdxList.push(i); });
    if (!idIdxList.length) return null;

    // 1순위: (숫자) 접미사 붙은 줄, 2순위: 가장 아래쪽 후보(메뉴와 가장 가까움)
    let idIdx = idIdxList.find(i => /\(\d+\)\s*$/.test(lines[i]));
    if (idIdx === undefined) idIdx = idIdxList[idIdxList.length - 1];

    const rawUserId = lines[idIdx];
    const userId = rawUserId.replace(/\(\d+\)\s*$/, '');

    // 닉네임은 보통 아이디 바로 위 줄. 같다면 한 줄 더 위 시도
    let nickname = idIdx > 0 ? lines[idIdx - 1] : lines[0];
    if (nickname === userId && idIdx > 1) nickname = lines[idIdx - 2];

    if (!nickname || !userId) return null;
    return { nickname, userId };
  }

  /* =============== 버튼 주입 =============== */
  function injectButton(menuContainer, firstMenuEl, nickname, userId) {
    // 이미 주입했으면 패스
    if (menuContainer.dataset.nickidInjected === '1') return;
    if (menuContainer.querySelector('.__nickid_btn__')) return;

    // menu-list(ul) 찾기
    // 팝업 구조: .chatIct-card .menu-list > li > button
    let menuList = menuContainer.querySelector('.menu-list');
    if (!menuList) {
      // 혹시 컨테이너가 menu-list일 수도 있음
      if (menuContainer.classList && menuContainer.classList.contains('menu-list')) {
        menuList = menuContainer;
      } else {
        // 메뉴 버튼이 있는 곳을 기준으로 상위에서 menu-list를 찾는다
        const candidate = firstMenuEl && firstMenuEl.closest('.menu-list');
        if (candidate) menuList = candidate;
      }
    }
    // menu-list가 없으면 컨테이너 바로 아래에 넣되, 사이트 스타일을 최대한 맞춤
    const useFallback = !menuList;

    // 엘리먼트 생성: li > button (사이트 기본 스타일을 그대로 상속)
    const li = document.createElement('li');
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = '__nickid_btn__';
    btn.setAttribute('aria-label', '닉네임 아이디 복사');
    // 아이콘 + 텍스트
    const icon = document.createElement('span');
    icon.className = '__nickid_btn_icon__';
    icon.textContent = '📄';
    btn.appendChild(icon);
    btn.appendChild(document.createTextNode('닉네임(아이디) 복사'));

    const handler = (e) => { e.preventDefault(); e.stopPropagation(); copyText(`${nickname}(${userId})`); };
    btn.addEventListener('click', handler);
    btn.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') handler(e); });

    li.appendChild(btn);

    if (!useFallback) {
      // 구독 배지(문구)가 menu-list 밖에 있는 경우가 많으므로, menu-list의 맨 앞에 삽입
      // (사이트 기본 스타일대로 height/패딩/색 모두 자동 적용)
      menuList.insertBefore(li, menuList.firstChild);
    } else {
      // fallback: menuContainer 맨 앞에 삽입 (스타일 상속 최대화)
      if (menuContainer.firstChild) menuContainer.insertBefore(li, menuContainer.firstChild);
      else menuContainer.appendChild(li);
    }

    menuContainer.dataset.nickidInjected = '1';
  }

  /* =============== 후보 노드 처리 =============== */
  function tryProcessRoot(root) {
    if (!(root instanceof HTMLElement)) return;

    const text = root.innerText || '';
    // 메뉴 텍스트 힌트가 최소 2개 이상일 때만 후보(채팅 영역 오탐 방지)
    if (countHints(text) < 2) return;

    const firstMenuEl = Array.from(root.querySelectorAll('*')).find(el =>
      el && el.textContent && MENU_TEXT_HINTS.includes(el.textContent.trim())
    );
    if (!firstMenuEl) return;

    const menuContainer = firstMenuEl.parentElement || root;
    if (menuContainer.dataset.nickidInjected === '1' || menuContainer.querySelector('.__nickid_btn__')) return;

    const info = extractNameAndId(root);
    if (!info) return;

    injectButton(menuContainer, firstMenuEl, info.nickname, info.userId);
  }

  /* =============== 감시 시작 + 초기 스캔 =============== */
  const observer = new MutationObserver((muts) => {
    for (const m of muts) {
      for (const node of m.addedNodes) {
        if (!(node instanceof HTMLElement)) continue;
        tryProcessRoot(node);
        node.querySelectorAll && node.querySelectorAll('*').forEach(tryProcessRoot);
      }
    }
  });
  observer.observe(document.body, { childList: true, subtree: true });

  setTimeout(() => {
    document.querySelectorAll('body *').forEach(tryProcessRoot);
  }, 600);
})();