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

Greasy fork 爱吃馍镜像

Greasy Fork is available in English.

📂 缓存分发状态(共享加速已生效)
🕒 页面同步时间:2026/01/07 04:08:49
🔄 下次更新时间:2026/01/07 05:08:49
手动刷新缓存

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

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

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

公众号二维码

扫码关注【爱吃馍】

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

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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

公众号二维码

扫码关注【爱吃馍】

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

// ==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);
})();