SOOP 채널/다시보기/라이브 프로필 & 배너 이미지 다운로드 (원본 LOGO 경로 사용)

Station: 프로필(원본 LOGO 경로) + 배너, VOD/Live: 프로필 이미지를 닉네임 파일명(.png)으로 저장. (페이지별 토스트/핫키 브리지/지연로딩/폴백 포함)

// ==UserScript==
// @name         SOOP 채널/다시보기/라이브 프로필 & 배너 이미지 다운로드 (원본 LOGO 경로 사용)
// @namespace    http://tampermonkey.net/
// @version      4.0
// @description  Station: 프로필(원본 LOGO 경로) + 배너, VOD/Live: 프로필 이미지를 닉네임 파일명(.png)으로 저장. (페이지별 토스트/핫키 브리지/지연로딩/폴백 포함)
// @author       WakViewer
// @match        https://www.sooplive.co.kr/station/*
// @match        https://vod.sooplive.co.kr/player/*
// @match        https://play.sooplive.co.kr/*
// @icon         https://res.sooplive.co.kr/afreeca.ico
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ====== 설정/상수 ======
  const MSG_TYPE = 'SOOP_DL_HOTKEY';
  const DEFAULT_SHORTCUT = 'ctrl+y';
  let shortcutKey = (GM_getValue('shortcutKey', DEFAULT_SHORTCUT) || DEFAULT_SHORTCUT).toLowerCase();

  function getEnv() {
    const h = location.hostname;
    if (h === 'www.sooplive.co.kr' && location.pathname.startsWith('/station/')) return 'station';
    if (h === 'vod.sooplive.co.kr' && location.pathname.startsWith('/player/')) return 'vod';
    if (h === 'play.sooplive.co.kr') return 'play'; // 라이브 또는 VOD 내장 플레이어
    return 'unknown';
  }
  const ENV = getEnv();
  const IS_TOP = window.top === window;

  // ====== 셀렉터 ======
  const SELECTORS = {
    station: {
      bannerDiv: '#layout_container__S7ueh > div.ChannelVisual_channelVisual__a2JA_ > div.TopBanner_TopBannerWrap__1Z87K',
      profileImgList: [
        '#soop_wrap > div.__soopui__Sidebar-module__Sidebar___CjdhU.__soopui__Sidebar-module__Expanded___DPQe9.ServiceLeftMenu_ChannelServiceLeftMenu__C6J0o.ServiceLeftMenu_expanded__IfzLg > div.__soopui__InnerLnb-module__InnerLnb___qASDV.__soopui__InnerLnb-module__Expanded___hmDdf.ServiceLeftMenu_innerLnb__0hMfP > div.ProfileInfo_streamer__tEqni > div.ProfileInfo_profileImg__mz9Nz > a > span > div > img',
        '#layout_container__S7ueh > div.ChannelVisual_channelVisual__a2JA_ > div.ChannelVisual_channelInfoWrapper__ovnvh > div > div.StreamerInfo_streamer__ZhKoB > div.StreamerInfo_profileImg__EC1En > span > div > img'
      ],
      nicknameList: [
        '#soop_wrap > div.__soopui__Sidebar-module__Sidebar___CjdhU.__soopui__Sidebar-module__Expanded___DPQe9.ServiceLeftMenu_ChannelServiceLeftMenu__C6J0o.ServiceLeftMenu_expanded__IfzLg > div.__soopui__InnerLnb-module__InnerLnb___qASDV.__soopui__InnerLnb-module__Expanded___hmDdf.ServiceLeftMenu_innerLnb__0hMfP > div.ProfileInfo_streamer__tEqni > div.ProfileInfo_nicknameWrapper__LdDE0 > p',
        '#layout_container__S7ueh > div.ChannelVisual_channelVisual__a2JA_ > div.ChannelVisual_channelInfoWrapper__ovnvh > div > div.StreamerInfo_streamer__ZhKoB > div:nth-child(2) > div.StreamerInfo_nicknameWrapper__NFtU2 > p'
      ]
    },
    vod: {
      profileImgList: [
        '#player_area > div.wrapping.player_bottom > div.broadcast_information > div:nth-child(1) > div.thumbnail_box > a > img',
        '#player_area .broadcast_information .thumbnail_box a img'
      ],
      nicknameList: [
        '#player_area > div.wrapping.player_bottom > div.broadcast_information > div:nth-child(1) > div.ictFunc.nickname > a',
        '#player_area .broadcast_information .ictFunc.nickname a'
      ]
    },
    play: { // 라이브 페이지 또는 VOD 내장 iframe
      profileImgList: ['#bjThumbnail > a > img'],
      nicknameList: ['#infoNickName']
    }
  };

  const DEFAULTS = {
    profilePrefix: 'https://res.sooplive.co.kr/images/svg/thumb_profile.svg',
    bannerLight: 'https://res.sooplive.co.kr/images/channel/ChannelVisualImageLight.jpg',
    bannerDark: 'https://res.sooplive.co.kr/images/channel/ChannelVisualImageDark.jpg'
  };

  // ====== 토스트 ======
  function getToastContainer() {
    if (ENV === 'vod') {
      let box = document.querySelector('#toastMessage');
      if (!box) {
        box = document.createElement('div');
        box.id = 'toastMessage';
        box.className = 'toast-box';
        document.body.appendChild(box);
      }
      return box;
    } else {
      let box = document.querySelector('body > div.toast-box');
      if (!box) {
        box = document.createElement('div');
        box.className = 'toast-box';
        document.body.appendChild(box);
      }
      return box;
    }
  }
  function showToast(msg, durationMs = 2500) {
    const box = getToastContainer();
    const item = document.createElement('div');
    const p = document.createElement('p');
    p.textContent = msg;
    item.appendChild(p);
    box.appendChild(item);
    setTimeout(() => item.remove?.(), durationMs);
  }

  // ====== 유틸 ======
  function sanitizeFilenameBase(s) {
    return (s || '').replace(/[\\/:*?"<>|]/g, '_').trim();
  }
  function ensurePngName(base) {
    const safe = sanitizeFilenameBase(base);
    return safe.toLowerCase().endsWith('.png') ? safe : `${safe}.png`;
  }
  function queryFirst(listOrSelector) {
    const arr = Array.isArray(listOrSelector) ? listOrSelector : [listOrSelector];
    for (const sel of arr) {
      const el = document.querySelector(sel);
      if (el) return el;
    }
    return null;
  }
  function firstFromSrcset(srcset) {
    if (!srcset) return '';
    return srcset.split(',')[0].trim().split(' ')[0].trim();
  }
  function getImgUrlFromEl(imgEl) {
    if (!imgEl) return '';
    return imgEl.getAttribute('src')
        || imgEl.currentSrc
        || imgEl.getAttribute?.('data-src')
        || firstFromSrcset(imgEl.getAttribute?.('srcset'))
        || '';
  }
  function parseBgUrlFromStyle(styleStr) {
    if (!styleStr) return '';
    const m = styleStr.match(/url\((['"]?)(https?:\/\/[^)]+)\1\)/i);
    return m ? m[2] : '';
  }
  function waitForElement(selector, timeoutMs = 12000) {
    return new Promise((resolve) => {
      const el = document.querySelector(selector);
      if (el) return resolve(el);
      const obs = new MutationObserver(() => {
        const t = document.querySelector(selector);
        if (t) { obs.disconnect(); resolve(t); }
      });
      obs.observe(document.documentElement, { childList: true, subtree: true });
      setTimeout(() => { obs.disconnect(); resolve(null); }, timeoutMs);
    });
  }
  function urlExists(url) {
    return new Promise((resolve) => {
      GM_xmlhttpRequest({
        method: 'HEAD', url,
        onload: (res) => resolve(res.status >= 200 && res.status < 400),
        onerror: () => resolve(false), ontimeout: () => resolve(false)
      });
    });
  }

  // ====== 아이디/닉네임 ======
  function getStationStreamerId() {
    // /station/<id>
    const parts = location.pathname.split('/').filter(Boolean);
    return parts[1] ? parts[1].toLowerCase() : '';
  }
  function getChannelIdFromUrl() {
    const parts = location.pathname.split('/').filter(Boolean);
    if (ENV === 'station') return parts[1] || 'streamer';
    if (ENV === 'play') return parts[0] || 'streamer';
    return 'streamer';
  }
  function getNickname() {
    const conf = SELECTORS[ENV] || {};
    const nickEl = queryFirst(conf.nicknameList);
    let nickTxt = nickEl?.textContent?.trim();
    if (!nickTxt && nickEl?.getAttribute) {
      const dataId = nickEl.getAttribute('data-bj_id');
      if (dataId) nickTxt = dataId.trim();
    }
    if (nickTxt) return nickTxt;

    const imgEl = queryFirst(conf.profileImgList);
    const alt = imgEl?.getAttribute('alt')?.trim();
    if (alt) return alt;

    return getChannelIdFromUrl();
  }

  // ====== 다운로드 ======
  function downloadImage(url, filenameBase) {
    const name = ensurePngName(filenameBase);
    GM_download({ url, name, onerror: (e) => console.error('[SOOP DL] Failed', e) });
  }

  // ====== 원본 LOGO URL 생성 (id → jpg, 없으면 webp) ======
  async function buildLogoUrlFromId(id) {
    if (!id) return '';
    const bucket = id.slice(0, 2).toLowerCase();
    const base = `https://stimg.sooplive.co.kr/LOGO/${bucket}/${id}/${id}`;
    const jpg = `${base}.jpg`;
    if (await urlExists(jpg)) return jpg;
    const webp = `${base}.webp`;
    if (await urlExists(webp)) return webp;
    return ''; // 없으면 빈 문자열
  }

  // ====== 동작 ======
  async function downloadProfile() {
    const nick = getNickname();

    if (ENV === 'station') {
      // ✅ 방송국: 썸네일 src 대신 아이디로 원본 LOGO 경로 구성
      const id = getStationStreamerId();
      const logoUrl = await buildLogoUrlFromId(id);
      if (logoUrl) {
        downloadImage(logoUrl, `${nick} 프로필`);
        showToast('프로필 이미지 다운로드 완료!');
        return;
      }
      // 폴백: 혹시나 LOGO에 없을 때 기존 DOM에서 시도
      const imgEl = queryFirst(SELECTORS.station.profileImgList);
      const domSrc = getImgUrlFromEl(imgEl);
      if (domSrc && !domSrc.startsWith(DEFAULTS.profilePrefix)) {
        downloadImage(domSrc, `${nick} 프로필`);
        showToast('프로필 이미지 다운로드 완료!(썸네일 소스)');
        return;
      }
      showToast('프로필 이미지가 없습니다!');
      return;
    }

    // VOD / 라이브: 기존 로직
    const conf = SELECTORS[ENV] || {};
    let imgEl = queryFirst(conf.profileImgList);
    let src = getImgUrlFromEl(imgEl);

    if (!src) {
      const firstSel = conf.profileImgList?.[0];
      if (firstSel) {
        await waitForElement(firstSel, 12000);
        imgEl = queryFirst(conf.profileImgList);
        src = getImgUrlFromEl(imgEl);
      }
    }

    if ((!src || src.startsWith(DEFAULTS.profilePrefix)) && location.hostname === 'vod.sooplive.co.kr') {
      // VOD는 닉네임 링크에서 station/<id> 추출해 LOGO URL 구성
      const a = await waitForElement(SELECTORS.vod.nicknameList[0], 8000);
      const href = a?.getAttribute('href') || '';
      const m = href.match(/\/station\/([A-Za-z0-9_]+)/);
      const id = m ? m[1].toLowerCase() : '';
      const built = await buildLogoUrlFromId(id);
      if (built) src = built;
    }

    if (!src || src.startsWith(DEFAULTS.profilePrefix)) {
      showToast('프로필 이미지가 없습니다!');
      return;
    }
    downloadImage(src, `${nick} 프로필`);
    showToast('프로필 이미지 다운로드 완료!');
  }

  function downloadBanner() {
    if (ENV !== 'station') {
      showToast('이 페이지에는 배너가 없습니다.');
      return;
    }
    const el = document.querySelector(SELECTORS.station.bannerDiv);
    if (!el) { showToast('배너 이미지가 없습니다!'); return; }
    const styleBg = el.getAttribute('style') || getComputedStyle(el).backgroundImage || '';
    const url = parseBgUrlFromStyle(styleBg);
    if (!url || url === DEFAULTS.bannerLight || url === DEFAULTS.bannerDark) {
      showToast('배너 이미지가 없습니다!'); return;
    }
    const nick = getNickname();
    downloadImage(url, `${nick} 배너`);
    showToast('배너 이미지 다운로드 완료!');
  }

  function downloadByEnvShortcut() {
    if (location.hostname === 'vod.sooplive.co.kr') {
      downloadProfile(); // VOD: 프로필만
    } else if (ENV === 'station') {
      downloadProfile(); downloadBanner(); // 방송국: 프로필(원본 LOGO) + 배너
    } else {
      downloadProfile(); // play(라이브/내장)도 프로필만
    }
  }

  // ====== 단축키 ======
  function formatKeyCombo(e) {
    let s = '';
    if (e.ctrlKey) s += 'ctrl+';
    if (e.shiftKey) s += 'shift+';
    if (e.altKey) s += 'alt+';
    s += (e.key || '').toLowerCase();
    return s;
  }
  function isEditableTarget(t) {
    if (!t) return false;
    const tag = (t.tagName || '').toLowerCase();
    return tag === 'input' || tag === 'textarea' || t.isContentEditable;
  }
  function keyHandler(e) {
    if (isEditableTarget(e.target)) return;
    const combo = formatKeyCombo(e);
    const match = (combo === shortcutKey) || (shortcutKey === 'y' && combo === 'y');
    if (!match) return;
    e.preventDefault();
    e.stopPropagation();

    if (IS_TOP) {
      downloadByEnvShortcut();
    } else {
      try { window.top.postMessage({ type: MSG_TYPE }, '*'); } catch (_) {}
    }
  }
  window.addEventListener('keydown', keyHandler, { capture: true, passive: false });
  document.addEventListener('keydown', keyHandler, { capture: true, passive: false });

  if (IS_TOP) {
    window.addEventListener('message', (ev) => {
      const data = ev?.data;
      if (data && data.type === MSG_TYPE) downloadByEnvShortcut();
    });
  }

  // ====== GM 메뉴 ======
  function setShortcutKey() {
    const cur = shortcutKey;
    const newKey = prompt('새로운 단축키를 입력하세요!\n\n예) Y, Ctrl+Y, Alt+Shift+P', cur);
    if (newKey) {
      shortcutKey = newKey.toLowerCase().trim();
      GM_setValue('shortcutKey', shortcutKey);
      showToast(`단축키 설정 완료: ${newKey}`);
    } else {
      showToast('단축키 설정이 취소되었습니다.');
    }
  }

  if (location.hostname === 'www.sooplive.co.kr') {
    GM_registerMenuCommand('프로필/배너 모두 다운로드', () => { downloadProfile(); downloadBanner(); });
    GM_registerMenuCommand('프로필 이미지만 다운로드', downloadProfile);
    GM_registerMenuCommand('배너 이미지만 다운로드', downloadBanner);
  } else {
    GM_registerMenuCommand('프로필 이미지만 다운로드', downloadProfile);
  }
  GM_registerMenuCommand(`단축키 설정 (현재: ${shortcutKey})`, setShortcutKey);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址