PikPak Direct Download Helper

PIKPAK 웹 버전에서의 직접 다운로드 환경 추가 스크립트

当前为 2025-11-23 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         PikPak Direct Download Helper
// @namespace    https://github.com/poihoii/PikPak_DirectDownloadHelper
// @version      1.0
// @description  PIKPAK 웹 버전에서의 직접 다운로드 환경 추가 스크립트
// @author       poihoii
// @match        https://mypikpak.com/*
// @grant        GM_setClipboard
// @run-at       document-end
// @license      MIT
// ==/UserScript==

/*
MIT License

Copyright (c) 2025 poihoii

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction...
*/

(function () {
  'use strict';

  /* ========= 공통 유틸 ========= */
  const sleep = (ms)=>new Promise(r=>setTimeout(r,ms));
  const esc = (s)=> (s||'').replace(/[&<>"']/g, m=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;', "'":'&#39;' }[m]));
  const fmtSize = (n)=>{
    n=parseInt(n||0,10);
    if(!n) return '0 B';
    const u=['B','KB','MB','GB','TB','PB'];
    let i=0;
    while(n>=1024 && i<u.length-1){
      n/=1024; i++;
    }
    return (n<10? n.toFixed(2): n.toFixed(1))+' '+u[i];
  };
  const fmtDate = (t)=> t ? new Date(t).toLocaleDateString() : ''; // 날짜 포맷 깔끔하게 변경
  function toast(msg){
    const el=document.createElement('div');
    Object.assign(el.style,{
      position:'fixed',left:'50%',bottom:'30px',
      transform:'translateX(-50%)',zIndex:10000,
      background:'rgba(30, 41, 59, 0.95)',color:'#fff',
      padding:'10px 20px',borderRadius:'50px',fontSize:'13px',fontWeight:'600',
      boxShadow:'0 10px 25px rgba(0,0,0,0.2)', backdropFilter:'blur(4px)',
      transition:'opacity 0.3s'
    });
    el.textContent=msg;
    document.body.appendChild(el);
    setTimeout(()=>{ el.style.opacity='0'; setTimeout(()=>el.remove(),300) }, 2000);
  }

  /* ========= PikPak API ========= */
  function apiHeaders(){
    let token='', captcha='', deviceId=localStorage.getItem('deviceid')||'';
    for(let i=0;i<localStorage.length;i++){
      const k=localStorage.key(i);
      if(!k) continue;
      if(k.startsWith('credentials')){
        try{ const v=JSON.parse(localStorage.getItem(k)); token=v.token_type+' '+v.access_token; }catch{}
      }
      if(k.startsWith('captcha')){
        try{ const v=JSON.parse(localStorage.getItem(k)); captcha=v.captcha_token; }catch{}
      }
    }
    const h={'Content-Type':'application/json'};
    if(token) h.Authorization = token;
    if(deviceId) h['x-device-id'] = deviceId;
    if(captcha) h['x-captcha-token'] = captcha;
    return h;
  }

  async function apiList(parent_id){
    const url=`https://api-drive.mypikpak.com/drive/v1/files?thumbnail_size=SIZE_MEDIUM&limit=500&parent_id=${parent_id||''}&with_audit=true&filters=%7B%22phase%22%3A%7B%22eq%22%3A%22PHASE_TYPE_COMPLETE%22%7D%2C%22trashed%22%3A%7B%22eq%22%3Afalse%7D%7D`;
    const r=await fetch(url,{headers:apiHeaders()});
    return (await r.json()).files||[];
  }

  async function apiGet(id){
    const r=await fetch(`https://api-drive.mypikpak.com/drive/v1/files/${id}?`,{headers:apiHeaders()});
    return r.json();
  }

  /* ========= UI 스타일 (디자인 리마스터) ========= */
  (function addCSS(){
    if (document.getElementById('ppk-direct-css')) return;
    const s=document.createElement('style'); s.id='ppk-direct-css';
    s.textContent = `
      :root {
        --ppk-primary: #4f46e5;
        --ppk-primary-hover: #4338ca;
        --ppk-bg: #ffffff;
        --ppk-text: #1e293b;
        --ppk-text-sub: #64748b;
        --ppk-border: #e2e8f0;
        --ppk-hover: #f1f5f9;
        --ppk-selected: #eef2ff;
        --ppk-radius: 12px;
        --ppk-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
      }

      /* 모달 컨테이너 */
      .ppk-ov{
        position:fixed;inset:0;background:rgba(15, 23, 42, 0.4);
        backdrop-filter: blur(2px); z-index:10001;
        display:flex;align-items:center;justify-content:center;
        opacity:0; animation:ppkFadeIn 0.2s forwards;
      }
      @keyframes ppkFadeIn{to{opacity:1}}

      .ppk-panel{
        width:min(900px, 92vw); max-height:85vh;
        background:var(--ppk-bg); border-radius:16px;
        box-shadow:var(--ppk-shadow); overflow:hidden;
        display:flex; flex-direction:column; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        color:var(--ppk-text);
      }

      /* 헤더 */
      .ppk-hd{
        padding:16px 24px; border-bottom:1px solid var(--ppk-border);
        display:flex; justify-content:space-between; align-items:center;
      }
      .ppk-title{font-size:18px; font-weight:700; color:var(--ppk-text);}

      /* 툴바 (필터/정렬) - 디자인 개선 */
      .ppk-bar{
        padding:12px 24px; border-bottom:1px solid var(--ppk-border);
        display:flex; align-items:center; background:#f8fafc; gap:12px;
      }
      .ppk-label-check{
        display:flex; align-items:center; gap:8px; font-weight:600; font-size:14px; cursor:pointer;
        user-select:none; color:var(--ppk-text);
      }
      .ppk-select-group{ margin-left:auto; display:flex; gap:8px; }

      /* 커스텀 셀렉트 박스 스타일 */
      .ppk-select{
        appearance:none; -webkit-appearance:none;
        padding:8px 32px 8px 12px;
        border:1px solid #cbd5e1; border-radius:8px;
        background-color:#fff; font-size:13px; font-weight:500; color:var(--ppk-text);
        cursor:pointer; outline:none; transition:all 0.15s;
        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
        background-repeat:no-repeat; background-position:right 8px center; background-size:14px;
      }
      .ppk-select:hover{ border-color:var(--ppk-primary); }
      .ppk-select:focus{ border-color:var(--ppk-primary); box-shadow:0 0 0 2px rgba(99, 102, 241, 0.1); }

      /* 리스트 헤더 */
      .ppk-th{
        display:grid; grid-template-columns:40px 1fr 120px 140px;
        padding:12px 24px; background:#fff; border-bottom:1px solid var(--ppk-border);
        font-size:12px; font-weight:700; color:var(--ppk-text-sub); text-transform:uppercase; letter-spacing:0.5px;
      }

      /* 리스트 목록 */
      .ppk-list{ flex:1; overflow-y:auto; overflow-x:hidden; min-height:300px; }

      /* 커스텀 스크롤바 */
      .ppk-list::-webkit-scrollbar { width: 6px; }
      .ppk-list::-webkit-scrollbar-track { background: transparent; }
      .ppk-list::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
      .ppk-list::-webkit-scrollbar-thumb:hover { background: #94a3b8; }

      .ppk-row{
        display:grid; grid-template-columns:40px 1fr 120px 140px;
        padding:10px 24px; border-bottom:1px solid #f1f5f9;
        align-items:center; transition:background 0.1s; cursor:pointer;
        font-size:14px; color:var(--ppk-text);
      }
      .ppk-row:hover{ background:var(--ppk-hover); }
      .ppk-row.selected{ background:var(--ppk-selected); }
      .ppk-row.selected .ppk-name{ color:var(--ppk-primary); font-weight:600; }

      .ppk-name{ display:flex; align-items:center; gap:8px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
      .ppk-icon{ font-size:16px; }
      .ppk-meta{ font-size:13px; color:var(--ppk-text-sub); }

      /* 푸터 & 브레드크럼 */
      .ppk-ft{
        padding:16px 24px; border-top:1px solid var(--ppk-border); background:#fff;
        display:flex; justify-content:space-between; align-items:center;
      }
      .ppk-info-box{ display:flex; flex-direction:column; gap:4px; max-width:60%; }

      .ppk-crumb{ font-size:13px; color:var(--ppk-text-sub); display:flex; gap:6px; align-items:center; }
      .ppk-crumb span{ cursor:pointer; transition:color 0.15s; }
      .ppk-crumb span:hover{ color:var(--ppk-primary); text-decoration:underline; }
      .ppk-crumb span.active{ color:var(--ppk-text); font-weight:600; cursor:default; text-decoration:none; }
      .ppk-crumb-sep{ color:#cbd5e1; font-size:10px; }

      .ppk-stat{ font-size:14px; font-weight:700; color:var(--ppk-primary); }

      /* 버튼 디자인 */
      .ppk-btn-group{ display:flex; gap:8px; }
      .ppk-btn{
        padding:10px 18px; border:none; border-radius:8px;
        font-size:13px; font-weight:600; cursor:pointer;
        transition:all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
        display:flex; align-items:center; justify-content:center;
      }
      .ppk-btn:active{ transform:scale(0.97); }
      .ppk-btn-primary{ background:var(--ppk-primary); color:#fff; box-shadow:0 4px 6px -1px rgba(79, 70, 229, 0.2); }
      .ppk-btn-primary:hover{ background:var(--ppk-primary-hover); transform:translateY(-1px); }

      .ppk-btn-success{ background:#10b981; color:#fff; box-shadow:0 4px 6px -1px rgba(16, 185, 129, 0.2); }
      .ppk-btn-success:hover{ background:#059669; transform:translateY(-1px); }

      .ppk-btn-sub{ background:#f1f5f9; color:#475569; }
      .ppk-btn-sub:hover{ background:#e2e8f0; color:#1e293b; }

      /* 반응형 플로팅 버튼 */
      @media (min-width: 980px){ .ppk-webdl-btn { display:inline-flex !important; } .ppk-webdl-fab { display:none !important; } }
      @media (max-width: 979px){ .ppk-webdl-btn { display:none !important; } .ppk-webdl-fab { display:flex !important; } }
      .ppk-webdl-fab{
        position:fixed; right:20px; bottom:90px; z-index:10002;
        width:56px; height:56px; border-radius:50%; border:none;
        background:var(--ppk-primary); color:#fff;
        box-shadow:0 10px 25px rgba(79, 70, 229, 0.4);
        display:flex; align-items:center; justify-content:center;
        cursor:pointer; transition:transform 0.2s;
      }
      .ppk-webdl-fab:hover{ transform:scale(1.1); }
      body.ppk-modal-open .ppk-webdl-fab{ display:none !important; }

      /* 다크모드 (모달 내부만 적용) */
      @media (prefers-color-scheme: dark) {
        :root {
          --ppk-bg: #1e293b; --ppk-text: #f1f5f9; --ppk-text-sub: #94a3b8;
          --ppk-border: #334155; --ppk-hover: #334155; --ppk-selected: #312e81;
        }
        .ppk-ov{ background:rgba(0,0,0,0.6); }
        .ppk-bar, .ppk-th, .ppk-ft { background:#1e293b; } /* 배경 통일 */
        .ppk-row { border-color: #334155; }
        .ppk-select {
          background-color:#0f172a; border-color:#475569; color:#f1f5f9;
        }
        .ppk-btn-sub { background:#334155; color:#cbd5e1; }
        .ppk-btn-sub:hover { background:#475569; color:#fff; }
      }
    `;
    document.head.appendChild(s);
  })();

  /* ========= 사이드바 버튼 (Native Style) ========= */
  async function injectWebBtn(){
    if (document.querySelector('.ppk-webdl-btn')) return true;
    const box=document.querySelector("#app > div.layout > div.main > div.sidebar > div:nth-child(1)");
    if(!box) return false;
    const cloud=box.querySelector('button.el-button.el-button--primary');
    if(!cloud) return false;

    const cs=getComputedStyle(cloud);
    const btn=document.createElement('button');
    btn.type='button';
    btn.className='el-button el-button--primary ppk-webdl-btn';

    // 버튼 스타일: 100% width, 마진 제거, 높이 맞춤
    btn.style.cssText=`
      display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box;
      padding:${cs.paddingTop} ${cs.paddingRight} ${cs.paddingBottom} ${cs.paddingLeft};
      line-height:${cs.lineHeight}; min-width:${cs.minWidth};
      width:${cs.width}; border-radius:${cs.borderRadius};
      margin-top:5px; margin-left:0 !important; font-weight:600 !important;
    `;
    btn.innerHTML=`
      <span style="display:inline-flex;align-items:center;gap:8px;">
        <span class="pp-icon" style="--icon-color:#fff;width:24px;height:24px;margin-right:8px;">
          <svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 4v10M8.5 11.5 12 15l3.5-3.5M6 19h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
        </span>
        <span>다이렉트 다운로드</span>
      </span>`;
    btn.addEventListener('click', openPicker, {capture:true});
    box.insertBefore(btn, cloud.nextSibling);
    return true;
  }

  function createFloatingWebBtn(){
    if (document.querySelector('.ppk-webdl-fab')) return;
    const btn = document.createElement('button');
    btn.className = 'ppk-webdl-fab';
    btn.title = '다이렉트 다운로드';
    btn.innerHTML = `<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>`;
    btn.addEventListener('click', openPicker, { capture:true });
    document.body.appendChild(btn);
  }

  /* ========= 모달 로직 ========= */
  function currentFolderId(){
    let id=location.href.split('/').pop();
    return id==='all' ? '' : id;
  }

  function openPicker(){
    if (document.querySelector('.ppk-ov')) return;
    document.body.classList.add('ppk-modal-open');

    const state = {
      stack: [{id: currentFolderId(), name: 'Home'}],
      cur: currentFolderId(),
      items: [],
      selected: new Set(),
      sortBy: 'name',
      sortDir: 'asc'
    };

    const ov=document.createElement('div'); ov.className='ppk-ov';
    ov.innerHTML=`
      <div class="ppk-panel" role="dialog">
        <div class="ppk-hd">
          <div class="ppk-title">파일 다운로드</div>
          <button class="ppk-btn ppk-btn-sub" id="ppk-test" style="padding:6px 12px;font-size:11px;">API Check</button>
        </div>
        <div class="ppk-bar">
          <label class="ppk-label-check"><input id="ppk-all" type="checkbox"> 전체 선택</label>
          <div class="ppk-select-group">
            <select id="ppk-sort" class="ppk-select">
              <option value="name">이름순</option>
              <option value="size">크기순</option>
              <option value="modified_time">날짜순</option>
            </select>
            <select id="ppk-dir" class="ppk-select">
              <option value="asc">오름차순</option>
              <option value="desc">내림차순</option>
            </select>
          </div>
        </div>
        <div class="ppk-th">
          <div>✓</div><div>이름</div><div>크기</div><div>날짜</div>
        </div>
        <div class="ppk-list" id="ppk-list"></div>
        <div class="ppk-ft">
          <div class="ppk-info-box">
             <div class="ppk-crumb" id="ppk-crumb"></div>
             <div id="ppk-stat" class="ppk-stat">항목을 선택하세요</div>
          </div>
          <div class="ppk-btn-group">
            <button class="ppk-btn ppk-btn-success" id="ppk-copy-links">링크 복사</button>
            <button class="ppk-btn ppk-btn-sub" id="ppk-cancel">취소</button>
            <button class="ppk-btn ppk-btn-primary" id="ppk-go">다운로드</button>
          </div>
        </div>
      </div>
    `;
    document.body.appendChild(ov);

    const elList=ov.querySelector('#ppk-list');
    const elAll=ov.querySelector('#ppk-all');
    const elStat=ov.querySelector('#ppk-stat');
    const elCrumb=ov.querySelector('#ppk-crumb');
    const elSort=ov.querySelector('#ppk-sort');
    const elDir=ov.querySelector('#ppk-dir');
    const elGo=ov.querySelector('#ppk-go');
    const elCopy=ov.querySelector('#ppk-copy-links');
    const elCancel=ov.querySelector('#ppk-cancel');
    const elTest=ov.querySelector('#ppk-test');

    // 이벤트 리스너
    elTest.onclick=async()=>{ try{ await apiList(state.cur); toast('API 연결 정상'); } catch{ toast('API 연결 실패'); } };
    elSort.onchange=()=>{ state.sortBy=elSort.value; renderList(); };
    elDir.onchange=()=>{ state.sortDir=elDir.value; renderList(); };
    elCancel.onclick=()=>{ ov.remove(); document.body.classList.remove('ppk-modal-open'); };
    ov.onclick=e=>{ if(e.target===ov) elCancel.click(); };

    // 리스트 렌더링
    function renderList(){
      elList.innerHTML='';
      // 정렬 로직
      const sb=state.sortBy, sd=state.sortDir;
      state.items.sort((a,b)=>{
        if(a.id==='..') return -1; if(b.id==='..') return 1;
        if(a.kind==='drive#folder' && b.kind!=='drive#folder') return -1;
        if(a.kind!=='drive#folder' && b.kind==='drive#folder') return 1;
        let av=a[sb], bv=b[sb];
        if(sb==='size'){ av=parseInt(av||0,10); bv=parseInt(bv||0,10); }
        if(sb==='modified_time'){ av=new Date(av||0).getTime(); bv=new Date(bv||0).getTime(); }
        if(av>bv) return sd==='asc'?1:-1; if(av<bv) return sd==='asc'?-1:1; return 0;
      });

      // 상위 폴더 버튼
      if(state.stack.length > 1 && !state.items.some(x=>x.id==='..')){
         state.items.unshift({id:'..', kind:'drive#folder', name:'..', size:0});
      }

      for(const it of state.items){
        const isFolder = it.kind==='drive#folder';
        const isParent = it.id === '..';
        const isSel = state.selected.has(it.id);

        const row=document.createElement('div');
        row.className=`ppk-row ${isSel?'selected':''}`;

        let icon = isFolder ? '📁' : '📄';
        if(isParent) icon = '🔙';

        row.innerHTML=`
          <div>${isParent ? '' : `<input type="checkbox" ${isSel?'checked':''}>`}</div>
          <div class="ppk-name">
            <span class="ppk-icon">${icon}</span> <span>${esc(it.name)}</span>
          </div>
          <div class="ppk-meta">${isParent ? '' : (isFolder ? '-' : fmtSize(it.size))}</div>
          <div class="ppk-meta">${isParent ? '' : fmtDate(it.modified_time)}</div>
        `;

        // 행 클릭 이벤트
        row.onclick = async (e) => {
          if(e.target.tagName === 'INPUT') return;
          if(isFolder){
             if(isParent){
                state.stack.pop(); state.cur = state.stack[state.stack.length-1].id;
             } else {
                state.stack.push({id:it.id, name:it.name}); state.cur = it.id;
             }
             await loadAndRender();
          } else {
             if(state.selected.has(it.id)) state.selected.delete(it.id);
             else state.selected.add(it.id);
             renderList();
          }
        };

        const chk = row.querySelector('input');
        if(chk) chk.onclick = (e) => { e.stopPropagation(); if(e.target.checked) state.selected.add(it.id); else state.selected.delete(it.id); renderList(); };
        elList.appendChild(row);
      }

      const realFiles = state.items.filter(x=>x.id!=='..');
      elAll.checked = realFiles.length>0 && realFiles.every(f=>state.selected.has(f.id));
      updateInfo();
    }

    // 하단 정보 & 브레드크럼 업데이트
    function updateInfo(){
      // Stats
      let bytes=0, files=0, folders=0;
      state.items.forEach(f=>{
        if(state.selected.has(f.id)){
           if(f.kind==='drive#folder') folders++;
           else { files++; bytes+=parseInt(f.size||0, 10); }
        }
      });
      if(files===0 && folders===0) elStat.textContent = '다운로드할 항목을 선택해주세요';
      else elStat.innerHTML = `선택: <span style="color:var(--ppk-text)">${files}개 파일</span> (${fmtSize(bytes)}) ${folders?` + 폴더 ${folders}개`:''}`;

      // Breadcrumbs
      elCrumb.innerHTML = '';
      state.stack.forEach((s, idx)=>{
         const span = document.createElement('span');
         span.textContent = s.name;
         if(idx === state.stack.length - 1) span.className = 'active';
         else {
            span.onclick = async () => {
               state.stack = state.stack.slice(0, idx+1);
               state.cur = s.id;
               await loadAndRender();
            };
         }
         elCrumb.appendChild(span);
         if(idx < state.stack.length - 1) {
             const sep = document.createElement('span');
             sep.className='ppk-crumb-sep'; sep.textContent='›';
             elCrumb.appendChild(sep);
         }
      });
    }

    async function loadAndRender(){
      elList.innerHTML = `<div style="padding:40px;text-align:center;color:var(--ppk-text-sub)">데이터를 불러오는 중...</div>`;
      state.selected.clear(); elAll.checked = false;
      try{ state.items = await apiList(state.cur); } catch(e){ state.items=[]; toast('목록 로드 실패'); }
      renderList();
    }

    elAll.onchange = ()=>{
      const realFiles = state.items.filter(x=>x.id!=='..');
      if(elAll.checked) realFiles.forEach(f=>state.selected.add(f.id)); else state.selected.clear();
      renderList();
    };

    /* === 액션 처리 === */
    async function processSelection(action){
      if(state.selected.size===0) { toast('선택된 항목이 없습니다.'); return; }

      const btn = action==='download' ? elGo : elCopy;
      const originText = btn.textContent;
      btn.disabled=true; btn.textContent='처리 중...';

      try{
        const targets = await expandSelection([...state.selected]);
        const fileTargets = targets.filter(f => f.kind !== 'drive#folder');

        if(fileTargets.length === 0) { toast('다운로드할 파일이 없습니다.'); return; }

        let links = [], ok=0, fail=0;

        for(const f of fileTargets){
           try{
             const meta = await apiGet(f.id);
             if(meta.web_content_link){
                if(action==='download'){
                   const a=document.createElement('a');
                   a.href=meta.web_content_link; a.target='_blank';
                   document.body.appendChild(a); a.click(); a.remove();
                   ok++; await sleep(150);
                } else {
                   links.push(meta.web_content_link); ok++;
                }
             } else fail++;
           } catch { fail++; }
        }

        if(action==='copy'){
           if(links.length > 0){ GM_setClipboard(links.join('\n')); toast(`${links.length}개 링크 복사 완료`); }
           else toast('복사 실패');
        } else {
           toast(`완료: ${ok}건 시작`);
           if(ok>0) elCancel.click();
        }
      } catch(e) { toast('오류: ' + e.message); }
      finally { btn.disabled=false; btn.textContent = originText; }
    }

    elGo.onclick = () => processSelection('download');
    elCopy.onclick = () => processSelection('copy');

    async function expandSelection(ids){
      const out=[]; const q=[...ids]; const cache=new Map();
      state.items.forEach(f=>cache.set(f.id, f));
      while(q.length){
        const id=q.shift(); if(id === '..') continue;
        let f = cache.get(id);
        if(!f){ try{ f=await apiGet(id); }catch{} }
        if(!f) continue;
        if(f.kind === 'drive#folder'){
           try{
             const sub = await apiList(f.id);
             sub.forEach(sf => { if(sf.kind === 'drive#folder') q.push(sf.id); else out.push(sf); });
           }catch{}
        } else out.push(f);
      }
      return out;
    }

    loadAndRender();
  }

  /* ========= 부가 기능 (ID 복사 등) ========= */
  function addRowTools(li){
    if(li.dataset._ppkEnh) return; li.dataset._ppkEnh='1';
    const id=li.id;
    const nameSpan=li.querySelector('.name span, .file-name span');
    if(!nameSpan||!id) return;
    const base=(nameSpan.innerText||'').trim().replace(/\.[^/.]+$/,'');

    const mk=(txt,fn)=>{
       const b=document.createElement('button'); b.textContent=txt;
       Object.assign(b.style,{
           border:'1px solid #e2e8f0', borderRadius:'6px', margin:'0 3px',
           cursor:'pointer', fontSize:'11px', background:'#fff', color:'#64748b', padding:'2px 6px'
       });
       b.onmouseenter=()=>b.style.borderColor='#cbd5e1';
       b.onclick=fn; return b;
    };
    const wrap=document.createElement('span'); wrap.style.marginLeft='8px';
    wrap.append(
      mk('🆔', ()=>{GM_setClipboard(id); toast('ID 복사됨');}),
      mk('🔍', ()=>{window.open(`https://www.google.com/search?q=${encodeURIComponent(base)}`,'_blank');})
    );
    nameSpan.parentNode.appendChild(wrap);
  }

  (async function boot(){
    for(let i=0;i<60;i++){ if(await injectWebBtn()) break; await sleep(300); }
    new MutationObserver(injectWebBtn).observe(document.body,{childList:true,subtree:true});
    createFloatingWebBtn();
    setInterval(()=>document.querySelectorAll('li.row').forEach(addRowTools), 1000);
  })();
})();