账号密码自动填充

账号密码自动填充 支持多账号 两步登录

// ==UserScript==
// @name         账号密码自动填充
// @description  账号密码自动填充 支持多账号 两步登录
// @version      1.0
// @author       WJ
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @run-at       document-end
// @license      MIT
// @namespace https://greasyfork.org/users/914996
// ==/UserScript==

(() => {
  'use strict';
  const STORAGE_KEY = 'siteCredentials';

  /* ---------- 初始化 ---------- */
  let Observer = null;
  const init = () => {
    const matched = loadAll().filter(it => location.href.startsWith(it.url));
    if (!matched.length) return;
    const stopObserver = () => { Observer?.disconnect(); Observer = null };
    stopObserver();
    Observer = new MutationObserver(tryFill);
    Observer.observe(document.body, { childList: true, subtree: true, attributes: true });
    addEventListener('beforeunload', stopObserver, { once: true });
    tryFill();
  };

  /* ---------- 查找输入框 ---------- */
  const findInputs = () => {
    const all = [];
    const walk = r => r.querySelectorAll('*').forEach(n => {
      if (n.tagName === 'INPUT') {
        const { display, visibility } = getComputedStyle(n);
        display !== 'none' && visibility !== 'hidden' && all.push(n);
      }
      n.shadowRoot && walk(n.shadowRoot);
    });
    walk(document.body);
    const sort = a => a.sort((x, y) => (x.tabIndex || 0) - (y.tabIndex || 0))[0];
    return {
      user: sort(all.filter(n => /^(text|email|tel|number)$/i.test(n.type) && /user|login|mail|phone|手机|邮箱|账号|用户名|账户|id/i.test(`${n.name}|${n.placeholder}|${n.id}`))),
      pwd:  sort(all.filter(n => n.type === 'password'))
    };
  };

  /* ---------- 自动填充 ---------- */
  let isFilling = false;
  const tryFill = async () => {
    if (isFilling) return;
    isFilling = true;
    const matched = loadAll().filter(it => location.href.startsWith(it.url));
    const rec = matched.length === 0 ? null
              : matched.length === 1 ? matched[0]
              : await pickAccount(matched);
    if (rec) {
      const { user, pwd } = findInputs();
      if (user && rec.account && !user.value && !user.hasAttribute('filled-u')) {
        setValue(user, rec.account);
        user.setAttribute('filled-u', '');
      }
      if (pwd && rec.password && !pwd.value && !pwd.hasAttribute('filled-p')) {
        setValue(pwd, rec.password);
        pwd.setAttribute('filled-p', '');
      }
    }
    isFilling = false;
  };

  /* ---------- 扫描 ---------- */
  const scanPage = () => {
    const url = location.origin + location.pathname;
    const title = document.title || '无标题';
    const { user, pwd } = findInputs();
    const list = loadAll();
    const idx = list.findIndex(it => it.title === title);
    const rec = { title, url, account: user?.value || '', password: pwd?.value || '' };
    idx >= 0 ? (list[idx] = rec) : list.push(rec);
    saveAll(list);
    openEditor();
  };

  /* ---------- 数据操作 ---------- */
  const loadAll = () => { try { return JSON.parse(GM_getValue(STORAGE_KEY, '[]')); } catch { return []; } };
  const saveAll = arr => GM_setValue(STORAGE_KEY, JSON.stringify(arr));
  const setValue = (el, val) => {
    if (!el) return;
    const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
    set ? (set.call(el, val), el.dispatchEvent(new Event('input', { bubbles: true })))
    : (el.value = val, ['input', 'change', 'blur'].forEach(evt => el.dispatchEvent(new Event(evt, { bubbles: true }))));
  };

  /* ---------- Toast ---------- */
  const toast = (m, d=3000) => {
    const t = document.body.appendChild(document.createElement('div'));
    t.textContent = m;
    t.style.cssText = 'position:fixed;left:50%;bottom:80px;transform:translateX(-50%);background:rgba(0,0,0,.75);color:#fff;padding:12px 20px;border-radius:4px;font-size:18px;z-index:99999;transition:opacity .3s;border:2px solid #5FB66B';
    setTimeout(() => (t.style.opacity = 0, setTimeout(t.remove, 300)), d);
  };

  /* ---------- 底部卡片选择 ---------- */
  const pickAccount = list => new Promise(resolve => {
    const key = `picked_${location.origin}${location.pathname}`;
    if (sessionStorage[key]) return resolve(JSON.parse(sessionStorage[key]));
    const wrap = document.createElement('div');
    wrap.id = 'WJ_bottomCards';
    wrap.style.cssText = `
      position:fixed;left:0;right:0;bottom:0;z-index:99999;
      background:#1e1e1e;color:#eee;padding:12px 16px 20px;
      box-shadow:0 -2px 12px rgba(0,0,0,.6);font-family:sans-serif`;
    wrap.innerHTML = `
      <div style="font-size:30px;font-weight:bold;text-align:center;margin-bottom:14px;color:#4E6BF5">选择账号登录</div>
      <div style="display:grid;grid-template-columns:1fr 1px 1fr;border-top:1px solid #444">
        ${list.map((rec, i) => {
          const last = i === list.length - 1;
          const oddLast = last && (i + 1) % 2 === 1;
          return `
            <div onclick="this.resolve()" style="${oddLast ? 'grid-column:1/-1;' : ''}padding:14px 8px;cursor:pointer;display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:52px;transition:.2s" onmouseenter="this.style.background='#333'" onmouseleave="this.style.background=''">
              <div style="font-size:20px;color:#4E6BF5;margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${rec.title || '未命名'}</div>
              <div style="font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${rec.account || '(空账号)'}</div>
            </div>
            ${(i + 1) % 2 === 1 && !last ? '<div style="background:#444"></div>' : ''}
            ${(i + 1) % 2 === 0 || last ? '<div style="height:1px;background:#444;grid-column:1/-1"></div>' : ''}`;
        }).join('')}
      </div>
    `.replace(/\s*\n\s*/g, '');
    wrap.querySelectorAll('[onclick]').forEach((c, i) => {
      c.resolve = () => {
        sessionStorage[key] = JSON.stringify(list[i]);
        wrap.remove();
        resolve(list[i]);
      };
    });
    document.body.appendChild(wrap);
    const t = setTimeout(() => wrap.remove(), 5000);
    wrap.onclick = () => clearTimeout(t);
  });

  /* ---------- 管理面板 ---------- */
  const openEditor = () => {
    const wrap = document.createElement('div');
    wrap.id = 'WJ_tmCredEditor';
    wrap.style.cssText = `position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:9999; display:flex; align-items:center; justify-content:center; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;`;
    const box = document.createElement('div');
    box.style.cssText = `width:95%; max-width:600px; max-height:90vh; overflow:auto; background:#262626; color:#eee; border-radius:6px; padding:15px;`;
    box.innerHTML = `
      <style>
        #WJ_tmCredEditor table{width:100%;border-collapse:collapse;table-layout:fixed;margin:0 -15px;width:calc(100% + 30px)}
        #WJ_tmCredEditor thead th{border:1px solid #555;padding:6px 4px;font-size:13px;text-align:center;color:#0E5484}
        #WJ_tmCredEditor tbody td{border:1px solid #555;height:50px;padding:0;text-align:center;vertical-align:middle;position:relative}
        #WJ_jsonArea{width:100%;height:150px;margin-top:10px;}#WJ_jsonArea::placeholder{font-size:20px;text-align:center;}
        .WJ_scroll-box{height:50px;overflow:auto;background:#333;cursor:text}
        .WJ_center-box:focus,#WJ_jsonArea:focus,.WJ_bottom-bar button:focus{outline:none}
        .WJ_center-box{min-height:50px;display:flex;align-items:center;justify-content:center;padding:0 4px;box-sizing:border-box;color:#eee;font-size:13px;line-height:1.2;text-align:center;white-space:pre-wrap;word-break:break-all;outline:none}
        .WJ_delete-btn{position:absolute;inset:0;background:#5D4401;color:#fff;font-size:14px;cursor:pointer;display:flex;align-items:center;justify-content:center;user-select:none;border:none;margin:0;padding:0;box-sizing:border-box}
        .WJ_delete-btn:hover{opacity:.9}
        .WJ_bottom-bar{display:flex;margin:15px -15px 0 -15px}
        .WJ_bottom-bar button{flex:1;height:48px;font-size:16px;color:#157530;background:none;border:1px solid #bbb;border-right:none;cursor:pointer}
        .WJ_bottom-bar button:last-child{border-right:1px solid #bbb}
      </style>
      <h2 style="margin:0 0 12px;text-align:center;font-size:26px;color:#0E5484">账号密码管理</h2>
      <table id="WJ_credTable">
        <thead>
          <tr>
            <th style="width:18%">标题</th>
            <th style="width:37%">网址</th>
            <th style="width:25%">账号</th>
            <th style="width:20%">密码</th>
            <th style="width:10%">删除</th>
          </tr>
        </thead>
        <tbody></tbody>
      </table>
      <div class="WJ_bottom-bar">
        <button id="WJ_addBtn">新增</button>
        <button id="WJ_exportBtn">导出</button>
        <button id="WJ_importBtn">导入</button>
        <button id="WJ_closeBtn">关闭</button>
      </div>
      <textarea id="WJ_jsonArea" style="display:none" placeholder="粘贴格式:标题,网址,账号,密码&#10;百度,https://www.baidu.com,zhanghao,mima"></textarea>`;
    wrap.appendChild(box);
    document.body.appendChild(wrap);
    const render = () => {
      const tbody = wrap.querySelector('#WJ_credTable tbody');
      tbody.innerHTML = '';
      loadAll().forEach((row, idx) => {
        const tr = document.createElement('tr');
        ['title', 'url', 'account', 'password'].forEach(key => {
          const td = document.createElement('td');
          const scroll = document.createElement('div');
          const inner = document.createElement('div');
          scroll.className = 'WJ_scroll-box';
          inner.className = 'WJ_center-box';
          inner.contentEditable = true;
          inner.spellcheck = false;
          inner.textContent = row[key] || '';
          inner.oninput = () => { const list = loadAll(); list[idx][key] = inner.textContent; saveAll(list); };
          scroll.appendChild(inner);
          td.appendChild(scroll);
          tr.appendChild(td);
        });
        const delTd = document.createElement('td');
        const delBtn = document.createElement('div');
        delBtn.textContent = '×';
        delBtn.className = 'WJ_delete-btn';
        delBtn.onclick = () => { const list = loadAll(); list.splice(idx, 1); saveAll(list); render(); toast('已删除'); };
        delTd.appendChild(delBtn);
        tr.appendChild(delTd);
        tbody.appendChild(tr);
      });
    };
    render();
    wrap.querySelector('#WJ_closeBtn').onclick = () => wrap.remove();
    wrap.querySelector('#WJ_exportBtn').onclick = () => {
      const rows = loadAll().map(r => [r.title, r.url, r.account, r.password].join(',')).join('\n');
      navigator.clipboard.writeText(rows).then(() => toast('已导出到剪贴板'));
    };
    wrap.querySelector('#WJ_importBtn').onclick = () => {
      const ta = wrap.querySelector('#WJ_jsonArea');
      if (ta.style.display === 'none') { ta.style.display = 'block'; return; }
      const val = ta.value.trim();
      if (!val) { ta.style.display = 'none'; return; }
      try {
        const arr = val.split('\n').map(line => {
          const cells = line.split(',');
          if (cells.length !== 4) throw new Error('格式错误');
          return { title: cells[0], url: cells[1], account: cells[2], password: cells[3] };
        });
        const list = loadAll();
        arr.forEach(r => {
          const idx = list.findIndex(it => it.title === r.title);
          idx >= 0 ? (list[idx] = r) : list.push(r);
        });
        saveAll(list); render(); ta.style.display='none'; ta.value=''; toast('导入成功');
      } catch(e) { toast(e.message); }
    };
    wrap.querySelector('#WJ_addBtn').onclick = () => {
      const list = loadAll();
      list.push({ title: '', url: '', account: '', password: '' });
      saveAll(list); render();
    };
  };

  /* ---------- 启动 ---------- */
  init();
  GM_registerMenuCommand('扫描页面账号', scanPage);
  GM_registerMenuCommand('账号密码管理', openEditor);
})();