SnapScore Automater

Multi-step auto-snap (Cam, Picture, Send, Final). Drag/upload profiles. Delay applies between every step. X = emergency stop.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         SnapScore Automater
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  Multi-step auto-snap (Cam, Picture, Send, Final). Drag/upload profiles. Delay applies between every step. X = emergency stop.
// @match        https://www.snapchat.com/web*
// @match        https://web.snapchat.com/*
// @grant        none
// @license      Unlicense
// ==/UserScript==

(function(){
  'use strict';

  /* ---------- UI ---------- */
  const overlay = document.createElement('div');
  Object.assign(overlay.style, {
    position:'fixed', top:'12px', right:'12px', zIndex:2147483647,
    background:'rgba(0,0,0,0.80)', color:'#fff', padding:'10px',
    borderRadius:'8px', fontFamily:'Inter, Arial, sans-serif', fontSize:'13px', minWidth:'460px',
    boxShadow:'0 6px 24px rgba(0,0,0,0.5)'
  });
  overlay.innerHTML = `
    <div style="display:flex;justify-content:space-between;align-items:center">
      <strong>Snap AutoClick</strong>
      <div style="display:flex;gap:6px;align-items:center">
        <button id="ac-close" title="Remove overlay" style="background:transparent;border:0;color:#fff;cursor:pointer">✕</button>
      </div>
    </div>

    <div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap">
      <button class="ac-set-btn" data-type="cam">Cam ON</button>
      <button class="ac-set-btn" data-type="picture">Picture</button>
      <button class="ac-set-btn" data-type="send">Send</button>
      <button class="ac-set-btn" data-type="final">Final Send</button>
    </div>

    <div style="margin-top:8px;display:flex;gap:8px;align-items:center">
      <label style="font-size:12px">Delay (s):</label>
      <input id="ac-delay" type="text" value="0.2" style="width:70px;padding:4px;border-radius:4px;border:none"/>
      <button id="ac-start">Start</button>
      <button id="ac-stop">Stop</button>
      <button id="ac-clear">Clear All</button>
      <button id="ac-upload">Upload</button>
    </div>

    <div style="margin-top:8px;font-size:12px;color:#ddd">Profiles (drag images here or use Upload):</div>
    <div id="ac-drop" style="border:1px dashed #888;min-height:80px;padding:6px;margin-top:6px;display:flex;gap:6px;flex-wrap:wrap;align-items:flex-start;overflow:auto;max-height:160px;"></div>

    <div id="ac-preview" style="margin-top:8px;font-size:12px;color:#9f9;max-height:80px;overflow:auto;border:1px solid #555;padding:6px">Preview: <i>none</i></div>
    <div id="ac-status" style="margin-top:8px;font-size:12px;color:#9f9">Status: idle</div>

    <div style="margin-top:8px;font-size:11px;opacity:0.85">
      Notes: X = emergency stop. Click a Set button then click the page element to save it. Script ignores left 1/3 of screen.
    </div>
  `;
  document.documentElement.appendChild(overlay);

  /* ---------- elements ---------- */
  const dropEl = document.getElementById('ac-drop');
  const startBtn = document.getElementById('ac-start');
  const stopBtn = document.getElementById('ac-stop');
  const clearBtn = document.getElementById('ac-clear');
  const uploadBtn = document.getElementById('ac-upload');
  const delayInput = document.getElementById('ac-delay');
  const statusEl = document.getElementById('ac-status');
  const previewEl = document.getElementById('ac-preview');

  /* ---------- state ---------- */
  // setButtons: each is { sel: string|null, el: Element|null }
  const setButtons = { cam:{sel:null,el:null}, picture:{sel:null,el:null}, send:{sel:null,el:null}, final:{sel:null,el:null} };
  let captureMode = null;
  let running = false;
  let emergency = false;
  // profiles
  let profileList = []; // {id, type:'data'|'url', src, name, w?, h?, enabled:true}
  let profileIdCounter = 1;

  /* ---------- utilities & safety fixes ---------- */
  function buildSelector(el){
    if(!el || el.nodeType !== 1) return null;
    if(el.id) return `#${CSS.escape(el.id)}`;
    const parts = [];
    let node = el;
    while(node && node.nodeType === 1 && node.tagName.toLowerCase() !== 'html'){
      let part = node.tagName.toLowerCase();
      if(node.className && typeof node.className === 'string'){
        const cls = node.className.split(/\s+/).filter(Boolean)[0];
        if(cls) part += `.${CSS.escape(cls)}`;
      }
      const parent = node.parentElement;
      if(parent){
        const siblings = Array.from(parent.children).filter(ch => ch.tagName === node.tagName);
        if(siblings.length > 1) part += `:nth-child(${Array.from(parent.children).indexOf(node)+1})`;
      }
      parts.unshift(part);
      node = node.parentElement;
      if(parts.length > 8) break;
    }
    return parts.length ? parts.join(' > ') : null;
  }

  function safeQuery(sel){
    try { return sel ? document.querySelector(sel) : null; } catch(e){ return null; }
  }

  function updateStatus(msg){
    if(msg) statusEl.textContent = msg;
    else statusEl.textContent = running ? `Running (delay ${delayInput.value}s)` : 'Status: idle';
    highlightSetButtons();
  }

  function highlightSetButtons(){
    document.querySelectorAll('.ac-set-btn').forEach(btn=>{
      const t = btn.dataset.type;
      const s = setButtons[t];
      if(s && (s.sel || s.el)){
        btn.style.border = '2px solid #0f0';
        btn.style.boxShadow = '0 0 6px rgba(0,255,0,0.12)';
      } else {
        btn.style.border = '1px solid #888';
        btn.style.boxShadow = 'none';
      }
    });
  }

  function updatePreview(){
    previewEl.innerHTML = profileList.length ? ('Profiles: ' + profileList.filter(p=>p.enabled).map(p=>p.name).join(', ') + (profileList.filter(p=>!p.enabled).length? ' <span style="color:#f88">(some disabled)</span>':'') ) : 'Preview: <i>none</i>';
  }

  function minDelayMs(){ return 20; } // minimum 20ms for fast but realistic timing

  function getDelayMs(){
    let d = parseFloat(delayInput.value);
    if(isNaN(d) || d < 0) d = 0.02;
    const ms = Math.round(d * 1000);
    return Math.max(ms, minDelayMs());
  }

  /* ---------- profile UI and drag/drop ---------- */
  function makeProfileCard(profile){
    const div = document.createElement('div');
    Object.assign(div.style, {display:'flex', flexDirection:'column', alignItems:'center', gap:'4px', width:'96px', padding:'6px', background:'rgba(255,255,255,0.03)', borderRadius:'6px'});
    const img = document.createElement('img'); img.src = profile.src; img.style.width='48px'; img.style.height='48px'; img.style.objectFit='cover'; img.style.borderRadius='6px';
    const label = document.createElement('div'); label.textContent = profile.name; label.style.fontSize='11px'; label.style.width='100%'; label.style.whiteSpace='nowrap'; label.style.overflow='hidden'; label.style.textOverflow='ellipsis'; label.style.color='#fff';
    const row = document.createElement('div'); row.style.display='flex'; row.style.gap='6px'; row.style.alignItems='center';
    const checkbox = document.createElement('input'); checkbox.type='checkbox'; checkbox.checked = !!profile.enabled; checkbox.addEventListener('change', ()=>{ profile.enabled = checkbox.checked; updatePreview(); });
    const del = document.createElement('button'); del.textContent='X'; Object.assign(del.style,{background:'#900', color:'#fff', border:'0', padding:'2px 6px', borderRadius:'6px', cursor:'pointer'}); del.addEventListener('click', ()=>{ profileList = profileList.filter(p=>p.id!==profile.id); renderProfiles(); updatePreview(); });
    row.appendChild(checkbox); row.appendChild(del);
    div.appendChild(img); div.appendChild(label); div.appendChild(row);
    return div;
  }

  function renderProfiles(){
    dropEl.innerHTML = '';
    profileList.forEach(p => dropEl.appendChild(makeProfileCard(p)));
  }

  // drag/drop handlers
  dropEl.addEventListener('dragover', e => { e.preventDefault(); dropEl.style.borderColor='#fff'; });
  dropEl.addEventListener('dragleave', e => { dropEl.style.borderColor='#888'; });
  dropEl.addEventListener('drop', e => {
    e.preventDefault(); dropEl.style.borderColor='#888';
    // files
    if(e.dataTransfer.files && e.dataTransfer.files.length > 0){
      Array.from(e.dataTransfer.files).forEach(file => {
        if(!file.type.startsWith('image/')) return;
        const reader = new FileReader();
        reader.onload = ev => {
          const src = ev.target.result;
          const id = profileIdCounter++;
          const profile = { id, type:'data', src, name: file.name, enabled:true };
          const img = new Image();
          img.onload = () => { profile.w = img.naturalWidth; profile.h = img.naturalHeight; profileList.push(profile); renderProfiles(); updatePreview(); };
          img.src = src;
        };
        reader.readAsDataURL(file);
      });
    }
    // dragged-from-page URL fallback
    const url = e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('text/plain');
    if(url){
      const first = url.split('\n')[0].trim();
      if(first){
        const id = profileIdCounter++;
        const profile = { id, type:'url', src:first, name: first.split('/').pop()||'dragged-image', enabled:true };
        const img = new Image(); img.crossOrigin='anonymous';
        img.onload = () => { profile.w = img.naturalWidth; profile.h = img.naturalHeight; profileList.push(profile); renderProfiles(); updatePreview(); };
        img.onerror = () => { profileList.push(profile); renderProfiles(); updatePreview(); };
        img.src = first;
      }
    }
  });

  // explicit Upload button (prevents accidental file opens)
  uploadBtn.addEventListener('click', () => {
    const inp = document.createElement('input');
    inp.type = 'file'; inp.accept = 'image/*'; inp.multiple = true;
    inp.onchange = () => {
      Array.from(inp.files || []).forEach(file => {
        if(!file.type.startsWith('image/')) return;
        const reader = new FileReader();
        reader.onload = ev => {
          const src = ev.target.result;
          const id = profileIdCounter++;
          const profile = { id, type:'data', src, name:file.name, enabled:true };
          const img = new Image(); img.onload = () => { profile.w = img.naturalWidth; profile.h = img.naturalHeight; profileList.push(profile); renderProfiles(); updatePreview(); };
          img.src = src;
        };
        reader.readAsDataURL(file);
      });
    };
    inp.click();
  });

  /* ---------- click helpers: ignore left 1/3, find smallest clickable ---------- */
  function inRightArea(rect){ return rect.left >= (window.innerWidth / 3); }

  function findSmallestClickableWithin(el){
    if(!el) return null;
    const candidates = [el].concat(Array.from(el.querySelectorAll('button, a, [role="button"], [onclick], img')));
    let best = null; let bestArea = Infinity;
    candidates.forEach(c => {
      try{
        const r = c.getBoundingClientRect();
        if(r.width <= 0 || r.height <= 0) return;
        if(!inRightArea(r)) return;
        const area = r.width * r.height;
        if(area > 0 && area < bestArea) { bestArea = area; best = c; }
      }catch(e){}
    });
    return best;
  }

  function clickElement(el){
    if(!el) return false;
    const target = findSmallestClickableWithin(el) || el;
    if(!target) return false;
    const rect = target.getBoundingClientRect();
    if(!inRightArea(rect)) return false;
    const props = { bubbles:true, cancelable:true, view:window, clientX: Math.round(rect.left + rect.width/2), clientY: Math.round(rect.top + rect.height/2) };
    try{
      target.dispatchEvent(new PointerEvent('pointerdown', props));
      target.dispatchEvent(new PointerEvent('pointerup', props));
      target.dispatchEvent(new MouseEvent('click', props));
      // flash highlight (safe restore)
      const prev = target.style.boxShadow;
      try{ target.style.boxShadow = '0 0 0 4px rgba(0,255,0,0.25)'; }catch(e){}
      setTimeout(()=>{ try{ target.style.boxShadow = prev; }catch(e){} }, 120);
      return true;
    }catch(e){ console.warn('click failed', e); return false; }
  }

  /* ---------- profile matching ---------- */
  function lastSegment(url){
    try{ return (new URL(url)).pathname.split('/').filter(Boolean).pop() || url; }catch(e){ return url.split('/').pop() || url; }
  }

  function findMatchingThumbnail(profile){
    const allImgs = Array.from(document.querySelectorAll('img'));
    const candidates = allImgs.filter(img=>{
      try{
        const r = img.getBoundingClientRect();
        if(r.width <= 0 || r.height <= 0) return false;
        if(!inRightArea(r)) return false;
        if(r.width <= 48 && r.height <= 48) return true;
        return false;
      }catch(e){ return false; }
    });
    if(candidates.length === 0) return null;

    let best = null; let bestScore = Infinity;
    candidates.forEach(img=>{
      try{
        const src = (img.currentSrc || img.src || '').toString();
        const r = img.getBoundingClientRect();
        const area = r.width * r.height;
        let score = 1000;
        if(profile.src && src === profile.src) score = 0;
        else if(profile.src && lastSegment(src) && lastSegment(profile.src) && lastSegment(src) === lastSegment(profile.src)) score = 10;
        if(profile.w && profile.h){
          const w = img.naturalWidth || img.width || r.width;
          const h = img.naturalHeight || img.height || r.height;
          score += Math.abs((w||0) - profile.w) + Math.abs((h||0) - profile.h);
        }
        score += area / 100;
        if(profile.type === 'data' && src.startsWith('data:')) score -= 5;
        if(score < bestScore){ bestScore = score; best = img; }
      }catch(e){}
    });
    return best;
  }

  /* ---------- capture logic for set buttons ---------- */
  document.querySelectorAll('.ac-set-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      captureMode = btn.dataset.type;
      updateStatus(`Capture: click page element for "${captureMode}"`);
      const handler = e => {
        if(e.composedPath && e.composedPath().includes(overlay)) return;
        e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
        const el = e.target;
        const sel = buildSelector(el);
        setButtons[captureMode] = { sel, el };
        captureMode = null;
        document.removeEventListener('click', handler, true);
        updateStatus();
      };
      document.addEventListener('click', handler, true);
    });
  });

  /* ---------- runner (async, respects delay for every step) ---------- */
  async function runSequenceOnce(delayMs){
    // 1) main buttons
    for(const k of ['cam','picture','send']){
      if(emergency) return false;
      const s = setButtons[k];
      const el = s && (s.sel ? safeQuery(s.sel) : s.el) || null;
      if(el) clickElement(el);
      await sleep(delayMs);
    }

    // 2) profiles in order
    for(const p of profileList){
      if(emergency) return false;
      if(!p.enabled) continue;
      const match = findMatchingThumbnail(p);
      if(match) clickElement(match);
      await sleep(delayMs);
    }

    // 3) final
    if(setButtons.final && (setButtons.final.sel || setButtons.final.el)){
      const sf = setButtons.final;
      const fe = sf.sel ? safeQuery(sf.sel) : sf.el;
      if(fe) clickElement(fe);
      await sleep(delayMs);
    }
    return true;
  }

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

  async function startRunner(){
    if(running) return;
    running = true;
    emergency = false;
    updateStatus();
    const delayMs = getDelayMs();
    // loop until stopped or emergency
    while(running && !emergency){
      const ok = await runSequenceOnce(delayMs);
      if(!ok) break;
      // short micro-yield between sequences to avoid lock, but main pacing is delayMs inside
      await sleep(2);
    }
    running = false;
    updateStatus();
  }

  function stopRunner(){
    running = false;
  }

  /* ---------- UI wiring ---------- */
  startBtn.addEventListener('click', () => {
    const anyBtn = Object.values(setButtons).some(s => s && (s.sel || s.el));
    if(!anyBtn && profileList.length===0){ updateStatus('Set a button or add profiles first'); setTimeout(()=>updateStatus(), 1200); return; }
    startRunner();
  });

  stopBtn.addEventListener('click', ()=>{ emergency = true; stopRunner(); updateStatus('Stopped'); setTimeout(()=>updateStatus(), 400); });

  clearBtn.addEventListener('click', ()=>{
    Object.keys(setButtons).forEach(k => setButtons[k] = { sel:null, el:null });
    profileList = [];
    profileIdCounter = 1;
    renderProfiles();
    updatePreview();
    updateStatus('Cleared');
    setTimeout(()=>updateStatus(), 800);
  });

  document.getElementById('ac-close').addEventListener('click', ()=>{ emergency=true; stopRunner(); overlay.remove(); });

  document.addEventListener('keydown', e => {
    if(e.key && e.key.toLowerCase() === 'x'){ emergency = true; stopRunner(); updateStatus('EMERGENCY STOP'); setTimeout(()=>{ emergency=false; updateStatus(); }, 700); }
  });

  /* ---------- init ---------- */
  renderProfiles();
  updatePreview();
  updateStatus();

  // debugging handle
  window.__SnapAutoClick = { startRunner, stopRunner, profileList, setButtons };

})();