// ==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 init = () => {
document.addEventListener('submit', e => trySaveForm(e.target), true);
const matched = loadAll().filter(it => location.href.startsWith(it.url));
if (!matched.length) return;
new MutationObserver(tryFill).observe(document.body,
{ childList: true, subtree: 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 fillPromise = null;
const tryFill = () => {
if (fillPromise) return;
fillPromise = (async () => {
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', '');
}
}
})().finally(() => { fillPromise = null; });
};
/* ---------- 自动保存 ---------- */
const trySaveForm = form => {
const { user, pwd } = findInputs(form);
const u = user?.value, p = pwd?.value;
if (!u || !p) return;
const list = loadAll();
if (list.some(it => it.url.startsWith(location.origin) && it.account === u && it.password === p)) return;
if (confirm(`是否保存账号/密码?\n账号:${u}\n密码:${p}`)) {
list.push({
title: document.title || location.hostname,
url: location.origin + location.pathname,
account: u, password: p
});
saveAll(list);
toast('已保存');
}
};
/* ---------- 扫描 ---------- */
const scanPage = () => {
const title = document.title || location.hostname;
const url = location.origin + location.pathname;
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('siteCredentials', '[]')); } catch { return []; } };
const saveAll = arr => GM_setValue('siteCredentials', 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 data-idx="${i}" 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, '');
document.body.appendChild(wrap);
const t = setTimeout(() => (wrap.remove(), resolve(null)), 5000);
wrap.addEventListener('click', e => {
if (!e.target.closest('[data-idx]')) return;
const selected = list[Number(e.target.closest('[data-idx]').dataset.idx)];
sessionStorage[key] = JSON.stringify(selected);
clearTimeout(t);
wrap.remove();
resolve(selected);
});
});
/* ---------- 管理面板 ---------- */
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="粘贴格式:标题,网址,账号,密码 百度,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);
})();