SnapScore Automater

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

当前为 2025-09-27 提交的版本,查看 最新版本

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

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

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

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

您需要先安装一款用户脚本管理器扩展,例如 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 };

})();