SP REPORT 2025

Shopee affiliate export: pick dates → export CSV → save to Google Drive; also save task URL for manual retry/open/clear. Skips upload if same-name file exists.

// ==UserScript==
// @name         SP REPORT 2025
// @namespace    https://markg.dev/userscripts/sp-income-drive
// @version      3.1.1
// @description  Shopee affiliate export: pick dates → export CSV → save to Google Drive; also save task URL for manual retry/open/clear. Skips upload if same-name file exists.
// @author       Mark
// @match        https://affiliate.shopee.ph/*
// @run-at       document-end
// @grant        GM_xmlhttpRequest
// @connect      affiliate.shopee.ph
// @connect      script.google.com
// @connect      script.googleusercontent.com
// ==/UserScript==

(function () {
  'use strict';

  /*** ====== CONFIG (SET THIS) ====== ***/
  const GAS_UPLOAD_URL   = 'https://script.google.com/macros/s/AKfycbwCfUTQqTQwu163I4RMDAUdGhnu268SdYSrP1XFhUei5RWhvFGel_1Kt6nVxy6tUzxC/exec';
  const DRIVE_FOLDER_ID  = '10j-BcwK46uKw6IRYrao7Kt2U-46DMXCT';
  const SKIP_IF_EXISTS   = true; // <-- Do not save if a file with the same name already exists

  /*** ====== CONSTS / STORAGE ====== ***/
  const LS_KEY_SAVED = 'sp_income_last_export_v1';
  const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];

  // Sanitize filename
  function sanitizeBaseName(name) {
    name = (name || '').split(/\r?\n/)[0];
    name = name.replace(/[\\/:*?"<>|]+/g, '_');
    name = name.replace(/\s+/g, '_');
    name = name.replace(/_+/g, '_');
    name = name.replace(/^_+|_+$/g, '');
    if (!name) name = 'export.csv';
    if (!/\.csv$/i.test(name)) name += '.csv';
    return name;
  }

  /*** ====== DATE HELPERS ====== ***/
  function yyyyMmDd(d) {
    const y=d.getFullYear(), m=String(d.getMonth()+1).padStart(2,'0'), dd=String(d.getDate()).padStart(2,'0');
    return `${y}-${m}-${dd}`;
  }
  function getYesterdayISO(){ const d=new Date(); d.setDate(d.getDate()-1); return yyyyMmDd(d); }
  function dayStartUnix(iso){ return Math.floor(new Date(iso+'T00:00:00').getTime()/1000); }
  function dayEndUnix(iso){ return Math.floor(new Date(iso+'T23:59:59').getTime()/1000); }
  function formatFilename(username, startISO, endISO, extHint){
    const s=new Date(startISO), e=new Date(endISO);
    const same = s.getFullYear()===e.getFullYear() && s.getMonth()===e.getMonth() && s.getDate()===e.getDate();
    const single = `${months[s.getMonth()]}_${s.getDate()}_${s.getFullYear()}`;
    const range  = `${months[s.getMonth()]}_${s.getDate()}_${e.getDate()}_${e.getFullYear()}`;
    return `${username}_${same?single:range}.${extHint||'csv'}`;
  }

  /*** ====== UI HELPER ====== ***/
  function logLine(out, msg){ out.style.display='block'; out.textContent+=(out.textContent?'\n':'')+msg; }

  /*** ====== CORE API ====== ***/
  function fetchShopeeUsername(){
    return new Promise((resolve,reject)=>{
      GM_xmlhttpRequest({
        method:'GET',
        url:'https://affiliate.shopee.ph/api/v3/user/profile',
        onload:(res)=>{
          try{
            const j=JSON.parse(res.responseText);
            const name=j?.data?.shopee_user_name || j?.data?.username;
            if(name) resolve(name); else reject(new Error('shopee_user_name not found'));
          }catch(e){reject(e);}
        },
        onerror:reject
      });
    });
  }

  function triggerExport(startISO, endISO){
    const s=dayStartUnix(startISO), e=dayEndUnix(endISO);
    const url=`https://affiliate.shopee.ph/api/v1/report/download?page_size=20&page_num=1&purchase_time_s=${s}&purchase_time_e=${e}&version=1`;
    return new Promise((resolve,reject)=>{
      GM_xmlhttpRequest({
        method:'GET', url,
        onload:(res)=>{
          try{
            const j=JSON.parse(res.responseText);
            const taskId=j?.data?.task_id;
            if(!taskId) return reject(new Error('No task_id in response'));
            resolve({taskId, startISO, endISO});
          }catch(e){reject(e);}
        },
        onerror:reject
      });
    });
  }

  function sleep(ms){ return new Promise(r=>setTimeout(r,ms)); }

  async function downloadWhenReady(taskId, out, opts={}){
    const maxAttempts = opts.maxAttempts ?? 30;
    const intervalMs  = opts.intervalMs  ?? 3000;
    for(let i=1; i<=maxAttempts; i++){
      logLine(out, `Checking export status (attempt ${i}/${maxAttempts})...`);
      const blob = await tryDownload(taskId);
      if (blob) return blob;
      await sleep(intervalMs);
    }
    throw new Error('File not ready after multiple attempts.');
  }

  function tryDownload(taskId){
    const url=`https://affiliate.shopee.ph/api/v1/export/download?task_id=${encodeURIComponent(taskId)}`;
    return new Promise((resolve)=>{
      GM_xmlhttpRequest({
        method:'GET',
        url,
        responseType:'arraybuffer',
        onload:(res)=>{
          const headers=res.responseHeaders||'';
          const ctMatch=headers.match(/content-type:\s*([^\r\n]+)/i);
          const dispMatch=headers.match(/content-disposition:\s*attachment/ig);
          const contentType=(ctMatch?ctMatch[1]:'').trim().toLowerCase();
          const isDownloadish = !!dispMatch || /text\/csv|application\/octet-stream/i.test(contentType);
          const ok = res.status===200 && res.response && res.response.byteLength>128 && isDownloadish;

          if(ok){
            let ext='csv';
            const nameFromDisp=headers.match(/filename="?([^"]+)"?/i);
            if(nameFromDisp && nameFromDisp[1]){
              const nm=nameFromDisp[1].trim();
              const dot=nm.lastIndexOf('.');
              if(dot>-1) ext=nm.slice(dot+1).toLowerCase();
            }
            const blob=new Blob([res.response], {type: contentType || 'text/csv;charset=utf-8'});
            blob.__extHint=ext;
            blob.__sourceURL=url;
            resolve(blob);
          }else{
            resolve(null);
          }
        },
        onerror:()=>resolve(null)
      });
    });
  }

  async function blobToBase64(blob){
    const buf=await blob.arrayBuffer();
    let binary=''; const bytes=new Uint8Array(buf);
    for(let i=0;i<bytes.length;i++) binary+=String.fromCharCode(bytes[i]);
    return btoa(binary);
  }

  // === UPDATED: upload includes skipIfExists and handles {exists:true} ===
  async function uploadToDriveViaGAS(fileBlob, filename, out){
    const safeMime = 'text/csv';
    const safeName = sanitizeBaseName(filename);
    const base64 = await blobToBase64(fileBlob);

    const payload = {
      folderId: DRIVE_FOLDER_ID,
      filename: safeName,
      mimeType: safeMime,
      base64: base64,
      skipIfExists: SKIP_IF_EXISTS
    };

    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'POST',
        url: GAS_UPLOAD_URL,
        headers: { 'Content-Type': 'application/json' },
        data: JSON.stringify(payload),
        onload: (res) => {
          const text = res.responseText || '';
          try {
            const j = JSON.parse(text);

            if (j.exists) {
              logLine(out, `Skipped: file already exists (${j.fileId}).`);
              resolve(j);
              return;
            }

            if (j.ok) {
              logLine(out, `Uploaded to Drive ✓ File ID: ${j.fileId}`);
              resolve(j);
              return;
            }

            logLine(out, `GAS error: ${j.error || 'Unknown error'}`);
            reject(new Error(j.error || 'GAS upload failed'));
          } catch {
            const preview = text.slice(0, 200).replace(/\s+/g, ' ');
            logLine(out, `GAS response parse error. HTTP ${res.status}. Preview: ${preview}`);
            reject(new Error('GAS returned non-JSON.'));
          }
        },
        onerror: (e) => {
          logLine(out, 'Network error calling GAS.');
          reject(e);
        }
      });
    });
  }

  /*** ====== SAVE / LOAD LAST TASK ====== ***/
  function saveLastExport(obj){
    localStorage.setItem(LS_KEY_SAVED, JSON.stringify(obj));
  }
  function loadLastExport(){
    try{ return JSON.parse(localStorage.getItem(LS_KEY_SAVED)||'null'); }catch(_){ return null; }
  }
  function clearLastExport(){
    localStorage.removeItem(LS_KEY_SAVED);
  }

function createGUI(){
  const wrap=document.createElement('div');
  Object.assign(wrap.style,{
    position:'fixed', right:'10px', bottom:'10px', zIndex:999999,
    background:'#fff', border:'1px solid #ddd', borderRadius:'10px', // <- fixed quotes
    padding:'12px', width:'340px', fontFamily:'Inter, Arial, sans-serif',
    boxShadow:'0 6px 20px rgba(0,0,0,0.15)'
  });

  const toggle=document.createElement('button');
  toggle.textContent='HIDE';
  Object.assign(toggle.style,{width:'100%',marginBottom:'8px',padding:'8px',cursor:'pointer'});

  // Date pickers
  const row1=document.createElement('div'); row1.style.marginBottom='8px';
  const row2=document.createElement('div'); row2.style.marginBottom='8px';
  const startLbl=document.createElement('label'); startLbl.textContent='Start date: ';
  const endLbl=document.createElement('label');   endLbl.textContent='End date: ';
  const startInput=document.createElement('input'); startInput.type='date'; startInput.value=getYesterdayISO();
  const endInput=document.createElement('input');   endInput.type='date';   endInput.value=getYesterdayISO();
  startInput.style.margin='4px 0'; endInput.style.margin='4px 0';

  // Action button
  const goBtn=document.createElement('button');
  goBtn.textContent='EXPORT → DRIVE';
  Object.assign(goBtn.style,{width:'100%',padding:'10px',cursor:'pointer',background:'#0d6efd',color:'#fff',border:'none',borderRadius:'6px'});

  // Saved export panel
  const savedBox=document.createElement('div');
  Object.assign(savedBox.style,{borderTop:'1px solid #eee',marginTop:'10px',paddingTop:'10px'});

  const savedTitle=document.createElement('div');
  savedTitle.textContent='Saved export (manual options)';
  savedTitle.style.fontWeight='600';
  savedTitle.style.marginBottom='6px';

  const savedInfo=document.createElement('div');
  savedInfo.style.fontSize='12px';
  savedInfo.style.marginBottom='6px';

  const linkOpen=document.createElement('a');
  linkOpen.href='#';
  linkOpen.textContent='Open download URL';
  linkOpen.style.display='inline-block';
  linkOpen.style.marginRight='8px';

  const btnManualReDownload=document.createElement('button');
  btnManualReDownload.textContent='Manual Download → Drive';
  Object.assign(btnManualReDownload.style,{padding:'6px 8px',marginRight:'8px',cursor:'pointer'});

  const btnClear=document.createElement('button');
  btnClear.textContent='Clear Saved';
  Object.assign(btnClear.style,{padding:'6px 8px',cursor:'pointer'});

  // Output area
  const out=document.createElement('pre');
  Object.assign(out.style,{display:'none',background:'#f6f8fa',border:'1px solid #eee',padding:'8px',maxHeight:'260px',overflow:'auto',borderRadius:'6px',fontSize:'12px',marginTop:'10px'});

  // Toggle behavior
  toggle.addEventListener('click', ()=>{
    const hidden = startInput.style.display==='none';
    for(const el of [startInput,endInput,startLbl,endLbl,goBtn,savedBox,out]){
      el.style.display = hidden ? (el===out && !out.textContent ? 'none' : 'block') : 'none';
    }
    startLbl.style.display = hidden ? 'inline' : 'none';
    endLbl.style.display   = hidden ? 'inline' : 'none';
    startInput.style.display = hidden ? 'inline-block' : 'none';
    endInput .style.display = hidden ? 'inline-block' : 'none';
    toggle.textContent = hidden ? 'HIDE' : 'SHOW';
  });

  // Layout (no re-declarations here)
  row1.appendChild(startLbl); row1.appendChild(startInput);
  row2.appendChild(endLbl);   row2.appendChild(endInput);
  wrap.appendChild(toggle);
  wrap.appendChild(row1);
  wrap.appendChild(row2);
  wrap.appendChild(goBtn);

  // Saved panel + output
  savedBox.appendChild(savedTitle);
  savedBox.appendChild(savedInfo);
  savedBox.appendChild(linkOpen);
  savedBox.appendChild(btnManualReDownload);
  savedBox.appendChild(btnClear);
  wrap.appendChild(savedBox);
  wrap.appendChild(out);
  document.body.appendChild(wrap);

  // Render saved
  function renderSaved(){
    const saved=loadLastExport();
    if(!saved){
      savedInfo.textContent='(none)';
      linkOpen.style.pointerEvents='none';
      linkOpen.style.opacity='0.5';
      btnManualReDownload.disabled=true;
      btnClear.disabled=true;
      return;
    }
    const {username,startISO,endISO,taskId,downloadURL}=saved;
    savedInfo.textContent=`User: ${username} | ${startISO} → ${endISO} | task_id: ${taskId}`;
    linkOpen.href = downloadURL || `https://affiliate.shopee.ph/api/v1/export/download?task_id=${encodeURIComponent(taskId)}`;
    linkOpen.style.pointerEvents='auto';
    linkOpen.style.opacity='1';
    btnManualReDownload.disabled=false;
    btnClear.disabled=false;
  }
  renderSaved();

  // Main export flow (unchanged; uses uploadToDriveViaGAS which already has skip-if-exists)
  // ... keep the rest of your listeners below ...
  linkOpen.addEventListener('click', ()=>{ linkOpen.target='_blank'; });

  btnManualReDownload.addEventListener('click', async ()=>{
    out.textContent='';
    const saved=loadLastExport();
    if(!saved){ logLine(out,'No saved export to download.'); return; }
    const {username,startISO,endISO,taskId}=saved;
    try{
      logLine(out,`Retrying download for task_id ${taskId}…`);
      const blob=await downloadWhenReady(taskId,out, {maxAttempts: 60, intervalMs: 3000});
      logLine(out,`File ready (${(blob.size/1024).toFixed(1)} KB)`);

      const rawName = formatFilename(username, startISO, endISO, blob.__extHint);
      const safeName = sanitizeBaseName(rawName);
      logLine(out, `Uploading to Drive as: ${safeName} ${SKIP_IF_EXISTS ? '(skip-if-exists ON)' : ''}`);
      await uploadToDriveViaGAS(blob, safeName, out);

      logLine(out,'Done ✓');
    }catch(err){
      logLine(out,'ERROR: '+(err?.message||String(err)));
    }
  });

  btnClear.addEventListener('click', ()=>{
    clearLastExport();
    renderSaved();
    out.textContent='';
    logLine(out,'Cleared saved export.');
  });

  // Hook up main button after everything is mounted
  goBtn.addEventListener('click', async ()=>{
    out.textContent='';
    try{
      const startISO=startInput.value, endISO=endInput.value;
      if(!startISO || !endISO){ logLine(out,'Please pick both start and end dates.'); return; }

      logLine(out,'Getting Shopee username…');
      const username=await fetchShopeeUsername();
      logLine(out,`Username: ${username}`);

      logLine(out,'Requesting export task…');
      const {taskId}=await triggerExport(startISO,endISO);
      logLine(out,`task_id: ${taskId}`);

      const downloadURL=`https://affiliate.shopee.ph/api/v1/export/download?task_id=${encodeURIComponent(taskId)}`;
      saveLastExport({username,startISO,endISO,taskId,downloadURL});
      renderSaved();

      logLine(out,'Waiting for file to be ready…');
      const blob=await downloadWhenReady(taskId,out);
      logLine(out,`File ready (${(blob.size/1024).toFixed(1)} KB)`);

      const rawName = formatFilename(username, startISO, endISO, blob.__extHint);
      const safeName = sanitizeBaseName(rawName);
      logLine(out, `Uploading to Drive as: ${safeName} ${SKIP_IF_EXISTS ? '(skip-if-exists ON)' : ''}`);
      await uploadToDriveViaGAS(blob, safeName, out);

      logLine(out,'Done ✓');
    }catch(err){
      logLine(out,'ERROR: '+(err?.message||String(err)));
      console.error(err);
    }
  });
}


  /*** ====== INIT ====== ***/
  if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', createGUI); }
  else{ createGUI(); }
})();