Greasy Fork

Greasy Fork is available in English.

🔐 密码填充

为Via设计的第三方密码保存/填充工具,支持管理、导入与导出密码

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         🔐 密码填充
// @namespace    https://ez118.github.io/
// @version      0.2.8
// @description  为Via设计的第三方密码保存/填充工具,支持管理、导入与导出密码
// @author       ZZY_WISU
// @match        *://*/*
// @license      GPLv3
// @run-at       document-end
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==


/* =====[ 变量存储 ]===== */

const ICONS = {
    'del': '<svg viewBox="0 0 24 24" width="20px" height="20px"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></svg>'
};

var savedAccount = []; // 缓存本地保存的账号列表
var cssInspectedFlag = false; // 记录css样式是否被注入(防止重复注入)

/* ====================== */
function Toast(text) {
    // Toast提示消息,适配VIA和MBrowser的原生Toast
    try{
        if (typeof(window.via) == "object") window.via.toast(text);
        else if (typeof(window.mbrowser) == "object") window.mbrowser.showToast(text);
        else alert(text);
    }catch{
        alert(text);
    }
}

function hash(str) {
    // 计算字符串哈希
    let hash = 5381;
    for (let i = 0; i < str.length; i++) {
        hash = (hash * 33) ^ str.charCodeAt(i);
    }
    return hash >>> 0;
}

function downloadFile(fileName, text) {
    // 下载指定内容的文件
    const url = window.URL || window.webkitURL || window;
    const blob = new Blob([text]);
    const saveLink = document.createElementNS('http://www.w3.org/1999/xhtml', 'a');
    saveLink.href = url.createObjectURL(blob);
    saveLink.download = fileName;
    saveLink.click();
}

function getHost() {
    // 获取当网站域名
    return window.location.host;
}

function findByKeyValue(array, key, value) {
    // 在JSON中,以键值匹配项
    return array.findIndex(item => item[key] === value);
}

function triggerFileSelect(callback) {
    // 打开文件选择框
    const fileInput = document.createElement('input');
    fileInput.type = 'file';
    fileInput.accept = '.csv';
    fileInput.multiple = false;

    // 监听文件选择事件
    fileInput.addEventListener('change', (event) => {
        const files = event.target.files;
        if (files.length > 0) {
            callback(files[0]);
        } else {
            // return null;
        }
    });
    fileInput.click();
}

function exportAccountData(){
    // 导出保存的账号
    let csvText = "name,url,username,password,note\n";
    let fileName = "密码填充_" + hash(csvText) % 1e8 + ".csv";
    savedAccount.forEach((item, index) => {
        csvText += `${item.host},https://${item.host}/,${item.username},${item.password},\n`;
    });
    downloadFile(fileName, csvText);
}

function importAccountData(){
    // 导入来自电脑浏览器的账号
    alert("【导入账号】 即将弹出文件选择,请选择 与Chrome/Firefox/Edge浏览器兼容 的CSV格式文件。");

    triggerFileSelect((file) => {
        try{
            const reader = new FileReader();

            reader.onload = function(e) {
                Toast("文件读取成功,正在导入...");

                const text = e.target.result;
                const lines = text.split('\n');

                var newDataList = [];

                // 遍历每一行
                lines.forEach((line, index) => {
                    // 第一行是 表头,直接跳过
                    if(index == 0 || line.length <= 6 || line.length > 512) { return; }

                    // 取得每一项的值
                    const item = line.split(",");
                    let username = item[2];
                    let password = item[3];
                    let host = item[1];

                    // 只留网址中的域名部分
                    if (host.includes("://")) { host = host.split("/")[2]; }

                    // 值缺失,则跳过
                    if(!username || !password || !host) { return; }

                    // 向新列表插入项
                    newDataList.push({
                        "id": hash(host + username + password).toString(),
                        "host": host,
                        "username": username,
                        "password": password
                    });
                });

                savedAccount = savedAccount.concat(newDataList);

                // 账号去重
                const uniqueDataList = savedAccount.reduce((accumulator, current) => {
                    const exists = accumulator.some(item => item.id === current.id);
                    if (!exists) { accumulator.push(current); }
                    return accumulator;
                }, []);

                savedAccount = uniqueDataList;
                GM_setValue('savedAccount', savedAccount);

                Toast("账号已导入合并,请刷新以查看更改");
            };

            reader.readAsText(file);
        } catch(e) {
            Toast("账号导入失败");
            console.error("【错误】账号导入失败,如果是脚本程序错误,请尽快向作者反馈并提供报错内容。 \n", e);
        }
    })
}



function isLoginPage() {
    // 检查当前网页是否是满足要求的登录页面
    let forms = document.getElementsByTagName("form");
    let isLogin = false;
    let formPosition = {x: 0, y: 0};
    let formobj = null;

    Array.prototype.forEach.call(forms, (form) => {
        let hasTextInput = false;
        let hasPasswordInput = false;

        // 获取所有 input 元素
        let inputs = form.getElementsByTagName("input");

        // 检查每个 input 的类型
        Array.prototype.forEach.call(inputs, (input) => {
            if (input.type === "text" || input.type === "email") {
                hasTextInput = true;
            } else if (input.type === "password") {
                hasPasswordInput = true;
            }
        });

        // 如果同时存在 text 和 password 类型的输入框,认为是登录页面
        if (hasTextInput && hasPasswordInput) {
            isLogin = true;

            let rectData = form.getClientRects()[0];
            formPosition.x = rectData.left + rectData.width / 2 - 90;
            formPosition.y = rectData.top + rectData.height - 15;

            formobj = form;
        }
    });

    return { isLogin, x: formPosition.x, y: formPosition.y, obj: formobj };
}

function getFormData(ele){
    // 获取当前页面内登录框的内容(ele传入登录框所在form元素的对象)
    let inputs = ele.getElementsByTagName("input");
    let usr = null;
    let psw = null;

    // 检查每个 input 的类型
    Array.prototype.forEach.call(inputs, (input) => {
        if ((input.type === "text" || input.type === "email") && !usr) {
            usr = input;
        } else if (input.type === "password" && !psw) {
            psw = input;
        }
    });

    return {password: psw.value, username: usr.value, psw: psw, usr: usr};
}




function initStyle() {
    if(cssInspectedFlag) { return; }

    // 初始化样式
    var websiteThemeColor = "#FFFFFFEE";
    var websiteFontColor = "#000";

    GM_addStyle(`
        body{ -webkit-appearance:none!important; }

        .userscript-quickFill{ user-select:none; background-color:${websiteThemeColor}; color:${websiteFontColor}; border:1px solid #99999999; padding:5px; font-size:12px; line-height:20px; width:180px; height:fit-content; position:absolute; display:flex; flex-direction:column; overflow:hidden auto; box-sizing:border-box; z-index:100000; font-family:"Hiragino Sans GB","Microsoft YaHei","WenQuanYi Micro Hei",sans-serif; border-radius:12px; box-shadow:0px 0px 8px #BBB; }
        .userscript-quickFill>.item{ margin:1px 0px; border-radius:8px; padding:5px 9px; width:100%; flex-basis:fit-content; flex-shrink:0; cursor:pointer; background-color:transparent; box-sizing:border-box }
        .userscript-quickFill>.item:hover{ background-color:rgba(128, 128, 128, 0.2); }
        .userscript-quickFill>.hideBtn{ margin:1px 0px; border-radius:8px; padding:3px 9px; width:100%; flex-basis:fit-content; flex-shrink:0; color:${websiteFontColor}; opacity:0.7; font-size:11px; box-sizing:border-box; cursor:pointer; text-align:right; }
        .userscript-quickFill>.hideBtn:active{ background-color:rgba(128, 128, 128, 0.2); opacity:1; }
        .userscript-quickFill>hr{ margin:0 4px; border:none; border-top:1px solid ${websiteFontColor}; background:none; opacity:0.2; width:calc(100% - 8px); }

        .userscript-pswmgrDlg{ user-select:none; background-color:${websiteThemeColor}; color:${websiteFontColor}; border:1px solid #99999999; position:fixed; top:50%; height:fit-content; left:50%; transform:translateX(-50%) translateY(-50%); width:92vw; max-width:300px; max-height:92vh; padding:15px; border-radius:15px; box-sizing:initial; z-index:100000; box-shadow:0 1px 10px #BBB; font-family:"Hiragino Sans GB","Microsoft YaHei","WenQuanYi Micro Hei",sans-serif; }
        .userscript-pswmgrDlg .ctrlbtn{ border:none; background-color:transparent; padding:8px; margin:0; color:#6d7fb4; cursor:pointer; overflow:hidden; }
        .userscript-pswmgrDlg h3{ margin:5px; margin-bottom:15px; font-size:24px; }
        .userscript-pswmgrDlg .subtitle{ margin:5px 1px; font-size:16px; font-weight:400; }

        .userscript-pswmgrDlg .list-item{ width:calc(100% - 10px); padding:10px 5px; margin:0; display:flex; flex-direction:row; vertical-align:middle; box-sizing:initial; }
        .userscript-pswmgrDlg .list-item:hover{ background-color:#55555555; }
        .userscript-pswmgrDlg .list-item>p{ padding:0; margin:0; font-size:16px; }
        .userscript-pswmgrDlg .list-item>.item-title{ flex-grow:1; margin-left:5px; }

        .userscript-pswmgrDlg .list-item>.item-delbtn{ cursor:pointer; width:25px; }
        .userscript-pswmgrDlg .list-item>.item-delbtn svg{ fill:${websiteFontColor}; height:100%; min-height:16px; }
    `);

    console.log("【密码填充】已注入脚本css样式,");
    cssInspectedFlag = true;
}



function showPswMgr() {
    // 显示账户管理界面
    initStyle();

    if (document.getElementById("userscript-pswmgrDlg")) { return; }

    let newAccountList = savedAccount.slice(); // 不直接引用
    let origAccountList = savedAccount.slice();

    // 创建元素、设置属性
    const optDlg = document.createElement('div');
    optDlg.className = 'userscript-pswmgrDlg';
    optDlg.id = 'userscript-pswmgrDlg';
    optDlg.style.display = 'none';
    document.body.appendChild(optDlg);

    // 循环输出账户列表的html
    let listHtml = newAccountList.map(item => `
        <div class="list-item" acid="${item.id}">
            <p class="item-title">${item.username} (${item.host})</p>
            <p class="item-delbtn" acid="${item.id}" title="移除">${ICONS.del}</p>
        </div>
    `).join('');

    // 显示管理对话框html框架
    optDlg.innerHTML = `
        <div style="height:fit-content; max-height:calc(80vh - 60px); overflow-x:hidden; overflow-y:auto;">
            <h3>管理</h3>
            <div style="height:fit-content; margin:5px;">
                <p class="subtitle">已保存的账户:</p>
                ${listHtml}
            </div>
        </div>
        <div align="right">
            <input type="button" value="导入" class="ctrlbtn" id="userscript-importBtn">
            <input type="button" value="导出" class="ctrlbtn" id="userscript-exportBtn">
            <input type="button" value="取消" class="ctrlbtn" id="userscript-cancelBtn">
            <input type="button" value="保存" class="ctrlbtn" id="userscript-saveBtn">
        </div>
    `;

    optDlg.style.display = 'block';

    // 绑定全局点击事件
    document.addEventListener('click', onClick);

    // 对全局点击事件进行判断,判断点击事件作用对象(ChatGPT的主意,实现方式奇怪,但兼容性变强了)
    function onClick(e) {
        if (e.target.parentElement.className == "item-delbtn" || e.target.parentElement.parentElement.className == "item-delbtn") {
            let btnEle = (e.target.parentElement.className == "item-delbtn") ? e.target.parentElement : e.target.parentElement.parentElement;
            console.log(btnEle)
            const acid = btnEle.getAttribute("acid");
            const index = findByKeyValue(newAccountList, 'id', acid);
            if (index !== -1) {
                newAccountList.splice(index, 1);
                btnEle.parentElement.remove();
            }
        }

        if (e.target.id === 'userscript-cancelBtn') {
            newAccountList = origAccountList; // 恢复原始账户列表
            closeDialog();
        }

        if (e.target.id === 'userscript-saveBtn') {
            savedAccount = newAccountList; // 更新全局账户列表
            GM_setValue('savedAccount', savedAccount);
            Toast("【密码填充】已保存,刷新页面以应用更改");
            closeDialog();
        }

        if (e.target.id === 'userscript-exportBtn') {
            exportAccountData();
            Toast("【密码填充】即将导出为csv文件,请注意下载");
        }

        if (e.target.id === 'userscript-importBtn') {
            importAccountData();
        }
    }

    // 关闭窗口
    function closeDialog() {
        const optDlg = document.getElementById("userscript-pswmgrDlg");
        optDlg.style.display = 'none';
        setTimeout(() => {
            optDlg.remove();
            document.removeEventListener('click', onClick);
        }, 110);
    }
}


function initEle(form, cx, cy) {
    // 创建搜索栏元素并添加到页面
    const quickFill = document.createElement('div');
    quickFill.className = 'userscript-quickFill';
    quickFill.id = 'userscript-quickFill';
    document.body.appendChild(quickFill);

    let html = '';
    const host = getHost();
    savedAccount.forEach(item => {
        if (item.host === host) {
            html += `<div class="item" acid="${item.id}">${item.username}</div>`;
        }
    });

    // 设定快速填充栏HTML内容
    quickFill.innerHTML = `
        <font color="#333333" size="small">&nbsp;保存的账户:</font>
        ${html}
        <hr>
        <div class="hideBtn">隐藏该窗口</div>
    `;

    // 设置快速填充栏位置
    quickFill.style.left = `${cx}px`;
    quickFill.style.top = `${cy}px`;

    // 选择保存过的第一个账号,自动填充到网页
    const formdata = getFormData(form);
    let dataindex = findByKeyValue(savedAccount, 'host', host);
    if (dataindex !== -1) {
        formdata.psw.value = savedAccount[dataindex].password;
        formdata.usr.value = savedAccount[dataindex].username;
    }

    // 添加点击事件监听器
    quickFill.addEventListener('click', function (e) {
        if (e.target.matches('.item')) {
            const acid = e.target.getAttribute("acid");
            let dataindex = findByKeyValue(savedAccount, 'id', acid);
            formdata.psw.value = savedAccount[dataindex].password;
            formdata.usr.value = savedAccount[dataindex].username;
        }

        if (e.target.matches('.hideBtn')) {
            quickFill.style.display = 'none';
        }
    });
}



function init() {
    let judgeRes = isLoginPage();

    if (judgeRes.isLogin) {
        /* 初始化 */
        console.log("【密码填充】检测到登录页面");
        initStyle();
        initEle(judgeRes.obj, judgeRes.x, judgeRes.y);

        judgeRes.obj.addEventListener('submit', function (e) {
            // 获取表单输入内容
            const formdata = getFormData(judgeRes.obj);
            const newdata = {
                "id": hash(getHost() + formdata.username + formdata.password).toString(),
                "host": getHost(),
                "username": formdata.username,
                "password": formdata.password
            };

            // 检查是否数据重复
            const oldidx = findByKeyValue(savedAccount, "host", newdata.host);
            if (oldidx !== -1 && savedAccount[oldidx] && savedAccount[oldidx].id === newdata.id) {
                return;
            }

            // 如果不是重复账号,则询问是否保存
            let res = window.confirm("【密码填充】是否保存账号?");
            if (res) {
                // 保存账户
                savedAccount.push(newdata);
                GM_setValue('savedAccount', savedAccount);

                Toast("【密码填充】账号已保存!");
            }
        });
    }
}



/* =====[ 菜单注册 ]===== */
var menu_mgr = GM_registerMenuCommand('⚙️ 管理密码', function () { showPswMgr(); }, 'o');


(function () {
    'use strict';

    if(GM_getValue('savedAccount') == null || GM_getValue('savedAccount') == "" || GM_getValue('savedAccount') == undefined){ GM_setValue('savedAccount', savedAccount); }
    else { savedAccount = GM_getValue('savedAccount'); }

    init();

    setTimeout(function () {
        if (document.querySelectorAll(".userscript-quickFill").length === 0) {
            init();
        }
    }, 1000);


})();