YouTube Subscription Manager (ES)

Cancelar suscripciones individuales o en lote en /feed/channels. Muestra resumen y permite descargar un .md con tabla.

// ==UserScript==
// @name         YouTube Subscription Manager (ES)
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @namespace    http://tampermonkey.net/
// @version      2.2-es
// @description  Cancelar suscripciones individuales o en lote en /feed/channels. Muestra resumen y permite descargar un .md con tabla.
// @author       Baarometh
// @match        https://www.youtube.com/feed/channels
// @grant        none
// @license      GNU GPLv3
// ==/UserScript==

(function () {
  'use strict';

  /* ===== utilidades ===== */
  const esperar = (ms) => new Promise(r => setTimeout(r, ms));
  const normalizar = (s) => (s||'').toString().normalize('NFD').replace(/[\u0300-\u036f]/g,'').trim().toLowerCase();
  const click = (el) => el && el.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true,buttons:1}));

  // limpia celdas para Markdown: sin saltos y con pipes escapados
  const limpiarCeldaMD = (s) => String(s ?? '')
    .replace(/\r?\n|\r/g, ' ')
    .replace(/\s+/g, ' ')
    .replace(/\|/g, '\\|')
    .trim();

  /* ===== vocabulario ===== */
  const txtDesuscribir = ['unsubscribe','anular','anular suscripcion','cancelar suscripcion','darse de baja','quitar suscripcion'].map(normalizar);
  const txtConfirmar   = ['unsubscribe','anular suscripcion','anular','confirmar','aceptar'].map(normalizar);

  /* ===== selectores ===== */
  const sel = {
    campana: '#notification-preference-button button, #notification-preference-button yt-icon-button button',
    suscrito: 'ytd-subscribe-button-renderer button, #subscribe-button button',
    confirmFijo: '#confirm-button button',
    menusAbiertos: 'tp-yt-iron-dropdown[opened], ytd-menu-popup-renderer[slot="dropdown-content"]',
    itemsMenu: 'tp-yt-paper-item, ytd-menu-service-item-renderer, tp-yt-paper-listbox tp-yt-paper-item',
    acciones: '#buttons',
    tarjeta: 'ytd-channel-renderer',
    raiz: 'ytd-page-manager'
  };

  /* ===== estado ===== */
  const eventos = [];  // {nombre, url, fechaIso, fechaLocal, estado}

  /* ===== helpers DOM ===== */
  const menuAbierto = () => {
    const m = Array.from(document.querySelectorAll(sel.menusAbiertos));
    return m.length ? m[m.length - 1] : document;
  };

  const buscarOpcionDesuscribir = (raiz) => {
    const items = Array.from(raiz.querySelectorAll(sel.itemsMenu));
    for (const it of items) {
      const lab = it.querySelector('yt-formatted-string, .label') || it;
      if (txtDesuscribir.includes(normalizar(lab.textContent))) return it;
    }
    return null;
  };

  const botonConfirmar = () => {
    const fijo = document.querySelector(sel.confirmFijo);
    if (fijo) return fijo;
    const cand = Array.from(document.querySelectorAll('tp-yt-paper-dialog button, ytd-popup-container tp-yt-paper-button, yt-button-renderer button'));
    return cand.find(b => txtConfirmar.some(w => normalizar(b.textContent).includes(w))) || null;
  };

  // nombre real del canal desde el título; evita arrastrar descripciones
  function obtenerNombreCanal(tarjeta) {
    const candidatos = [
      'ytd-channel-name #text',
      'ytd-channel-name yt-formatted-string#text',
      'yt-formatted-string.ytd-channel-name',
      'a#main-link > yt-formatted-string',
      '#channel-title'
    ];
    for (const s of candidatos) {
      const el = tarjeta.querySelector(s);
      if (el && el.innerText && el.innerText.trim()) {
        return el.innerText.replace(/\r?\n|\r/g, ' ').replace(/\s+/g, ' ').trim();
      }
    }
    const img = tarjeta.querySelector('img[alt]');
    if (img?.alt?.trim()) return img.alt.trim();
    const a = tarjeta.querySelector('a#main-link, a[href*="/@"]');
    const m = a?.href?.match(/\/@([^\/]+)/);
    if (m) return `@${decodeURIComponent(m[1])}`;
    return 'Canal';
  }

  function obtenerUrl(tarjeta) {
    const a = tarjeta.querySelector('a#main-link, a[href*="/@"], a[href^="/channel/"]');
    if (!a?.href) return '';
    try { return new URL(a.getAttribute('href'), location.origin).href; } catch { return a.href; }
  }

  /* ===== flujo por canal ===== */
  const abrirMenu = async (tarjeta) => {
    let b = tarjeta.querySelector(sel.campana) || tarjeta.querySelector(sel.suscrito);
    if (!b) return null;
    click(b);
    await esperar(250);
    return menuAbierto();
  };

  const desuscribirCanal = async (tarjeta) => {
    const nombre = obtenerNombreCanal(tarjeta);
    const url = obtenerUrl(tarjeta);

    tarjeta.scrollIntoView({behavior:'smooth', block:'center'});
    await esperar(150);

    const menu = await abrirMenu(tarjeta);
    if (!menu) return;

    let item = buscarOpcionDesuscribir(menu);
    if (!item) { await esperar(250); item = buscarOpcionDesuscribir(menuAbierto()); }
    if (!item) return;

    click(item);
    await esperar(300);

    let btn = botonConfirmar();
    if (!btn) { await esperar(300); btn = botonConfirmar(); }
    if (btn) {
      click(btn);
      eventos.push({
        nombre,
        url,
        fechaIso: new Date().toISOString(),
        fechaLocal: new Date().toLocaleString(),
        estado: 'Desuscrito'
      });
    }
  };

  /* ===== resumen + descarga Markdown ===== */
  function generarMarkdown() {
    const total = eventos.length;
    const fecha = new Date().toLocaleString();

    let md = `# Resumen de desuscripciones en YouTube\n\n`;
    md += `- Fecha de ejecucion: ${limpiarCeldaMD(fecha)}\n`;
    md += `- Total de canales: **${total}**\n\n`;
    md += `| # | Canal | URL | Fecha | Estado |\n`;
    md += `|---:|---|---|---|---|\n`;

    eventos.forEach((e, i) => {
      const nombre = limpiarCeldaMD(e.nombre);
      const url = limpiarCeldaMD(e.url);
      const fechaLocal = limpiarCeldaMD(e.fechaLocal);
      const estado = limpiarCeldaMD(e.estado);
      md += `| ${i + 1} | [${nombre}](${url || '#'}) | ${url} | ${fechaLocal} | ${estado} |\n`;
    });

    return md + '\n';
  }

  function descargarMarkdown() {
    const contenido = generarMarkdown();
    const blob = new Blob([contenido], { type: 'text/markdown;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    const fecha = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
    a.href = url;
    a.download = `desuscripciones-${fecha}.md`;
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
  }

  function mostrarResumen() {
    const total = eventos.length;
    const vista = eventos.slice(0, 15);

    const overlay = document.createElement('div');
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10000;display:flex;align-items:center;justify-content:center;';

    const modal = document.createElement('div');
    modal.style.cssText = 'width:min(640px,90vw);max-height:80vh;background:#fff;color:#0f0f0f;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.35);display:flex;flex-direction:column;';

    const header = document.createElement('div');
    header.style.cssText = 'padding:16px 20px;border-bottom:1px solid #e5e5e5;font-weight:700;font-size:16px;';
    header.textContent = `Te has desuscrito de ${total} canal${total!==1?'es':''}`;

    const cuerpo = document.createElement('div');
    cuerpo.style.cssText = 'padding:12px 20px;overflow:auto;flex:1;';
    const ul = document.createElement('ul');
    ul.style.cssText = 'margin:8px 0;padding-left:18px;';
    vista.forEach(e => { const li = document.createElement('li'); li.textContent = e.nombre; ul.appendChild(li); });
    cuerpo.appendChild(ul);
    if (eventos.length > vista.length) {
      const extra = document.createElement('div');
      extra.style.cssText = 'color:#606060;font-size:12px;';
      extra.textContent = `…y ${eventos.length - vista.length} más.`;
      cuerpo.appendChild(extra);
    }

    const acciones = document.createElement('div');
    acciones.style.cssText = 'padding:12px 20px;border-top:1px solid #e5e5e5;display:flex;gap:12px;justify-content:flex-end;';

    const btnMd = document.createElement('button');
    btnMd.textContent = 'Descargar .md';
    btnMd.style.cssText = 'background:#e5e5e5;color:#0f0f0f;border:none;padding:10px 14px;border-radius:4px;font-weight:600;cursor:pointer;';
    btnMd.addEventListener('click', descargarMarkdown);

    const btnOk = document.createElement('button');
    btnOk.textContent = 'Aceptar';
    btnOk.style.cssText = 'background:#FF0000;color:#fff;border:none;padding:10px 16px;border-radius:4px;font-weight:700;cursor:pointer;';
    btnOk.addEventListener('click', () => overlay.remove());

    acciones.appendChild(btnMd);
    acciones.appendChild(btnOk);

    modal.appendChild(header);
    modal.appendChild(cuerpo);
    modal.appendChild(acciones);
    overlay.appendChild(modal);
    document.body.appendChild(overlay);
  }

  /* ===== lote ===== */
  async function desuscribirSeleccionados() {
    eventos.length = 0;
    const checks = document.querySelectorAll('.casilla-desuscribir:checked');
    for (const c of checks) {
      const tarjeta = c.closest(sel.tarjeta);
      if (tarjeta) {
        await desuscribirCanal(tarjeta);
        await esperar(600);
      }
    }
    mostrarResumen();
  }

  /* ===== UI ===== */
  function asegurarBotonFlotante() {
    if (document.querySelector('#boton-desuscribir-lote')) return;
    const b = document.createElement('button');
    b.id = 'boton-desuscribir-lote';
    b.textContent = 'Cancelar suscripciones seleccionadas';
    b.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:9999;background:#FF0000;color:#fff;border:none;padding:12px 16px;font-size:14px;font-weight:600;border-radius:4px;cursor:pointer;box-shadow:0 2px 5px rgba(0,0,0,.2);';
    b.addEventListener('click', desuscribirSeleccionados);
    document.body.appendChild(b);
  }

  function decorarTarjeta(t) {
    if (!t.querySelector('.boton-desuscribir')) {
      const b = document.createElement('button');
      b.className = 'boton-desuscribir';
      b.textContent = 'Cancelar suscripción';
      b.style.cssText = 'margin-left:12px;padding:8px 12px;background:#FF0000;color:#fff;border:none;border-radius:4px;font-size:13px;font-weight:600;cursor:pointer;transition:filter .2s;';
      b.addEventListener('mouseover',()=>{b.style.filter='brightness(.9)';});
      b.addEventListener('mouseout',()=>{b.style.filter='';});
      b.addEventListener('click',()=>desuscribirCanal(t));
      (t.querySelector(sel.acciones) || t).appendChild(b);
    }
    if (!t.querySelector('.casilla-desuscribir')) {
      const c = document.createElement('input');
      c.type = 'checkbox';
      c.title = 'Seleccionar canal';
      c.className = 'casilla-desuscribir';
      c.style.cssText = 'margin-left:10px;transform:scale(1.4);cursor:pointer;';
      (t.querySelector(sel.acciones) || t).appendChild(c);
    }
  }

  function montarUI() {
    document.querySelectorAll(sel.tarjeta).forEach(decorarTarjeta);
    asegurarBotonFlotante();
  }

  async function autodesplazar() {
    const cont = document.querySelector('ytd-section-list-renderer, ytd-two-column-browse-results-renderer') || document.scrollingElement;
    if (!cont) return;
    cont.scrollTop = cont.scrollHeight;
    await esperar(250);
  }

  /* ===== arranque ===== */
  montarUI();
  new MutationObserver(montarUI).observe(document.querySelector(sel.raiz) || document.body, { childList:true, subtree:true });

  document.addEventListener('click', (e) => {
    if (e.target && e.target.id === 'boton-desuscribir-lote') autodesplazar();
  });
})();